diff --git a/.gitignore b/.gitignore index 7ca3e49..6c7825b 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ # Signing keys *.jks *.keystore + +# modding/hacked build output (see modding/README.md) +/modding/dist/ diff --git a/AGENTS.md b/AGENTS.md index dd0ab04..5f7a348 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ These come from the maintainer. Follow them without being asked again. | `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), `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_.`, 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. | diff --git a/modding/Makefile b/modding/Makefile new file mode 100644 index 0000000..acb4507 --- /dev/null +++ b/modding/Makefile @@ -0,0 +1,30 @@ +# Builds the modding subproject - see README.md. +# +# Targets: +# all (default) - alias for `hacked`. +# 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/. +# clean - removes dist/. +.PHONY: all build-image hacked clean + +all: hacked + +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 + +clean: + rm -rf dist diff --git a/modding/README.md b/modding/README.md new file mode 100644 index 0000000..140c442 --- /dev/null +++ b/modding/README.md @@ -0,0 +1,84 @@ +# 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. + +Both pieces here come from the [InkyBlackness](https://inkyblackness.github.io) +project and are vendored unmodified, each under its own upstream license (see +below) - neither is relicensed by this repository's own MIT license. + +## Layout + +- `hacked/` - [InkyBlackness HackEd](https://github.com/inkyblackness/hacked), + the graphical mod editor. A vendored source snapshot (New BSD License), + taken from the `main` branch on 2026-09-26. To update it, replace this + directory with a fresh checkout and update that date. +- `ss-specs/` - [InkyBlackness ss-specs](https://github.com/inkyblackness/ss-specs), + the unofficial file-format specifications the editor (and any other modding + tool) is written against. Reference documentation only, not built. A + vendored snapshot (CC BY-NC-SA 4.0 - **non-commercial**, unlike the rest of + this repository), taken from the `main` branch on 2026-09-26. +- `hacked-build-image/` - the Dockerfile (and supporting scripts) for the + `hacked` build environment: the Go toolchain plus every dependency + `hacked/go.mod` pins, fetched once at image-build time so the actual build + needs no network access. Mirrors the main project's own `build-image/` + (see the repository root's [README.md](../README.md) section 4) one level + down, for this subproject. +- `hacked-build-image.sh` / `hacked-build-run-image.sh` / + `hacked-build-upload-image.sh` - build the image, run it to compile + `hacked/`, and push it to a registry, respectively. Same pattern as the + root project's own `build-image.sh`/`run-image.sh`/`upload-image.sh`. +- `Makefile` - `hacked` (default) builds `hacked/` via the image; `build-image` + (re)builds the image itself; `clean` removes `dist/`. +- `dist/` - build output (git-ignored), created by `make hacked`. + +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. + +## Building `hacked` + +Packages `dist/.linux64.tgz` and `dist/.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 +# hacked/go.mod|go.sum changes) +./hacked-build-image.sh + +# 2. Compile hacked/ for Linux and Windows +make hacked +``` + +`` is `hacked-`, 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). + +### 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`. diff --git a/modding/hacked-build-image.sh b/modding/hacked-build-image.sh new file mode 100755 index 0000000..dce0014 --- /dev/null +++ b/modding/hacked-build-image.sh @@ -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" diff --git a/modding/hacked-build-image/Dockerfile b/modding/hacked-build-image/Dockerfile new file mode 100644 index 0000000..d5423dc --- /dev/null +++ b/modding/hacked-build-image/Dockerfile @@ -0,0 +1,64 @@ +# Build environment for modding/hacked/ (vendored from +# https://github.com/inkyblackness/hacked, see ../README.md). Fetches the Go +# toolchain and this module's dependencies once, here, at image-build time, so +# ../hacked-build-run-image.sh can compile fully offline afterward. Rebuild +# with ../hacked-build-image.sh whenever a version below changes, or whenever +# ../hacked/go.mod or go.sum changes (its dependencies are baked into the +# 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 hacked/go.mod 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 diff --git a/modding/hacked-build-image/VERSION b/modding/hacked-build-image/VERSION new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/modding/hacked-build-image/VERSION @@ -0,0 +1 @@ +1 diff --git a/modding/hacked-build-image/build-hacked.sh b/modding/hacked-build-image/build-hacked.sh new file mode 100755 index 0000000..1364033 --- /dev/null +++ b/modding/hacked-build-image/build-hacked.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Compiles modding/hacked/ for Linux and Windows (amd64), entirely offline - +# every Go module dependency was already fetched into the build-image at +# image-build time (see Dockerfile). Baked into the image at +# /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)" +HACKED_SRC="$MODDING_ROOT/hacked" +OUT_DIR="$MODDING_ROOT/dist" + +VERSION="hacked-$(date -u +%Y%m%d)" +echo "Version: $VERSION" + +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-) 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" diff --git a/modding/hacked-build-image/docker-entrypoint.sh b/modding/hacked-build-image/docker-entrypoint.sh new file mode 100755 index 0000000..113edde --- /dev/null +++ b/modding/hacked-build-image/docker-entrypoint.sh @@ -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 "$@" diff --git a/modding/hacked-build-run-image.sh b/modding/hacked-build-run-image.sh new file mode 100755 index 0000000..d4bd8d3 --- /dev/null +++ b/modding/hacked-build-run-image.sh @@ -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" \ + "$@" diff --git a/modding/hacked-build-upload-image.sh b/modding/hacked-build-upload-image.sh new file mode 100755 index 0000000..8897874 --- /dev/null +++ b/modding/hacked-build-upload-image.sh @@ -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" diff --git a/modding/hacked/.gitattributes b/modding/hacked/.gitattributes new file mode 100644 index 0000000..314766e --- /dev/null +++ b/modding/hacked/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf diff --git a/modding/hacked/.gitignore b/modding/hacked/.gitignore new file mode 100644 index 0000000..161cf9a --- /dev/null +++ b/modding/hacked/.gitignore @@ -0,0 +1,5 @@ +.idea/ +_build/ + +imgui.ini +*.syso diff --git a/modding/hacked/.golangci.yml b/modding/hacked/.golangci.yml new file mode 100644 index 0000000..d99e540 --- /dev/null +++ b/modding/hacked/.golangci.yml @@ -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 diff --git a/modding/hacked/CONTRIBUTING.md b/modding/hacked/CONTRIBUTING.md new file mode 100644 index 0000000..bda74ca --- /dev/null +++ b/modding/hacked/CONTRIBUTING.md @@ -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. diff --git a/modding/hacked/LICENSE b/modding/hacked/LICENSE new file mode 100644 index 0000000..f5722bf --- /dev/null +++ b/modding/hacked/LICENSE @@ -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. diff --git a/modding/hacked/README.md b/modding/hacked/README.md new file mode 100644 index 0000000..b8ff4d5 --- /dev/null +++ b/modding/hacked/README.md @@ -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: +![v1.0.0 Level Editing](https://user-images.githubusercontent.com/1508944/43354141-b8c536b0-9246-11e8-8014-c306be4ab1bb.png) + +Media editing details: +![v1.0.0 Media Editing](https://user-images.githubusercontent.com/1508944/43354142-c3035742-9246-11e8-86d8-31cf62b7ca1b.png) + +4K glory showing all the windows: +![v1.0.0 4K Glory](https://user-images.githubusercontent.com/1508944/43354138-a4676cc4-9246-11e8-883f-46ddde4aba25.png) + + +## License + +The project is available under the terms of the **New BSD License** (see LICENSE file). diff --git a/modding/hacked/_resources/build/win/hacked.exe.manifest b/modding/hacked/_resources/build/win/hacked.exe.manifest new file mode 100644 index 0000000..35c4079 --- /dev/null +++ b/modding/hacked/_resources/build/win/hacked.exe.manifest @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/modding/hacked/_resources/build/win/hacked.ico b/modding/hacked/_resources/build/win/hacked.ico new file mode 100755 index 0000000..5816c1a Binary files /dev/null and b/modding/hacked/_resources/build/win/hacked.ico differ diff --git a/modding/hacked/_resources/build/win/hacked.rc b/modding/hacked/_resources/build/win/hacked.rc new file mode 100644 index 0000000..5f964df --- /dev/null +++ b/modding/hacked/_resources/build/win/hacked.rc @@ -0,0 +1,3 @@ +100 ICON "hacked.ico" +100 24 "hacked.exe.manifest" +GLFW_ICON ICON "hacked.ico" diff --git a/modding/hacked/_resources/dist/README.md b/modding/hacked/_resources/dist/README.md new file mode 100644 index 0000000..71eb17e --- /dev/null +++ b/modding/hacked/_resources/dist/README.md @@ -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 diff --git a/modding/hacked/build.sh b/modding/hacked/build.sh new file mode 100755 index 0000000..89e5b2d --- /dev/null +++ b/modding/hacked/build.sh @@ -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 diff --git a/modding/hacked/crash/Handler.go b/modding/hacked/crash/Handler.go new file mode 100644 index 0000000..25dba1c --- /dev/null +++ b/modding/hacked/crash/Handler.go @@ -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) {} diff --git a/modding/hacked/crash/Handler_windows.go b/modding/hacked/crash/Handler_windows.go new file mode 100644 index 0000000..6f8f9ea --- /dev/null +++ b/modding/hacked/crash/Handler_windows.go @@ -0,0 +1,42 @@ +package crash + +//#include +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 +} diff --git a/modding/hacked/editor/Application.go b/modding/hacked/editor/Application.go new file mode 100644 index 0000000..fe2b42c --- /dev/null +++ b/modding/hacked/editor/Application.go @@ -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(), + } +} diff --git a/modding/hacked/editor/ClipboardAdapter.go b/modding/hacked/editor/ClipboardAdapter.go new file mode 100644 index 0000000..cb6b6e8 --- /dev/null +++ b/modding/hacked/editor/ClipboardAdapter.go @@ -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) +} diff --git a/modding/hacked/editor/about/LicensesView.go b/modding/hacked/editor/about/LicensesView.go new file mode 100644 index 0000000..cf4272c --- /dev/null +++ b/modding/hacked/editor/about/LicensesView.go @@ -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() + } +} diff --git a/modding/hacked/editor/about/View.go b/modding/hacked/editor/about/View.go new file mode 100644 index 0000000..d2d790f --- /dev/null +++ b/modding/hacked/editor/about/View.go @@ -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() + } +} diff --git a/modding/hacked/editor/about/ViewModel.go b/modding/hacked/editor/about/ViewModel.go new file mode 100644 index 0000000..b80849e --- /dev/null +++ b/modding/hacked/editor/about/ViewModel.go @@ -0,0 +1,9 @@ +package about + +type viewModel struct { + windowOpen bool +} + +func freshViewModel() viewModel { + return viewModel{} +} diff --git a/modding/hacked/editor/about/licenses.go b/modding/hacked/editor/about/licenses.go new file mode 100644 index 0000000..e502920 --- /dev/null +++ b/modding/hacked/editor/about/licenses.go @@ -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 + +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. +` diff --git a/modding/hacked/editor/animations/SetAnimationCommand.go b/modding/hacked/editor/animations/SetAnimationCommand.go new file mode 100644 index 0000000..9f0b366 --- /dev/null +++ b/modding/hacked/editor/animations/SetAnimationCommand.go @@ -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 +} diff --git a/modding/hacked/editor/animations/View.go b/modding/hacked/editor/animations/View.go new file mode 100644 index 0000000..bb09b28 --- /dev/null +++ b/modding/hacked/editor/animations/View.go @@ -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) +} diff --git a/modding/hacked/editor/animations/ViewModel.go b/modding/hacked/editor/animations/ViewModel.go new file mode 100644 index 0000000..aaacf7b --- /dev/null +++ b/modding/hacked/editor/animations/ViewModel.go @@ -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), + } +} diff --git a/modding/hacked/editor/archives/EditBooleanVariableDialog.go b/modding/hacked/editor/archives/EditBooleanVariableDialog.go new file mode 100644 index 0000000..14a71e0 --- /dev/null +++ b/modding/hacked/editor/archives/EditBooleanVariableDialog.go @@ -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() +} diff --git a/modding/hacked/editor/archives/EditIntegerVariableDialog.go b/modding/hacked/editor/archives/EditIntegerVariableDialog.go new file mode 100644 index 0000000..d969893 --- /dev/null +++ b/modding/hacked/editor/archives/EditIntegerVariableDialog.go @@ -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 +} diff --git a/modding/hacked/editor/archives/SetArchiveDataCommand.go b/modding/hacked/editor/archives/SetArchiveDataCommand.go new file mode 100644 index 0000000..e4d7872 --- /dev/null +++ b/modding/hacked/editor/archives/SetArchiveDataCommand.go @@ -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 +} diff --git a/modding/hacked/editor/archives/View.go b/modding/hacked/editor/archives/View.go new file mode 100644 index 0000000..3b29a30 --- /dev/null +++ b/modding/hacked/editor/archives/View.go @@ -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", "") +} diff --git a/modding/hacked/editor/archives/ViewModel.go b/modding/hacked/editor/archives/ViewModel.go new file mode 100644 index 0000000..5f9ce07 --- /dev/null +++ b/modding/hacked/editor/archives/ViewModel.go @@ -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{} +} diff --git a/modding/hacked/editor/bitmaps/SetBitmapCommand.go b/modding/hacked/editor/bitmaps/SetBitmapCommand.go new file mode 100644 index 0000000..7e2b309 --- /dev/null +++ b/modding/hacked/editor/bitmaps/SetBitmapCommand.go @@ -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 +} diff --git a/modding/hacked/editor/bitmaps/View.go b/modding/hacked/editor/bitmaps/View.go new file mode 100644 index 0000000..26d4017 --- /dev/null +++ b/modding/hacked/editor/bitmaps/View.go @@ -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) +} diff --git a/modding/hacked/editor/bitmaps/ViewModel.go b/modding/hacked/editor/bitmaps/ViewModel.go new file mode 100644 index 0000000..0e9b35a --- /dev/null +++ b/modding/hacked/editor/bitmaps/ViewModel.go @@ -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), + } +} diff --git a/modding/hacked/editor/doc.go b/modding/hacked/editor/doc.go new file mode 100644 index 0000000..9440b37 --- /dev/null +++ b/modding/hacked/editor/doc.go @@ -0,0 +1,4 @@ +/* +Package editor and its sub-packages contain the editor-specifics of hacked. +*/ +package editor diff --git a/modding/hacked/editor/external/Clipboard.go b/modding/hacked/editor/external/Clipboard.go new file mode 100644 index 0000000..2438a25 --- /dev/null +++ b/modding/hacked/editor/external/Clipboard.go @@ -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) +} diff --git a/modding/hacked/editor/external/Export.go b/modding/hacked/editor/external/Export.go new file mode 100644 index 0000000..6bb4aa8 --- /dev/null +++ b/modding/hacked/editor/external/Export.go @@ -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) +} diff --git a/modding/hacked/editor/external/ExportStartState.go b/modding/hacked/editor/external/ExportStartState.go new file mode 100644 index 0000000..b287c33 --- /dev/null +++ b/modding/hacked/editor/external/ExportStartState.go @@ -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) { +} diff --git a/modding/hacked/editor/external/ExportWaitingState.go b/modding/hacked/editor/external/ExportWaitingState.go new file mode 100644 index 0000000..eec3aa1 --- /dev/null +++ b/modding/hacked/editor/external/ExportWaitingState.go @@ -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 +} diff --git a/modding/hacked/editor/external/FileDialog.go b/modding/hacked/editor/external/FileDialog.go new file mode 100644 index 0000000..6fbe589 --- /dev/null +++ b/modding/hacked/editor/external/FileDialog.go @@ -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) + } +} diff --git a/modding/hacked/editor/external/Import.go b/modding/hacked/editor/external/Import.go new file mode 100644 index 0000000..8518366 --- /dev/null +++ b/modding/hacked/editor/external/Import.go @@ -0,0 +1,117 @@ +package external + +import ( + "image" + "image/color" + "math" + "os" + + "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" +) + +// Import starts an import dialog series, calling the given callback with a file name. +func Import(machine gui.ModalStateMachine, info string, types []TypeInfo, callback func(string), lastFailed bool) { + machine.SetState(&importStartState{ + machine: machine, + callback: callback, + info: info, + typeInfo: types, + withError: lastFailed, + }) +} + +// ImportAudio is a helper to handle audio file import. The callback is called with the loaded audio. +func ImportAudio(machine gui.ModalStateMachine, callback func(l8 audio.L8)) { + info := "File must be a WAV file, 22050 Hz, 8-bit or 16-bit, uncompressed." + types := []TypeInfo{{Title: "Audio files (*.wav)", Extensions: []string{"wav"}}} + var fileHandler func(string) + + fileHandler = func(filename string) { + reader, err := os.Open(filename) + if err != nil { + Import(machine, info, types, fileHandler, true) + return + } + defer func() { _ = reader.Close() }() + sound, err := wav.Load(reader) + if err != nil { + Import(machine, info, types, fileHandler, true) + return + } + callback(sound) + } + + Import(machine, info, types, fileHandler, false) +} + +// ImportImage is a helper to handle image file import. The callback is called with the loaded image. +func ImportImage(machine gui.ModalStateMachine, paletteRetriever func() (bitmap.Palette, error), callback func(bitmap.Bitmap)) { + info := "File should be either a BMP, GIF, or a PNG file.\nPaletted images matching game palette are taken 1:1,\nothers are mapped closest fitting." + types := []TypeInfo{{Title: "Image files (*.bmp, *.gif, *.png)", Extensions: []string{"bmp", "gif", "png"}}} + var fileHandler func(string) + + fileHandler = func(filename string) { + reader, err := os.Open(filename) + if err != nil { + Import(machine, "Could not open file.\n"+info, types, fileHandler, true) + return + } + defer func() { _ = reader.Close() }() + img, _, err := image.Decode(reader) + if err != nil { + Import(machine, "File not recognized as image.\n"+info, types, fileHandler, true) + return + } + + var bmp bitmap.Bitmap + importMapped := true + rawPalette, err := paletteRetriever() + if err != nil { + Import(machine, "Can not import image without having a palette loaded.\n"+info, types, fileHandler, true) + return + } + if palettedImg, isPaletted := img.(image.PalettedImage); isPaletted { + imgPalette, hasPalette := palettedImg.ColorModel().(color.Palette) + if hasPalette && paletteMatches(imgPalette, rawPalette.ColorPalette(false)) { + bounds := img.Bounds() + + bmp.Header.Width = int16(math.Max(0, math.Min(float64(bounds.Dx()), math.MaxInt16))) + bmp.Header.Height = int16(math.Max(0, math.Min(float64(bounds.Dy()), math.MaxInt16))) + bmp.Pixels = make([]byte, int(bmp.Header.Width)*int(bmp.Header.Height)) + for row := 0; row < int(bmp.Header.Height); row++ { + for column := 0; column < int(bmp.Header.Width); column++ { + bmp.Pixels[row*int(bmp.Header.Width)+column] = palettedImg.ColorIndexAt(column, row) + } + } + importMapped = false + } + } + if importMapped { + bitmapper := bitmap.NewBitmapper(&rawPalette) + bmp = bitmapper.Map(img) + } + callback(bmp) + } + + Import(machine, info, types, fileHandler, false) +} + +func paletteMatches(imgPalette color.Palette, rawPalette color.Palette) bool { + if len(imgPalette) > len(rawPalette) { + return false + } + + for index, clr := range imgPalette { + imgR, imgG, imgB, _ := clr.RGBA() + rawR, rawG, rawB, _ := rawPalette[index].RGBA() + + if (imgR != rawR) || (imgG != rawG) || (imgB != rawB) { + return false + } + } + + return true +} diff --git a/modding/hacked/editor/external/ImportStartState.go b/modding/hacked/editor/external/ImportStartState.go new file mode 100644 index 0000000..e55a180 --- /dev/null +++ b/modding/hacked/editor/external/ImportStartState.go @@ -0,0 +1,34 @@ +package external + +import ( + "time" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type importStartState struct { + machine gui.ModalStateMachine + callback func(string) + info string + typeInfo []TypeInfo + withError bool +} + +func (state importStartState) Render() { + imgui.OpenPopup("Import") + nextState := &importWaitingState{ + machine: state.machine, + callback: state.callback, + info: state.info, + typeInfo: state.typeInfo, + } + if state.withError { + nextState.failureTime = time.Now() + } + state.machine.SetState(nextState) +} + +func (state importStartState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/external/ImportWaitingState.go b/modding/hacked/editor/external/ImportWaitingState.go new file mode 100644 index 0000000..ef5d149 --- /dev/null +++ b/modding/hacked/editor/external/ImportWaitingState.go @@ -0,0 +1,83 @@ +package external + +import ( + "os" + "time" + + "github.com/inkyblackness/imgui-go/v3" + "github.com/sqweek/dialog" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type importWaitingState struct { + machine gui.ModalStateMachine + callback func(string) + info string + typeInfo []TypeInfo + + failureTime time.Time +} + +func (state *importWaitingState) Render() { + if imgui.BeginPopupModalV("Import", nil, + imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + imgui.Text("Waiting for file.") + 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 load 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 file +that shall be loaded into the editor window. +`) + imgui.Text(state.info) + imgui.Separator() + if imgui.Button("Browse...") { + dlgBuilder := dialog.File() + for _, info := range state.typeInfo { + dlgBuilder = dlgBuilder.Filter(info.Title, info.Extensions...) + } + dlgBuilder = dlgBuilder.Filter("All files (*.*)", "*") + filename, err := dlgBuilder.Load() + 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 *importWaitingState) HandleFiles(names []string) { + filename, ok := state.verifyFile(names) + if ok { + state.machine.SetState(nil) + state.callback(filename) + } else { + state.failureTime = time.Now() + } +} + +func (state importWaitingState) verifyFile(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 +} diff --git a/modding/hacked/editor/external/TypeInfo.go b/modding/hacked/editor/external/TypeInfo.go new file mode 100644 index 0000000..b47e85f --- /dev/null +++ b/modding/hacked/editor/external/TypeInfo.go @@ -0,0 +1,7 @@ +package external + +// TypeInfo describes a file. +type TypeInfo struct { + Title string + Extensions []string +} diff --git a/modding/hacked/editor/graphics/BitmapTexture.go b/modding/hacked/editor/graphics/BitmapTexture.go new file mode 100644 index 0000000..1556af6 --- /dev/null +++ b/modding/hacked/editor/graphics/BitmapTexture.go @@ -0,0 +1,91 @@ +package graphics + +import "github.com/inkyblackness/hacked/ui/opengl" + +// BitmapTexture wraps an OpenGL handle for a downloaded image. +type BitmapTexture struct { + gl opengl.OpenGL + handle uint32 + + width, height float32 + u, v float32 + + pixelData []byte +} + +func powerOfTwo(value int) int { + result := 2 + + for (result < value) && (result < 0x1000) { + result *= 2 + } + + return result +} + +// NewBitmapTexture downloads the provided raw data to OpenGL and returns a BitmapTexture instance. +func NewBitmapTexture(gl opengl.OpenGL, width, height int, pixelData []byte) *BitmapTexture { + textureWidth := powerOfTwo(width) + textureHeight := powerOfTwo(height) + tex := &BitmapTexture{ + gl: gl, + handle: gl.GenTextures(1)[0], + + width: float32(width), + height: float32(height), + + pixelData: pixelData, + } + tex.u = tex.width / float32(textureWidth) + tex.v = tex.height / float32(textureHeight) + + paddedData := make([]byte, textureWidth*textureHeight) + for y := 0; y < height; y++ { + inStart := y * width + outOffset := y * textureWidth + for x := 0; x < width; x++ { + value := pixelData[inStart+x] + paddedData[outOffset] = value + outOffset++ + } + } + + gl.BindTexture(opengl.TEXTURE_2D, tex.handle) + gl.TexImage2D(opengl.TEXTURE_2D, 0, opengl.RED, int32(textureWidth), int32(textureHeight), + 0, opengl.RED, opengl.UNSIGNED_BYTE, paddedData) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MAG_FILTER, opengl.NEAREST) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MIN_FILTER, opengl.NEAREST) + gl.GenerateMipmap(opengl.TEXTURE_2D) + gl.BindTexture(opengl.TEXTURE_2D, 0) + + return tex +} + +// Dispose releases the OpenGL texture. +func (tex *BitmapTexture) Dispose() { + if tex.handle != 0 { + tex.gl.DeleteTextures([]uint32{tex.handle}) + tex.handle = 0 + } +} + +// Handle returns the texture handle. +func (tex *BitmapTexture) Handle() uint32 { + return tex.handle +} + +// PixelData returns the original paletted pixel data. +func (tex *BitmapTexture) PixelData() []byte { + return tex.pixelData +} + +// Size returns the dimensions of the bitmap, in pixels. +func (tex *BitmapTexture) Size() (width, height float32) { + return tex.width, tex.height +} + +// UV returns the maximum U and V values for the bitmap. The bitmap will be +// stored in a power-of-two texture, which may be larger than the bitmap. +func (tex *BitmapTexture) UV() (u, v float32) { + return tex.u, tex.v +} diff --git a/modding/hacked/editor/graphics/FrameCache.go b/modding/hacked/editor/graphics/FrameCache.go new file mode 100644 index 0000000..0b10479 --- /dev/null +++ b/modding/hacked/editor/graphics/FrameCache.go @@ -0,0 +1,76 @@ +package graphics + +import ( + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ui/opengl" +) + +// FrameCacheKey is used to identify a cached frame. +type FrameCacheKey uint16 + +// FrameCache loads bitmaps with their palettes and provides OpenGL textures. +type FrameCache struct { + gl opengl.OpenGL + + textures map[FrameCacheKey]*BitmapTexture + palettes map[FrameCacheKey]*PaletteTexture + + keyCounter uint16 +} + +// NewFrameCache returns a new instance. +func NewFrameCache(gl opengl.OpenGL) *FrameCache { + cache := &FrameCache{ + gl: gl, + textures: make(map[FrameCacheKey]*BitmapTexture), + palettes: make(map[FrameCacheKey]*PaletteTexture), + } + return cache +} + +// AllocateKey returns a new key to be used with uploading a new frame. +func (cache *FrameCache) AllocateKey() FrameCacheKey { + cache.keyCounter++ + return FrameCacheKey(cache.keyCounter) +} + +// SetTexture registers a texture based on given bitmap under given key. +// The bitmap should contain a palette, otherwise it will most likely not be displayed. +func (cache *FrameCache) SetTexture(key FrameCacheKey, width, height uint16, pixels []byte, palette *bitmap.Palette) { + cache.DropTextureForKey(key) + tex := NewBitmapTexture(cache.gl, int(width), int(height), pixels) + cache.textures[key] = tex + if palette == nil { + return + } + pal := NewPaletteTexture(cache.gl, palette) + cache.palettes[key] = pal +} + +// DropTextureForKey removes the currently cached frame. +func (cache *FrameCache) DropTextureForKey(key FrameCacheKey) { + if tex, existing := cache.textures[key]; existing { + tex.Dispose() + delete(cache.textures, key) + } + if pal, existing := cache.palettes[key]; existing { + pal.Dispose() + delete(cache.palettes, key) + } +} + +// HandlesForKey returns the OpenGL handles for both palette and texture for given key. +func (cache FrameCache) HandlesForKey(key FrameCacheKey) (palette uint32, texture uint32) { + if tex, existing := cache.textures[key]; existing { + texture = tex.Handle() + } + if pal, existing := cache.palettes[key]; existing { + palette = pal.Handle() + } + return +} + +// Texture returns the buffered texture for given key. +func (cache *FrameCache) Texture(key FrameCacheKey) *BitmapTexture { + return cache.textures[key] +} diff --git a/modding/hacked/editor/graphics/PaletteCache.go b/modding/hacked/editor/graphics/PaletteCache.go new file mode 100644 index 0000000..14441b0 --- /dev/null +++ b/modding/hacked/editor/graphics/PaletteCache.go @@ -0,0 +1,71 @@ +package graphics + +import ( + "encoding/binary" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/opengl" +) + +// PaletteCache loads palettes and provides OpenGL textures. +type PaletteCache struct { + gl opengl.OpenGL + localizer resource.Localizer + + palettes map[resource.Key]*PaletteTexture +} + +// NewPaletteCache returns a new instance. +func NewPaletteCache(gl opengl.OpenGL, localizer resource.Localizer) *PaletteCache { + cache := &PaletteCache{ + gl: gl, + localizer: localizer, + palettes: make(map[resource.Key]*PaletteTexture), + } + return cache +} + +// InvalidateResources lets the cache remove any palette from resources that are specified in the given slice. +func (cache *PaletteCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key, texture := range cache.palettes { + if key.ID == id { + texture.Dispose() + delete(cache.palettes, key) + } + } + } +} + +// Palette returns the palette with given index - if available. +func (cache *PaletteCache) Palette(index int) (*PaletteTexture, error) { + key := resource.KeyOf(ids.GamePalettesStart.Plus(index), resource.LangAny, 0) + pal, existing := cache.palettes[key] + if existing { + return pal, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID) + if err != nil { + return nil, err + } + if view.ContentType() != resource.Palette { + return nil, resource.ErrWrongType(key, resource.Palette) + } + reader, err := view.Block(key.Index) + if err != nil { + return nil, err + } + var palette bitmap.Palette + err = binary.Read(reader, binary.LittleEndian, &palette) + if err != nil { + return nil, err + } + + pal = NewPaletteTexture(cache.gl, &palette) + cache.palettes[key] = pal + + return pal, nil +} diff --git a/modding/hacked/editor/graphics/PaletteTexture.go b/modding/hacked/editor/graphics/PaletteTexture.go new file mode 100644 index 0000000..29b10c5 --- /dev/null +++ b/modding/hacked/editor/graphics/PaletteTexture.go @@ -0,0 +1,70 @@ +package graphics + +import ( + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ui/opengl" +) + +// PaletteTexture contains a palette stored as OpenGL texture. +type PaletteTexture struct { + gl opengl.OpenGL + + handle uint32 + palette bitmap.Palette +} + +// NewPaletteTexture creates a new PaletteTexture instance. +func NewPaletteTexture(gl opengl.OpenGL, palette *bitmap.Palette) *PaletteTexture { + tex := &PaletteTexture{ + gl: gl, + handle: gl.GenTextures(1)[0], + } + + tex.Update(palette) + + return tex +} + +// Dispose releases the OpenGL texture. +func (tex *PaletteTexture) Dispose() { + if tex.handle != 0 { + tex.gl.DeleteTextures([]uint32{tex.handle}) + tex.handle = 0 + } +} + +// Palette returns the original palette. +func (tex *PaletteTexture) Palette() bitmap.Palette { + return tex.palette +} + +// Handle returns the texture handle. +func (tex *PaletteTexture) Handle() uint32 { + return tex.handle +} + +// Update reloads the palette. +func (tex *PaletteTexture) Update(palette *bitmap.Palette) { + gl := tex.gl + const bytesPerRGBA = 4 + const colors = 256 + var data [colors * bytesPerRGBA]byte + + for i := 0; i < colors; i++ { + entry := palette[i] + data[i*bytesPerRGBA+0] = entry.Red + data[i*bytesPerRGBA+1] = entry.Green + data[i*bytesPerRGBA+2] = entry.Blue + data[i*bytesPerRGBA+3] = 0xFF + } + data[3] = 0x00 + + gl.BindTexture(opengl.TEXTURE_2D, tex.handle) + gl.TexImage2D(opengl.TEXTURE_2D, 0, opengl.RGBA, int32(colors), 1, 0, opengl.RGBA, opengl.UNSIGNED_BYTE, data[:]) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MAG_FILTER, opengl.NEAREST) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MIN_FILTER, opengl.NEAREST) + gl.GenerateMipmap(opengl.TEXTURE_2D) + gl.BindTexture(opengl.TEXTURE_2D, 0) + + tex.palette = *palette +} diff --git a/modding/hacked/editor/graphics/TextureCache.go b/modding/hacked/editor/graphics/TextureCache.go new file mode 100644 index 0000000..04c516d --- /dev/null +++ b/modding/hacked/editor/graphics/TextureCache.go @@ -0,0 +1,94 @@ +package graphics + +import ( + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ui/opengl" +) + +const ( + errReferenceNotFound ss1.StringError = "reference not existing" + errReferenceWrongDimensions ss1.StringError = "reference has wrong dimensions" +) + +// TextureCache loads bitmaps and provides OpenGL textures. +type TextureCache struct { + gl opengl.OpenGL + localizer resource.Localizer + + textures map[resource.Key]*BitmapTexture +} + +// NewTextureCache returns a new instance. +func NewTextureCache(gl opengl.OpenGL, localizer resource.Localizer) *TextureCache { + cache := &TextureCache{ + gl: gl, + localizer: localizer, + textures: make(map[resource.Key]*BitmapTexture), + } + return cache +} + +// InvalidateResources lets the cache remove any textures from resources that are specified in the given slice. +func (cache *TextureCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key, texture := range cache.textures { + if key.ID == id { + texture.Dispose() + delete(cache.textures, key) + } + } + } +} + +// Texture returns the texture with given key - if available. +func (cache *TextureCache) Texture(key resource.Key) (*BitmapTexture, error) { + return cache.TextureReferenced(key, nil) +} + +// TextureReferenced returns the texture with given key - if available. +// Should the underlying bitmap have to be loaded, then the given reference is taken as a basis for compressed bitmaps. +// If reference is nil, then no reference is used. +func (cache *TextureCache) TextureReferenced(key resource.Key, reference *resource.Key) (*BitmapTexture, error) { + tex, existing := cache.textures[key] + if existing { + return tex, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID) + if err != nil { + return nil, err + } + if view.ContentType() != resource.Bitmap { + return nil, resource.ErrWrongType(key, resource.Bitmap) + } + reader, err := view.Block(key.Index) + if err != nil { + return nil, err + } + bmp, err := bitmap.DecodeReferenced(reader, func(width, height int16) ([]byte, error) { + buf := make([]byte, int(width)*int(height)) + if reference == nil { + return buf, nil + } + refTex, refExisting := cache.textures[*reference] + if !refExisting { + return nil, errReferenceNotFound + } + refWidth, refHeight := refTex.Size() + if (int16(refWidth) != width) || (int16(refHeight) != height) { + return nil, errReferenceWrongDimensions + } + copy(buf, refTex.PixelData()) + return buf, nil + }) + if err != nil { + return nil, err + } + + tex = NewBitmapTexture(cache.gl, int(bmp.Header.Width), int(bmp.Header.Height), bmp.Pixels) + cache.textures[key] = tex + + return tex, nil +} diff --git a/modding/hacked/editor/levels/BackgroundGrid.go b/modding/hacked/editor/levels/BackgroundGrid.go new file mode 100644 index 0000000..5ec73ce --- /dev/null +++ b/modding/hacked/editor/levels/BackgroundGrid.go @@ -0,0 +1,162 @@ +package levels + +import ( + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var gridVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec3 vertexPosition; + +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; + +out vec4 gridColor; +out vec3 originalPosition; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * vec4(vertexPosition, 1.0); + + gridColor = vec4(0.0, 0.1, 0.0, 0.6); + originalPosition = vertexPosition; +} +` + +var gridFragmentShaderSource = ` +#version 150 +precision mediump float; + +uniform vec4 gridSize; + +in vec4 gridColor; +in vec3 originalPosition; + +out vec4 fragColor; + +float modulo(float x, float y) { + return x - y * floor(x/y); +} + +float nearGrid(float stepSize, float value) { + float remainder = modulo(value - (stepSize / 2.0), stepSize) * 2.0; + + if (remainder >= stepSize) { + remainder = (stepSize * 2.0) - remainder; + } + + return remainder / stepSize; +} + +void main(void) { + float alphaX = nearGrid(256.0, originalPosition.x); + float alphaY = nearGrid(256.0, originalPosition.y); + bool beyondX = (originalPosition.x / 256.0) >= gridSize.x || (originalPosition.x < 0.0); + bool beyondY = (originalPosition.y / 256.0) >= gridSize.y || (originalPosition.y < 0.0); + float alpha = 0.0; + + if (!beyondX && !beyondY) { + alpha = max(alphaX, alphaY); + } else if (beyondX && !beyondY) { + alpha = alphaX; + } else if (beyondY && !beyondX) { + alpha = alphaY; + } else { + alpha = min(alphaX, alphaY); + } + + alpha = pow(2.0, 10.0 * (alpha - 1.0)); + + fragColor = vec4(gridColor.rgb, gridColor.a * alpha); +} +` + +// BackgroundGrid renders a grid with transparent holes. +type BackgroundGrid struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + gridSizeUniform opengl.Vector4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + + lastReportedColumns int + lastReportedRows int +} + +// NewBackgroundGrid returns a new instance of BackgroundGrid. +func NewBackgroundGrid(context *render.Context) *BackgroundGrid { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, gridVertexShaderSource, gridFragmentShaderSource) + if programErr != nil { + panic(opengl.NamedShaderError{Name: "BackgroundGridShader", Nested: programErr}) + } + grid := &BackgroundGrid{ + context: context, + program: program, + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + gridSizeUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "gridSize")), + } + + return grid +} + +// Render renders the grid. +func (grid *BackgroundGrid) Render(columns, rows int) { + gl := grid.context.OpenGL + + grid.setGridSize(columns, rows) + + grid.vao.OnShader(func() { + grid.viewMatrixUniform.Set(gl, grid.context.ViewMatrix) + grid.projectionMatrixUniform.Set(gl, &grid.context.ProjectionMatrix) + grid.gridSizeUniform.Set(gl, &[4]float32{float32(columns), float32(rows), 0, 0}) + + gl.DrawArrays(opengl.TRIANGLES, 0, 6) + }) +} + +func (grid *BackgroundGrid) setGridSize(columns, rows int) { + if (grid.lastReportedColumns == columns) && (grid.lastReportedRows == rows) { + return + } + grid.lastReportedColumns = columns + grid.lastReportedRows = rows + + gl := grid.context.OpenGL + { + hTiles := float32(columns) + vTiles := float32(rows) + + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.vertexPositionBuffer) + fineHalf := level.FineCoordinatesPerTileSide / float32(2.0) + hLimit := level.FineCoordinatesPerTileSide*hTiles + fineHalf + vLimit := level.FineCoordinatesPerTileSide*vTiles + fineHalf + var vertices = []float32{ + -fineHalf, -fineHalf, 0.0, + hLimit, -fineHalf, 0.0, + hLimit, vLimit, 0.0, + + hLimit, vLimit, 0.0, + -fineHalf, vLimit, 0.0, + -fineHalf, -fineHalf, 0.0} + gl.BufferData(opengl.ARRAY_BUFFER, len(vertices)*4, vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + grid.vao.WithSetter(func(gl opengl.OpenGL) { + gl.EnableVertexAttribArray(uint32(grid.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(grid.vertexPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) +} diff --git a/modding/hacked/editor/levels/ColorDisplay.go b/modding/hacked/editor/levels/ColorDisplay.go new file mode 100644 index 0000000..cf73f38 --- /dev/null +++ b/modding/hacked/editor/levels/ColorDisplay.go @@ -0,0 +1,34 @@ +package levels + +import ( + "fmt" +) + +// ColorDisplay is an enumeration to how a tile should be colored. +type ColorDisplay int + +// ColorDisplay constants are listed below. +const ( + ColorDisplayNone ColorDisplay = 0 + ColorDisplayFloor ColorDisplay = 1 + ColorDisplayCeiling ColorDisplay = 2 +) + +// String returns a textual representation. +func (display ColorDisplay) String() string { + switch display { + case ColorDisplayNone: + return "None" + case ColorDisplayFloor: + return "Floor" + case ColorDisplayCeiling: + return "Ceiling" + default: + return fmt.Sprintf("Unknown%d", int(display)) + } +} + +// ColorDisplays returns all ColorDisplay constants. +func ColorDisplays() []ColorDisplay { + return []ColorDisplay{ColorDisplayNone, ColorDisplayFloor, ColorDisplayCeiling} +} diff --git a/modding/hacked/editor/levels/Constants.go b/modding/hacked/editor/levels/Constants.go new file mode 100644 index 0000000..d5edc23 --- /dev/null +++ b/modding/hacked/editor/levels/Constants.go @@ -0,0 +1,6 @@ +package levels + +const ( + hintUnknown = "???" + hintReadOnly = " (read-only)" +) diff --git a/modding/hacked/editor/levels/ControlView.go b/modding/hacked/editor/levels/ControlView.go new file mode 100644 index 0000000..2077396 --- /dev/null +++ b/modding/hacked/editor/levels/ControlView.go @@ -0,0 +1,409 @@ +package levels + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +// ControlView is the core view for level editing. +type ControlView struct { + levels *edit.EditableLevels + levelSelection *edit.LevelSelectionService + editor *edit.LevelEditorService + + guiScale float32 + registry cmd.Registry + + textCache *text.Cache + textureCache *graphics.TextureCache + + model controlViewModel +} + +// NewControlView returns a new instance. +func NewControlView(levels *edit.EditableLevels, levelSelection *edit.LevelSelectionService, editor *edit.LevelEditorService, + guiScale float32, textCache *text.Cache, textureCache *graphics.TextureCache, + registry cmd.Registry) *ControlView { + view := &ControlView{ + levels: levels, + levelSelection: levelSelection, + editor: editor, + + guiScale: guiScale, + registry: registry, + textCache: textCache, + textureCache: textureCache, + model: freshControlViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *ControlView) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *ControlView) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 400 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + title := "Level Control" + readOnly := view.editor.IsReadOnly() + if readOnly { + title += hintReadOnly + } + if imgui.BeginV(title+"###Level Control", view.WindowOpen(), imgui.WindowFlagsNoCollapse) { + view.renderContent(view.editor.Level(), readOnly) + } + imgui.End() + } +} + +var levelHeights = []string{ + "32 Tiles", + "16 Tiles", + "8 Tiles", + "4 Tiles", + "2 Tiles", + "1 Tile", + "1/2 Tile", + "1/4 Tile", +} + +func (view *ControlView) renderContent(lvl *level.Level, readOnly bool) { + imgui.PushItemWidth(-200 * view.guiScale) + selectedLevel := view.levelSelection.CurrentLevelID() + if gui.StepSliderInt("Active Level", &selectedLevel, 0, archive.MaxLevels-1) { + view.levelSelection.SetCurrentLevelID(selectedLevel) + } + imgui.Separator() + levelType := "Real World" + if lvl.IsCyberspace() { + levelType = "Cyberspace" + } + imgui.LabelText("Type", levelType) + view.renderLevelHeight(lvl, readOnly) + + if !lvl.IsCyberspace() { + view.renderTextureAtlas(lvl, readOnly) + view.renderSurveillanceObjects(lvl, readOnly) + view.renderHazards(lvl, readOnly) + view.renderTextureAnimations(lvl, readOnly) + } + + imgui.PopItemWidth() +} + +func (view *ControlView) renderLevelHeight(lvl *level.Level, readOnly bool) { + _, _, currentShift := lvl.Size() + if readOnly { + imgui.LabelText("Level Height", levelHeights[currentShift]) + } else if imgui.BeginCombo("Level Height", levelHeights[currentShift]) { + for shift, height := range levelHeights { + if imgui.SelectableV(height, shift == int(currentShift), 0, imgui.Vec2{}) { + view.requestSetZShift(lvl, shift) + } + } + imgui.EndCombo() + } +} + +func (view *ControlView) textureName(index int) string { + key := resource.KeyOf(ids.TextureNames, resource.LangDefault, index) + name, err := view.textCache.Text(key) + suffix := "" + if err == nil { + suffix = ": " + name + } + return fmt.Sprintf("%3d", index) + suffix +} + +func (view *ControlView) renderTextureAtlas(lvl *level.Level, readOnly bool) { + imgui.Separator() + + atlas := lvl.TextureAtlas() + { + render.TextureSelector("Level Textures", -200, view.guiScale, + len(atlas), view.model.selectedAtlasIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.LargeTextures.Plus(int(atlas[index])), resource.LangAny, 0) + }, + func(index int) string { + textureType := "(F/C/W)" + if index >= level.FloorCeilingTextureLimit { + textureType = "(walls only)" + } + return fmt.Sprintf("Atlas Index %2d ", index) + textureType + "\n" + view.textureName(int(atlas[index])) + }, func(newIndex int) { + view.model.selectedAtlasIndex = newIndex + }) + imgui.SameLine() + imgui.Text("Level Textures") + } + gameTextureIndex := -1 + if (view.model.selectedAtlasIndex >= 0) && (view.model.selectedAtlasIndex < len(atlas)) { + gameTextureIndex = int(atlas[view.model.selectedAtlasIndex]) + } + if !readOnly { + render.TextureSelector("Game Textures", -200, view.guiScale, + world.MaxWorldTextures, gameTextureIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.LargeTextures.Plus(index), resource.LangAny, 0) + }, + view.textureName, + func(newIndex int) { + view.requestSetLevelTexture(lvl, view.model.selectedAtlasIndex, newIndex) + }) + imgui.SameLine() + imgui.Text("Game Textures") + } +} + +func (view *ControlView) renderSurveillanceObjects(lvl *level.Level, readOnly bool) { + imgui.Separator() + + if imgui.BeginCombo("Surveillance Object", fmt.Sprintf("Object %d", view.model.selectedSurveillanceObjectIndex)) { + for i := 0; i < level.SurveillanceObjectCount; i++ { + if imgui.SelectableV(fmt.Sprintf("Object %d", i), i == view.model.selectedSurveillanceObjectIndex, 0, imgui.Vec2{}) { + view.model.selectedSurveillanceObjectIndex = i + } + } + imgui.EndCombo() + } + sources := lvl.SurveillanceSources() + surrogates := lvl.SurveillanceSurrogates() + capacity := lvl.ObjectCapacity() + + view.renderSliderInt(readOnly, "Surveillance Source", int(sources[view.model.selectedSurveillanceObjectIndex]), + func(int) string { return "%d" }, + 0, capacity, + func(newValue int) { + view.requestSetSurveillanceSource(lvl, view.model.selectedSurveillanceObjectIndex, level.ObjectID(newValue)) + }) + view.renderSliderInt(readOnly, "Surveillance Surrogate", int(surrogates[view.model.selectedSurveillanceObjectIndex]), + func(int) string { return "%d" }, + 0, capacity, + func(newValue int) { + view.requestSetSurveillanceSurrogate(lvl, view.model.selectedSurveillanceObjectIndex, level.ObjectID(newValue)) + }) +} + +func (view *ControlView) renderHazards(lvl *level.Level, readOnly bool) { + imgui.Separator() + + parameters := lvl.Parameters() + currentCeiling := currentCeilingHazard(parameters) + if readOnly { + imgui.LabelText("Ceiling Hazard", currentCeiling.title) + } else if imgui.BeginCombo("Ceiling Hazard", currentCeiling.title) { + for _, info := range ceilingHazards { + if imgui.SelectableV(info.title, info.title == currentCeiling.title, 0, imgui.Vec2{}) { + view.requestSetCeilingHazard(lvl, info) + } + } + imgui.EndCombo() + } + view.renderSliderInt(readOnly, "Ceiling Hazard Level", int(parameters.CeilingHazardLevel), + currentCeiling.formatter, 0, 255, + func(newValue int) { + view.requestSetCeilingHazardLevel(lvl, byte(newValue)) + }) + + currentFloor := currentFloorHazard(parameters) + if readOnly { + imgui.LabelText("Floor Hazard", currentFloor.title) + } else if imgui.BeginCombo("Floor Hazard", currentFloor.title) { + for _, info := range floorHazards { + if imgui.SelectableV(info.title, info.title == currentFloor.title, 0, imgui.Vec2{}) { + view.requestSetFloorHazard(lvl, info) + } + } + imgui.EndCombo() + } + view.renderSliderInt(readOnly, "Floor Hazard Level", int(parameters.FloorHazardLevel), + currentFloor.formatter, 0, 255, + func(newValue int) { + view.requestSetFloorHazardLevel(lvl, byte(newValue)) + }) +} + +func (view *ControlView) renderTextureAnimations(lvl *level.Level, readOnly bool) { + imgui.Separator() + + animations := lvl.TextureAnimations() + selectedText := "" + if (view.model.selectedTextureAnimationIndex >= 1) && (view.model.selectedTextureAnimationIndex < len(animations)) { + selectedText = fmt.Sprintf("%d", view.model.selectedTextureAnimationIndex) + } + if imgui.BeginCombo("Texture Animation Group", selectedText) { + for i := 1; i < len(animations); i++ { + if imgui.SelectableV(fmt.Sprintf("%2d", i), view.model.selectedTextureAnimationIndex == i, 0, imgui.Vec2{}) { + view.model.selectedTextureAnimationIndex = i + } + } + imgui.EndCombo() + } + + if (view.model.selectedTextureAnimationIndex >= 1) && (view.model.selectedTextureAnimationIndex < len(animations)) { + animation := animations[view.model.selectedTextureAnimationIndex] + view.renderSliderInt(readOnly, "Texture Animation Time", int(animation.FrameTime), + func(int) string { return "%d msec" }, + 0, 1000, + func(newValue int) { + view.requestSetTextureAnimationTime(lvl, view.model.selectedTextureAnimationIndex, uint16(newValue)) + }) + view.renderSliderInt(readOnly, "Texture Animation Frame Count", int(animation.FrameCount), + func(int) string { return "%d" }, + 0, 10, + func(newValue int) { + view.requestSetTextureAnimationFrameCount(lvl, view.model.selectedTextureAnimationIndex, byte(newValue)) + }) + if readOnly { + imgui.LabelText("Texture Animation Loop Type", animation.LoopType.String()) + } else if imgui.BeginCombo("Texture Animation Loop Type", animation.LoopType.String()) { + loopTypes := level.TextureAnimationLoopTypes() + for _, loopType := range loopTypes { + if imgui.SelectableV(loopType.String(), loopType == animation.LoopType, 0, imgui.Vec2{}) { + view.requestSetTextureAnimationType(lvl, view.model.selectedTextureAnimationIndex, loopType) + } + } + imgui.EndCombo() + } + } +} + +func (view *ControlView) renderSliderInt(readOnly bool, label string, selectedValue int, + formatter func(int) string, min, max int, changeHandler func(int)) { + selectedString := formatter(selectedValue) + labelValue := fmt.Sprintf(selectedString, selectedValue) + if readOnly { + imgui.LabelText(label, labelValue) + } else if gui.StepSliderIntV(label, &selectedValue, min, max, selectedString) { + changeHandler(selectedValue) + } +} + +func (view *ControlView) requestSetZShift(lvl *level.Level, newValue int) { + lvl.SetHeightShift(level.HeightShift(newValue)) + view.patchLevelResources(lvl, func() {}) +} + +func (view *ControlView) requestSetLevelTexture(lvl *level.Level, atlasIndex, worldTextureIndex int) { + lvl.SetTextureAtlasEntry(atlasIndex, level.TextureIndex(worldTextureIndex)) + view.patchLevelResources(lvl, func() { + view.model.selectedAtlasIndex = atlasIndex + }) +} + +func (view *ControlView) requestSetSurveillanceSource(lvl *level.Level, objectIndex int, objectID level.ObjectID) { + lvl.SetSurveillanceSource(objectIndex, objectID) + view.patchLevelResources(lvl, func() { + view.model.selectedSurveillanceObjectIndex = objectIndex + }) +} + +func (view *ControlView) requestSetSurveillanceSurrogate(lvl *level.Level, objectIndex int, objectID level.ObjectID) { + lvl.SetSurveillanceSurrogate(objectIndex, objectID) + view.patchLevelResources(lvl, func() { + view.model.selectedSurveillanceObjectIndex = objectIndex + }) +} + +func (view *ControlView) requestSetCeilingHazard(lvl *level.Level, info ceilingHazardInfo) { + parameters := lvl.Parameters() + parameters.RadiationRegister = 0 + if info.radiationRegister { + parameters.RadiationRegister = 2 + } + view.patchLevelResources(lvl, func() {}) +} + +func (view *ControlView) requestSetCeilingHazardLevel(lvl *level.Level, value byte) { + lvl.Parameters().CeilingHazardLevel = value + view.patchLevelResources(lvl, func() {}) +} + +func (view *ControlView) requestSetFloorHazard(lvl *level.Level, info floorHazardInfo) { + parameters := lvl.Parameters() + parameters.BiohazardRegister = 0 + parameters.FloorHazardIsGravity = 0 + if info.isGravity { + parameters.FloorHazardIsGravity = 1 + } else if info.biohazardRegister { + parameters.BiohazardRegister = 2 + } + view.patchLevelResources(lvl, func() {}) +} + +func (view *ControlView) requestSetFloorHazardLevel(lvl *level.Level, value byte) { + lvl.Parameters().FloorHazardLevel = value + view.patchLevelResources(lvl, func() {}) +} + +func (view *ControlView) requestSetTextureAnimationTime(lvl *level.Level, index int, value uint16) { + lvl.TextureAnimations()[index].FrameTime = value + view.patchLevelResources(lvl, func() { + view.model.selectedTextureAnimationIndex = index + }) +} + +func (view *ControlView) requestSetTextureAnimationFrameCount(lvl *level.Level, index int, value byte) { + lvl.TextureAnimations()[index].FrameCount = value + view.patchLevelResources(lvl, func() { + view.model.selectedTextureAnimationIndex = index + }) +} + +func (view *ControlView) requestSetTextureAnimationType(lvl *level.Level, index int, value level.TextureAnimationLoopType) { + lvl.TextureAnimations()[index].LoopType = value + view.patchLevelResources(lvl, func() { + view.model.selectedTextureAnimationIndex = index + }) +} + +func (view *ControlView) patchLevelResources(lvl *level.Level, extraRestoreState func()) { + oldLevelID := view.levelSelection.CurrentLevelID() + extraTask := func(world.Modder) error { + extraRestoreState() + return nil + } + + err := view.registry.Register(cmd.Named("PatchLevel"), + cmd.Forward(view.restoreFocusTask()), + cmd.Forward(view.levelSelection.SetCurrentLevelIDTask(lvl.ID())), + cmd.Forward(extraTask), + cmd.Nested(func() error { return view.levels.CommitLevelChanges(lvl.ID()) }), + cmd.Reverse(extraTask), + cmd.Reverse(view.levelSelection.SetCurrentLevelIDTask(oldLevelID)), + cmd.Reverse(view.restoreFocusTask())) + if err != nil { + panic(err) + } +} + +func (view *ControlView) restoreFocusTask() cmd.Task { + return func(modder world.Modder) error { + view.model.restoreFocus = true + return nil + } +} diff --git a/modding/hacked/editor/levels/ControlViewModel.go b/modding/hacked/editor/levels/ControlViewModel.go new file mode 100644 index 0000000..4773ecb --- /dev/null +++ b/modding/hacked/editor/levels/ControlViewModel.go @@ -0,0 +1,16 @@ +package levels + +type controlViewModel struct { + selectedAtlasIndex int + selectedSurveillanceObjectIndex int + selectedTextureAnimationIndex int + + restoreFocus bool + windowOpen bool +} + +func freshControlViewModel() controlViewModel { + return controlViewModel{ + selectedTextureAnimationIndex: 1, + } +} diff --git a/modding/hacked/editor/levels/Formatters.go b/modding/hacked/editor/levels/Formatters.go new file mode 100644 index 0000000..204c0a2 --- /dev/null +++ b/modding/hacked/editor/levels/Formatters.go @@ -0,0 +1,39 @@ +package levels + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +func tileHeightFormatterFor(levelHeight level.HeightShift) func(int) string { + return func(value int) string { + tileHeight, err := levelHeight.ValueFromTileHeight(level.TileHeightUnit(value)) + tileHeightString := hintUnknown + if err == nil { + tileHeightString = fmt.Sprintf("%2.3f", tileHeight) + } + return tileHeightString + " tile(s) - raw: %d" + } +} + +func objectHeightFormatterFor(levelHeight level.HeightShift) func(int) string { + return func(value int) string { + tileHeight, err := levelHeight.ValueFromObjectHeight(level.HeightUnit(value)) + tileHeightString := hintUnknown + if err == nil { + tileHeightString = fmt.Sprintf("%2.3f", tileHeight) + } + return tileHeightString + " tile(s) - raw: %d" + } +} + +func moveTileHeightFormatterFor(levelHeight level.HeightShift) func(int) string { + normalFormatter := tileHeightFormatterFor(levelHeight) + return func(value int) string { + if (value >= 0) && (value <= int(level.TileHeightUnitMax)) { + return normalFormatter(value) + } + return "Don't change - raw: %d" + } +} diff --git a/modding/hacked/editor/levels/Highlighter.go b/modding/hacked/editor/levels/Highlighter.go new file mode 100644 index 0000000..7be5f95 --- /dev/null +++ b/modding/hacked/editor/levels/Highlighter.go @@ -0,0 +1,124 @@ +package levels + +import ( + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var highlighterVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec3 vertexPosition; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0); +} +` + +var highlighterFragmentShaderSource = ` +#version 150 +precision mediump float; + +uniform vec4 inColor; +out vec4 fragColor; + +void main(void) { + fragColor = inColor; +} +` + +// Highlighter draws a simple highlighting of a rectangular area. +type Highlighter struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + inColorUniform opengl.Vector4Uniform +} + +// NewHighlighter returns a new instance of Highlighter. +func NewHighlighter(context *render.Context) *Highlighter { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, highlighterVertexShaderSource, highlighterFragmentShaderSource) + + if programErr != nil { + panic(opengl.NamedShaderError{Name: "HighlighterShader", Nested: programErr}) + } + highlighter := &Highlighter{ + context: context, + program: program, + + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + inColorUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "inColor"))} + + { + gl.BindBuffer(opengl.ARRAY_BUFFER, highlighter.vertexPositionBuffer) + half := float32(0.5) + var vertices = []float32{ + -half, -half, 0.0, + half, -half, 0.0, + half, half, 0.0, + + half, half, 0.0, + -half, half, 0.0, + -half, -half, 0.0} + gl.BufferData(opengl.ARRAY_BUFFER, len(vertices)*4, vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + + highlighter.vao.OnShader(func() { + gl.EnableVertexAttribArray(uint32(highlighter.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, highlighter.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(highlighter.vertexPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) + + return highlighter +} + +// Dispose releases all resources. +func (highlighter *Highlighter) Dispose() { + gl := highlighter.context.OpenGL + + highlighter.vao.Dispose() + gl.DeleteBuffers([]uint32{highlighter.vertexPositionBuffer}) + gl.DeleteShader(highlighter.program) +} + +// Render renders the highlights. +func (highlighter *Highlighter) Render(positions []MapPosition, sideLength float32, color [4]float32) { + gl := highlighter.context.OpenGL + + highlighter.vao.OnShader(func() { + highlighter.viewMatrixUniform.Set(gl, highlighter.context.ViewMatrix) + highlighter.projectionMatrixUniform.Set(gl, &highlighter.context.ProjectionMatrix) + highlighter.inColorUniform.Set(gl, &color) + + for _, pos := range positions { + modelMatrix := mgl.Ident4(). + Mul4(mgl.Translate3D(float32(pos.X), float32(pos.Y), 0.0)). + Mul4(mgl.Scale3D(sideLength, sideLength, 1.0)) + + highlighter.modelMatrixUniform.Set(gl, &modelMatrix) + + gl.DrawArrays(opengl.TRIANGLES, 0, 6) + } + }) +} diff --git a/modding/hacked/editor/levels/HoverItems.go b/modding/hacked/editor/levels/HoverItems.go new file mode 100644 index 0000000..f62132c --- /dev/null +++ b/modding/hacked/editor/levels/HoverItems.go @@ -0,0 +1,135 @@ +package levels + +import ( + "sort" + + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +type hoverItem interface { + Pos() MapPosition + Size() float32 + IsIn(lvl *level.Level) bool +} + +type tileHoverItem struct { + pos MapPosition +} + +func (item tileHoverItem) Pos() MapPosition { + return item.pos +} + +func (item tileHoverItem) Size() float32 { + return level.FineCoordinatesPerTileSide +} + +func (item tileHoverItem) IsIn(lvl *level.Level) bool { + return lvl.Tile(item.pos.Tile()) != nil +} + +type objectHoverItem struct { + id level.ObjectID + pos MapPosition +} + +func (item objectHoverItem) Pos() MapPosition { + return item.pos +} + +func (item objectHoverItem) Size() float32 { + return level.FineCoordinatesPerTileSide / 4 +} + +func (item objectHoverItem) IsIn(lvl *level.Level) bool { + obj := lvl.Object(item.id) + return (obj != nil) && (obj.InUse != 0) && (item.pos.X == obj.X) && (item.pos.Y == obj.Y) +} + +type hoverItems struct { + available []hoverItem + activeIndex int + activeItem hoverItem +} + +func (items *hoverItems) reset() { + items.available = nil + items.activeIndex = 0 + items.activeItem = nil +} + +func (items *hoverItems) scroll(forward bool) { + count := len(items.available) + if count > 0 { + diff := 1 + if !forward { + diff = -1 + } + items.activeIndex = (count + (items.activeIndex + diff)) % count + items.activeItem = items.available[items.activeIndex] + } +} + +func (items *hoverItems) find(lvl *level.Level, pos MapPosition) { + refVec := mgl.Vec2{float32(pos.X), float32(pos.Y)} + var distances []float32 + + items.available = nil + lvl.ForEachObject(func(id level.ObjectID, entry level.ObjectMainEntry) { + entryVec := mgl.Vec2{float32(entry.X), float32(entry.Y)} + distance := refVec.Sub(entryVec).Len() + if distance < level.FineCoordinatesPerTileSide/4 { + items.available = append(items.available, + objectHoverItem{ + id: id, + pos: MapPosition{X: entry.X, Y: entry.Y}, + }) + distances = append(distances, distance) + } + }) + items.available = append(items.available, + tileHoverItem{ + pos: MapPosition{ + X: level.CoordinateAt(pos.X.Tile(), level.FineCoordinatesPerTileSide/2), + Y: level.CoordinateAt(pos.Y.Tile(), level.FineCoordinatesPerTileSide/2), + }, + }) + distances = append(distances, level.FineCoordinatesPerTileSide) + sort.Slice(items.available, func(a, b int) bool { return distances[a] < distances[b] }) + + items.activeIndex = 0 + items.activeItem = items.available[items.activeIndex] +} + +func (items *hoverItems) validate(lvl *level.Level) { + allValid := true + for _, item := range items.available { + if !item.IsIn(lvl) { + allValid = false + break + } + } + if allValid { + return + } + + newItems := make([]hoverItem, 0, len(items.available)) + for index, item := range items.available { + isActive := index == items.activeIndex + if item.IsIn(lvl) { + if isActive { + items.activeIndex = len(newItems) + } + newItems = append(newItems, item) + } else if isActive { + items.activeIndex = 0 + } + } + items.available = newItems + items.activeItem = nil + if items.activeIndex < len(items.available) { + items.activeItem = items.available[items.activeIndex] + } +} diff --git a/modding/hacked/editor/levels/LimitedCamera.go b/modding/hacked/editor/levels/LimitedCamera.go new file mode 100644 index 0000000..39beba8 --- /dev/null +++ b/modding/hacked/editor/levels/LimitedCamera.go @@ -0,0 +1,98 @@ +package levels + +import ( + "math" + + mgl "github.com/go-gl/mathgl/mgl32" +) + +// LimitedCamera is a camera implementation with ranged controls for zooming and moving. +type LimitedCamera struct { + viewportWidth, viewportHeight float32 + minZoom, maxZoom float32 + minPos, maxPos float32 + + requestedZoomLevel float32 + viewOffsetX, viewOffsetY float32 + + viewMatrix mgl.Mat4 +} + +// NewLimitedCamera returns a new instance of a LimitedCamera. +func NewLimitedCamera(minZoom, maxZoom float32, minPos, maxPos float32) *LimitedCamera { + cam := &LimitedCamera{ + viewportWidth: 1.0, + viewportHeight: 1.0, + minZoom: minZoom, + maxZoom: maxZoom, + minPos: minPos, + maxPos: maxPos, + viewMatrix: mgl.Ident4()} + + return cam +} + +// SetViewportSize notifies the camera how big the view is. +func (cam *LimitedCamera) SetViewportSize(width, height float32) { + if (cam.viewportWidth != width) || (cam.viewportHeight != height) { + cam.viewportWidth, cam.viewportHeight = width, height + cam.updateViewMatrix() + } +} + +// ViewMatrix implements the Viewer interface. +func (cam *LimitedCamera) ViewMatrix() *mgl.Mat4 { + return &cam.viewMatrix +} + +// MoveBy adjusts the requested view offset by given delta values in world coordinates. +func (cam *LimitedCamera) MoveBy(dx, dy float32) { + cam.MoveTo(cam.viewOffsetX+dx, cam.viewOffsetY+dy) +} + +// MoveTo sets the requested view offset to the given world coordinates. +func (cam *LimitedCamera) MoveTo(worldX, worldY float32) { + cam.viewOffsetX = cam.limitValue(worldX, -cam.maxPos, cam.minPos) + cam.viewOffsetY = cam.limitValue(worldY, -cam.maxPos, cam.minPos) + cam.updateViewMatrix() +} + +// ZoomAt adjusts the requested zoom level by given delta, centered around given world position. +// Positive values zoom in. +func (cam *LimitedCamera) ZoomAt(levelDelta float32, x, y float32) { + cam.requestedZoomLevel = cam.limitValue(cam.requestedZoomLevel+levelDelta, cam.minZoom, cam.maxZoom) + + focusPoint := mgl.Vec4{x, y, 0.0, 1.0} + oldPixel := cam.viewMatrix.Mul4x1(focusPoint) + + cam.updateViewMatrix() + + newPixel := cam.viewMatrix.Mul4x1(focusPoint) + scaleFactor := cam.scaleFactor() + cam.MoveBy(-(newPixel[0]-oldPixel[0])/scaleFactor, +(newPixel[1]-oldPixel[1])/scaleFactor) +} + +func (cam *LimitedCamera) limitValue(value float32, min, max float32) float32 { + result := value + + if result < min { + result = min + } + if result > max { + result = max + } + + return result +} + +func (cam *LimitedCamera) scaleFactor() float32 { + return float32(math.Pow(2.0, float64(cam.requestedZoomLevel))) +} + +func (cam *LimitedCamera) updateViewMatrix() { + scaleFactor := cam.scaleFactor() + cam.viewMatrix = mgl.Ident4(). + Mul4(mgl.Translate3D(cam.viewportWidth/2.0, cam.viewportHeight/2.0, 0)). + Mul4(mgl.Scale3D(scaleFactor, -scaleFactor, 1.0)). + Mul4(mgl.Translate3D(cam.viewOffsetX, cam.viewOffsetY, 0)) +} diff --git a/modding/hacked/editor/levels/MapColors.go b/modding/hacked/editor/levels/MapColors.go new file mode 100644 index 0000000..3046766 --- /dev/null +++ b/modding/hacked/editor/levels/MapColors.go @@ -0,0 +1,179 @@ +package levels + +import ( + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var mapColorsVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec4 vertexColor; +in vec2 vertexPosition; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; + +out vec4 color; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition.xy, 0.0, 1.0); + color = vertexColor; +} +` + +var mapColorsFragmentShaderSource = ` +#version 150 +precision mediump float; + +in vec4 color; + +out vec4 fragColor; + +void main(void) { + fragColor = color; +} +` + +// ColorQuery returns the color for the specified tile. +type ColorQuery func(level.TilePosition) [4]float32 + +// MapColors is a renderable for the tile colorings. +type MapColors struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + vertexColorBuffer uint32 + vertexColorAttrib int32 + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + + lastColorGrid [][][4]float32 + lastColorGridColumns int + lastColorGridRows int +} + +// NewMapColors returns a new instance of a renderable for tile colorings. +func NewMapColors(context *render.Context) *MapColors { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, mapColorsVertexShaderSource, mapColorsFragmentShaderSource) + + if programErr != nil { + panic(opengl.NamedShaderError{Name: "MapColorsShader", Nested: programErr}) + } + renderable := &MapColors{ + context: context, + program: program, + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + vertexColorBuffer: gl.GenBuffers(1)[0], + vertexColorAttrib: gl.GetAttribLocation(program, "vertexColor"), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + } + + { + top := float32(level.FineCoordinatesPerTileSide) + left := float32(0.0) + right := float32(level.FineCoordinatesPerTileSide) + bottom := float32(0.0) + + vertices := []float32{ + left, bottom, + left, top, + right, top, + + right, top, + right, bottom, + left, bottom} + + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(vertices)*4, vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + + renderable.vao.WithSetter(func(gl opengl.OpenGL) { + gl.EnableVertexAttribArray(uint32(renderable.vertexPositionAttrib)) + gl.EnableVertexAttribArray(uint32(renderable.vertexColorAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(renderable.vertexPositionAttrib), 2, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexColorBuffer) + gl.VertexAttribOffset(uint32(renderable.vertexColorAttrib), 4, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) + + return renderable +} + +// Dispose releases any internal resources. +func (renderable *MapColors) Dispose() { + gl := renderable.context.OpenGL + gl.DeleteProgram(renderable.program) + gl.DeleteBuffers([]uint32{renderable.vertexPositionBuffer}) + renderable.vao.Dispose() +} + +// Render renders the renderable. +func (renderable *MapColors) Render(columnCount, rowCount int, query ColorQuery) { + gl := renderable.context.OpenGL + + if (renderable.lastColorGridColumns != columnCount) || (renderable.lastColorGridRows != rowCount) { + renderable.lastColorGrid = make([][][4]float32, rowCount+1) + for y := 0; y < rowCount; y++ { + renderable.lastColorGrid[y] = make([][4]float32, columnCount+1) + } + renderable.lastColorGrid[rowCount] = make([][4]float32, columnCount+1) + renderable.lastColorGridColumns = columnCount + renderable.lastColorGridRows = rowCount + } + for y, row := range renderable.lastColorGrid { + for x := 0; x < columnCount; x++ { + row[x] = query(level.TilePosition{X: byte(x), Y: byte(y)}) + } + } + + renderable.vao.OnShader(func() { + var colors [24]float32 + + renderable.viewMatrixUniform.Set(gl, renderable.context.ViewMatrix) + renderable.projectionMatrixUniform.Set(gl, &renderable.context.ProjectionMatrix) + + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexColorBuffer) + + for y := 0; y < rowCount; y++ { + for x := 0; x < columnCount; x++ { + colorBottomLeft := renderable.lastColorGrid[y][x] + colorBottomRight := renderable.lastColorGrid[y][x+1] + colorTopLeft := renderable.lastColorGrid[y+1][x] + colorTopRight := renderable.lastColorGrid[y+1][x+1] + + modelMatrix := mgl.Ident4(). + Mul4(mgl.Translate3D((float32(x))*level.FineCoordinatesPerTileSide, (float32(y))*level.FineCoordinatesPerTileSide, 0.0)) + renderable.modelMatrixUniform.Set(gl, &modelMatrix) + + copy(colors[0:4], colorBottomLeft[:]) + copy(colors[4:8], colorTopLeft[:]) + copy(colors[8:12], colorTopRight[:]) + copy(colors[12:16], colorTopRight[:]) + copy(colors[16:20], colorBottomRight[:]) + copy(colors[20:24], colorBottomLeft[:]) + + gl.BufferData(opengl.ARRAY_BUFFER, len(colors)*4, colors[:], opengl.STATIC_DRAW) + gl.DrawArrays(opengl.TRIANGLES, 0, 6) + } + } + + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) +} diff --git a/modding/hacked/editor/levels/MapDisplay.go b/modding/hacked/editor/levels/MapDisplay.go new file mode 100644 index 0000000..3b8e729 --- /dev/null +++ b/modding/hacked/editor/levels/MapDisplay.go @@ -0,0 +1,587 @@ +package levels + +import ( + "fmt" + + mgl "github.com/go-gl/mathgl/mgl32" + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/input" + "github.com/inkyblackness/hacked/ui/opengl" +) + +const contextMenuName = "MapContextMenu" + +// MapDisplay renders a level map. +type MapDisplay struct { + gameObjects *edit.GameObjectsService + levelSelection *edit.LevelSelectionService + editor *edit.LevelEditorService + + context render.Context + camera *LimitedCamera + guiScale float32 + + background *BackgroundGrid + textures *MapTextures + colors *MapColors + mapGrid *MapGrid + highlighter *Highlighter + icons *MapIcons + + moveCapture func(pixelX, pixelY float32) + mouseMoved bool + + positionPopupPos imgui.Vec2 + positionValid bool + position MapPosition + contextMenuRequested bool + + selectionReference *level.TilePosition + + hoverItems hoverItems +} + +// NewMapDisplay returns a new instance. +func NewMapDisplay(gameObjects *edit.GameObjectsService, levelSelection *edit.LevelSelectionService, editor *edit.LevelEditorService, + gl opengl.OpenGL, guiScale float32, + textureQuery TextureQuery) *MapDisplay { + tilesPerMapSide := float32(64) + + tileBaseLength := float32(level.FineCoordinatesPerTileSide) + tileBaseHalf := tileBaseLength / 2.0 + camLimit := tilesPerMapSide*tileBaseLength - tileBaseHalf + zoomShift := guiScale - 1.0 + zoomLevelMin := float32(-5) + zoomShift + zoomLevelMax := float32(1) + zoomShift + + display := &MapDisplay{ + gameObjects: gameObjects, + levelSelection: levelSelection, + editor: editor, + context: render.Context{ + OpenGL: gl, + ProjectionMatrix: mgl.Ident4(), + }, + camera: NewLimitedCamera(zoomLevelMin, zoomLevelMax, -tileBaseHalf, camLimit), + guiScale: guiScale, + moveCapture: func(float32, float32) {}, + } + display.context.ViewMatrix = display.camera.ViewMatrix() + display.background = NewBackgroundGrid(&display.context) + display.textures = NewMapTextures(&display.context, textureQuery) + display.colors = NewMapColors(&display.context) + display.mapGrid = NewMapGrid(&display.context) + display.highlighter = NewHighlighter(&display.context) + display.icons = NewMapIcons(&display.context) + + centerX, centerY := (tilesPerMapSide*tileBaseLength)/-2.0, (tilesPerMapSide*tileBaseLength)/-2.0 + display.camera.ZoomAt(-3+zoomShift, centerX, centerY) + display.camera.MoveTo(centerX, centerY) + + return display +} + +// Render renders the whole map display. +func (display *MapDisplay) Render( + paletteTexture *graphics.PaletteTexture, textureRetriever func(resource.Key) (*graphics.BitmapTexture, error), + textureDisplay TextureDisplay, colorDisplay ColorDisplay) { + lvl := display.editor.Level() + columns, rows, _ := lvl.Size() + + display.hoverItems.validate(lvl) + + display.background.Render(columns, rows) + if lvl.IsCyberspace() { + display.renderCyberspaceColors(lvl, columns, rows, paletteTexture, colorDisplay) + } else { + display.renderRealWorldTextures(lvl, columns, rows, paletteTexture, textureDisplay) + display.renderRealWorldShadows(lvl, columns, rows, colorDisplay) + } + display.mapGrid.Render(columns, rows, lvl) + display.renderTileSelection() + display.renderObjectBackgrounds(lvl) + display.renderObjectIcons(lvl, paletteTexture, textureRetriever) + display.renderObjectSelection() + display.renderActiveHoverItem() + display.renderPositionOverlay(lvl) + display.renderContextMenu() +} + +func (display *MapDisplay) renderCyberspaceColors(lvl *level.Level, columns int, rows int, paletteTexture *graphics.PaletteTexture, colorDisplay ColorDisplay) { + if paletteTexture == nil { + return + } + var colorQuery ColorQuery + palette := paletteTexture.Palette() + if colorDisplay == ColorDisplayFloor { + colorQuery = display.colorQueryFor(lvl, func(tile *level.TileMapEntry) [4]float32 { + rgb := palette[tile.TextureInfo.FloorPaletteIndex()] + return [4]float32{float32(rgb.Red) / 255, float32(rgb.Green) / 255, float32(rgb.Blue) / 255, 0.8} + }) + } else if colorDisplay == ColorDisplayCeiling { + colorQuery = display.colorQueryFor(lvl, func(tile *level.TileMapEntry) [4]float32 { + rgb := palette[tile.TextureInfo.CeilingPaletteIndex()] + return [4]float32{float32(rgb.Red) / 255, float32(rgb.Green) / 255, float32(rgb.Blue) / 255, 0.8} + }) + } + if colorQuery != nil { + display.colors.Render(columns, rows, colorQuery) + } +} + +func (display *MapDisplay) renderRealWorldTextures(lvl *level.Level, columns int, rows int, + paletteTexture *graphics.PaletteTexture, textureDisplay TextureDisplay) { + if paletteTexture == nil { + return + } + display.textures.Render(columns, rows, func(pos level.TilePosition) (level.TileType, level.TextureIndex, int) { + tile := lvl.Tile(pos) + if tile == nil { + return level.TileTypeSolid, 0, 0 + } + atlasIndex, textureRotations := textureDisplay.Func()(tile) + atlas := lvl.TextureAtlas() + textureIndex := level.TextureIndex(-1) + if (int(atlasIndex) >= 0) && (int(atlasIndex) < len(atlas)) { + textureIndex = atlas[atlasIndex] + } + return tile.Type, textureIndex, textureRotations + }, paletteTexture) +} + +func (display *MapDisplay) renderRealWorldShadows(lvl *level.Level, columns int, rows int, colorDisplay ColorDisplay) { + var colorQuery ColorQuery + if colorDisplay == ColorDisplayFloor { + colorQuery = display.colorQueryFor(lvl, func(tile *level.TileMapEntry) [4]float32 { + return [4]float32{0.0, 0.0, 0.0, float32(tile.Flags.ForRealWorld().FloorShadow()) / 15.0} + }) + } else if colorDisplay == ColorDisplayCeiling { + colorQuery = display.colorQueryFor(lvl, func(tile *level.TileMapEntry) [4]float32 { + return [4]float32{0.0, 0.0, 0.0, float32(tile.Flags.ForRealWorld().CeilingShadow()) / 15.0} + }) + } + if colorQuery != nil { + display.colors.Render(columns, rows, colorQuery) + } +} + +func (display *MapDisplay) renderTileSelection() { + selectedTiles := display.levelSelection.CurrentSelectedTiles() + tileMapPositions := make([]MapPosition, 0, len(selectedTiles)) + for _, pos := range selectedTiles { + tileMapPositions = append(tileMapPositions, MapPosition{ + X: level.CoordinateAt(pos.X, level.FineCoordinatesPerTileSide/2), + Y: level.CoordinateAt(pos.Y, level.FineCoordinatesPerTileSide/2), + }) + } + display.highlighter.Render(tileMapPositions, level.FineCoordinatesPerTileSide, [4]float32{0.0, 0.8, 0.2, 0.5}) +} + +func (display *MapDisplay) renderObjectBackgrounds(lvl *level.Level) { + var objects []MapPosition + lvl.ForEachObject(func(id level.ObjectID, entry level.ObjectMainEntry) { + objects = append(objects, MapPosition{X: entry.X, Y: entry.Y}) + }) + display.highlighter.Render(objects, level.FineCoordinatesPerTileSide/4, [4]float32{1.0, 1.0, 1.0, 0.3}) +} + +func (display *MapDisplay) renderObjectIcons(lvl *level.Level, paletteTexture *graphics.PaletteTexture, + textureRetriever func(resource.Key) (*graphics.BitmapTexture, error)) { + if paletteTexture == nil { + return + } + tripleInfo := display.gameObjects.BitmapInfo() + var icons []iconData + var highlightIcon iconData + var highlightID level.ObjectID + + if display.hoverItems.activeItem != nil { + objectItem, isObject := display.hoverItems.activeItem.(objectHoverItem) + if isObject { + highlightID = objectItem.id + } + } + lvl.ForEachObject(func(id level.ObjectID, entry level.ObjectMainEntry) { + triple := entry.Triple() + info, cached := tripleInfo[triple] + if !cached { + return + } + key := resource.KeyOf(ids.ObjectBitmaps, resource.LangAny, info.Start+info.IconRecommendation) + texture, err := textureRetriever(key) + if err == nil { + icon := iconData{pos: MapPosition{X: entry.X, Y: entry.Y}, texture: texture} + if highlightID == id { + highlightIcon = icon + } else { + icons = append(icons, icon) + } + } + }) + if (highlightID != 0) && (highlightIcon.texture != nil) { + icons = append(icons, highlightIcon) + } + display.icons.Render(paletteTexture, level.FineCoordinatesPerTileSide/4, icons) +} + +func (display *MapDisplay) renderObjectSelection() { + selectedObjects := display.editor.Objects() + selectedObjectHighlights := make([]MapPosition, 0, len(selectedObjects)) + for _, obj := range selectedObjects { + objPos := MapPosition{X: obj.X, Y: obj.Y} + selectedObjectHighlights = append(selectedObjectHighlights, objPos) + } + display.highlighter.Render(selectedObjectHighlights, level.FineCoordinatesPerTileSide/4, [4]float32{0.0, 0.8, 0.2, 0.5}) +} + +func (display *MapDisplay) renderActiveHoverItem() { + if display.hoverItems.activeItem == nil { + return + } + display.highlighter.Render( + []MapPosition{display.hoverItems.activeItem.Pos()}, + display.hoverItems.activeItem.Size(), + [4]float32{0.0, 0.2, 0.8, 0.3}) +} + +func (display *MapDisplay) colorQueryFor(lvl *level.Level, tileToColor func(*level.TileMapEntry) [4]float32) func(level.TilePosition) [4]float32 { + return func(pos level.TilePosition) [4]float32 { + tile := lvl.Tile(pos) + if tile == nil { + return [4]float32{} + } + return tileToColor(tile) + } +} + +func (display *MapDisplay) renderPositionOverlay(lvl *level.Level) { + imgui.SetNextWindowPosV(display.positionPopupPos, imgui.ConditionAlways, imgui.Vec2{X: 1.0, Y: 1.0}) + imgui.SetNextWindowSize(imgui.Vec2{X: 140 * display.guiScale, Y: 0}) + imgui.SetNextWindowBgAlpha(0.3) + if imgui.BeginV("Position", nil, imgui.WindowFlagsNoMove|imgui.WindowFlagsNoTitleBar|imgui.WindowFlagsNoResize|imgui.WindowFlagsAlwaysAutoResize| + imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsNoFocusOnAppearing|imgui.WindowFlagsNoNav) { + typeString := "---" + hasPos := false + var pos MapPosition + hasFloor := false + var floorRaw int + floorString := hintUnknown + hasCeiling := false + var ceilingRaw int + ceilingString := hintUnknown + + if display.hoverItems.activeItem != nil { + pos = display.hoverItems.activeItem.Pos() + hasPos = true + + if _, isTileItem := display.hoverItems.activeItem.(tileHoverItem); isTileItem { + pos = display.position // use raw cursor position for this display + typeString = "Tile" + tile := lvl.Tile(pos.Tile()) + if (tile != nil) && (tile.Type != level.TileTypeSolid) { + _, _, heightShift := lvl.Size() + floorHeight := tile.Floor.AbsoluteHeight() + floorHeightInTiles, err := heightShift.ValueFromTileHeight(floorHeight) + if err == nil { + floorString = fmt.Sprintf("%2.3f", floorHeightInTiles) + } + floorRaw = int(floorHeight) + hasFloor = true + + ceilingHeight := tile.Ceiling.AbsoluteHeight() + ceilingHeightInTiles, err := heightShift.ValueFromTileHeight(ceilingHeight) + if err == nil { + ceilingString = fmt.Sprintf("%2.3f", ceilingHeightInTiles) + } + ceilingRaw = int(ceilingHeight) + hasCeiling = true + } + } else if objectItem, isObjectItem := display.hoverItems.activeItem.(objectHoverItem); isObjectItem { + _, _, heightShift := lvl.Size() + obj := lvl.Object(objectItem.id) + if obj != nil { + typeString = fmt.Sprintf("%3d = %v", objectItem.id, obj.Triple()) + heightInTiles, err := heightShift.ValueFromObjectHeight(obj.Z) + if err == nil { + floorString = fmt.Sprintf("%2.3f", heightInTiles) + } + floorRaw = int(obj.Z) + hasFloor = true + } + } + } + imgui.Text("T: " + typeString) + if hasPos { + imgui.Text(fmt.Sprintf("X: T %2d F %3d", pos.X.Tile(), pos.X.Fine())) + imgui.Text(fmt.Sprintf("Y: T %2d F %3d", pos.Y.Tile(), pos.Y.Fine())) + } else { + imgui.Text("X: T -- F ---") + imgui.Text("Y: T -- F ---") + } + if hasFloor { + imgui.Text(fmt.Sprintf("F: %3d = %s", floorRaw, floorString)) + } else { + imgui.Text("F: -- = --.---") + } + if hasCeiling { + imgui.Text(fmt.Sprintf("C: %3d = %s", ceilingRaw, ceilingString)) + } else { + imgui.Text("C: -- = --.---") + } + imgui.End() + } +} + +func (display *MapDisplay) renderContextMenu() { + if display.contextMenuRequested { + imgui.OpenPopup(contextMenuName) + display.contextMenuRequested = false + } + if imgui.BeginPopupV(contextMenuName, imgui.PopupFlagsMouseButtonRight) { + readOnly := display.editor.IsReadOnly() + if imgui.BeginMenu("New...") { + implicitTriple := display.editor.NewObjectTriple() + canCreateImplicitClass := display.canCreateObjectOf(implicitTriple.Class) + if imgui.MenuItemV("Object", "Ctrl+2ndClick", false, canCreateImplicitClass) { + display.requestCreateNewObject(false, implicitTriple) + } + if imgui.MenuItemV("Object (at grid)", "Ctrl+Shift+2ndClick", false, canCreateImplicitClass) { + display.requestCreateNewObject(true, implicitTriple) + } + imgui.EndMenu() + } + imgui.Separator() + if imgui.MenuItemV("Delete Objects", "", false, !readOnly && display.editor.HasSelectedObjects()) { + _ = display.editor.DeleteObjects() + } + if imgui.MenuItemV("Clear Tiles", "", false, !readOnly && display.editor.HasSelectedTiles()) { + _ = display.editor.ClearTiles() + } + imgui.EndPopup() + } +} + +// WindowResized must be called to notify of a change in window geometry. +func (display *MapDisplay) WindowResized(width int, height int) { + display.context.ProjectionMatrix = mgl.Ortho2D(0.0, float32(width), float32(height), 0.0) + display.camera.SetViewportSize(float32(width), float32(height)) + display.positionPopupPos.X = float32(width) - 10.0 + display.positionPopupPos.Y = float32(height) - 10.0 +} + +func (display *MapDisplay) unprojectPixel(pixelX, pixelY float32) (x, y float32) { + pixelVec := mgl.Vec4{pixelX, pixelY, 0.0, 1.0} + invertedView := display.camera.ViewMatrix().Inv() + result := invertedView.Mul4x1(pixelVec) + + return result[0], result[1] +} + +// MouseButtonDown must be called when a button was pressed. +func (display *MapDisplay) MouseButtonDown(mouseX, mouseY float32, button uint32) { + display.updateMouseWorldPosition(mouseX, mouseY) + if button == input.MousePrimary { + lastPixelX, lastPixelY := mouseX, mouseY + + display.mouseMoved = false + display.moveCapture = func(pixelX, pixelY float32) { + lastWorldX, lastWorldY := display.unprojectPixel(lastPixelX, lastPixelY) + worldX, worldY := display.unprojectPixel(pixelX, pixelY) + + display.camera.MoveBy(worldX-lastWorldX, worldY-lastWorldY) + lastPixelX, lastPixelY = pixelX, pixelY + display.mouseMoved = true + } + } +} + +// MouseButtonUp must be called when a button was released. +func (display *MapDisplay) MouseButtonUp(mouseX, mouseY float32, button uint32, modifier input.Modifier) { + display.updateMouseWorldPosition(mouseX, mouseY) + if button == input.MousePrimary { + display.moveCapture = func(float32, float32) {} + if !display.mouseMoved && display.positionValid { + switch { + case modifier.Has(input.ModControl): + display.toggleSelectionAtActiveHoverItem() + case modifier.Has(input.ModShift) && (display.selectionReference != nil): + firstPos := *display.selectionReference + + fromX := int(firstPos.X) + fromY := int(firstPos.Y) + toX := int(display.position.X.Tile()) + toY := int(display.position.Y.Tile()) + xIncrement := 1 + yIncrement := 1 + if fromX > toX { + xIncrement = -1 + } + if fromY > toY { + yIncrement = -1 + } + toX += xIncrement + toY += yIncrement + var newList []level.TilePosition + for y := fromY; y != toY; y += yIncrement { + for x := fromX; x != toX; x += xIncrement { + newList = append(newList, level.TilePosition{X: byte(x), Y: byte(y)}) + } + } + display.levelSelection.SetCurrentSelectedTiles(newList) + display.levelSelection.SetCurrentSelectedObjects(display.objectsInTiles(newList)) + default: + display.setSelectionByActiveHoverItem() + } + } + } else if button == input.MouseSecondary { + if modifier.IsClear() { + display.contextMenuRequested = true + } else if modifier.Has(input.ModControl) { + display.requestCreateNewObject(modifier.Has(input.ModShift), display.editor.NewObjectTriple()) + } + } +} + +func (display *MapDisplay) canCreateObjectOf(class object.Class) bool { + if display.editor.IsReadOnly() { + return false + } + lvl := display.editor.Level() + return lvl.HasRoomForObjectOf(class) && display.positionValid +} + +func (display *MapDisplay) requestCreateNewObject(snapToGrid bool, triple object.Triple) { + if !display.canCreateObjectOf(triple.Class) { + return + } + pos := display.position + if snapToGrid { + toGrid := func(value byte) byte { + cornerSnapDistance := level.FineCoordinatesPerTileSide / 4 + switch { + case value < byte(cornerSnapDistance): + return 0x00 + case value >= byte(level.FineCoordinatesPerTileSide-cornerSnapDistance): + return level.FineCoordinatesPerTileSide - 1 + default: + return level.FineCoordinatesPerTileSide / 2 + } + } + pos.X = level.CoordinateAt(pos.X.Tile(), toGrid(pos.X.Fine())) + pos.Y = level.CoordinateAt(pos.Y.Tile(), toGrid(pos.Y.Fine())) + } + err := display.editor.CreateNewObject(triple, func(obj *level.ObjectMainEntry) { + obj.X = pos.X + obj.Y = pos.Y + }) + if err != nil { + panic(err) + } +} + +func (display *MapDisplay) setSelectionByActiveHoverItem() { + var tiles []level.TilePosition + var objects []level.ObjectID + if display.hoverItems.activeItem != nil { + if tileItem, isTile := display.hoverItems.activeItem.(tileHoverItem); isTile { + tiles = append(tiles, tileItem.pos.Tile()) + } else if objectItem, isObject := display.hoverItems.activeItem.(objectHoverItem); isObject { + objects = append(objects, objectItem.id) + } + } + display.levelSelection.SetCurrentSelectedTiles(tiles) + if len(tiles) > 0 { + display.selectionReference = &tiles[0] + display.levelSelection.SetCurrentSelectedObjects(display.objectsInTiles(tiles)) + } else { + display.levelSelection.SetCurrentSelectedObjects(objects) + } +} + +func (display *MapDisplay) toggleSelectionAtActiveHoverItem() { + if display.hoverItems.activeItem != nil { + if tileItem, isTile := display.hoverItems.activeItem.(tileHoverItem); isTile { + wasSelected := display.levelSelection.IsTileSelected(tileItem.pos.Tile()) + tiles := []level.TilePosition{tileItem.pos.Tile()} + if wasSelected { + display.levelSelection.RemoveCurrentSelectedTiles(tiles) + display.levelSelection.RemoveCurrentSelectedObjects(display.objectsInTiles(tiles)) + } else { + display.levelSelection.AddCurrentSelectedTiles(tiles) + display.levelSelection.AddCurrentSelectedObjects(display.objectsInTiles(tiles)) + } + } else if objectItem, isObject := display.hoverItems.activeItem.(objectHoverItem); isObject { + display.levelSelection.ToggleObjectSelection([]level.ObjectID{objectItem.id}) + } + } +} + +func (display *MapDisplay) objectsInTiles(tiles []level.TilePosition) []level.ObjectID { + tilesContain := func(pos level.TilePosition) bool { + for _, entry := range tiles { + if entry == pos { + return true + } + } + return false + } + + var objects []level.ObjectID + lvl := display.editor.Level() + lvl.ForEachObject(func(id level.ObjectID, entry level.ObjectMainEntry) { + if tilesContain(entry.TilePosition()) { + objects = append(objects, id) + } + }) + return objects +} + +// MouseMoved must be called for a mouse move. +func (display *MapDisplay) MouseMoved(mouseX, mouseY float32) { + display.updateMouseWorldPosition(mouseX, mouseY) + if display.positionValid { + display.hoverItems.find(display.editor.Level(), display.position) + } else { + display.hoverItems.reset() + } + display.moveCapture(mouseX, mouseY) +} + +// MouseScrolled must be called for a mouse scroll. +func (display *MapDisplay) MouseScrolled(mouseX, mouseY float32, deltaX, deltaY float32, modifier input.Modifier) { + if modifier.Has(input.ModControl) { + display.hoverItems.scroll(deltaY > 0) + } else { + worldX, worldY := display.unprojectPixel(mouseX, mouseY) + + if deltaY < 0 { + display.camera.ZoomAt(-0.5, worldX, worldY) + } + if deltaY > 0 { + display.camera.ZoomAt(0.5, worldX, worldY) + } + } +} + +func (display *MapDisplay) updateMouseWorldPosition(mouseX, mouseY float32) { + worldX, worldY := display.unprojectPixel(mouseX, mouseY) + lvl := display.editor.Level() + width, height, _ := lvl.Size() + worldWidth := float32(width) * level.FineCoordinatesPerTileSide + worldHeight := float32(height) * level.FineCoordinatesPerTileSide + + display.positionValid = (worldX >= 0.0) && (worldX < worldWidth) && (worldY >= 0.0) && (worldY < worldHeight) + if display.positionValid { + display.position = MapPosition{X: level.Coordinate(worldX + 0.5), Y: level.Coordinate(worldY + 0.5)} + } else { + display.position = MapPosition{} + } +} diff --git a/modding/hacked/editor/levels/MapGrid.go b/modding/hacked/editor/levels/MapGrid.go new file mode 100644 index 0000000..1f316b7 --- /dev/null +++ b/modding/hacked/editor/levels/MapGrid.go @@ -0,0 +1,312 @@ +package levels + +import ( + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var mapTileGridVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec4 vertexPosition; +out float colorAlpha; + +uniform mat4 alphaMatrix; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition.xy, 0.0, 1.0); + colorAlpha = alphaMatrix[int(vertexPosition.z)][int(vertexPosition.w)]; +} +` + +var mapTileGridFragmentShaderSource = ` +#version 150 +precision mediump float; + +uniform vec4 color; +in float colorAlpha; +out vec4 fragColor; + +void main(void) { + fragColor = vec4(color.rgb, color.a*colorAlpha); +} +` + +// TileMapper returns basic information to draw a 2D map. +type TileMapper interface { + MapGridInfo(pos level.TilePosition) (level.TileType, level.TileSlopeControl, level.WallHeights) +} + +// MapGrid renders the grid of the map, based on calculated wall heights. +type MapGrid struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + tickVertexPositionBuffer uint32 + vertexPositionAttrib int32 + colorUniform opengl.Vector4Uniform + + /* + The alpha matrix is set up to specify the alpha value used for the wall sections. + There are four cardinal walls with three sections each, plus two diagonal walls (single section), as well + as the slope-ticks that re-use the shader. + The matrix is meant to be used like this: + N N N D1 + E E E D2 + S S S - + W W W T + D1 and D2 being the two diagonals, and T specifying the cell for the tick-marks. + The vertex buffers are an array of v4 entries, with z/w specifying the row and column index to take. + */ + alphaMatrixUniform opengl.Matrix4Uniform + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + + slopeTicksByType [][]int + floorTickStarts []int32 + ceilingTickStarts []int32 +} + +// NewMapGrid returns a new instance. +func NewMapGrid(context *render.Context) *MapGrid { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, mapTileGridVertexShaderSource, mapTileGridFragmentShaderSource) + if programErr != nil { + panic(opengl.NamedShaderError{Name: "MapGrid", Nested: programErr}) + } + grid := &MapGrid{ + context: context, + program: program, + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + tickVertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + colorUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "color")), + alphaMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "alphaMatrix")), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + } + + { + tickVertices := grid.calculateTickVertices() + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.tickVertexPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(tickVertices)*4, tickVertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + { + const parts = 3 + left := float32(-0.5) + right := float32(0.5) + top := float32(0.5) + bottom := float32(-0.5) + finePerPart := float32(1.0 / parts) + + var wallVertices []float32 + for i := 0; i < parts; i++ { + wallVertices = append(wallVertices, left+(finePerPart*float32(i)), top, 0.0, float32(i)) + wallVertices = append(wallVertices, left+(finePerPart*float32(i+1)), top, 0.0, float32(i)) + } + for i := 0; i < parts; i++ { + wallVertices = append(wallVertices, right, top-(finePerPart*float32(i)), 1.0, float32(i)) + wallVertices = append(wallVertices, right, top-(finePerPart*float32(i+1)), 1.0, float32(i)) + } + for i := 0; i < parts; i++ { + wallVertices = append(wallVertices, right-(finePerPart*float32(i)), bottom, 2.0, float32(i)) + wallVertices = append(wallVertices, right-(finePerPart*float32(i+1)), bottom, 2.0, float32(i)) + } + for i := 0; i < parts; i++ { + wallVertices = append(wallVertices, left, bottom+(finePerPart*float32(i)), 3.0, float32(i)) + wallVertices = append(wallVertices, left, bottom+(finePerPart*float32(i+1)), 3.0, float32(i)) + } + wallVertices = append(wallVertices, left, top, 0.0, 3.0) + wallVertices = append(wallVertices, right, bottom, 0.0, 3.0) + wallVertices = append(wallVertices, right, top, 1.0, 3.0) + wallVertices = append(wallVertices, left, bottom, 1.0, 3.0) + + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.vertexPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(wallVertices)*4, wallVertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + { + grid.slopeTicksByType = [][]int{ + nil, + nil, + + nil, + nil, + nil, + nil, + + {0, 1}, + {1, 2}, + {2, 3}, + {3, 0}, + + {3, 0, 1}, + {0, 1, 2}, + {1, 2, 3}, + {2, 3, 0}, + + {2}, + {3}, + {0}, + {1}, + } + grid.floorTickStarts = []int32{0, 6, 12, 18} + grid.ceilingTickStarts = []int32{3, 9, 15, 21} + } + + return grid +} + +// Dispose releases any internal resources. +func (grid *MapGrid) Dispose() { + gl := grid.context.OpenGL + gl.DeleteProgram(grid.program) + gl.DeleteBuffers([]uint32{grid.vertexPositionBuffer, grid.tickVertexPositionBuffer}) + grid.vao.Dispose() +} + +func (grid *MapGrid) calculateTickVertices() []float32 { + dotHalf := float32(0.05) + dotBase := float32(0.5) - (dotHalf * 2.0) + + top := dotBase + dotHalf + topEnd := dotBase - dotHalf + left := -dotBase - dotHalf + leftEnd := -dotBase + dotHalf + + right := top + rightEnd := topEnd + bottom := left + bottomEnd := leftEnd + + return []float32{ + left, top, 3.0, 3.0, leftEnd, top, 3.0, 3.0, left, topEnd, 3.0, 3.0, + leftEnd, top, 3.0, 3.0, leftEnd, topEnd, 3.0, 3.0, left, topEnd, 3.0, 3.0, + + right, top, 3.0, 3.0, right, topEnd, 3.0, 3.0, rightEnd, top, 3.0, 3.0, + rightEnd, top, 3.0, 3.0, right, topEnd, 3.0, 3.0, rightEnd, topEnd, 3.0, 3.0, + + right, bottom, 3.0, 3.0, rightEnd, bottom, 3.0, 3.0, right, bottomEnd, 3.0, 3.0, + right, bottomEnd, 3.0, 3.0, rightEnd, bottom, 3.0, 3.0, rightEnd, bottomEnd, 3.0, 3.0, + + left, bottomEnd, 3.0, 3.0, leftEnd, bottom, 3.0, 3.0, left, bottom, 3.0, 3.0, + left, bottomEnd, 3.0, 3.0, leftEnd, bottomEnd, 3.0, 3.0, leftEnd, bottom, 3.0, 3.0, + } +} + +// Render renders the grid. +func (grid *MapGrid) Render(columns, rows int, mapper TileMapper) { + gl := grid.context.OpenGL + + grid.vao.OnShader(func() { + modelMatrix := mgl.Ident4() + lineColor := [4]float32{0.0, 0.8, 0.0, 1.0} + + grid.viewMatrixUniform.Set(gl, grid.context.ViewMatrix) + grid.projectionMatrixUniform.Set(gl, &grid.context.ProjectionMatrix) + + heightFactor := [32*2 + 1]float32{ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, + 0.1, 0.2, 0.3, 0.3, 0.4, 0.4, 0.5, 0.7, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + } + + for y := 0; y < rows; y++ { + for x := 0; x < columns; x++ { + tileType, slopeControl, wallHeights := mapper.MapGridInfo(level.TilePosition{X: byte(x), Y: byte(y)}) + if tileType == level.TileTypeSolid { + continue + } + + modelMatrix = mgl.Ident4(). + Mul4(mgl.Translate3D((float32(x)+0.5)*level.FineCoordinatesPerTileSide, (float32(y)+0.5)*level.FineCoordinatesPerTileSide, 0.0)). + Mul4(mgl.Scale3D(level.FineCoordinatesPerTileSide, level.FineCoordinatesPerTileSide, 1.0)) + grid.modelMatrixUniform.Set(gl, &modelMatrix) + var alphaMatrix mgl.Mat4 + alphaMatrix[15] = 1.0 // for tick marks + + gl.EnableVertexAttribArray(uint32(grid.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(grid.vertexPositionAttrib), 4, opengl.FLOAT, false, 0, 0) + + alphaMatrix[0+0] = heightFactor[int(wallHeights.North[0])+32] + alphaMatrix[0+1] = heightFactor[int(wallHeights.North[1])+32] + alphaMatrix[0+2] = heightFactor[int(wallHeights.North[2])+32] + + alphaMatrix[4+0] = heightFactor[int(wallHeights.East[0])+32] + alphaMatrix[4+1] = heightFactor[int(wallHeights.East[1])+32] + alphaMatrix[4+2] = heightFactor[int(wallHeights.East[2])+32] + + alphaMatrix[8+0] = heightFactor[int(wallHeights.South[0])+32] + alphaMatrix[8+1] = heightFactor[int(wallHeights.South[1])+32] + alphaMatrix[8+2] = heightFactor[int(wallHeights.South[2])+32] + + alphaMatrix[12+0] = heightFactor[int(wallHeights.West[0])+32] + alphaMatrix[12+1] = heightFactor[int(wallHeights.West[1])+32] + alphaMatrix[12+2] = heightFactor[int(wallHeights.West[2])+32] + if tileType == level.TileTypeDiagonalOpenNorthEast || tileType == level.TileTypeDiagonalOpenSouthWest { + alphaMatrix[0+3] = 1.0 + } + if tileType == level.TileTypeDiagonalOpenNorthWest || tileType == level.TileTypeDiagonalOpenSouthEast { + alphaMatrix[4+3] = 1.0 + } + + grid.colorUniform.Set(gl, &lineColor) + grid.alphaMatrixUniform.Set(gl, &alphaMatrix) + gl.DrawArrays(opengl.LINES, 0, 28) + + grid.renderSlopeTicks(gl, tileType, slopeControl) + } + } + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) +} + +func (grid *MapGrid) renderSlopeTicks(gl opengl.OpenGL, tileType level.TileType, slopeControl level.TileSlopeControl) { + var floorTicks []int + var ceilingTicks []int + + if slopeControl != level.TileSlopeControlFloorFlat { + floorTicks = grid.slopeTicksByType[tileType] + } + if slopeControl == level.TileSlopeControlCeilingMirrored { + ceilingTicks = grid.slopeTicksByType[tileType] + } else if slopeControl != level.TileSlopeControlCeilingFlat { + ceilingTicks = grid.slopeTicksByType[tileType.Info().SlopeInvertedType] + } + + gl.EnableVertexAttribArray(uint32(grid.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.tickVertexPositionBuffer) + gl.VertexAttribOffset(uint32(grid.vertexPositionAttrib), 4, opengl.FLOAT, false, 0, 0) + + floorTickColor := [4]float32{0.0, 0.8, 0.0, 0.9} + grid.colorUniform.Set(gl, &floorTickColor) + for _, index := range floorTicks { + gl.DrawArrays(opengl.TRIANGLES, grid.floorTickStarts[index], 3) + } + ceilingTickColor := [4]float32{0.8, 0.0, 0.0, 0.9} + grid.colorUniform.Set(gl, &ceilingTickColor) + for _, index := range ceilingTicks { + gl.DrawArrays(opengl.TRIANGLES, grid.ceilingTickStarts[index], 3) + } +} diff --git a/modding/hacked/editor/levels/MapIcons.go b/modding/hacked/editor/levels/MapIcons.go new file mode 100644 index 0000000..c103529 --- /dev/null +++ b/modding/hacked/editor/levels/MapIcons.go @@ -0,0 +1,198 @@ +package levels + +import ( + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var mapIconsVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec3 vertexPosition; +in vec3 uvPosition; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; +uniform vec4 uvLimit; + +out vec2 uv; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0); + + uv = vec2(uvPosition.x * uvLimit.x, uvPosition.y * uvLimit.y); +} +` + +var mapIconsFragmentShaderSource = ` +#version 150 +precision mediump float; + +uniform sampler2D palette; +uniform sampler2D bitmap; + +in vec2 uv; +out vec4 fragColor; + +void main(void) { + vec4 pixel = texture(bitmap, uv); + + if (pixel.r > 0.0) { + fragColor = texture(palette, vec2(pixel.r, 0.5)); + } else { + discard; + } +} +` + +// MapIcons is a renderable for simple bitmaps. +type MapIcons struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + uvPositionBuffer uint32 + uvPositionAttrib int32 + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + uvLimitUniform opengl.Vector4Uniform + + paletteUniform int32 + bitmapUniform int32 +} + +type iconData struct { + pos MapPosition + texture *graphics.BitmapTexture +} + +// NewMapIcons returns a new instance. +func NewMapIcons(context *render.Context) *MapIcons { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, mapIconsVertexShaderSource, mapIconsFragmentShaderSource) + + if programErr != nil { + panic(opengl.NamedShaderError{Name: "MapIconsShader", Nested: programErr}) + } + renderable := &MapIcons{ + context: context, + program: program, + + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + uvPositionBuffer: gl.GenBuffers(1)[0], + uvPositionAttrib: gl.GetAttribLocation(program, "uvPosition"), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + uvLimitUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "uvLimit")), + + paletteUniform: gl.GetUniformLocation(program, "palette"), + bitmapUniform: gl.GetUniformLocation(program, "bitmap")} + + { + half := float32(0.5) + var vertices = []float32{ + -half, half, 0.0, + half, half, 0.0, + half, -half, 0.0, + + half, -half, 0.0, + -half, -half, 0.0, + -half, half, 0.0, + } + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(vertices)*4, vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + { + var uv = []float32{ + 0.0, 0.0, 0.0, + 1.0, 0.0, 0.0, + 1.0, 1.0, 0.0, + + 1.0, 1.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, + } + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.uvPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(uv)*4, uv, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + renderable.vao.WithSetter(func(gl opengl.OpenGL) { + gl.EnableVertexAttribArray(uint32(renderable.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(renderable.vertexPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.EnableVertexAttribArray(uint32(renderable.uvPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.uvPositionBuffer) + gl.VertexAttribOffset(uint32(renderable.uvPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) + + return renderable +} + +// Render renders the icons with their center at given position. +func (renderable *MapIcons) Render(paletteTexture *graphics.PaletteTexture, iconSize float32, icons []iconData) { + gl := renderable.context.OpenGL + + renderable.vao.OnShader(func() { + renderable.viewMatrixUniform.Set(gl, renderable.context.ViewMatrix) + renderable.projectionMatrixUniform.Set(gl, &renderable.context.ProjectionMatrix) + + textureUnit := int32(0) + gl.ActiveTexture(opengl.TEXTURE0 + uint32(textureUnit)) + gl.BindTexture(opengl.TEXTURE_2D, paletteTexture.Handle()) + gl.Uniform1i(renderable.paletteUniform, textureUnit) + + textureUnit = 1 + gl.ActiveTexture(opengl.TEXTURE0 + uint32(textureUnit)) + gl.Uniform1i(renderable.bitmapUniform, textureUnit) + for _, icon := range icons { + x, y := float32(icon.pos.X), float32(icon.pos.Y) + u, v := icon.texture.UV() + width, height := renderable.limitedSize(iconSize, icon.texture) + modelMatrix := mgl.Ident4(). + Mul4(mgl.Translate3D(x, y, 0.0)). + Mul4(mgl.Scale3D(width, height, 1.0)) + + renderable.modelMatrixUniform.Set(gl, &modelMatrix) + + uvLimit := [4]float32{u, v, 0, 0} + renderable.uvLimitUniform.Set(gl, &uvLimit) + + gl.BindTexture(opengl.TEXTURE_2D, icon.texture.Handle()) + gl.DrawArrays(opengl.TRIANGLES, 0, 6) + } + gl.BindTexture(opengl.TEXTURE_2D, 0) + }) +} + +func (renderable *MapIcons) limitedSize(iconSize float32, texture *graphics.BitmapTexture) (width, height float32) { + width, height = texture.Size() + referenceSize := float32(16.0) + larger := width + + if larger < height { + larger = height + } + if larger > referenceSize { + ratio := referenceSize / larger + width *= ratio + height *= ratio + } + factor := iconSize / referenceSize + width *= factor + height *= factor + + return +} diff --git a/modding/hacked/editor/levels/MapPosition.go b/modding/hacked/editor/levels/MapPosition.go new file mode 100644 index 0000000..cc0d49d --- /dev/null +++ b/modding/hacked/editor/levels/MapPosition.go @@ -0,0 +1,19 @@ +package levels + +import "github.com/inkyblackness/hacked/ss1/content/archive/level" + +// MapPosition describes a specific two-dimensional point on the map. +type MapPosition struct { + X level.Coordinate + Y level.Coordinate +} + +// Tile returns the tile position of the exact position. +func (pos MapPosition) Tile() level.TilePosition { + return level.TilePosition{X: pos.X.Tile(), Y: pos.Y.Tile()} +} + +// Fine returns the fine position of the exact position. +func (pos MapPosition) Fine() level.FinePosition { + return level.FinePosition{X: pos.X.Fine(), Y: pos.Y.Fine()} +} diff --git a/modding/hacked/editor/levels/MapTextures.go b/modding/hacked/editor/levels/MapTextures.go new file mode 100644 index 0000000..a5654dc --- /dev/null +++ b/modding/hacked/editor/levels/MapTextures.go @@ -0,0 +1,255 @@ +package levels + +import ( + "math" + + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var mapTexturesVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec3 vertexPosition; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; +uniform mat4 uvMatrix; + +out vec2 uv; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0); + + uv = (uvMatrix * vec4(vertexPosition, 1.0)).xy; +} +` + +var mapTexturesFragmentShaderSource = ` +#version 150 +precision mediump float; + +in vec2 uv; + +uniform sampler2D palette; +uniform sampler2D bitmap; + +out vec4 fragColor; + +void main(void) { + vec4 pixel = texture(bitmap, uv); + + fragColor = texture(palette, vec2(pixel.r, 0.5)); +} +` + +// TextureQuery is a getter function to retrieve the texture for the given +// level texture index. +type TextureQuery func(index level.TextureIndex) (*graphics.BitmapTexture, error) + +// TileTextureQuery is a getter function to retrieve properties for rendering a texture of a tile. +type TileTextureQuery func(pos level.TilePosition) (tileType level.TileType, textureIndex level.TextureIndex, textureRotations int) + +// MapTextures is a renderable for textures. +type MapTextures struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + uvMatrixUniform opengl.Matrix4Uniform + + paletteUniform int32 + bitmapUniform int32 + + textureQuery TextureQuery + + lastTileType level.TileType +} + +type tileTypeVertices struct { + vertices []float32 + equivalentType level.TileType +} + +var tileTypeVerticesLookup []tileTypeVertices +var uvRotations [4]*mgl.Mat4 + +func init() { + for i := 0; i < 4; i++ { + matrix := mgl.Translate3D(0.5, 0.5, 0.0). + Mul4(mgl.HomogRotate3DZ(math.Pi * float32(i) / -2.0)). + Mul4(mgl.Translate3D(-0.5, -0.5, 0.0)). + Mul4(mgl.Scale3D(1.0, -1.0, 1.0)) + uvRotations[i] = &matrix + } + + for _, tileType := range level.TileTypes() { + for int(tileType) >= len(tileTypeVerticesLookup) { + tileTypeVerticesLookup = append(tileTypeVerticesLookup, tileTypeVertices{equivalentType: level.TileTypeSolid}) + } + switch tileType { + case level.TileTypeSolid: + case level.TileTypeDiagonalOpenNorthEast: + tileTypeVerticesLookup[int(tileType)] = tileTypeVertices{ + vertices: []float32{ + 0.0, 1.0, 0.0, + 1.0, 1.0, 0.0, + 1.0, 0.0, 0.0, + }, + equivalentType: level.TileTypeDiagonalOpenNorthEast, + } + case level.TileTypeDiagonalOpenNorthWest: + tileTypeVerticesLookup[int(tileType)] = tileTypeVertices{ + vertices: []float32{ + 0.0, 1.0, 0.0, + 1.0, 1.0, 0.0, + 0.0, 0.0, 0.0, + }, + equivalentType: level.TileTypeDiagonalOpenNorthWest, + } + case level.TileTypeDiagonalOpenSouthEast: + tileTypeVerticesLookup[int(tileType)] = tileTypeVertices{ + vertices: []float32{ + 1.0, 1.0, 0.0, + 1.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + }, + equivalentType: level.TileTypeDiagonalOpenSouthEast, + } + case level.TileTypeDiagonalOpenSouthWest: + tileTypeVerticesLookup[int(tileType)] = tileTypeVertices{ + vertices: []float32{ + 0.0, 1.0, 0.0, + 1.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + }, + equivalentType: level.TileTypeDiagonalOpenSouthWest, + } + default: + tileTypeVerticesLookup[int(tileType)] = tileTypeVertices{ + vertices: []float32{ + 0.0, 0.0, 0.0, + 1.0, 0.0, 0.0, + 1.0, 1.0, 0.0, + + 1.0, 1.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, + }, + equivalentType: level.TileTypeOpen, + } + } + } +} + +// NewMapTextures returns a new instance of a renderable for tile map textures. +func NewMapTextures(context *render.Context, textureQuery TextureQuery) *MapTextures { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, mapTexturesVertexShaderSource, mapTexturesFragmentShaderSource) + if programErr != nil { + panic(opengl.NamedShaderError{Name: "MapTexturesShader", Nested: programErr}) + } + renderable := &MapTextures{ + context: context, + program: program, + + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + uvMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "uvMatrix")), + paletteUniform: gl.GetUniformLocation(program, "palette"), + bitmapUniform: gl.GetUniformLocation(program, "bitmap"), + textureQuery: textureQuery, + lastTileType: level.TileTypeSolid, + } + + renderable.vao.WithSetter(func(gl opengl.OpenGL) { + gl.EnableVertexAttribArray(uint32(renderable.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(renderable.vertexPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) + + return renderable +} + +// Dispose releases any internal resources. +func (renderable *MapTextures) Dispose() { + gl := renderable.context.OpenGL + + renderable.vao.Dispose() + gl.DeleteProgram(renderable.program) + gl.DeleteBuffers([]uint32{renderable.vertexPositionBuffer}) +} + +// Render renders the textures. +func (renderable *MapTextures) Render(columns, rows int, tileTextureQuery TileTextureQuery, paletteTexture *graphics.PaletteTexture) { + gl := renderable.context.OpenGL + + renderable.vao.OnShader(func() { + renderable.viewMatrixUniform.Set(gl, renderable.context.ViewMatrix) + renderable.projectionMatrixUniform.Set(gl, &renderable.context.ProjectionMatrix) + + textureUnit := int32(0) + gl.ActiveTexture(opengl.TEXTURE0 + uint32(textureUnit)) + gl.BindTexture(opengl.TEXTURE_2D, paletteTexture.Handle()) + gl.Uniform1i(renderable.paletteUniform, textureUnit) + + textureUnit = 1 + gl.ActiveTexture(opengl.TEXTURE0 + uint32(textureUnit)) + gl.Uniform1i(renderable.bitmapUniform, textureUnit) + + scaling := mgl.Scale3D(level.FineCoordinatesPerTileSide, level.FineCoordinatesPerTileSide, 1.0) + for y := 0; y < rows; y++ { + for x := 0; x < columns; x++ { + tileType, textureIndex, textureRotations := tileTextureQuery(level.TilePosition{X: byte(x), Y: byte(y)}) + if tileType == level.TileTypeSolid { + continue + } + texture, _ := renderable.textureQuery(textureIndex) + if texture == nil { + continue + } + + modelMatrix := mgl.Translate3D(float32(x)*level.FineCoordinatesPerTileSide, float32(y)*level.FineCoordinatesPerTileSide, 0.0). + Mul4(scaling) + + uvMatrix := uvRotations[textureRotations] + renderable.uvMatrixUniform.Set(gl, uvMatrix) + renderable.modelMatrixUniform.Set(gl, &modelMatrix) + gl.BindTexture(opengl.TEXTURE_2D, texture.Handle()) + renderable.renderTileType(tileType) + } + } + + gl.BindTexture(opengl.TEXTURE_2D, 0) + }) +} + +func (renderable *MapTextures) renderTileType(tileType level.TileType) { + info := tileTypeVerticesLookup[int(tileType)] + vertexCount := len(info.vertices) / 3 + gl := renderable.context.OpenGL + if info.equivalentType != renderable.lastTileType { + renderable.lastTileType = info.equivalentType + + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(info.vertices)*4, info.vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + gl.DrawArrays(opengl.TRIANGLES, 0, int32(vertexCount)) +} diff --git a/modding/hacked/editor/levels/ObjectsView.go b/modding/hacked/editor/levels/ObjectsView.go new file mode 100644 index 0000000..e6ed175 --- /dev/null +++ b/modding/hacked/editor/levels/ObjectsView.go @@ -0,0 +1,1020 @@ +package levels + +import ( + "fmt" + "math" + "strings" + + mgl "github.com/go-gl/mathgl/mgl32" + "github.com/inkyblackness/hacked/ui/gui" + "github.com/inkyblackness/hacked/ui/opengl" + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "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/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/ids" +) + +const shouldRenderRotationView = false + +// ObjectsView is for object properties. +type ObjectsView struct { + gameObjects *edit.GameObjectsService + levelSelection *edit.LevelSelectionService + editor *edit.LevelEditorService + varInfoProvider archive.GameVariableInfoProvider + textCache *text.Cache + textureCache *graphics.TextureCache + + guiScale float32 + registry cmd.Registry + + textureRenderer *render.TextureRenderer + orientationView *render.OrientationView + + model objectsViewModel +} + +// NewObjectsView returns a new instance. +func NewObjectsView(gameObjects *edit.GameObjectsService, + editor *edit.LevelEditorService, + levelSelection *edit.LevelSelectionService, + varInfoProvider archive.GameVariableInfoProvider, + guiScale float32, textCache *text.Cache, textureCache *graphics.TextureCache, + registry cmd.Registry, gl opengl.OpenGL) *ObjectsView { + textureRenderer := render.NewTextureRenderer(gl) + viewMatrix := mgl.LookAt(0.35, 0.35, -1, 0, 0, 0, 1.0, 0.0, 0.0) + context := render.Context{ + OpenGL: gl, + ViewMatrix: &viewMatrix, + ProjectionMatrix: mgl.Ortho(-0.5, 0.5, 0.5, -0.5, -10, 10.0), + } + // Set up an orientation view. The orientation parameter would match the + // map as seen from above, and the rotation to match how the objects rotate in world. + orientationView := render.NewOrientationView(context, + mgl.Ident4(). + Mul4(mgl.HomogRotate3D(math.Pi/2, mgl.Vec3{0.0, 0.0, 1.0})). + Mul4(mgl.HomogRotate3D(math.Pi, mgl.Vec3{1.0, 0.0, 0.0})), + mgl.Vec3{1.0, 1.0, -1.0}) + + view := &ObjectsView{ + gameObjects: gameObjects, + levelSelection: levelSelection, + editor: editor, + varInfoProvider: varInfoProvider, + textCache: textCache, + textureCache: textureCache, + + guiScale: guiScale, + registry: registry, + + textureRenderer: textureRenderer, + orientationView: orientationView, + + model: freshObjectsViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *ObjectsView) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *ObjectsView) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + lvl := view.editor.Level() + objects := view.editor.Objects() + imgui.SetNextWindowSizeV(imgui.Vec2{X: 400 * view.guiScale, Y: 500 * view.guiScale}, imgui.ConditionFirstUseEver) + title := fmt.Sprintf("Level Objects, %d selected", len(objects)) + readOnly := view.editor.IsReadOnly() + if readOnly { + title += hintReadOnly + } + if imgui.BeginV(title+"###Level Objects", view.WindowOpen(), imgui.WindowFlagsHorizontalScrollbar|imgui.WindowFlagsAlwaysVerticalScrollbar) { + view.renderContent(lvl, objects, readOnly) + } + imgui.End() + } +} + +func (view *ObjectsView) renderContent(lvl *level.Level, objects []*level.ObjectMainEntry, readOnly bool) { + classUnifier := values.NewUnifier() + typeUnifier := values.NewUnifier() + zUnifier := values.NewUnifier() + tileXUnifier := values.NewUnifier() + fineXUnifier := values.NewUnifier() + tileYUnifier := values.NewUnifier() + fineYUnifier := values.NewUnifier() + rotationXUnifier := values.NewUnifier() + rotationYUnifier := values.NewUnifier() + rotationZUnifier := values.NewUnifier() + hitpointsUnifier := values.NewUnifier() + + for _, obj := range objects { + classUnifier.Add(object.TripleFrom(int(obj.Class), 0, 0)) + typeUnifier.Add(obj.Triple()) + zUnifier.Add(obj.Z) + tileXUnifier.Add(obj.X.Tile()) + fineXUnifier.Add(obj.X.Fine()) + tileYUnifier.Add(obj.Y.Tile()) + fineYUnifier.Add(obj.Y.Fine()) + rotationXUnifier.Add(int32(obj.XRotation)) + rotationYUnifier.Add(int32(obj.YRotation)) + rotationZUnifier.Add(int32(obj.ZRotation)) + hitpointsUnifier.Add(obj.Hitpoints) + } + + imgui.PushItemWidth(-150 * view.guiScale) + columns, rows, levelHeight := lvl.Size() + + objectHeightFormatter := objectHeightFormatterFor(levelHeight) + + if !readOnly { + classString := func(class object.Class) string { + active, capacity := lvl.ObjectClassStats(class) + if class == object.ClassSmallStuff { + // The engine backs up inventory items in the level during a level transition. + // For this it requires space. If there is none, bugs do appear. + // This limit could apply for several classes, yet only small stuff is being used. + practicalLimit := 0 + if capacity > level.InventorySize { + practicalLimit = capacity - level.InventorySize + } + return fmt.Sprintf("%2d: %v -- %d/%d (%d)", int(class), class, active, practicalLimit, capacity) + } + return fmt.Sprintf("%2d: %v -- %d/%d", int(class), class, active, capacity) + } + if imgui.BeginCombo("New Object Class", classString(view.editor.NewObjectTriple().Class)) { + for _, class := range object.Classes() { + if imgui.SelectableV(classString(class), class == view.editor.NewObjectTriple().Class, 0, imgui.Vec2{}) { + view.editor.SetNewObjectTriple(object.TripleFrom(int(class), 0, 0)) + } + } + imgui.EndCombo() + } + if imgui.BeginCombo("New Object Type", view.tripleName(view.editor.NewObjectTriple())) { + allTypes := view.gameObjects.AllProperties().TriplesInClass(view.editor.NewObjectTriple().Class) + bitmapInfo := view.gameObjects.BitmapInfo() + lineHeight := imgui.TextLineHeight() + iconSize := imgui.Vec2{X: lineHeight * view.guiScale, Y: lineHeight * view.guiScale} + for index, triple := range allTypes { + info := bitmapInfo[triple] + iconKey := resource.KeyOf(ids.ObjectBitmaps, resource.LangAny, info.Start+info.IconRecommendation) + imgui.PushIDInt(index) + imgui.BeginGroup() + render.TextureImage("Icon", view.textureCache, iconKey, iconSize) + imgui.SameLine() + if imgui.SelectableV(view.tripleName(triple), triple == view.editor.NewObjectTriple(), 0, imgui.Vec2{}) { + view.editor.SetNewObjectTriple(triple) + } + imgui.EndGroup() + imgui.PopID() + } + imgui.EndCombo() + } + if imgui.Button("Delete Selected") { + view.requestDeleteObjects() + } + imgui.Separator() + } + + selectedIDs := view.levelSelection.CurrentSelectedObjects() + switch { + case len(selectedIDs) == 1: + imgui.LabelText("ID", fmt.Sprintf("%3d", int(selectedIDs[0]))) + case len(selectedIDs) > 1: + imgui.LabelText("ID", "(multiple)") + default: + imgui.LabelText("ID", "") + } + + objTypeRenderer := values.ObjectTypeControlRenderer{ + Meta: view.gameObjects.AllProperties(), + TextCache: view.textCache, + } + objTypeRenderer.Render(readOnly, "Object Type", classUnifier, typeUnifier, + func(u values.Unifier) object.Triple { return u.Unified().(object.Triple) }, + func(newValue object.Triple) { + view.requestChangeObjects("ChangeBaseType", func(entry *level.ObjectMainEntry) { + entry.Subclass = newValue.Subclass + entry.Type = newValue.Type + }) + }) + + if imgui.TreeNodeV("Base Properties", imgui.TreeNodeFlagsDefaultOpen|imgui.TreeNodeFlagsFramed) { + values.RenderUnifiedSliderInt(readOnly, "Z", zUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.HeightUnit)) }, + objectHeightFormatter, + 0, 0xFF, + func(newValue int) { + view.requestChangeObjects("ChangeBaseZ", func(entry *level.ObjectMainEntry) { entry.Z = level.HeightUnit(newValue) }) + }) + values.RenderUnifiedSliderInt(readOnly, "Tile X", tileXUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, columns-1, + func(newValue int) { + view.requestChangeObjects("ChangeBaseTileX", func(entry *level.ObjectMainEntry) { entry.X = level.CoordinateAt(byte(newValue), entry.X.Fine()) }) + }) + values.RenderUnifiedSliderInt(readOnly, "Fine X", fineXUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, level.FineCoordinatesPerTileSide-1, + func(newValue int) { + view.requestChangeObjects("ChangeBaseFineX", func(entry *level.ObjectMainEntry) { entry.X = level.CoordinateAt(entry.X.Tile(), byte(newValue)) }) + }) + values.RenderUnifiedSliderInt(readOnly, "Tile Y", tileYUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, rows-1, + func(newValue int) { + view.requestChangeObjects("ChangeBaseTileY", func(entry *level.ObjectMainEntry) { entry.Y = level.CoordinateAt(byte(newValue), entry.Y.Fine()) }) + }) + values.RenderUnifiedSliderInt(readOnly, "Fine Y", fineYUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, level.FineCoordinatesPerTileSide-1, + func(newValue int) { + view.requestChangeObjects("ChangeFineY", func(entry *level.ObjectMainEntry) { entry.Y = level.CoordinateAt(entry.Y.Tile(), byte(newValue)) }) + }) + + values.RenderUnifiedRotation(readOnly, "Rotation X", rotationXUnifier, + 0, 0x0FF, + values.RotationInfo{Horizontal: true, Positive: false, Clockwise: false}, + func(newValue int) { + view.requestChangeObjects("ChangeBaseRotationX", func(entry *level.ObjectMainEntry) { entry.XRotation = level.RotationUnit(newValue) }) + }) + values.RenderUnifiedRotation(readOnly, "Rotation Y", rotationYUnifier, + 0, 0x0FF, + values.RotationInfo{Horizontal: false, Positive: true, Clockwise: true}, + func(newValue int) { + view.requestChangeObjects("ChangeBaseRotationY", func(entry *level.ObjectMainEntry) { entry.YRotation = level.RotationUnit(newValue) }) + }) + values.RenderUnifiedRotation(readOnly, "Rotation Z", rotationZUnifier, + 0, 0x0FF, + values.RotationInfo{Horizontal: false, Positive: false, Clockwise: true}, + func(newValue int) { + view.requestChangeObjects("ChangeBaseRotationZ", func(entry *level.ObjectMainEntry) { entry.ZRotation = level.RotationUnit(newValue) }) + }) + + if shouldRenderRotationView { + var rotation mgl.Vec3 + if rotationXUnifier.IsUnique() { + rotation[0] = float32(rotationXUnifier.Unified().(int32)*360) / 256.0 + } + if rotationYUnifier.IsUnique() { + rotation[1] = float32(rotationYUnifier.Unified().(int32)*360) / 256.0 + } + if rotationZUnifier.IsUnique() { + rotation[2] = float32(rotationZUnifier.Unified().(int32)*360) / 256.0 + } + view.textureRenderer.Render(func() { view.orientationView.Render(rotation) }) + imgui.ImageV(gui.TextureIDForColorTexture(view.textureRenderer.Handle()), + imgui.Vec2{X: 200 * view.guiScale, Y: 200 * view.guiScale}, + imgui.Vec2{X: 0.0, Y: 0.0}, imgui.Vec2{X: 1.0, Y: 1.0}, + imgui.Vec4{X: 1, Y: 1, Z: 1, W: 1.0}, imgui.Vec4{X: 0, Y: 0, Z: 0, W: 0}) + } + + values.RenderUnifiedSliderInt(readOnly, "Hitpoints", hitpointsUnifier, + func(u values.Unifier) int { return int(u.Unified().(int16)) }, + func(value int) string { return "%d" }, + 0, 10000, + func(newValue int) { + view.requestChangeObjects("ChangeBaseHitpoints", func(entry *level.ObjectMainEntry) { entry.Hitpoints = int16(newValue) }) + }) + imgui.TreePop() + } + if imgui.TreeNodeV("Extra Properties", imgui.TreeNodeFlagsFramed) { + view.renderProperties(lvl, objects, readOnly, + func(obj *level.ObjectMainEntry) *interpreters.Instance { return lvl.ObjectExtraData(obj) }) + imgui.TreePop() + } + if imgui.TreeNodeV("Class Properties", imgui.TreeNodeFlagsFramed) { + view.renderProperties(lvl, objects, readOnly, + func(obj *level.ObjectMainEntry) *interpreters.Instance { return lvl.ObjectClassData(obj) }) + view.renderBlockPuzzleControl(lvl, objects, readOnly) + imgui.TreePop() + } + + imgui.PopItemWidth() +} + +func (view *ObjectsView) renderProperties(lvl *level.Level, objects []*level.ObjectMainEntry, readOnly bool, + interpreterRetriever func(*level.ObjectMainEntry) *interpreters.Instance) { + propertyUnifier := make(map[string]*values.Unifier) + propertyDescribers := make(map[string]func(*interpreters.Simplifier)) + var propertyOrder []string + describer := func(interpreter *interpreters.Instance, key string) func(simpl *interpreters.Simplifier) { + return func(simpl *interpreters.Simplifier) { interpreter.Describe(key, simpl) } + } + + var unifyInterpreter func(string, *interpreters.Instance, bool, map[string]bool) + unifyInterpreter = func(path string, interpreter *interpreters.Instance, first bool, thisKeys map[string]bool) { + for _, key := range interpreter.Keys() { + fullPath := path + key + thisKeys[fullPath] = true + if unifier, existing := propertyUnifier[fullPath]; existing || first { + if !existing { + u := values.NewUnifier() + unifier = &u + propertyUnifier[fullPath] = unifier + propertyDescribers[fullPath] = describer(interpreter, key) + propertyOrder = append(propertyOrder, fullPath) + } + unifier.Add(int32(interpreter.Get(key))) + } + } + for _, key := range interpreter.ActiveRefinements() { + unifyInterpreter(path+key+".", interpreter.Refined(key), first, thisKeys) + } + } + + for index, obj := range objects { + interpreter := interpreterRetriever(obj) + + thisKeys := make(map[string]bool) + unifyInterpreter("", interpreter, index == 0, thisKeys) + { + var toRemove []string + for previousKey := range propertyUnifier { + if !thisKeys[previousKey] { + toRemove = append(toRemove, previousKey) + } + } + for _, key := range toRemove { + delete(propertyUnifier, key) + } + } + } + + lastTitle := "" + for _, key := range propertyOrder { + if unifier, existing := propertyUnifier[key]; existing { + subKeys := strings.Split(key, ".") + baseKey := "" + if len(subKeys) > 1 { + baseKey = subKeys[0] + } + if baseKey != lastTitle { + imgui.Separator() + imgui.Text(baseKey + ":") + lastTitle = baseKey + } + view.renderPropertyControl(lvl, readOnly, key, *unifier, propertyDescribers[key], + func(modifier func(uint32) uint32) { + view.requestPropertiesChange(interpreterRetriever, key, modifier) // nolint: scopelint + }) + } + } +} + +func (view *ObjectsView) renderPropertyControl(lvl *level.Level, readOnly bool, + fullKey string, unifier values.Unifier, describer func(*interpreters.Simplifier), + updater func(func(uint32) uint32)) { + keys := strings.Split(fullKey, ".") + key := keys[len(keys)-1] + label := key + "###" + fullKey + _, _, levelHeight := lvl.Size() + tileHeightFormatter := tileHeightFormatterFor(levelHeight) + objectHeightFormatter := objectHeightFormatterFor(levelHeight) + moveTileHeightFormatter := moveTileHeightFormatterFor(levelHeight) + + objTypeRenderer := values.ObjectTypeControlRenderer{ + Meta: view.gameObjects.AllProperties(), + TextCache: view.textCache, + } + simplifier := values.StandardSimplifier(readOnly, fullKey, unifier, updater, objTypeRenderer) + + simplifier.SetObjectIDHandler(func() { + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + func(value int) string { return "%d" }, + 0, lvl.ObjectCapacity(), + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + + addVariableKey := func() { + variableTypes := []string{"boolean", "Integer"} + typeMask := 0x1000 + + typeLabel := key + "-Type###" + fullKey + "-Type" + values.RenderUnifiedCombo(readOnly, typeLabel, unifier, + func(u values.Unifier) int { + value := u.Unified().(int32) + result := 0 + if (value & int32(typeMask)) != 0 { + result = 1 + } + return result + }, + func(index int) string { return variableTypes[index] }, + len(variableTypes), + func(newIndex int) { + setMask := uint32(0) + if newIndex > 0 { + setMask |= uint32(typeMask) + } + updater(func(oldValue uint32) uint32 { + return (oldValue & uint32(0xE000)) | setMask + }) + }) + + valueLabel := key + "-Value###" + fullKey + "-Value" + switch { + case unifier.IsUnique(): + key := unifier.Unified().(int32) + isIntegerVar := (key & int32(typeMask)) != 0 + limit := archive.BooleanVarCount + if isIntegerVar { + limit = archive.IntegerVarCount + } + values.RenderUnifiedSliderInt(readOnly, valueLabel, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) & 0x1FF }, + func(value int) string { + if isIntegerVar { + return fmt.Sprintf("%%d: %s", view.varInfoProvider.IntegerVariable(value).Name) + } + return fmt.Sprintf("%%d: %s", view.varInfoProvider.BooleanVariable(value).Name) + }, + 0, limit-1, + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x01FF) + result |= uint32(newValue) & uint32(0x01FF) + return result + }) + }) + + case !unifier.IsUnique(): + imgui.LabelText(valueLabel, "(multiple)") + default: + imgui.LabelText(valueLabel, "") + } + } + + simplifier.SetSpecialHandler("VariableKey", addVariableKey) + simplifier.SetSpecialHandler("VariableCondition", func() { + addVariableKey() + + comparisons := []string{ + "Var == Val", + "Var < Val", + "Var <= Val", + "Var > Val", + "Var >= Val", + "Var != Val", + } + + comboLabel := key + "-Check###" + fullKey + "-Value" + + values.RenderUnifiedCombo(readOnly, comboLabel, unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int((key >> 13) & 0x7) + }, + func(index int) string { return comparisons[index] }, + len(comparisons), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0xE000) + result |= uint32(newIndex<<13) & uint32(0xE000) + return result + }) + }) + }) + simplifier.SetSpecialHandler("LockVariable", func() { + limit := archive.BooleanVarCount + valueLabel := key + "###" + fullKey + values.RenderUnifiedSliderInt(readOnly, valueLabel, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) & 0x1FF }, + func(value int) string { + return fmt.Sprintf("%%d: %s", view.varInfoProvider.BooleanVariable(value).Name) + }, + 0, limit-1, + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x01FF) + result |= uint32(newValue) & uint32(0x01FF) + return result + }) + }) + }) + + simplifier.SetSpecialHandler("BinaryCodedDecimal", func() { + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return int(numbers.FromBinaryCodedDecimal(uint16(u.Unified().(int32)))) }, + func(value int) string { return "%03d" }, + 0, 999, + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(numbers.ToBinaryCodedDecimal(uint16(newValue))) }) + }) + }) + + simplifier.SetSpecialHandler("LevelTexture", func() { + atlas := lvl.TextureAtlas() + selectedIndex := -1 + if unifier.IsUnique() { + selectedIndex = int(unifier.Unified().(int32)) + } + + values.RenderUnifiedSliderInt(readOnly, key+" (atlas index)###"+fullKey+"-atlas", unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + func(value int) string { return "%d" }, + 0, len(atlas)-1, + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + render.TextureSelector(label, -1, view.guiScale, len(atlas), selectedIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.LargeTextures.Plus(int(atlas[index])), resource.LangAny, 0) + }, + func(index int) string { return view.textureName(int(atlas[index])) }, + func(index int) { + if !readOnly { + updater(func(oldValue uint32) uint32 { return uint32(index) }) + } + }) + }) + simplifier.SetSpecialHandler("MaterialOrLevelTexture", func() { + types := []string{"Material", "Level Texture"} + selectedType := -1 + selectedIndex := -1 + if unifier.IsUnique() { + value := int(unifier.Unified().(int32)) + selectedType = (value >> 7) & 1 + selectedIndex = value & 0x7F + } + + values.RenderUnifiedCombo(readOnly, key+"###"+fullKey+"-Type", unifier, + func(u values.Unifier) int { return selectedType }, + func(index int) string { return types[index] }, + len(types), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { + newValue := uint32(0) + if newIndex > 0 { + newValue = 0x80 + } + return newValue + }) + }) + selectorLabel := key + "-Texture###" + fullKey + "-Texture" + if selectedType == 0 { + resInfo, _ := ids.Info(ids.ObjectMaterialBitmaps) + render.TextureSelector(selectorLabel, -1, view.guiScale, resInfo.MaxCount, selectedIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.ObjectMaterialBitmaps.Plus(index), resource.LangAny, 0) + }, + func(index int) string { return fmt.Sprintf("%3d", index) }, + func(index int) { + if !readOnly { + updater(func(oldValue uint32) uint32 { return uint32(index) }) + } + }) + } else { + atlas := lvl.TextureAtlas() + render.TextureSelector(selectorLabel, -1, view.guiScale, len(atlas), selectedIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.LargeTextures.Plus(int(atlas[index])), resource.LangAny, 0) + }, + func(index int) string { return view.textureName(int(atlas[index])) }, + func(index int) { + if !readOnly { + updater(func(oldValue uint32) uint32 { return uint32(0x80 | index) }) + } + }) + } + }) + + simplifier.SetSpecialHandler("TileHeight", func() { + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + tileHeightFormatter, + 0, int(level.TileHeightUnitMax-1), + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + simplifier.SetSpecialHandler("ObjectHeight", func() { + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + objectHeightFormatter, + 0, 255, + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + simplifier.SetSpecialHandler("MoveTileHeight", func() { + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + moveTileHeightFormatter, + 0, 0x0FFF, + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + + simplifier.SetSpecialHandler("TileType", func() { + values.RenderUnifiedCombo(readOnly, label, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + func(index int) string { return level.TileType(index).String() }, + len(level.TileTypes()), + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + + simplifier.SetSpecialHandler("MultiAnimation", func() { + /* + animationTypes := []string{"Random"} + selectedType := -1 + if unifier.IsUnique() { + value := int(unifier.Unified().(int32)) + selectedType = value >> 12 + } + + values.RenderUnifiedCombo(readOnly, key+"###"+fullKey+"-Type", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int(key >> 12) + }, + func(index int) string { return animationTypes[index] }, + len(animationTypes), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0xF000) + result |= uint32(newIndex<<12) & uint32(0xF000) + return result + }) + }) + */ + + // if selectedType == 0 { + values.RenderUnifiedSliderInt(readOnly, key+" infrequency###"+fullKey+"-infrequency", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int(key & 0x0FFF) + }, + func(value int) string { return "%d" }, + 0, 0x0FFF, + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x0FFF) + result |= uint32(newValue) & uint32(0x0FFF) + return result + }) + }) + //} + }) + + simplifier.SetSpecialHandler("PictureSource", func() { + indexConverter := func(u values.Unifier) int { return int(u.Unified().(int32)) & 0x007F } + indexUpdater := func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x007F) + result |= uint32(newValue) & uint32(0x007F) + return result + }) + } + + sourceType := []string{"Data", "ObjectID"} + selectedType := -1 + if unifier.IsUnique() { + value := int(unifier.Unified().(int32)) + selectedType = (value & 0x1000) >> 12 + } + + imgui.Separator() + imgui.Text(key + ":") + + values.RenderUnifiedCombo(readOnly, "Source###"+fullKey+"-Source", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int((key & 0x1000) >> 12) + }, + func(index int) string { return sourceType[index] }, + len(sourceType), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x1000) + result |= uint32(newIndex<<12) & uint32(0x1000) + return result + }) + }) + + if selectedType == 0 { + dataType := []string{"Screen", "Custom", "Text", "Scrolling Text"} + textureData := -1 + index := -1 + selectedDataType := -1 + + if unifier.IsUnique() { + value := int(unifier.Unified().(int32)) + textureData = value & 0x0FFF + index = textureData & 0x007F + selectedDataType = (value & 0x0180) >> 7 + } + + values.RenderUnifiedCombo(readOnly, "Type###"+fullKey+"-Type", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int((key & 0x0180) >> 7) + }, + func(index int) string { return dataType[index] }, + len(dataType), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x0180) + result |= uint32(newIndex<<7) & uint32(0x0180) + return result + }) + }) + + if selectedDataType == 0 { // Screen + resInfo, _ := ids.Info(ids.ScreenTextures) + + values.RenderUnifiedSliderInt(readOnly, "Index###"+fullKey+"-Index", unifier, + indexConverter, + func(value int) string { return "%d" }, + 0, resInfo.MaxCount-1, + indexUpdater) + + render.TextureSelector(key+"-Screen###"+fullKey+"-Screen", -1, view.guiScale, resInfo.MaxCount, index, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.ScreenTextures.Plus(index), resource.LangAny, 0) + }, + func(index int) string { return fmt.Sprintf("%3d", index) }, + func(index int) { + if !readOnly { + indexUpdater(index) + } + }) + } else if selectedDataType == 1 { // Custom + values.RenderUnifiedSliderInt(readOnly, "Index###"+fullKey+"-Index", unifier, + indexConverter, + func(value int) string { + if value >= 0x78 && value <= (0x78+8) { + return "%d - Camera" + } else if value == 0x77 { + return "%d - Static" + } else if value == 0x76 { + return "%d - SHODAN Proximity" + } else if value >= 0x70 && value <= (0x70+6) { + return "%d - Automap" + } else { + return "%d" + } + }, + 0, 0x007F, + indexUpdater) + + resInfo, _ := ids.Info(ids.ObjectMaterialBitmaps) + if index < resInfo.MaxCount { + render.TextureImage("Preview", view.textureCache, resource.KeyOf(ids.ObjectMaterialBitmaps.Plus(index), resource.LangAny, 0), imgui.Vec2{X: 64 * view.guiScale, Y: 64 * view.guiScale}) + } + } else if selectedDataType == 2 { // Text + values.RenderUnifiedSliderInt(readOnly, "Index###"+fullKey+"-Index", unifier, + indexConverter, + func(value int) string { + if value == 0x007F { + return "%d - (Random number)" + } else { + return "%d" + } + }, + 0, 0x007F, + indexUpdater) + } else if selectedDataType == 3 { // Scrolling Text + values.RenderUnifiedSliderInt(readOnly, "Index###"+fullKey+"-Index", unifier, + indexConverter, + func(value int) string { return "%d" }, + 0, 0x007F, + indexUpdater) + } + + if selectedDataType == 2 || selectedDataType == 3 { + fontNames := []string{"Large Tech", "Small Tech"} + values.RenderUnifiedCombo(readOnly, "Font###"+fullKey+"-Font", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int((key & 0x0800) >> 11) + }, + func(value int) string { return fontNames[value] }, + len(fontNames), + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x0800) + result |= uint32(newValue<<11) & uint32(0x0800) + return result + }) + }) + } + + values.RenderUnifiedSliderInt(readOnly, "Scale###"+fullKey+"-Scale", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int((key & 0x0600) >> 9) + }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x0600) + result |= uint32(newValue<<9) & uint32(0x0600) + return result + }) + }) + } else { + // Indirection to another object + values.RenderUnifiedSliderInt(readOnly, "ObjectID###"+fullKey+"-Object", unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) & 0x0FFF }, + func(value int) string { return "%d" }, + 0, lvl.ObjectCapacity(), + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x0FFF) + result |= uint32(newValue) & uint32(0x0FFF) + return result + }) + }) + } + }) + + describer(simplifier) +} + +func (view *ObjectsView) renderBlockPuzzleControl(lvl *level.Level, objects []*level.ObjectMainEntry, readOnly bool) { + var blockPuzzleData *interpreters.Instance + + if len(objects) == 1 { + interpreter := lvl.ObjectClassData(objects[0]) + isBlockPuzzle := interpreter.Refined("Puzzle").Get("Type") == 0x10 + if isBlockPuzzle { + blockPuzzleData = interpreter.Refined("Puzzle").Refined("Block") + } + } + if blockPuzzleData != nil { + blockPuzzleDataID := level.ObjectID(blockPuzzleData.Get("StateStoreObjectID")) + dataObj := lvl.Object(blockPuzzleDataID) + blockPuzzleDataState := lvl.ObjectClassData(dataObj).Raw() + expectedTriple := object.TripleFrom(int(object.ClassTrap), 0, 1) + + imgui.Separator() + if (dataObj != nil) && (dataObj.InUse != 0) && + (dataObj.Triple() == expectedTriple) && + (len(blockPuzzleDataState) >= (16 + 6)) { + blockLayout := blockPuzzleData.Get("Layout") + blockWidth := int((blockLayout >> 20) & 7) + blockHeight := int((blockLayout >> 24) & 7) + state := level.NewBlockPuzzleState(blockPuzzleDataState[6:6+16], blockHeight, blockWidth) + startRow := 1 + (7-blockHeight)/2 + startColumn := 1 + (7-blockWidth)/2 + var cells [9][9]string + + placeConnector := func(side, offset int, text string) { + xOffsets := []int{offset, offset, -1, blockWidth} + yOffsets := []int{-1, blockHeight, offset, offset} + y := startRow + yOffsets[side] + x := startColumn + xOffsets[side] + + if (x >= 0) && (x < 9) && (y >= 0) && (y < 9) { + cells[y][x] = text + } + } + stateMapping := []string{" ", "X", "+", "(+)", "F", "(F)", "H", "(H)"} + + for row := 0; row < blockHeight; row++ { + for col := 0; col < blockWidth; col++ { + value := state.CellValue(row, col) + cells[startRow+row][startColumn+col] = stateMapping[value] + } + } + placeConnector(int((blockLayout>>7)&3), int((blockLayout>>4)&7), "S") + placeConnector(int((blockLayout>>15)&3), int((blockLayout>>12)&7), "D") + + cellSize := imgui.Vec2{X: imgui.TextLineHeightWithSpacing() * 1.5, Y: imgui.TextLineHeightWithSpacing() * 1.5} + for x := 0; x < 9; x++ { + imgui.BeginGroup() + for y := 0; y < 9; y++ { + cellText := cells[y][x] + imgui.PushID(fmt.Sprintf("%d:%d", x, y)) + if len(cellText) > 0 { + if imgui.ButtonV(cellText, cellSize) && !readOnly { + clickRow := y - startRow + clickCol := x - startColumn + if (clickRow >= 0) && (clickRow < blockHeight) && (clickCol >= 0) && (clickCol < blockWidth) { + oldValue := state.CellValue(clickRow, clickCol) + state.SetCellValue(clickRow, clickCol, (8+oldValue+1)%8) + + // The following seems to do nothing, yet the data is still synchronized. + // This appears to be a bad design - maybe find a better way to design the concept + // of block puzzle references... + view.requestAction("ChangeBlockPuzzle", func() error { + return view.editor.ChangeObjects(func(entry *level.ObjectMainEntry) {}) + }) + } + } + } else { + imgui.Dummy(cellSize) + } + imgui.PopID() + } + imgui.EndGroup() + imgui.SameLine() + } + } else { + imgui.Text("No proper state store found for block puzzle!\n'StateStoreObjectID' must refer to a " + expectedTriple.String() + " object.") + } + } +} + +func (view *ObjectsView) requestChangeObjects(name string, modifier func(*level.ObjectMainEntry)) { + view.requestAction(name, func() error { + return view.editor.ChangeObjects(modifier) + }) +} + +func (view *ObjectsView) requestPropertiesChange(interpreterRetriever func(*level.ObjectMainEntry) *interpreters.Instance, + key string, modifier func(uint32) uint32) { + subKeys := strings.Split(key, ".") + valueIndex := len(subKeys) - 1 + + view.requestAction("ChangeProperties", func() error { + return view.editor.ChangeObjects(func(obj *level.ObjectMainEntry) { + interpreter := interpreterRetriever(obj) + for subIndex := 0; subIndex < valueIndex; subIndex++ { + interpreter = interpreter.Refined(subKeys[subIndex]) + } + subKey := subKeys[valueIndex] + interpreter.Set(subKey, modifier(interpreter.Get(subKey))) + }) + }) +} + +func (view *ObjectsView) requestDeleteObjects() { + view.requestAction("DeleteObjects", view.editor.DeleteObjects) +} + +func (view *ObjectsView) requestAction(name string, nested func() error) { + err := view.registry.Register(cmd.Named(name), + cmd.Forward(view.restoreFocusTask()), + cmd.Nested(nested), + cmd.Reverse(view.restoreFocusTask())) + if err != nil { + panic(err) + } +} + +func (view *ObjectsView) restoreFocusTask() cmd.Task { + return func(modder world.Modder) error { + view.model.restoreFocus = true + return nil + } +} + +func (view *ObjectsView) tripleName(triple object.Triple) string { + suffix := hintUnknown + linearIndex := view.gameObjects.AllProperties().TripleIndex(triple) + if linearIndex >= 0 { + key := resource.KeyOf(ids.ObjectLongNames, resource.LangDefault, linearIndex) + objName, err := view.textCache.Text(key) + if err == nil { + suffix = objName + } + } + return triple.String() + ": " + suffix +} + +func (view *ObjectsView) textureName(index int) string { + key := resource.KeyOf(ids.TextureNames, resource.LangDefault, index) + name, err := view.textCache.Text(key) + suffix := "" + if err == nil { + suffix = ": " + name + } + return fmt.Sprintf("%3d", index) + suffix +} diff --git a/modding/hacked/editor/levels/ObjectsViewModel.go b/modding/hacked/editor/levels/ObjectsViewModel.go new file mode 100644 index 0000000..072a667 --- /dev/null +++ b/modding/hacked/editor/levels/ObjectsViewModel.go @@ -0,0 +1,10 @@ +package levels + +type objectsViewModel struct { + restoreFocus bool + windowOpen bool +} + +func freshObjectsViewModel() objectsViewModel { + return objectsViewModel{} +} diff --git a/modding/hacked/editor/levels/TextureDisplay.go b/modding/hacked/editor/levels/TextureDisplay.go new file mode 100644 index 0000000..4260c93 --- /dev/null +++ b/modding/hacked/editor/levels/TextureDisplay.go @@ -0,0 +1,64 @@ +package levels + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +// TextureDisplay is an enumeration which texture to display in a 2D map view. +type TextureDisplay int + +// TextureDisplay constants are listed below. +const ( + TextureDisplayFloor TextureDisplay = 0 + TextureDisplayWall TextureDisplay = 1 + TextureDisplayCeiling TextureDisplay = 2 +) + +// String returns a textual representation. +func (display TextureDisplay) String() string { + switch display { + case TextureDisplayFloor: + return "Floor" + case TextureDisplayWall: + return "Wall" + case TextureDisplayCeiling: + return "Ceiling" + default: + return fmt.Sprintf("Unknown%d", int(display)) + } +} + +// TextureDisplays returns all TextureDisplay constants. +func TextureDisplays() []TextureDisplay { + return []TextureDisplay{TextureDisplayFloor, TextureDisplayWall, TextureDisplayCeiling} +} + +// TextureDisplayFunc is one that resolves texture properties. +type TextureDisplayFunc func(*level.TileMapEntry) (atlasIndex level.AtlasIndex, textureRotations int) + +// FloorTexture returns the properties for the floor. +func FloorTexture(tile *level.TileMapEntry) (atlasIndex level.AtlasIndex, textureRotations int) { + return tile.TextureInfo.FloorTextureIndex(), tile.Floor.TextureRotations() +} + +// CeilingTexture returns the properties for the ceiling. +func CeilingTexture(tile *level.TileMapEntry) (atlasIndex level.AtlasIndex, textureRotations int) { + return tile.TextureInfo.CeilingTextureIndex(), tile.Ceiling.TextureRotations() +} + +// WallTexture returns the properties for the floor. +func WallTexture(tile *level.TileMapEntry) (atlasIndex level.AtlasIndex, textureRotations int) { + return tile.TextureInfo.WallTextureIndex(), 0 +} + +// Func returns the display func for the current display setting. +func (display TextureDisplay) Func() TextureDisplayFunc { + if display == TextureDisplayFloor { + return FloorTexture + } else if display == TextureDisplayCeiling { + return CeilingTexture + } + return WallTexture +} diff --git a/modding/hacked/editor/levels/TilesView.go b/modding/hacked/editor/levels/TilesView.go new file mode 100644 index 0000000..bb2c090 --- /dev/null +++ b/modding/hacked/editor/levels/TilesView.go @@ -0,0 +1,551 @@ +package levels + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/editor/values" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// TilesView is for tile properties. +type TilesView struct { + editor *edit.LevelEditorService + textCache *text.Cache + textureCache *graphics.TextureCache + + guiScale float32 + registry cmd.Registry + model tilesViewModel +} + +// NewTilesView returns a new instance. +func NewTilesView(editor *edit.LevelEditorService, + guiScale float32, textCache *text.Cache, textureCache *graphics.TextureCache, registry cmd.Registry) *TilesView { + view := &TilesView{ + editor: editor, + textCache: textCache, + textureCache: textureCache, + + guiScale: guiScale, + model: freshTilesViewModel(), + registry: registry, + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *TilesView) WindowOpen() *bool { + return &view.model.windowOpen +} + +// TextureDisplay returns the current setting which textures should be displayed. +func (view TilesView) TextureDisplay() TextureDisplay { + return view.model.textureDisplay +} + +// ColorDisplay returns the current setting which colors should be displayed. +func (view TilesView) ColorDisplay() ColorDisplay { + lvl := view.editor.Level() + if lvl.IsCyberspace() { + return view.model.cyberColorDisplay + } + return view.model.shadowDisplay +} + +// Render renders the view. +func (view *TilesView) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + lvl := view.editor.Level() + tiles := view.editor.Tiles() + readOnly := view.editor.IsReadOnly() + title := fmt.Sprintf("Level Tiles, %d selected", len(tiles)) + if readOnly { + title += hintReadOnly + } + imgui.SetNextWindowSizeV(imgui.Vec2{X: 400 * view.guiScale, Y: 500 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV(title+"###Level Tiles", view.WindowOpen(), 0) { + view.renderContent(lvl, tiles, readOnly) + } + imgui.End() + } +} + +func (view *TilesView) renderContent(lvl *level.Level, tiles []*level.TileMapEntry, readOnly bool) { + isCyberspace := lvl.IsCyberspace() + tileTypeUnifier := values.NewUnifier() + floorHeightUnifier := values.NewUnifier() + ceilingHeightUnifier := values.NewUnifier() + slopeHeightUnifier := values.NewUnifier() + slopeControlUnifier := values.NewUnifier() + musicZoneUnifier := values.NewUnifier() + + floorPaletteIndexUnifier := values.NewUnifier() + ceilingPaletteIndexUnifier := values.NewUnifier() + flightPullTypeUnifier := values.NewUnifier() + gameOfLightStateUnifier := values.NewUnifier() + + floorTextureIndexUnifier := values.NewUnifier() + floorTextureRotationsUnifier := values.NewUnifier() + ceilingTextureIndexUnifier := values.NewUnifier() + ceilingTextureRotationsUnifier := values.NewUnifier() + wallTextureIndexUnifier := values.NewUnifier() + wallTextureOffsetUnifier := values.NewUnifier() + useAdjacentWallTextureUnifier := values.NewUnifier() + wallTexturePatternUnifier := values.NewUnifier() + floorLightUnifier := values.NewUnifier() + floorLightDeltaUnifier := values.NewUnifier() + ceilingLightUnifier := values.NewUnifier() + ceilingLightDeltaUnifier := values.NewUnifier() + deconstructedUnifier := values.NewUnifier() + perildUnifier := values.NewUnifier() + floorHazardUnifier := values.NewUnifier() + ceilingHazardUnifier := values.NewUnifier() + + for _, tile := range tiles { + tileTypeUnifier.Add(tile.Type) + floorHeightUnifier.Add(tile.Floor.AbsoluteHeight()) + ceilingHeightUnifier.Add(tile.Ceiling.AbsoluteHeight()) + slopeHeightUnifier.Add(tile.SlopeHeight) + slopeControlUnifier.Add(tile.Flags.SlopeControl()) + musicZoneUnifier.Add(tile.Flags.MusicZone()) + perildUnifier.Add(tile.Flags.Peril()) + if isCyberspace { + floorPaletteIndexUnifier.Add(tile.TextureInfo.FloorPaletteIndex()) + ceilingPaletteIndexUnifier.Add(tile.TextureInfo.CeilingPaletteIndex()) + flightPullTypeUnifier.Add(tile.Flags.ForCyberspace().FlightPull()) + gameOfLightStateUnifier.Add(tile.Flags.ForCyberspace().GameOfLifeState()) + } else { + flags := tile.Flags.ForRealWorld() + floorTextureIndexUnifier.Add(int(tile.TextureInfo.FloorTextureIndex())) + floorTextureRotationsUnifier.Add(tile.Floor.TextureRotations()) + ceilingTextureIndexUnifier.Add(int(tile.TextureInfo.CeilingTextureIndex())) + ceilingTextureRotationsUnifier.Add(tile.Ceiling.TextureRotations()) + wallTextureIndexUnifier.Add(int(tile.TextureInfo.WallTextureIndex())) + wallTextureOffsetUnifier.Add(flags.WallTextureOffset()) + useAdjacentWallTextureUnifier.Add(flags.UseAdjacentWallTexture()) + wallTexturePatternUnifier.Add(flags.WallTexturePattern()) + floorLightUnifier.Add(level.GradesOfShadow - 1 - flags.FloorShadow()) + floorLightDeltaUnifier.Add(tile.LightDelta.OfFloor()) + ceilingLightUnifier.Add(level.GradesOfShadow - 1 - flags.CeilingShadow()) + ceilingLightDeltaUnifier.Add(tile.LightDelta.OfCeiling()) + deconstructedUnifier.Add(flags.Deconstructed()) + floorHazardUnifier.Add(tile.Floor.HasHazard()) + ceilingHazardUnifier.Add(tile.Ceiling.HasHazard()) + } + } + + imgui.PushItemWidth(-250 * view.guiScale) + + _, _, levelHeight := lvl.Size() + tileHeightFormatter := tileHeightFormatterFor(levelHeight) + + tileTypes := level.TileTypes() + values.RenderUnifiedCombo(readOnly, "Tile Type", tileTypeUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileType)) }, + func(value int) string { return tileTypes[value].String() }, + len(tileTypes), + func(newValue int) { view.changeTiles(setTileTypeTo(level.TileType(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Floor Height", floorHeightUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileHeightUnit)) }, + tileHeightFormatter, + 0, int(level.TileHeightUnitMax)-1, + func(newValue int) { view.changeTiles(setFloorHeightTo(level.TileHeightUnit(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Ceiling Height (abs)", ceilingHeightUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileHeightUnit)) }, + tileHeightFormatter, + 1, int(level.TileHeightUnitMax), + func(newValue int) { view.changeTiles(setCeilingHeightTo(level.TileHeightUnit(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Slope Height", slopeHeightUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileHeightUnit)) }, + tileHeightFormatter, + 0, int(level.TileHeightUnitMax)-1, + func(newValue int) { view.changeTiles(setSlopeHeightTo(level.TileHeightUnit(newValue))) }) + slopeControls := level.TileSlopeControls() + values.RenderUnifiedCombo(readOnly, "Slope Control", slopeControlUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileSlopeControl)) }, + func(value int) string { return slopeControls[value].String() }, + len(slopeControls), + func(newValue int) { view.changeTiles(setSlopeControlTo(slopeControls[newValue])) }) + musicZones := level.TileMusicZones() + values.RenderUnifiedCombo(readOnly, "Music Zone", musicZoneUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileMusicZone)) }, + func(value int) string { return musicZones[value].String() }, + len(musicZones), + func(newValue int) { view.changeTiles(setMusicZoneTo(musicZones[newValue])) }) + values.RenderUnifiedCheckboxCombo(readOnly, "Music Peril", perildUnifier, + func(newValue bool) { view.changeTiles(setPerilTo(newValue)) }) + + imgui.Separator() + + if isCyberspace { + if imgui.BeginCombo("Color View", view.model.cyberColorDisplay.String()) { + displays := ColorDisplays() + for _, display := range displays { + displayString := display.String() + + if imgui.SelectableV(displayString, display == view.model.cyberColorDisplay, 0, imgui.Vec2{}) { + view.model.cyberColorDisplay = display + } + } + imgui.EndCombo() + } + + values.RenderUnifiedSliderInt(readOnly, "Floor Color", floorPaletteIndexUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, bitmap.PaletteSize-1, + func(newValue int) { view.changeTiles(setFloorPaletteIndexTo(newValue)) }) + values.RenderUnifiedSliderInt(readOnly, "Ceiling Color", ceilingPaletteIndexUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, bitmap.PaletteSize-1, + func(newValue int) { view.changeTiles(setCeilingPaletteIndexTo(newValue)) }) + + imgui.Separator() + + flightPulls := level.CyberspaceFlightPulls() + values.RenderUnifiedCombo(readOnly, "Flight Pull Type", flightPullTypeUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.CyberspaceFlightPull)) }, + func(value int) string { return flightPulls[value].String() }, + len(flightPulls), + func(newValue int) { view.changeTiles(setFlightPullTypeTo(flightPulls[newValue])) }) + values.RenderUnifiedSliderInt(readOnly, "Game Of Life State", gameOfLightStateUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { view.changeTiles(setGameOfLightStateTo(newValue)) }) + } else { + atlas := lvl.TextureAtlas() + + if imgui.BeginCombo("Texture View", view.model.textureDisplay.String()) { + displays := TextureDisplays() + for _, display := range displays { + displayString := display.String() + + if imgui.SelectableV(displayString, display == view.model.textureDisplay, 0, imgui.Vec2{}) { + view.model.textureDisplay = display + } + } + imgui.EndCombo() + } + + values.RenderUnifiedSliderInt(readOnly, "Floor Texture (atlas index)", floorTextureIndexUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.FloorCeilingTextureLimit-1, + func(newValue int) { view.changeTiles(setFloorTextureIndexTo(level.AtlasIndex(newValue))) }) + view.renderTextureSelector(readOnly, "Floor Texture", floorTextureIndexUnifier, atlas, 0, level.FloorCeilingTextureLimit-1, + func(newValue int) { view.changeTiles(setFloorTextureIndexTo(level.AtlasIndex(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Floor Texture Rotations", floorTextureRotationsUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { view.changeTiles(setFloorTextureRotationsTo(newValue)) }) + + values.RenderUnifiedSliderInt(readOnly, "Ceiling Texture (atlas index)", ceilingTextureIndexUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.FloorCeilingTextureLimit-1, + func(newValue int) { view.changeTiles(setCeilingTextureIndexTo(level.AtlasIndex(newValue))) }) + view.renderTextureSelector(readOnly, "Ceiling Texture", ceilingTextureIndexUnifier, atlas, 0, level.FloorCeilingTextureLimit-1, + func(newValue int) { view.changeTiles(setCeilingTextureIndexTo(level.AtlasIndex(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Ceiling Texture Rotations", ceilingTextureRotationsUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { view.changeTiles(setCeilingTextureRotationsTo(newValue)) }) + + values.RenderUnifiedSliderInt(readOnly, "Wall Texture (atlas index)", wallTextureIndexUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, len(atlas)-1, + func(newValue int) { view.changeTiles(setWallTextureIndexTo(level.AtlasIndex(newValue))) }) + view.renderTextureSelector(readOnly, "Wall Texture", wallTextureIndexUnifier, atlas, 0, len(atlas)-1, + func(newValue int) { view.changeTiles(setWallTextureIndexTo(level.AtlasIndex(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Wall Texture Offset", wallTextureOffsetUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileHeightUnit)) }, + tileHeightFormatter, + 0, int(level.TileHeightUnitMax)-1, + func(newValue int) { view.changeTiles(setWallTextureOffsetTo(level.TileHeightUnit(newValue))) }) + + values.RenderUnifiedCheckboxCombo(readOnly, "Use Adjacent Wall Texture", useAdjacentWallTextureUnifier, + func(newValue bool) { view.changeTiles(setUseAdjacentWallTextureTo(newValue)) }) + wallTexturePatterns := level.WallTexturePatterns() + values.RenderUnifiedCombo(readOnly, "Wall Texture Pattern", wallTexturePatternUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.WallTexturePattern)) }, + func(value int) string { return wallTexturePatterns[value].String() }, + len(wallTexturePatterns), + func(newValue int) { view.changeTiles(setWallTexturePatternTo(wallTexturePatterns[newValue])) }) + + imgui.Separator() + + if imgui.BeginCombo("Shadow View", view.model.shadowDisplay.String()) { + displays := ColorDisplays() + for _, display := range displays { + displayString := display.String() + + if imgui.SelectableV(displayString, display == view.model.shadowDisplay, 0, imgui.Vec2{}) { + view.model.shadowDisplay = display + } + } + imgui.EndCombo() + } + + values.RenderUnifiedSliderInt(readOnly, "Floor Light", floorLightUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.GradesOfShadow-1, + func(newValue int) { view.changeTiles(setFloorLightTo(newValue)) }) + values.RenderUnifiedSliderInt(readOnly, "Floor Light Delta", floorLightDeltaUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.GradesOfShadow-1, + func(newValue int) { view.changeTiles(setFloorLightDeltaTo(newValue)) }) + values.RenderUnifiedSliderInt(readOnly, "Ceiling Light", ceilingLightUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.GradesOfShadow-1, + func(newValue int) { view.changeTiles(setCeilingLightTo(newValue)) }) + values.RenderUnifiedSliderInt(readOnly, "Ceiling Light Delta", ceilingLightDeltaUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.GradesOfShadow-1, + func(newValue int) { view.changeTiles(setCeilingLightDeltaTo(newValue)) }) + + imgui.Separator() + + values.RenderUnifiedCheckboxCombo(readOnly, "Deconstructed", deconstructedUnifier, + func(newValue bool) { view.changeTiles(setDeconstructedTo(newValue)) }) + values.RenderUnifiedCheckboxCombo(readOnly, "Floor Hazard", floorHazardUnifier, + func(newValue bool) { view.changeTiles(setFloorHazardTo(newValue)) }) + values.RenderUnifiedCheckboxCombo(readOnly, "Ceiling Hazard", ceilingHazardUnifier, + func(newValue bool) { view.changeTiles(setCeilingHazardTo(newValue)) }) + } + + imgui.PopItemWidth() +} + +func (view *TilesView) renderTextureSelector(readOnly bool, label string, unifier values.Unifier, + atlas level.TextureAtlas, minIndex, maxIndex int, changeHandler func(int)) { + selectedIndex := -1 + if unifier.IsUnique() { + selectedIndex = unifier.Unified().(int) + } + + count := maxIndex - minIndex + 1 + if count > len(atlas) { + count = len(atlas) + } + render.TextureSelector(label, -1, view.guiScale, count, selectedIndex-minIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.LargeTextures.Plus(int(atlas[minIndex+index])), resource.LangAny, 0) + }, + func(index int) string { return view.textureName(int(atlas[minIndex+index])) }, + func(index int) { + if !readOnly { + changeHandler(index) + } + }) +} + +func (view *TilesView) textureName(index int) string { + key := resource.KeyOf(ids.TextureNames, resource.LangDefault, index) + name, err := view.textCache.Text(key) + suffix := "" + if err == nil { + suffix = ": " + name + } + return fmt.Sprintf("%3d", index) + suffix +} + +func setTileTypeTo(tileType level.TileType) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Type = tileType + } +} + +func setFloorHeightTo(height level.TileHeightUnit) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Floor = tile.Floor.WithAbsoluteHeight(height) + } +} + +func setCeilingHeightTo(height level.TileHeightUnit) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Ceiling = tile.Ceiling.WithAbsoluteHeight(height) + } +} + +func setSlopeHeightTo(height level.TileHeightUnit) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.SlopeHeight = height + } +} + +func setSlopeControlTo(value level.TileSlopeControl) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.WithSlopeControl(value) + } +} + +func setPerilTo(value bool) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.WithPeril(value) + } +} + +func setMusicZoneTo(value level.TileMusicZone) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.WithMusicZone(value) + } +} + +func setFloorTextureIndexTo(value level.AtlasIndex) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.TextureInfo = tile.TextureInfo.WithFloorTextureIndex(value) + } +} + +func setFloorTextureRotationsTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Floor = tile.Floor.WithTextureRotations(value) + } +} + +func setCeilingTextureIndexTo(value level.AtlasIndex) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.TextureInfo = tile.TextureInfo.WithCeilingTextureIndex(value) + } +} + +func setCeilingTextureRotationsTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Ceiling = tile.Ceiling.WithTextureRotations(value) + } +} + +func setWallTextureIndexTo(value level.AtlasIndex) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.TextureInfo = tile.TextureInfo.WithWallTextureIndex(value) + } +} + +func setWallTextureOffsetTo(value level.TileHeightUnit) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithWallTextureOffset(value).AsTileFlag() + } +} + +func setUseAdjacentWallTextureTo(value bool) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithUseAdjacentWallTexture(value).AsTileFlag() + } +} + +func setWallTexturePatternTo(value level.WallTexturePattern) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithWallTexturePattern(value).AsTileFlag() + } +} + +func setFloorLightTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithFloorShadow(level.GradesOfShadow - 1 - value).AsTileFlag() + } +} + +func setFloorLightDeltaTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.LightDelta = tile.LightDelta.WithFloor(value) + } +} + +func setCeilingLightTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithCeilingShadow(level.GradesOfShadow - 1 - value).AsTileFlag() + } +} + +func setCeilingLightDeltaTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.LightDelta = tile.LightDelta.WithCeiling(value) + } +} + +func setDeconstructedTo(value bool) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithDeconstructed(value).AsTileFlag() + } +} + +func setFloorHazardTo(value bool) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Floor = tile.Floor.WithHazard(value) + } +} + +func setCeilingHazardTo(value bool) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Ceiling = tile.Ceiling.WithHazard(value) + } +} + +func setFloorPaletteIndexTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.TextureInfo = tile.TextureInfo.WithFloorPaletteIndex(byte(value)) + } +} + +func setCeilingPaletteIndexTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.TextureInfo = tile.TextureInfo.WithCeilingPaletteIndex(byte(value)) + } +} + +func setFlightPullTypeTo(value level.CyberspaceFlightPull) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForCyberspace().WithFlightPull(value).AsTileFlag() + } +} + +func setGameOfLightStateTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForCyberspace().WithGameOfLifeState(value).AsTileFlag() + } +} + +type tileMapEntryModifier func(*level.TileMapEntry) + +func (view *TilesView) changeTiles(modifier tileMapEntryModifier) { + err := view.registry.Register(cmd.Named("ChangeTiles"), + cmd.Forward(view.restoreFocusTask()), + cmd.Nested(func() error { return view.editor.ChangeTiles(modifier) }), + cmd.Reverse(view.restoreFocusTask())) + if err != nil { + panic(err) + } +} + +func (view *TilesView) restoreFocusTask() cmd.Task { + return func(modder world.Modder) error { + view.model.restoreFocus = true + return nil + } +} diff --git a/modding/hacked/editor/levels/TilesViewModel.go b/modding/hacked/editor/levels/TilesViewModel.go new file mode 100644 index 0000000..27c7dea --- /dev/null +++ b/modding/hacked/editor/levels/TilesViewModel.go @@ -0,0 +1,18 @@ +package levels + +type tilesViewModel struct { + textureDisplay TextureDisplay + shadowDisplay ColorDisplay + cyberColorDisplay ColorDisplay + + restoreFocus bool + windowOpen bool +} + +func freshTilesViewModel() tilesViewModel { + return tilesViewModel{ + textureDisplay: TextureDisplayFloor, + shadowDisplay: ColorDisplayNone, + cyberColorDisplay: ColorDisplayNone, + } +} diff --git a/modding/hacked/editor/levels/hazards.go b/modding/hacked/editor/levels/hazards.go new file mode 100644 index 0000000..a3275e6 --- /dev/null +++ b/modding/hacked/editor/levels/hazards.go @@ -0,0 +1,64 @@ +package levels + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +type hazardFormatter func(int) string + +func rawHazardValue(int) string { + return "raw: %d" +} + +func lbpHazardValue(value int) string { + return fmt.Sprintf("%3.1f LBP - raw: %%d", float32(value)/2) +} + +func percentHazardValue(value int) string { + return fmt.Sprintf("%v%%%% - raw: %%d", value*25) +} + +type ceilingHazardInfo struct { + title string + radiationRegister bool + formatter hazardFormatter +} + +var ceilingHazards = []ceilingHazardInfo{ + {title: "Off", radiationRegister: false, formatter: rawHazardValue}, + {title: "Radiation", radiationRegister: true, formatter: lbpHazardValue}, +} + +func currentCeilingHazard(param *level.Parameters) ceilingHazardInfo { + radiationRegister := param.RadiationRegister > 1 + if radiationRegister { + return ceilingHazards[1] + } + return ceilingHazards[0] +} + +type floorHazardInfo struct { + title string + biohazardRegister bool + isGravity bool + formatter hazardFormatter +} + +var floorHazards = []floorHazardInfo{ + {title: "Off", biohazardRegister: false, isGravity: false, formatter: rawHazardValue}, + {title: "Gravity", biohazardRegister: false, isGravity: true, formatter: percentHazardValue}, + {title: "Biohazard", biohazardRegister: true, isGravity: false, formatter: lbpHazardValue}, +} + +func currentFloorHazard(param *level.Parameters) floorHazardInfo { + biohazardRegister := param.BiohazardRegister > 1 + isGravity := param.FloorHazardIsGravity != 0 + if isGravity { + return floorHazards[1] + } else if biohazardRegister { + return floorHazards[2] + } + return floorHazards[0] +} diff --git a/modding/hacked/editor/messages/SetMessageDataCommand.go b/modding/hacked/editor/messages/SetMessageDataCommand.go new file mode 100644 index 0000000..93d9595 --- /dev/null +++ b/modding/hacked/editor/messages/SetMessageDataCommand.go @@ -0,0 +1,52 @@ +package messages + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +type messageDataEntry struct { + oldData [][]byte + newData [][]byte +} + +type setMessageDataCommand struct { + model *viewModel + + key resource.Key + showVerboseText bool + + textEntries map[resource.Language]messageDataEntry + audioEntries map[resource.Language]messageDataEntry +} + +func (cmd setMessageDataCommand) Do(modder world.Modder) error { + return cmd.perform(modder, func(entry messageDataEntry) [][]byte { return entry.newData }) +} + +func (cmd setMessageDataCommand) Undo(modder world.Modder) error { + return cmd.perform(modder, func(entry messageDataEntry) [][]byte { return entry.oldData }) +} + +func (cmd setMessageDataCommand) perform(modder world.Modder, dataResolver func(messageDataEntry) [][]byte) error { + cmd.saveEntries(modder, cmd.key.ID.Plus(cmd.key.Index), cmd.textEntries, dataResolver) + cmd.saveEntries(modder, cmd.key.ID.Plus(cmd.key.Index).Plus(300), cmd.audioEntries, dataResolver) + + cmd.model.restoreFocus = true + cmd.model.currentKey = cmd.key + cmd.model.showVerboseText = cmd.showVerboseText + return nil +} + +func (cmd setMessageDataCommand) saveEntries(modder world.Modder, id resource.ID, + entries map[resource.Language]messageDataEntry, + dataResolver func(messageDataEntry) [][]byte) { + for lang, entry := range entries { + data := dataResolver(entry) + if len(data) > 0 { + modder.SetResourceBlocks(lang, id, data) + } else { + modder.DelResource(lang, id) + } + } +} diff --git a/modding/hacked/editor/messages/View.go b/modding/hacked/editor/messages/View.go new file mode 100644 index 0000000..50bff54 --- /dev/null +++ b/modding/hacked/editor/messages/View.go @@ -0,0 +1,488 @@ +package messages + +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/editor/values" + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/content/text" + "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 messageInfo struct { + title string +} + +var knownMessageTypes = map[resource.ID]messageInfo{ + ids.MailsStart: {title: "Mails"}, + ids.LogsStart: {title: "Logs"}, + ids.FragmentsStart: {title: "Fragments"}, +} +var knownMessageTypesOrder = []resource.ID{ids.MailsStart, ids.LogsStart, ids.FragmentsStart} + +var knownMailTypes = []string{ + "Audio/Text", + "VMail: 'Shield' (0)", + "VMail: 'Grove' (1)", + "VMail: 'Bridge' (2)", + "VMail: 'Laser' (3, 4)", + "VMail: 'Status' (11)", + "VMail: 'Explode' (5..9)", +} + +const videoMailDisplayBase = 256 + +// View provides edit controls for messages. +type View struct { + mod *world.Mod + messageCache *text.ElectronicMessageCache + cp text.Codepage + movieCache *movie.Cache + imageCache *graphics.TextureCache + + modalStateMachine gui.ModalStateMachine + clipboard external.Clipboard + guiScale float32 + commander cmd.Commander + + model viewModel +} + +// NewMessagesView returns a new instance. +func NewMessagesView(mod *world.Mod, messageCache *text.ElectronicMessageCache, cp text.Codepage, + movieCache *movie.Cache, imageCache *graphics.TextureCache, + modalStateMachine gui.ModalStateMachine, clipboard external.Clipboard, + guiScale float32, commander cmd.Commander) *View { + view := &View{ + mod: mod, + messageCache: messageCache, + cp: cp, + movieCache: movieCache, + imageCache: imageCache, + + 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: 640 * view.guiScale, Y: 480 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Messages", view.WindowOpen(), imgui.WindowFlagsNoCollapse) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + if imgui.BeginChildV("Properties", imgui.Vec2{X: 350 * view.guiScale, Y: -200 * view.guiScale}, false, 0) { + imgui.PushItemWidth(-150 * view.guiScale) + if imgui.BeginCombo("Message Type", knownMessageTypes[view.model.currentKey.ID].title) { + for _, id := range knownMessageTypesOrder { + if imgui.SelectableV(knownMessageTypes[id].title, id == view.model.currentKey.ID, 0, imgui.Vec2{}) { + view.model.currentKey.ID = id + view.model.currentKey.Index = 0 + } + } + imgui.EndCombo() + } + info, _ := ids.Info(view.model.currentKey.ID) + index := view.model.currentKey.Index + if gui.StepSliderInt("Index", &index, 0, info.MaxCount-1) { + view.model.currentKey.Index = index + } + 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() + } + + message, msgReadOnly := view.currentMessage() + availableDisplays := view.availableDisplays() + + if imgui.ButtonV("Clear All", imgui.Vec2{}) { + view.requestClearAll() + } + imgui.SameLine() + if !msgReadOnly { + if imgui.ButtonV("Remove All", imgui.Vec2{}) { + view.requestRemoveAll() + } + } else { + imgui.Text("(read-only)") + } + if view.hasAudio() { + imgui.PushID("audio") + imgui.Separator() + sound, soundReadOnly := view.currentSound() + if !sound.Empty() { + imgui.LabelText("Audio", fmt.Sprintf("%.2f sec", sound.Duration())) + if imgui.Button("Export") { + view.requestExportAudio(sound) + } + if !soundReadOnly { + imgui.SameLine() + if imgui.Button("Remove") { + view.requestRemoveAudio() + } + } + imgui.SameLine() + } else { + imgui.LabelText("Audio", "(no sound)") + } + if imgui.Button("Import") { + view.requestImportAudio() + } + imgui.SameLine() + if imgui.Button("Clear") { + view.requestClearAudio() + } + imgui.PopID() + } + imgui.Separator() + + textModes := map[bool]string{true: "Verbose", false: "Terse"} + if imgui.BeginCombo("Text Mode", textModes[view.model.showVerboseText]) { + for _, setting := range []bool{true, false} { + if imgui.SelectableV(textModes[setting], view.model.showVerboseText == setting, 0, imgui.Vec2{}) { + view.model.showVerboseText = setting + } + } + imgui.EndCombo() + } + view.renderText(false, "Title", message.Title, func(newValue string) { + view.requestTextChange(func(msg *text.ElectronicMessage) { msg.Title = text.Blocked(newValue)[0] }) + }) + view.renderText(false, "Sender", message.Sender, func(newValue string) { + view.requestTextChange(func(msg *text.ElectronicMessage) { msg.Sender = text.Blocked(newValue)[0] }) + }) + view.renderText(false, "Subject", message.Subject, func(newValue string) { + view.requestTextChange(func(msg *text.ElectronicMessage) { msg.Subject = text.Blocked(newValue)[0] }) + }) + nextMessageIndex := values.NewUnifier() + nextMessageIndex.Add(message.NextMessage) + values.RenderUnifiedSliderInt(false, "Next Message Index", nextMessageIndex, + func(u values.Unifier) int { return u.Unified().(int) }, + func(int) string { return "%d" }, + -1, 255, + func(newValue int) { + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.NextMessage = newValue }) + }) + + isInterrupt := values.NewUnifier() + isInterrupt.Add(message.IsInterrupt) + values.RenderUnifiedCheckboxCombo(false, "Is Interrupt", isInterrupt, + func(newValue bool) { + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.IsInterrupt = newValue }) + }) + + colorIndex := values.NewUnifier() + colorIndex.Add(message.ColorIndex) + values.RenderUnifiedSliderInt(false, "Color Index", colorIndex, + func(u values.Unifier) int { return u.Unified().(int) }, + func(int) string { return "%d" }, + -1, 255, + func(newValue int) { + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.ColorIndex = newValue }) + }) + + if view.model.currentKey.ID == ids.MailsStart { + mailType := message.LeftDisplay - videoMailDisplayBase + 1 + selectedType := fmt.Sprintf("Unknown (0x%03X)", message.LeftDisplay) + if mailType < 0 { + mailType = 0 + } + if mailType < len(knownMailTypes) { + selectedType = knownMailTypes[mailType] + } + if msgReadOnly { + imgui.LabelText("Mail Type", selectedType) + } else if imgui.BeginCombo("Mail Type", selectedType) { + for typeIndex, typeString := range knownMailTypes { + if imgui.SelectableV(typeString, mailType == typeIndex, 0, imgui.Vec2{}) { + newIndex := -1 + if typeIndex > 0 { + newIndex = videoMailDisplayBase + (typeIndex - 1) + } + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.LeftDisplay = newIndex }) + } + } + imgui.EndCombo() + } + } + if message.LeftDisplay < videoMailDisplayBase { + view.renderDisplayGallery(false, "Left Display", message.LeftDisplay, availableDisplays, + func(newValue int) { + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.LeftDisplay = newValue }) + }) + view.renderDisplayGallery(false, "Right Display", message.RightDisplay, availableDisplays, + func(newValue int) { + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.RightDisplay = newValue }) + }) + } + + imgui.PopItemWidth() + } + imgui.EndChild() + + view.renderMFDs() +} + +func (view *View) renderMFDs() { + message, _ := view.currentMessage() + + view.renderSideImage("LeftMFD", message.LeftDisplay) + imgui.SameLineV(0, 0) + textToDisplay := message.VerboseText + if imgui.BeginChildV("Text", imgui.Vec2{X: -250 * view.guiScale, Y: 190 * view.guiScale}, true, 0) { + if !view.model.showVerboseText { + textToDisplay = message.TerseText + } + + imgui.PushTextWrapPos() + if len(textToDisplay) == 0 { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1.0, Y: 1.0, Z: 1.0, W: 0.5}) + imgui.Text("(empty)") + imgui.PopStyleColor() + } else { + imgui.Text(textToDisplay) + } + imgui.PopTextWrapPos() + } + imgui.EndChild() + view.clipboardPopup(false, "Text", textToDisplay, func(newValue string) { + view.requestTextChange(func(msg *text.ElectronicMessage) { + if view.model.showVerboseText { + msg.VerboseText = newValue + } else { + msg.TerseText = newValue + } + }) + }) + imgui.SameLineV(0, 0) + view.renderSideImage("RightMFD", message.RightDisplay) +} + +func (view View) currentMessage() (msg text.ElectronicMessage, readOnly bool) { + msg = view.messageOf(view.model.currentKey) + readOnly = len(view.mod.ModifiedBlocks(view.model.currentKey.Lang, view.model.currentKey.ID.Plus(view.model.currentKey.Index))) == 0 + return +} + +func (view View) hasAudio() bool { + return (view.model.currentKey.ID == ids.MailsStart) || (view.model.currentKey.ID == ids.LogsStart) +} + +func (view *View) currentSound() (audio.L8, bool) { + if view.hasAudio() { + key := view.model.currentKey + key.ID = key.ID.Plus(300) + sound, _ := view.movieCache.Audio(key) + readOnly := len(view.mod.ModifiedBlocks(key.Lang, key.ID.Plus(view.model.currentKey.Index))) == 0 + return sound, readOnly + } + return audio.L8{}, true +} + +func (view View) messageOf(key resource.Key) text.ElectronicMessage { + msg, cacheErr := view.messageCache.Message(key) + if cacheErr != nil { + msg = text.EmptyElectronicMessage() + } + return msg +} + +func (view *View) availableDisplays() int { + selector := view.mod.LocalizedResources(view.model.currentKey.Lang) + res, err := selector.Select(ids.MfdDataBitmaps) + if err != nil { + return 0 + } + return res.BlockCount() +} + +func (view *View) renderText(readOnly bool, label string, value string, changeCallback func(string)) { + imgui.LabelText(label, value) + view.clipboardPopup(readOnly, label, value, changeCallback) +} + +func (view *View) clipboardPopup(readOnly bool, label string, value string, changeCallback func(string)) { + if imgui.BeginPopupContextItemV(label+"-Popup", 1) { + if imgui.Selectable("Copy to Clipboard") { + view.clipboard.SetString(value) + } + if !readOnly && imgui.Selectable("Copy from Clipboard") { + newValue, err := view.clipboard.String() + if err == nil { + changeCallback(newValue) + } + } + imgui.EndPopup() + } +} + +func (view *View) renderDisplayGallery(readOnly bool, label string, index, availableDisplays int, + changeCallback func(int)) { + unifier := values.NewUnifier() + unifier.Add(index) + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(int) string { return "%d" }, + -1, availableDisplays-1, + changeCallback) + render.TextureSelector("###"+label+" Texture", -1, view.guiScale, availableDisplays, + index, view.imageCache, + func(index int) resource.Key { + return resource.KeyOf(ids.MfdDataBitmaps, view.model.currentKey.Lang, index) + }, + func(index int) string { return fmt.Sprintf("%d", index) }, + changeCallback) +} + +func (view *View) renderSideImage(label string, index int) { + size := imgui.Vec2{X: 250 * view.guiScale, Y: 190 * view.guiScale} + imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{X: size.X, Y: -size.Y}) + if imgui.BeginChildV(label, size, false, 0) { + if index >= 0 { + key := resource.KeyOf(ids.MfdDataBitmaps, view.model.currentKey.Lang, index) + render.TextureImage(label, view.imageCache, key, size) + } + } + imgui.PopStyleVar() + imgui.EndChild() +} + +func (view *View) requestExportAudio(sound audio.L8) { + filename := fmt.Sprintf("%05d_%s.wav", + view.model.currentKey.ID.Plus(view.model.currentKey.Index).Plus(300).Value(), + view.model.currentKey.Lang.String()) + + external.ExportAudio(view.modalStateMachine, filename, sound) +} + +func (view *View) requestRemoveAudio() { + view.requestAudioChange(nil) +} + +func (view *View) requestImportAudio() { + external.ImportAudio(view.modalStateMachine, func(sound audio.L8) { + movieData := movie.ContainSoundData(sound) + view.requestAudioChange(movieData) + }) +} + +func (view *View) requestClearAudio() { + view.requestAudioChange(view.silence()) +} + +func (view View) silence() []byte { + silence := audio.L8{SampleRate: 22050, Samples: []byte{0x80}} + return movie.ContainSoundData(silence) +} + +func (view *View) requestClearAll() { + view.requestWipe(text.EmptyElectronicMessage().Encode(view.cp), view.silence()) +} + +func (view *View) requestRemoveAll() { + view.requestWipe(nil, nil) +} + +func (view *View) requestWipe(newTextData [][]byte, newAudioData []byte) { + textEntries := make(map[resource.Language]messageDataEntry) + audioEntries := make(map[resource.Language]messageDataEntry) + textID := view.model.currentKey.ID.Plus(view.model.currentKey.Index) + for lang := resource.Language(0); lang < resource.LanguageCount; lang++ { + textEntries[lang] = messageDataEntry{ + oldData: view.mod.ModifiedBlocks(lang, textID), + newData: newTextData, + } + if view.hasAudio() { + audioEntries[lang] = messageDataEntry{ + oldData: view.mod.ModifiedBlocks(lang, textID.Plus(300)), + newData: [][]byte{newAudioData}, + } + } + } + view.requestSetMessageData(textEntries, audioEntries) +} + +func (view *View) requestTextChange(modifier func(*text.ElectronicMessage)) { + msg := view.messageOf(view.model.currentKey) + entries := make(map[resource.Language]messageDataEntry) + modifier(&msg) + + entries[view.model.currentKey.Lang] = messageDataEntry{ + oldData: view.mod.ModifiedBlocks(view.model.currentKey.Lang, view.model.currentKey.ID.Plus(view.model.currentKey.Index)), + newData: msg.Encode(view.cp), + } + view.requestSetMessageData(entries, nil) +} + +func (view *View) requestPropertyChange(modifier func(*text.ElectronicMessage)) { + entries := make(map[resource.Language]messageDataEntry) + for lang := resource.Language(0); lang < resource.LanguageCount; lang++ { + key := view.model.currentKey + key.Lang = lang + msg := view.messageOf(key) + modifier(&msg) + + entries[lang] = messageDataEntry{ + oldData: view.mod.ModifiedBlocks(lang, key.ID.Plus(key.Index)), + newData: msg.Encode(view.cp), + } + } + view.requestSetMessageData(entries, nil) +} + +func (view *View) requestAudioChange(movieData []byte) { + entries := make(map[resource.Language]messageDataEntry) + + entries[view.model.currentKey.Lang] = messageDataEntry{ + oldData: view.mod.ModifiedBlocks(view.model.currentKey.Lang, view.model.currentKey.ID.Plus(view.model.currentKey.Index).Plus(300)), + newData: [][]byte{movieData}, + } + view.requestSetMessageData(nil, entries) +} + +func (view *View) requestSetMessageData(textEntries map[resource.Language]messageDataEntry, audioEntries map[resource.Language]messageDataEntry) { + command := setMessageDataCommand{ + key: view.model.currentKey, + showVerboseText: view.model.showVerboseText, + model: &view.model, + textEntries: textEntries, + audioEntries: audioEntries, + } + view.commander.Queue(command) +} diff --git a/modding/hacked/editor/messages/ViewModel.go b/modding/hacked/editor/messages/ViewModel.go new file mode 100644 index 0000000..cd39528 --- /dev/null +++ b/modding/hacked/editor/messages/ViewModel.go @@ -0,0 +1,21 @@ +package messages + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + + currentKey resource.Key + showVerboseText bool +} + +func freshViewModel() viewModel { + return viewModel{ + currentKey: resource.KeyOf(ids.MailsStart, resource.LangDefault, 0), + showVerboseText: true, + } +} diff --git a/modding/hacked/editor/movies/CompressingStartState.go b/modding/hacked/editor/movies/CompressingStartState.go new file mode 100644 index 0000000..e939adf --- /dev/null +++ b/modding/hacked/editor/movies/CompressingStartState.go @@ -0,0 +1,36 @@ +package movies + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ui/gui" +) + +type compressionListenerFunc func(compressionResult) + +type compressingStartState struct { + machine gui.ModalStateMachine + view *View + + width int + height int + input movie.Scene + listener compressionListenerFunc +} + +func (state compressingStartState) Render() { + imgui.OpenPopup("Compressing...") + task := newCompressionTask(state.input, state.width, state.height) + state.machine.SetState(&compressingWaitingState{ + machine: state.machine, + view: state.view, + input: state.input, + listener: state.listener, + task: task, + }) + go task.run() +} + +func (state compressingStartState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/movies/CompressingWaitingState.go b/modding/hacked/editor/movies/CompressingWaitingState.go new file mode 100644 index 0000000..bb8ecf4 --- /dev/null +++ b/modding/hacked/editor/movies/CompressingWaitingState.go @@ -0,0 +1,45 @@ +package movies + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ui/gui" +) + +type compressingWaitingState struct { + machine gui.ModalStateMachine + view *View + + input movie.Scene + listener compressionListenerFunc + task *compressionTask +} + +func (state *compressingWaitingState) Render() { + if imgui.BeginPopupModalV("Compressing...", nil, + imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + imgui.Text("This may take a while.\n" + + "Yes, your PC is probably capable of recoding HD movies way quicker;\n" + + "Sadly, this codec of '94 is quite tricky.") + + if imgui.Button("Cancel") { + state.task.cancel() + } + + result := state.task.update() + if result != nil { + state.machine.SetState(nil) + imgui.CloseCurrentPopup() + + state.listener(result) + } + + imgui.EndPopup() + } else { + state.machine.SetState(nil) + } +} + +func (state *compressingWaitingState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/movies/CompressionTask.go b/modding/hacked/editor/movies/CompressionTask.go new file mode 100644 index 0000000..4fad787 --- /dev/null +++ b/modding/hacked/editor/movies/CompressionTask.go @@ -0,0 +1,65 @@ +package movies + +import ( + "context" + + "github.com/inkyblackness/hacked/ss1/content/movie" +) + +type compressionTask struct { + width int + height int + input movie.Scene + ctx context.Context + ctxCancel context.CancelFunc + resultChan chan compressionResult +} + +type compressionResult interface{} + +type compressionAborted struct{} + +type compressionFailed struct{ err error } + +type compressionFinished struct{ scene movie.HighResScene } + +func newCompressionTask(scene movie.Scene, width, height int) *compressionTask { + task := &compressionTask{ + width: width, + height: height, + input: scene, + resultChan: make(chan compressionResult), + } + task.ctx, task.ctxCancel = context.WithCancel(context.Background()) + + return task +} + +func (task *compressionTask) run() { + defer close(task.resultChan) + highResScene, err := movie.HighResSceneFrom(task.ctx, task.input, task.width, task.height) + switch { + case task.ctx.Err() != nil: + task.resultChan <- compressionAborted{} + case err != nil: + task.resultChan <- compressionFailed{err: err} + default: + task.resultChan <- compressionFinished{scene: highResScene} + } +} + +func (task *compressionTask) update() compressionResult { + select { + case result, ok := <-task.resultChan: + if ok { + return result + } + return compressionAborted{} + default: + return nil + } +} + +func (task *compressionTask) cancel() { + task.ctxCancel() +} diff --git a/modding/hacked/editor/movies/View.go b/modding/hacked/editor/movies/View.go new file mode 100644 index 0000000..76fb0b9 --- /dev/null +++ b/modding/hacked/editor/movies/View.go @@ -0,0 +1,615 @@ +package movies + +import ( + "fmt" + "image" + "image/color" + "image/gif" + "os" + "path/filepath" + "time" + + "github.com/asticode/go-astisub" + "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/audio" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/movie" + "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" +) + +type movieInfo struct { + title string + multilang bool +} + +var knownMovies = map[resource.ID]movieInfo{ + ids.MovieIntro: {title: "Intro", multilang: true}, + ids.MovieDeath: {title: "Death", multilang: false}, + ids.MovieEnd: {title: "End", multilang: false}, +} + +var knownMoviesOrder = []resource.ID{ids.MovieIntro, ids.MovieDeath, ids.MovieEnd} + +// View provides edit controls for animations. +type View struct { + mod *world.Mod + + frameCache *graphics.FrameCache + frameCacheKey graphics.FrameCacheKey + + movieService undoable.MovieService + + modalStateMachine gui.ModalStateMachine + guiScale float32 + commander cmd.Commander + + model viewModel +} + +// NewMoviesView returns a new instance. +func NewMoviesView(mod *world.Mod, frameCache *graphics.FrameCache, + movieService undoable.MovieService, + modalStateMachine gui.ModalStateMachine, guiScale float32, commander cmd.Commander) *View { + view := &View{ + mod: mod, + + frameCache: frameCache, + frameCacheKey: frameCache.AllocateKey(), + + movieService: movieService, + + 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("Movies", 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("Movie", knownMovies[view.model.currentKey.ID].title) { + for _, id := range knownMoviesOrder { + if imgui.SelectableV(knownMovies[id].title, id == view.model.currentKey.ID, 0, imgui.Vec2{}) { + view.model.currentKey.ID = id + view.model.currentKey.Index = 0 + view.model.currentScene = 0 + view.model.currentFrame = 0 + view.model.frameTimeFraction = -1 + } + } + imgui.EndCombo() + } + + if knownMovies[view.model.currentKey.ID].multilang { + 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 { + imgui.LabelText("Language", "(not localized)") + } + + imgui.Separator() + + view.renderProperties() + + imgui.PopItemWidth() + } + imgui.EndChild() + + scenes := view.movieService.Video(view.model.currentKey) + imgui.SameLine() + imgui.BeginGroup() + imgui.BeginChildV("Scenes", imgui.Vec2{X: 200 * view.guiScale, Y: -60 * view.guiScale}, true, + imgui.WindowFlagsHorizontalScrollbar|imgui.WindowFlagsAlwaysVerticalScrollbar) + for index, scene := range scenes { + sceneInfo := fmt.Sprintf("Scene %02d (%d frames)", index, len(scene.Frames)) + if imgui.SelectableV(sceneInfo, view.model.currentScene == index, 0, imgui.Vec2{}) { + view.model.currentScene = index + view.model.currentFrame = 0 + view.model.frameTimeFraction = -1 + } + } + imgui.EndChild() + if imgui.Button("Up") { + view.requestMoveSceneEarlier() + } + imgui.SameLine() + if imgui.Button("Down") { + view.requestMoveSceneLater() + } + imgui.SameLine() + if imgui.Button("Remove") { + view.requestRemoveScene() + } + if imgui.Button("Import") { + view.requestImportScene("") + } + imgui.SameLine() + if imgui.Button("Export") { + view.requestExportScene() + } + imgui.EndGroup() + imgui.SameLine() + if imgui.BeginChildV("Frames", imgui.Vec2{X: -1, Y: 0}, false, 0) { + var scene *movie.Scene + if (view.model.currentScene >= 0) && (view.model.currentScene < len(scenes)) { + scene = &scenes[view.model.currentScene] + } + if scene != nil { + var frame *movie.Frame + if (view.model.currentFrame >= 0) && (view.model.currentFrame < len(scene.Frames)) { + frame = &scene.Frames[view.model.currentFrame] + } + if frame != nil { + // This code updates the texture every render cycle. In case of performance loss, this is a point to optimize. + view.frameCache.SetTexture(view.frameCacheKey, 600, 300, frame.Pixels, &scene.Palette) + + render.FrameImage("Frame", view.frameCache, view.frameCacheKey, + imgui.Vec2{ + X: float32(movie.HighResDefaultWidth) * view.guiScale, + Y: float32(movie.HighResDefaultHeight) * view.guiScale, + }) + } + + imgui.PushItemWidth(-150 * view.guiScale) + gui.StepSliderInt("Frame Index", &view.model.currentFrame, 0, len(scene.Frames)-1) + imgui.PopItemWidth() + if (frame != nil) && (frame.DisplayTime < (time.Second / 4)) { + imgui.Separator() + if view.model.frameTimeFraction < 0 { + view.model.frameTimeFraction = int((frame.DisplayTime * 0x10000) / time.Second) + } + imgui.PushItemWidth(-150 * view.guiScale) + gui.StepSliderInt("Frame time fraction", &view.model.frameTimeFraction, 0, 0x3FFF) + imgui.PopItemWidth() + if imgui.Button("Set for scene") { + displayTime := (time.Duration(view.model.frameTimeFraction) * time.Second) / 0x10000 + view.requestSetFramesDisplayTime(displayTime) + } + } + } + } + imgui.EndChild() +} + +func (view *View) renderProperties() { + view.renderAudioProperties() + view.renderSubtitlesProperties() + + if view.movieService.SizeWarning(view.model.currentKey) { + view.renderSizeWarning() + } +} + +func (view *View) renderAudioProperties() { + imgui.PushID("audio") + imgui.Separator() + sound := view.currentSound() + imgui.LabelText("Audio", fmt.Sprintf("%.2f sec", sound.Duration())) + if imgui.Button("Import") { + view.requestImportAudio() + } + if !sound.Empty() { + imgui.SameLine() + if imgui.Button("Export") { + view.requestExportAudio(sound) + } + imgui.SameLine() + if imgui.Button("Clear") { + view.requestClearAudio() + } + } + imgui.PopID() +} + +func (view *View) renderSubtitlesProperties() { + imgui.PushID("subtitles") + imgui.Separator() + if imgui.BeginCombo("Sub Language", view.model.currentSubtitleLang.String()) { + languages := resource.Languages() + for _, lang := range languages { + if imgui.SelectableV(lang.String(), lang == view.model.currentSubtitleLang, 0, imgui.Vec2{}) { + view.model.currentSubtitleLang = lang + } + } + imgui.EndCombo() + } + sub := view.currentSubtitles() + imgui.Text(fmt.Sprintf("%d lines", len(sub.Entries))) + if imgui.Button("Import") { + view.requestImportSubtitles() + } + if len(sub.Entries) > 0 { + imgui.SameLine() + if imgui.Button("Export") { + view.requestExportSubtitles() + } + imgui.SameLine() + if imgui.Button("Clear") { + view.requestClearSubtitles() + } + } + imgui.PopID() +} + +func (view *View) renderSizeWarning() { + imgui.Separator() + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1}) + imgui.Text("Size warning - reduce content!") + imgui.PopStyleColor() + if imgui.IsItemHovered() { + imgui.BeginTooltip() + imgui.SetTooltip("The movie has reached a size larger than officially supported.\n" + + "While the game might still show the movie, a future update may stop supporting it.") + imgui.EndTooltip() + } +} + +func (view *View) restoreFunc() func() { + return view.restoreFuncWithScene(view.model.currentScene) +} + +func (view *View) restoreFuncWithScene(oldScene int) func() { + oldKey := view.model.currentKey + oldSubtitlesLang := view.model.currentSubtitleLang + oldFrame := view.model.currentFrame + + return func() { + view.model.restoreFocus = true + view.model.currentKey = oldKey + view.model.currentSubtitleLang = oldSubtitlesLang + view.model.currentScene = oldScene + view.model.currentFrame = oldFrame + view.model.frameTimeFraction = -1 + } +} + +func (view *View) currentSound() audio.L8 { + return view.movieService.Audio(view.model.currentKey) +} + +func (view *View) requestExportAudio(sound audio.L8) { + filename := fmt.Sprintf("%s_%s.wav", knownMovies[view.model.currentKey.ID].title, view.model.currentKey.Lang.String()) + + external.ExportAudio(view.modalStateMachine, filename, sound) +} + +func (view *View) requestImportAudio() { + external.ImportAudio(view.modalStateMachine, func(sound audio.L8) { + view.movieService.RequestSetAudio(view.model.currentKey, sound, view.restoreFunc()) + }) +} + +func (view *View) requestClearAudio() { + view.movieService.RequestSetAudio(view.model.currentKey, audio.L8{}, view.restoreFunc()) +} + +func (view *View) currentSubtitles() movie.SubtitleList { + return view.movieService.Subtitles(view.model.currentKey, view.model.currentSubtitleLang) +} + +func (view View) requestExportSubtitles() { + filename := fmt.Sprintf("%s_%s.srt", knownMovies[view.model.currentKey.ID].title, view.model.currentSubtitleLang.String()) + info := "File to be written: " + filename + var exportTo func(string) + currentSubtitles := view.currentSubtitles() + + 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() }() + + sub := astisub.NewSubtitles() + + var lastItem *astisub.Item + for _, entry := range currentSubtitles.Entries { + var item astisub.Item + var line astisub.Line + line.Items = append(line.Items, astisub.LineItem{Text: entry.Text}) + item.Lines = []astisub.Line{line} + item.StartAt = entry.Timestamp + item.EndAt = item.StartAt + if lastItem != nil { + lastItem.EndAt = item.StartAt + } + lastItem = &item + sub.Items = append(sub.Items, lastItem) + } + + err = sub.WriteToSRT(writer) + if err != nil { + external.Export(view.modalStateMachine, "Could not export subtitles.\n"+info, exportTo, true) + return + } + } + + external.Export(view.modalStateMachine, info, exportTo, false) +} + +func (view *View) requestImportSubtitles() { + info := "File must be an .SRT file." + types := []external.TypeInfo{{Title: "Subtitle files (*.srt)", Extensions: []string{"srt"}}} + 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() }() + + subtitles, err := astisub.ReadFromSRT(reader) + if err != nil { + external.Import(view.modalStateMachine, "File not recognized as SRT.\n"+info, types, fileHandler, true) + return + } + var newSubtitles movie.SubtitleList + for _, item := range subtitles.Items { + var newEntry movie.Subtitle + newEntry.Timestamp = item.StartAt + for _, line := range item.Lines { + for _, lineItem := range line.Items { + if len(newEntry.Text) > 0 { + newEntry.Text += "\n" + } + newEntry.Text += lineItem.Text + } + } + newSubtitles.Entries = append(newSubtitles.Entries, newEntry) + } + + view.movieService.RequestSetSubtitles(view.model.currentKey, view.model.currentSubtitleLang, + newSubtitles, view.restoreFunc()) + } + + external.Import(view.modalStateMachine, info, types, fileHandler, false) +} + +func (view *View) requestClearSubtitles() { + view.movieService.RequestSetSubtitles(view.model.currentKey, view.model.currentSubtitleLang, + movie.SubtitleList{}, view.restoreFunc()) +} + +func (view *View) requestImportScene(returningInfo string) { + info := fmt.Sprintf("File must be an animated GIF file in size %dx%d.", + movie.HighResDefaultWidth, movie.HighResDefaultHeight) + 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 + } + + if (data.Config.Width != movie.HighResDefaultWidth) || (data.Config.Height != movie.HighResDefaultHeight) { + external.Import(view.modalStateMachine, info, types, fileHandler, true) + return + } + + var scene movie.Scene + scene.Frames = make([]movie.Frame, len(data.Image)) + var palette bitmap.Palette + if gifPalette, isPal := data.Config.ColorModel.(color.Palette); isPal { + for index, clr := range gifPalette { + r, g, b, _ := clr.RGBA() + palette[index].Red = byte(r >> 8) + palette[index].Green = byte(g >> 8) + palette[index].Blue = byte(b >> 8) + } + } + framebuffer := make([]byte, data.Config.Width*data.Config.Height) + framebufferSnapshot := func() []byte { + buf := make([]byte, len(framebuffer)) + copy(buf, framebuffer) + return buf + } + + var usedColors [256]bool + for _, img := range data.Image { + for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ { + for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ { + colorIndex := img.ColorIndexAt(x, y) + usedColors[colorIndex] = true + } + } + } + var indexMap [256]byte + for i := 0; i < 256; i++ { + indexMap[byte(i)] = byte(i) + } + // Ensure color index 0 is not used. + if usedColors[0] && (data.BackgroundIndex != 0) { + if data.BackgroundIndex == 255 { + indexMap[0] = palette.IndexClosestTo(palette[0], []byte{0, 255}) + } else { + palette[0], palette[data.BackgroundIndex] = palette[data.BackgroundIndex], palette[0] + indexMap[0] = data.BackgroundIndex + indexMap[data.BackgroundIndex] = 0 + } + } + palette[0] = bitmap.RGB{Red: 0x00, Green: 0x00, Blue: 0x00} // default color for background + // In case the last index is in use by the animation, use a similar color as this entry is reserved for subtitles. + if usedColors[255] && (data.BackgroundIndex != 255) { + indexMap[255] = palette.IndexClosestTo(palette[255], []byte{0, 255}) + } + palette[255] = bitmap.RGB{Red: 0x9A, Green: 0x35, Blue: 0x35} // default color in intro + + scene.Palette = palette + for index, img := range data.Image { + for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ { + for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ { + colorIndex := img.ColorIndexAt(x, y) + if colorIndex != data.BackgroundIndex { + framebuffer[y*data.Config.Width+x] = indexMap[colorIndex] + } + } + } + + scene.Frames[index].DisplayTime = time.Duration(data.Delay[index]) * 10 * time.Millisecond // nolint: durationcheck + scene.Frames[index].Pixels = framebufferSnapshot() + } + + view.compressAndAddScene(scene, data.Config.Width, data.Config.Height) + } + + external.Import(view.modalStateMachine, returningInfo+info, types, fileHandler, false) +} + +func (view *View) compressAndAddScene(scene movie.Scene, width, height int) { + view.modalStateMachine.SetState(&compressingStartState{ + machine: view.modalStateMachine, + view: view, + width: width, + height: height, + input: scene, + listener: view.onCompressionResult, + }) +} + +func (view *View) onCompressionResult(result compressionResult) { + switch typedResult := result.(type) { + case compressionAborted: + case compressionFinished: + view.requestAddScene(typedResult.scene) + case compressionFailed: + view.requestImportScene("Could not compress. Follow recommendations and retry.\n" + + "Technical details:\n" + typedResult.err.Error() + "\n\n") + } +} + +func (view *View) requestAddScene(scene movie.HighResScene) { + view.movieService.RequestAddScene(view.model.currentKey, scene, view.restoreFunc()) +} + +func (view *View) requestExportScene() { + filename := fmt.Sprintf("%s_Scene%02d_%s.gif", + knownMovies[view.model.currentKey.ID].title, + view.model.currentScene, + view.model.currentKey.Lang.String()) + info := "File to be written: " + filename + var exportTo func(string) + + scenes := view.movieService.Video(view.model.currentKey) + var scene *movie.Scene + if view.model.currentScene >= 0 && view.model.currentScene < len(scenes) { + scene = &scenes[view.model.currentScene] + } + + if (scene == nil) || len(scene.Frames) == 0 { + return + } + + 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() }() + + colorPalette := scene.Palette.ColorPalette(false) + data := gif.GIF{ + Config: image.Config{ + Width: 600, + Height: 300, + ColorModel: colorPalette, + }, + LoopCount: -1, + } + + imageRect := image.Rect(0, 0, data.Config.Width, data.Config.Height) + for _, frame := range scene.Frames { + frameImg := image.NewPaletted(imageRect, colorPalette) + frameImg.Pix = frame.Pixels + frameImg.Stride = data.Config.Width + data.Image = append(data.Image, frameImg) + data.Delay = append(data.Delay, int(((frame.DisplayTime/time.Millisecond)+5)/10)) + } + + 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) requestMoveSceneEarlier() { + scenes := view.movieService.Video(view.model.currentKey) + if (view.model.currentScene > 0) && (view.model.currentScene < len(scenes)) { + view.movieService.RequestMoveSceneEarlier(view.model.currentKey, view.model.currentScene, + view.restoreFuncWithScene(view.model.currentScene-1)) + } +} + +func (view *View) requestMoveSceneLater() { + scenes := view.movieService.Video(view.model.currentKey) + if (view.model.currentScene >= 0) && (view.model.currentScene < (len(scenes) - 1)) { + view.movieService.RequestMoveSceneLater(view.model.currentKey, view.model.currentScene, + view.restoreFuncWithScene(view.model.currentScene+1)) + } +} + +func (view *View) requestRemoveScene() { + scenes := view.movieService.Video(view.model.currentKey) + if (view.model.currentScene >= 0) && (view.model.currentScene < len(scenes)) { + view.movieService.RequestRemoveScene(view.model.currentKey, view.model.currentScene, view.restoreFunc()) + } +} + +func (view *View) requestSetFramesDisplayTime(displayTime time.Duration) { + view.movieService.RequestSetSceneFramesDisplayTime(view.model.currentKey, view.model.currentScene, displayTime, view.restoreFunc()) +} diff --git a/modding/hacked/editor/movies/ViewModel.go b/modding/hacked/editor/movies/ViewModel.go new file mode 100644 index 0000000..e8bfc81 --- /dev/null +++ b/modding/hacked/editor/movies/ViewModel.go @@ -0,0 +1,26 @@ +package movies + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + + currentKey resource.Key + currentSubtitleLang resource.Language + currentScene int + currentFrame int + + frameTimeFraction int +} + +func freshViewModel() viewModel { + return viewModel{ + currentKey: resource.KeyOf(ids.MovieIntro, resource.LangDefault, 0), + currentSubtitleLang: resource.LangDefault, + frameTimeFraction: -1, + } +} diff --git a/modding/hacked/editor/objects/SetObjectBitmapCommand.go b/modding/hacked/editor/objects/SetObjectBitmapCommand.go new file mode 100644 index 0000000..2d63af6 --- /dev/null +++ b/modding/hacked/editor/objects/SetObjectBitmapCommand.go @@ -0,0 +1,35 @@ +package objects + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setObjectBitmapCommand struct { + model *viewModel + + triple object.Triple + bitmap int + + resourceKey resource.Key + oldData []byte + newData []byte +} + +func (command setObjectBitmapCommand) Do(modder world.Modder) error { + return command.perform(modder, command.newData) +} + +func (command setObjectBitmapCommand) Undo(modder world.Modder) error { + return command.perform(modder, command.oldData) +} + +func (command setObjectBitmapCommand) perform(modder world.Modder, data []byte) error { + modder.SetResourceBlock(command.resourceKey.Lang, command.resourceKey.ID, command.resourceKey.Index, data) + + command.model.restoreFocus = true + command.model.currentObject = command.triple + command.model.currentBitmap = command.bitmap + return nil +} diff --git a/modding/hacked/editor/objects/SetObjectPropertiesCommand.go b/modding/hacked/editor/objects/SetObjectPropertiesCommand.go new file mode 100644 index 0000000..ba66919 --- /dev/null +++ b/modding/hacked/editor/objects/SetObjectPropertiesCommand.go @@ -0,0 +1,33 @@ +package objects + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setObjectPropertiesCommand struct { + model *viewModel + + triple object.Triple + bitmap int + + oldProperties object.Properties + newProperties object.Properties +} + +func (command setObjectPropertiesCommand) Do(modder world.Modder) error { + return command.perform(modder, command.newProperties) +} + +func (command setObjectPropertiesCommand) Undo(modder world.Modder) error { + return command.perform(modder, command.oldProperties) +} + +func (command setObjectPropertiesCommand) perform(modder world.Modder, properties object.Properties) error { + modder.SetObjectProperties(command.triple, properties) + + command.model.restoreFocus = true + command.model.currentObject = command.triple + command.model.currentBitmap = command.bitmap + return nil +} diff --git a/modding/hacked/editor/objects/SetObjectTextCommand.go b/modding/hacked/editor/objects/SetObjectTextCommand.go new file mode 100644 index 0000000..c3f7566 --- /dev/null +++ b/modding/hacked/editor/objects/SetObjectTextCommand.go @@ -0,0 +1,35 @@ +package objects + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setObjectTextCommand struct { + model *viewModel + + triple object.Triple + bitmap int + key resource.Key + + oldData []byte + newData []byte +} + +func (command setObjectTextCommand) Do(modder world.Modder) error { + return command.perform(modder, command.newData) +} + +func (command setObjectTextCommand) Undo(modder world.Modder) error { + return command.perform(modder, command.oldData) +} + +func (command setObjectTextCommand) perform(modder world.Modder, data []byte) error { + modder.SetResourceBlock(command.key.Lang, command.key.ID, command.key.Index, data) + command.model.restoreFocus = true + command.model.currentObject = command.triple + command.model.currentBitmap = command.bitmap + command.model.currentLang = command.key.Lang + return nil +} diff --git a/modding/hacked/editor/objects/View.go b/modding/hacked/editor/objects/View.go new file mode 100644 index 0000000..78f6755 --- /dev/null +++ b/modding/hacked/editor/objects/View.go @@ -0,0 +1,710 @@ +package objects + +import ( + "fmt" + "strings" + + "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/editor/values" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/object/objprop" + "github.com/inkyblackness/hacked/ss1/content/text" + "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" +) + +// View provides edit controls for game objects. +type View struct { + mod *world.Mod + textCache *text.Cache + cp text.Codepage + imageCache *graphics.TextureCache + paletteCache *graphics.PaletteCache + + modalStateMachine gui.ModalStateMachine + clipboard external.Clipboard + guiScale float32 + commander cmd.Commander + + model viewModel +} + +// NewView returns a new instance. +func NewView(mod *world.Mod, textCache *text.Cache, cp text.Codepage, + imageCache *graphics.TextureCache, paletteCache *graphics.PaletteCache, + modalStateMachine gui.ModalStateMachine, + clipboard external.Clipboard, guiScale float32, commander cmd.Commander) *View { + view := &View{ + mod: mod, + textCache: textCache, + cp: cp, + 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: 600 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Game Objects", view.WindowOpen(), imgui.WindowFlagsNoCollapse|imgui.WindowFlagsHorizontalScrollbar) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + if imgui.BeginChildV("Properties", imgui.Vec2{X: -330 * view.guiScale, Y: 0}, false, imgui.WindowFlagsHorizontalScrollbar) { + imgui.PushItemWidth(-260 * view.guiScale) + classString := func(class object.Class) string { + return fmt.Sprintf("%2d: %v", int(class), class) + } + if imgui.BeginCombo("Object Class", classString(view.model.currentObject.Class)) { + for _, class := range object.Classes() { + if imgui.SelectableV(classString(class), class == view.model.currentObject.Class, 0, imgui.Vec2{}) { + view.model.currentObject = object.TripleFrom(int(class), 0, 0) + view.model.currentBitmap = 0 + } + } + imgui.EndCombo() + } + if imgui.BeginCombo("Object Type", view.tripleName(view.model.currentObject)) { + allTypes := view.mod.ObjectProperties().TriplesInClass(view.model.currentObject.Class) + for _, triple := range allTypes { + if imgui.SelectableV(view.tripleName(triple), triple == view.model.currentObject, 0, imgui.Vec2{}) { + view.model.currentObject = triple + view.model.currentBitmap = 0 + } + } + imgui.EndCombo() + } + + readOnly := !view.mod.HasModifiableObjectProperties() + properties, propErr := view.mod.ObjectProperties().ForObject(view.model.currentObject) + + imgui.Separator() + bitmapLimit := 0 + if propErr == nil { + bitmapLimit = int(properties.Common.Bitmap3D.FrameNumber() + 2) + } + gui.StepSliderInt("Bitmap", &view.model.currentBitmap, 0, bitmapLimit) + render.TextureSelector("BitmapSelector", -1, view.guiScale, bitmapLimit+1, view.model.currentBitmap, + view.imageCache, view.currentBitmapKeyFor, func(int) string { return "" }, func(newValue int) { + view.model.currentBitmap = newValue + }) + + imgui.Separator() + + if imgui.BeginCombo("Language", view.model.currentLang.String()) { + languages := resource.Languages() + for _, lang := range languages { + if imgui.SelectableV(lang.String(), lang == view.model.currentLang, 0, imgui.Vec2{}) { + view.model.currentLang = lang + } + } + imgui.EndCombo() + } + view.renderText(false, "Long Name", + view.objectName(view.model.currentObject, view.model.currentLang, true), + func(newValue string) { + view.requestSetObjectName(view.model.currentObject, true, newValue) + }) + view.renderText(false, "Short Name", + view.objectName(view.model.currentObject, view.model.currentLang, false), + func(newValue string) { + view.requestSetObjectName(view.model.currentObject, false, newValue) + }) + + if propErr == nil { + if imgui.TreeNodeV("Common Properties", imgui.TreeNodeFlagsDefaultOpen|imgui.TreeNodeFlagsFramed) { + view.renderCommonProperties(readOnly, properties) + imgui.TreePop() + } + if imgui.TreeNodeV("Generic Properties", imgui.TreeNodeFlagsFramed) { + view.renderGenericProperties(readOnly, properties) + imgui.TreePop() + } + if imgui.TreeNodeV("Specific Properties", imgui.TreeNodeFlagsFramed) { + view.renderSpecificProperties(readOnly, properties) + imgui.TreePop() + } + } else { + imgui.Text("(properties unavailable)") + } + + imgui.PopItemWidth() + } + imgui.EndChild() + imgui.SameLine() + + imgui.BeginGroup() + view.renderObjectBitmap() + imgui.EndGroup() +} + +func (view *View) renderText(readOnly bool, label string, value string, changeCallback func(string)) { + imgui.LabelText(label, value) + view.clipboardPopup(readOnly, label, value, changeCallback) +} + +func (view *View) tripleName(triple object.Triple) string { + return triple.String() + ": " + view.objectName(triple, resource.LangDefault, true) +} + +func (view *View) objectName(triple object.Triple, lang resource.Language, longName bool) string { + result := "???" + linearIndex := view.mod.ObjectProperties().TripleIndex(triple) + if linearIndex >= 0 { + nameID := ids.ObjectShortNames + if longName { + nameID = ids.ObjectLongNames + } + key := resource.KeyOf(nameID, lang, linearIndex) + objName, err := view.textCache.Text(key) + if err == nil { + result = objName + } + } + return result +} + +func (view *View) clipboardPopup(readOnly bool, label string, value string, changeCallback func(string)) { + if imgui.BeginPopupContextItemV(label+"-Popup", 1) { + if imgui.Selectable("Copy to Clipboard") { + view.clipboard.SetString(value) + } + if !readOnly && imgui.Selectable("Copy from Clipboard") { + newValue, err := view.clipboard.String() + if err == nil { + changeCallback(newValue) + } + } + imgui.EndPopup() + } +} + +func (view *View) requestSetObjectName(triple object.Triple, longName bool, newValue string) { + linearIndex := view.mod.ObjectProperties().TripleIndex(triple) + if linearIndex >= 0 { + id := ids.ObjectShortNames + if longName { + id = ids.ObjectLongNames + } + key := resource.KeyOf(id, view.model.currentLang, linearIndex) + oldValue, _ := view.textCache.Text(key) + + if oldValue != newValue { + command := setObjectTextCommand{ + model: &view.model, + triple: view.model.currentObject, + bitmap: view.model.currentBitmap, + key: key, + oldData: view.cp.Encode(oldValue), + newData: view.cp.Encode(text.Blocked(newValue)[0]), + } + view.commander.Queue(command) + } + } +} + +func (view *View) requestSetObjectProperties(modifier func(*object.Properties)) { + command := setObjectPropertiesCommand{ + model: &view.model, + triple: view.model.currentObject, + bitmap: view.model.currentBitmap, + } + currentProp, err := view.mod.ObjectProperties().ForObject(command.triple) + if err != nil { + return + } + command.oldProperties = currentProp.Clone() + command.newProperties = currentProp.Clone() + modifier(&command.newProperties) + view.commander.Queue(command) +} + +func (view *View) renderCommonProperties(readOnly bool, properties *object.Properties) { + intIdentity := func(u values.Unifier) int { return u.Unified().(int) } + intFormat := func(value int) string { return "%d" } + + if imgui.TreeNode("Flags") { + renderFlag := func(flag object.CommonFlag) { + flagUnifier := values.NewUnifier() + flagUnifier.Add(properties.Common.Flags.Has(flag)) + values.RenderUnifiedCheckboxCombo(readOnly, flag.String(), flagUnifier, func(newValue bool) { + view.requestSetObjectProperties(func(prop *object.Properties) { + if newValue { + prop.Common.Flags = prop.Common.Flags.With(flag) + } else { + prop.Common.Flags = prop.Common.Flags.Without(flag) + } + }) + }) + } + for _, flag := range object.CommonFlags() { + renderFlag(flag) + } + + imgui.TreePop() + } + + lightTypes := object.LightTypes() + lightTypeUnifier := values.NewUnifier() + lightTypeUnifier.Add(int(properties.Common.Flags.LightType())) + values.RenderUnifiedCombo(readOnly, "LightType", lightTypeUnifier, intIdentity, + func(value int) string { + return object.LightType(value).String() + }, len(lightTypes), func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Flags = prop.Common.Flags.WithLightType(object.LightType(newValue)) + }) + }) + + useModeUnifier := values.NewUnifier() + useModeUnifier.Add(int(properties.Common.Flags.UseMode())) + values.RenderUnifiedCombo(readOnly, "UseMode", useModeUnifier, intIdentity, + func(value int) string { + return object.UseMode(value).String() + }, 4, func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Flags = prop.Common.Flags.WithUseMode(object.UseMode(newValue)) + }) + }) + + massUnifier := values.NewUnifier() + massUnifier.Add(int(properties.Common.Mass)) + values.RenderUnifiedSliderInt(readOnly, "Mass", massUnifier, intIdentity, intFormat, -1, 5000, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Mass = int32(newValue) + }) + }) + + hitpointsUnifier := values.NewUnifier() + hitpointsUnifier.Add(int(properties.Common.Hitpoints)) + values.RenderUnifiedSliderInt(readOnly, "Hitpoints", hitpointsUnifier, intIdentity, intFormat, 0, 10000, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Hitpoints = int16(newValue) + }) + }) + + armorUnifier := values.NewUnifier() + armorUnifier.Add(int(properties.Common.Armor)) + values.RenderUnifiedSliderInt(readOnly, "Armor", armorUnifier, intIdentity, intFormat, 0, 255, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Armor = byte(newValue) + }) + }) + + renderTypeUnifier := values.NewUnifier() + renderTypeUnifier.Add(int(properties.Common.RenderType)) + values.RenderUnifiedCombo(readOnly, "Render Type", renderTypeUnifier, intIdentity, + func(value int) string { return object.RenderType(value).String() }, + len(object.RenderTypes()), func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.RenderType = object.RenderType(newValue) + }) + }) + + physicsModelUnifier := values.NewUnifier() + physicsModelUnifier.Add(int(properties.Common.PhysicsModel)) + values.RenderUnifiedCombo(readOnly, "Physics Model", physicsModelUnifier, intIdentity, + func(value int) string { return object.PhysicsModel(value).String() }, + len(object.PhysicsModels()), func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.PhysicsModel = object.PhysicsModel(newValue) + }) + }) + + hardnessUnifier := values.NewUnifier() + hardnessUnifier.Add(int(properties.Common.Hardness)) + values.RenderUnifiedSliderInt(readOnly, "Hardness", hardnessUnifier, intIdentity, intFormat, 0, object.HardnessLimit, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Hardness = byte(newValue) + }) + }) + + physicsXRUnifier := values.NewUnifier() + physicsXRUnifier.Add(int(properties.Common.PhysicsXR)) + values.RenderUnifiedSliderInt(readOnly, "Physics XR", physicsXRUnifier, intIdentity, intFormat, 0, object.PhysicsXRLimit, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.PhysicsXR = byte(newValue) + }) + }) + + physicsZUnifier := values.NewUnifier() + physicsZUnifier.Add(int(properties.Common.PhysicsZ)) + values.RenderUnifiedSliderInt(readOnly, "Physics Z", physicsZUnifier, intIdentity, intFormat, 0, 255, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.PhysicsZ = byte(newValue) + }) + }) + + if imgui.TreeNode("Vulnerabilities") { + renderVulnerability := func(damageType object.DamageType) { + damageUnifier := values.NewUnifier() + damageUnifier.Add(properties.Common.Vulnerabilities.Has(damageType)) + values.RenderUnifiedCheckboxCombo(readOnly, damageType.String(), damageUnifier, func(newValue bool) { + view.requestSetObjectProperties(func(prop *object.Properties) { + if newValue { + prop.Common.Vulnerabilities = prop.Common.Vulnerabilities.With(damageType) + } else { + prop.Common.Vulnerabilities = prop.Common.Vulnerabilities.Without(damageType) + } + }) + }) + } + for _, damageType := range object.DamageTypes() { + renderVulnerability(damageType) + } + + primaryUnifier := values.NewUnifier() + primaryUnifier.Add(properties.Common.SpecialVulnerabilities.PrimaryValue()) + values.RenderUnifiedSliderInt(readOnly, "Primary (double dmg)", primaryUnifier, intIdentity, intFormat, 0, object.SpecialDamageTypeLimit, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.SpecialVulnerabilities = prop.Common.SpecialVulnerabilities.WithPrimaryValue(newValue) + }) + }) + + superUnifier := values.NewUnifier() + superUnifier.Add(properties.Common.SpecialVulnerabilities.SuperValue()) + values.RenderUnifiedSliderInt(readOnly, "Super (quad dmg)", superUnifier, intIdentity, intFormat, 0, object.SpecialDamageTypeLimit, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.SpecialVulnerabilities = prop.Common.SpecialVulnerabilities.WithSuperValue(newValue) + }) + }) + + imgui.TreePop() + } + + defenseUnifier := values.NewUnifier() + defenseUnifier.Add(int(properties.Common.Defense)) + values.RenderUnifiedSliderInt(readOnly, "Defense", defenseUnifier, intIdentity, + func(value int) string { + if value == object.DefenseNoCriticals { + return "%d - (no criticals)" + } + return "%d" + }, 0, 255, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Defense = byte(newValue) + }) + }) + + toughnessUnifier := values.NewUnifier() + toughnessUnifier.Add(int(properties.Common.Toughness)) + values.RenderUnifiedSliderInt(readOnly, "Toughness", toughnessUnifier, intIdentity, + func(value int) string { + if value == object.ToughnessNoDamage { + return "(no damage) -- raw: %d" + } + return fmt.Sprintf("%d:1 dmg -- raw: %%d", 1< 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) renderObjectBitmap() { + render.TextureImage("BitmapImage", view.imageCache, view.currentBitmapKey(), imgui.Vec2{X: 320 * view.guiScale, Y: 240 * view.guiScale}) + if imgui.Button("Clear") { + view.requestClearBitmap() + } + imgui.SameLine() + if imgui.Button("Import") { + view.requestImportBitmap() + } + imgui.SameLine() + if imgui.Button("Export") { + view.requestExportBitmap() + } + if view.hasModCurrentBitmap() { + imgui.SameLine() + if imgui.Button("Remove") { + view.requestSetBitmapData(nil) + } + } +} + +func (view *View) requestClearBitmap() { + bmp := bitmap.Bitmap{ + Header: bitmap.Header{ + Width: 1, + Height: 1, + }, + Pixels: []byte{0x00}, + } + view.requestSetBitmap(bmp) +} + +func (view *View) requestImportBitmap() { + 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) + }) +} + +func (view *View) requestExportBitmap() { + key := view.currentBitmapKey() + 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("%02d_%d_%02d-%02d.png", + view.model.currentObject.Class, view.model.currentObject.Subclass, view.model.currentObject.Type, view.model.currentBitmap) + 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) hasModCurrentBitmap() bool { + key := view.currentBitmapKey() + return len(view.mod.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0 +} + +func (view *View) requestSetBitmap(bmp bitmap.Bitmap) { + highestBitShift := func(value int16) (result byte) { + if value != 0 { + for (value >> result) != 1 { + result++ + } + } + return + } + + bmp.Header.Flags = bitmap.FlagTransparent + bmp.Header.Type = bitmap.TypeFlat8Bit + 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.currentBitmapKey() + + command := setObjectBitmapCommand{ + model: &view.model, + triple: view.model.currentObject, + bitmap: view.model.currentBitmap, + + resourceKey: resourceKey, + oldData: view.mod.ModifiedBlock(resourceKey.Lang, resourceKey.ID, resourceKey.Index), + newData: newData, + } + view.commander.Queue(command) +} + +func (view *View) currentBitmapKey() resource.Key { + return view.currentBitmapKeyFor(view.model.currentBitmap) +} + +func (view *View) currentBitmapKeyFor(offset int) resource.Key { + baseOffset := 1 + done := false + view.mod.ObjectProperties().Iterate(func(triple object.Triple, prop *object.Properties) bool { + if !done && triple == view.model.currentObject { + done = true + } + if done { + return false + } + numExtra := int(prop.Common.Bitmap3D.FrameNumber()) + baseOffset += 3 + numExtra + return true + }) + + return resource.KeyOf(ids.ObjectBitmaps, resource.LangAny, baseOffset+offset) +} diff --git a/modding/hacked/editor/objects/ViewModel.go b/modding/hacked/editor/objects/ViewModel.go new file mode 100644 index 0000000..f54b660 --- /dev/null +++ b/modding/hacked/editor/objects/ViewModel.go @@ -0,0 +1,19 @@ +package objects + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/resource" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + + currentObject object.Triple + currentBitmap int + currentLang resource.Language +} + +func freshViewModel() viewModel { + return viewModel{} +} diff --git a/modding/hacked/editor/project/AddManifestEntryStartState.go b/modding/hacked/editor/project/AddManifestEntryStartState.go new file mode 100644 index 0000000..cb42d69 --- /dev/null +++ b/modding/hacked/editor/project/AddManifestEntryStartState.go @@ -0,0 +1,23 @@ +package project + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type addManifestEntryStartState struct { + machine gui.ModalStateMachine + view *View +} + +func (state addManifestEntryStartState) Render() { + imgui.OpenPopup("Add static world data") + state.machine.SetState(&addManifestEntryWaitingState{ + machine: state.machine, + view: state.view, + }) +} + +func (state addManifestEntryStartState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/project/AddManifestEntryWaitingState.go b/modding/hacked/editor/project/AddManifestEntryWaitingState.go new file mode 100644 index 0000000..e5f039f --- /dev/null +++ b/modding/hacked/editor/project/AddManifestEntryWaitingState.go @@ -0,0 +1,61 @@ +package project + +import ( + "time" + + "github.com/inkyblackness/imgui-go/v3" + "github.com/sqweek/dialog" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type addManifestEntryWaitingState struct { + machine gui.ModalStateMachine + view *View + failureTime time.Time +} + +func (state *addManifestEntryWaitingState) Render() { + if imgui.BeginPopupModalV("Add static world data", nil, + imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + imgui.Text("Waiting for folders/files.") + if !state.failureTime.IsZero() { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1}) + imgui.Text("Previous attempt failed, no usable data detected.\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 (or files) +of the static data you want to reference into the editor window. +Typically, you would use the main "data" directory of the game +(where all the .res files are). +`) + 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 *addManifestEntryWaitingState) HandleFiles(names []string) { + err := state.view.tryAddManifestEntryFrom(names) + if err != nil { + state.failureTime = time.Now() + } else { + state.machine.SetState(nil) + } +} diff --git a/modding/hacked/editor/project/LoadModStartState.go b/modding/hacked/editor/project/LoadModStartState.go new file mode 100644 index 0000000..822ade3 --- /dev/null +++ b/modding/hacked/editor/project/LoadModStartState.go @@ -0,0 +1,23 @@ +package project + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type loadModStartState struct { + machine gui.ModalStateMachine + view *View +} + +func (state loadModStartState) Render() { + imgui.OpenPopup("Load mod") + state.machine.SetState(&loadModWaitingState{ + machine: state.machine, + view: state.view, + }) +} + +func (state loadModStartState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/project/LoadModWaitingState.go b/modding/hacked/editor/project/LoadModWaitingState.go new file mode 100644 index 0000000..872aa82 --- /dev/null +++ b/modding/hacked/editor/project/LoadModWaitingState.go @@ -0,0 +1,62 @@ +package project + +import ( + "time" + + "github.com/inkyblackness/imgui-go/v3" + "github.com/sqweek/dialog" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type loadModWaitingState struct { + machine gui.ModalStateMachine + view *View + failureTime time.Time +} + +func (state *loadModWaitingState) Render() { + if imgui.BeginPopupModalV("Load mod", 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, no usable data detected.\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 +of the mod you want to work on into the editor window. +If you want to modify the main game files, +use the main "data" directory of the game. +`) + imgui.Text("This action will clear the undo/redo buffer\nand you will lose any unsaved changes.") + 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 *loadModWaitingState) HandleFiles(names []string) { + err := state.view.tryLoadModFrom(names) + if err != nil { + state.failureTime = time.Now() + } else { + state.machine.SetState(nil) + } +} diff --git a/modding/hacked/editor/project/SaveModAsStartState.go b/modding/hacked/editor/project/SaveModAsStartState.go new file mode 100644 index 0000000..9369b43 --- /dev/null +++ b/modding/hacked/editor/project/SaveModAsStartState.go @@ -0,0 +1,23 @@ +package project + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type saveModAsStartState struct { + machine gui.ModalStateMachine + view *View +} + +func (state saveModAsStartState) Render() { + imgui.OpenPopup("Save mod as") + state.machine.SetState(&saveModAsWaitingState{ + machine: state.machine, + view: state.view, + }) +} + +func (state saveModAsStartState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/project/SaveModAsWaitingState.go b/modding/hacked/editor/project/SaveModAsWaitingState.go new file mode 100644 index 0000000..19402bf --- /dev/null +++ b/modding/hacked/editor/project/SaveModAsWaitingState.go @@ -0,0 +1,82 @@ +package project + +import ( + "os" + "time" + + "github.com/inkyblackness/imgui-go/v3" + "github.com/sqweek/dialog" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type saveModAsWaitingState struct { + machine gui.ModalStateMachine + view *View + failureTime time.Time + errorInfo string +} + +func (state *saveModAsWaitingState) Render() { + if imgui.BeginPopupModalV("Save mod as", 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, not a proper folder.\nPlease check and try again.") + if len(state.errorInfo) > 0 { + imgui.Text(state.errorInfo) + } + 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("It is recommended to use an empty folder.\nSaving will potentially overwrite existing files.") + imgui.PopStyleColor() + 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 *saveModAsWaitingState) HandleFiles(names []string) { + modPath, ok := state.verifyDir(names) + if ok { + state.machine.SetState(nil) + state.view.requestSaveMod(modPath) + } else { + state.failureTime = time.Now() + } +} + +func (state saveModAsWaitingState) 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 +} diff --git a/modding/hacked/editor/project/SaveModFailedState.go b/modding/hacked/editor/project/SaveModFailedState.go new file mode 100644 index 0000000..228ab47 --- /dev/null +++ b/modding/hacked/editor/project/SaveModFailedState.go @@ -0,0 +1,28 @@ +package project + +import ( + "time" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type saveModFailedState struct { + machine gui.ModalStateMachine + view *View + errorInfo string +} + +func (state saveModFailedState) Render() { + imgui.OpenPopup("Save mod as") + state.machine.SetState(&saveModAsWaitingState{ + machine: state.machine, + view: state.view, + failureTime: time.Now(), + errorInfo: state.errorInfo, + }) +} + +func (state saveModFailedState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/project/View.go b/modding/hacked/editor/project/View.go new file mode 100644 index 0000000..4fc8c31 --- /dev/null +++ b/modding/hacked/editor/project/View.go @@ -0,0 +1,246 @@ +package project + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ui/gui" +) + +// View handles the project display. +type View struct { + service *edit.ProjectService + + modalStateMachine gui.ModalStateMachine + guiScale float32 + commander cmd.Registry + + model viewModel +} + +// NewView creates a new instance for the project display. +func NewView(service *edit.ProjectService, modalStateMachine gui.ModalStateMachine, guiScale float32, commander cmd.Registry) *View { + return &View{ + service: service, + + modalStateMachine: modalStateMachine, + guiScale: guiScale, + commander: commander, + + model: freshViewModel(), + } +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *View) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render requests to render the view. +func (view *View) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + title := "Project" + saveStatus := view.service.SaveStatus() + if saveStatus.FilesModified > 0 { + title += fmt.Sprintf(" - %d file(s) pending save", saveStatus.FilesModified) + if saveStatus.SavePending && (saveStatus.SaveIn.Seconds() < 4) { + title += fmt.Sprintf(" - auto-save in %d", int(saveStatus.SaveIn.Seconds())) + } + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 400 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV(title+"###Project", view.WindowOpen(), 0) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + imgui.Text("Mod Location") + imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{X: 1, Y: 0}) + imgui.BeginChildV("ModLocation", imgui.Vec2{X: -200*view.guiScale - 10*view.guiScale, Y: imgui.TextLineHeight() * 1.5}, true, + imgui.WindowFlagsNoScrollbar|imgui.WindowFlagsNoScrollWithMouse) + modPath := view.service.ModPath() + if len(modPath) > 0 { + imgui.Text(modPath) + } else { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1.0, Y: 1.0, Z: 1.0, W: 0.5}) + imgui.Text("(new mod)") + imgui.PopStyleColor() + } + imgui.EndChild() + imgui.PopStyleVar() + imgui.BeginGroup() + imgui.SameLine() + if imgui.ButtonV("Save", imgui.Vec2{X: 100 * view.guiScale, Y: 0}) { + view.StartSavingMod() + } + imgui.SameLine() + if imgui.ButtonV("Load...", imgui.Vec2{X: 100 * view.guiScale, Y: 0}) { + view.startLoadingMod() + } + imgui.EndGroup() + + imgui.Text("Static World Data") + imgui.BeginChildV("ManifestEntries", imgui.Vec2{X: -100 * view.guiScale, Y: 0}, true, 0) + manifest := view.service.Mod().World() + entries := manifest.EntryCount() + for i := entries - 1; i >= 0; i-- { + entry, _ := manifest.Entry(i) + if imgui.SelectableV(entry.ID, view.model.selectedManifestEntry == i, 0, imgui.Vec2{}) { + view.model.selectedManifestEntry = i + } + } + imgui.EndChild() + imgui.SameLine() + imgui.BeginGroup() + if imgui.ButtonV("Add...", imgui.Vec2{X: -1, Y: 0}) { + view.startAddingManifestEntry() + } + if imgui.ButtonV("Up", imgui.Vec2{X: -1, Y: 0}) { + view.requestMoveManifestEntryUp() + } + if imgui.ButtonV("Down", imgui.Vec2{X: -1, Y: 0}) { + view.requestMoveManifestEntryDown() + } + if imgui.ButtonV("Remove", imgui.Vec2{X: -1, Y: 0}) { + view.requestRemoveManifestEntry() + } + imgui.EndGroup() +} + +func (view *View) startLoadingMod() { + view.modalStateMachine.SetState(&loadModStartState{ + machine: view.modalStateMachine, + view: view, + }) +} + +// StartSavingMod initiates to save the mod. +// It either opens the save-as dialog, or simply saves under the current folder. +func (view *View) StartSavingMod() { + if view.service.ModHasStorageLocation() { + err := view.service.SaveMod() + if err != nil { + view.handleSaveModFailure(err) + } + } else { + view.modalStateMachine.SetState(&saveModAsStartState{ + machine: view.modalStateMachine, + view: view, + }) + } +} + +func (view *View) startAddingManifestEntry() { + view.modalStateMachine.SetState(&addManifestEntryStartState{ + machine: view.modalStateMachine, + view: view, + }) +} + +func (view *View) requestMoveManifestEntryUp() { + manifest := view.service.Mod().World() + entries := manifest.EntryCount() + if (view.model.selectedManifestEntry >= 0) && (view.model.selectedManifestEntry < (entries - 1)) { + view.requestMoveManifestEntry(view.model.selectedManifestEntry+1, view.model.selectedManifestEntry) + } +} + +func (view *View) requestMoveManifestEntryDown() { + if view.model.selectedManifestEntry > 0 { + view.requestMoveManifestEntry(view.model.selectedManifestEntry-1, view.model.selectedManifestEntry) + } +} + +func (view *View) requestMoveManifestEntry(to, from int) { + _ = view.commander.Register( + cmd.Named("moveManifestEntry"), + cmd.Reverse(view.taskToRestoreFocus()), + cmd.Reverse(view.taskToSelectEntry(from)), + cmd.Nested(func() error { return view.service.MoveManifestEntry(to, from) }), + cmd.Forward(view.taskToSelectEntry(to)), + cmd.Forward(view.taskToRestoreFocus()), + ) +} + +func (view *View) tryAddManifestEntryFrom(names []string) error { + entry, err := world.NewManifestEntryFrom(names) + if err != nil { + return err + } + + view.requestAddManifestEntry(entry) + return nil +} + +func (view *View) requestAddManifestEntry(entry *world.ManifestEntry) { + at := view.model.selectedManifestEntry + 1 + _ = view.commander.Register( + cmd.Named("addManifestEntry"), + cmd.Reverse(view.taskToRestoreFocus()), + cmd.Reverse(view.taskToSelectEntry(view.model.selectedManifestEntry)), + cmd.Nested(func() error { return view.service.AddManifestEntry(at, entry) }), + cmd.Forward(view.taskToSelectEntry(at)), + cmd.Forward(view.taskToRestoreFocus()), + ) +} + +func (view *View) requestRemoveManifestEntry() { + manifest := view.service.Mod().World() + at := view.model.selectedManifestEntry + if (at < 0) || (at >= manifest.EntryCount()) { + return + } + + _ = view.commander.Register( + cmd.Named("removeManifestEntry"), + cmd.Reverse(view.taskToRestoreFocus()), + cmd.Reverse(view.taskToSelectEntry(view.model.selectedManifestEntry)), + cmd.Nested(func() error { return view.service.RemoveManifestEntry(at) }), + cmd.Forward(view.taskToSelectEntry(view.model.selectedManifestEntry-1)), + cmd.Forward(view.taskToRestoreFocus()), + ) +} + +func (view *View) tryLoadModFrom(names []string) error { + return view.service.TryLoadModFrom(names) +} + +func (view *View) requestSaveMod(modPath string) { + err := view.service.SaveModUnder(modPath) + if err != nil { + view.handleSaveModFailure(err) + } +} + +func (view *View) handleSaveModFailure(err error) { + view.modalStateMachine.SetState(&saveModFailedState{ + machine: view.modalStateMachine, + view: view, + errorInfo: err.Error(), + }) +} + +func (view *View) taskToRestoreFocus() cmd.Task { + return func(modder world.Modder) error { + view.model.restoreFocus = true + return nil + } +} + +func (view *View) taskToSelectEntry(index int) cmd.Task { + return func(modder world.Modder) error { + view.model.selectedManifestEntry = index + return nil + } +} diff --git a/modding/hacked/editor/project/ViewModel.go b/modding/hacked/editor/project/ViewModel.go new file mode 100644 index 0000000..3594ee5 --- /dev/null +++ b/modding/hacked/editor/project/ViewModel.go @@ -0,0 +1,14 @@ +package project + +type viewModel struct { + restoreFocus bool + windowOpen bool + selectedManifestEntry int +} + +func freshViewModel() viewModel { + return viewModel{ + windowOpen: false, + selectedManifestEntry: -1, + } +} diff --git a/modding/hacked/editor/render/Context.go b/modding/hacked/editor/render/Context.go new file mode 100644 index 0000000..9002f95 --- /dev/null +++ b/modding/hacked/editor/render/Context.go @@ -0,0 +1,15 @@ +package render + +import ( + "github.com/inkyblackness/hacked/ui/opengl" + + mgl "github.com/go-gl/mathgl/mgl32" +) + +// Context provides current render properties. +type Context struct { + OpenGL opengl.OpenGL + + ViewMatrix *mgl.Mat4 + ProjectionMatrix mgl.Mat4 +} diff --git a/modding/hacked/editor/render/FrameImage.go b/modding/hacked/editor/render/FrameImage.go new file mode 100644 index 0000000..07287d7 --- /dev/null +++ b/modding/hacked/editor/render/FrameImage.go @@ -0,0 +1,39 @@ +package render + +import ( + "math" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" +) + +// FrameImage renders an image centered and fitted within the given size. +func FrameImage(label string, frameCache *graphics.FrameCache, key graphics.FrameCacheKey, size imgui.Vec2) { + textureID := TextureIDForBitmapFrame(key) + + imgui.PushStyleColor(imgui.StyleColorChildBg, imgui.Vec4{X: 0, Y: 0, Z: 0, W: 1}) + imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{X: 0, Y: 0}) + if imgui.BeginChildV(label, size, false, + imgui.WindowFlagsNoNav|imgui.WindowFlagsNoInputs|imgui.WindowFlagsNoScrollWithMouse| + imgui.WindowFlagsNoScrollbar) { + texture := frameCache.Texture(key) + if texture != nil { + var uv imgui.Vec2 + uv.X, uv.Y = texture.UV() + width, height := texture.Size() + + scaleFactor := float32(math.Min(float64(size.X/width), float64(size.Y/height))) + imageSize := imgui.Vec2{X: width * scaleFactor, Y: height * scaleFactor} + + bufferSize := imgui.Vec2{X: (size.X - imageSize.X) / 2, Y: (size.Y - imageSize.Y) / 2} + imgui.SetCursorPos(bufferSize) + + imgui.ImageV(textureID, imageSize, imgui.Vec2{}, uv, + imgui.Vec4{X: 1, Y: 1, Z: 1, W: 1}, imgui.Vec4{X: 0, Y: 0, Z: 0, W: 0}) + } + } + imgui.EndChild() + imgui.PopStyleVar() + imgui.PopStyleColor() +} diff --git a/modding/hacked/editor/render/OrientationView.go b/modding/hacked/editor/render/OrientationView.go new file mode 100644 index 0000000..1c8d69c --- /dev/null +++ b/modding/hacked/editor/render/OrientationView.go @@ -0,0 +1,234 @@ +package render + +import ( + "math" + + mgl "github.com/go-gl/mathgl/mgl32" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var orientationViewVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec3 vertexPosition; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; + +out vec3 position; +out float zCenter; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0); + zCenter = (projectionMatrix * viewMatrix * modelMatrix * vec4(0.0, 0.0, 0.0, 1.0)).z; + position = gl_Position.xyz; +} +` + +var orientationViewFragmentShaderSource = ` +#version 150 +precision mediump float; + +uniform vec4 foregroundColor; +uniform vec4 backgroundColor; + +in vec3 position; +in float zCenter; + +out vec4 fragColor; + +void main(void) { + if (position.z <= zCenter) + { + fragColor = foregroundColor; + } + else + { + fragColor = backgroundColor; + } +} +` + +// OrientationView is a control that displays how an object is oriented. +// This is still not working properly. The orientation arrow is not properly rotated, +// and I suspect the typical issue of three-angle rotation versus quaternion rotation. +// Though I doubt the original engine used quaternions, it remains too confusing for me. +type OrientationView struct { + context Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + foregroundColorUniform opengl.Vector4Uniform + backgroundColorUniform opengl.Vector4Uniform + + baseOrientation mgl.Mat4 + rotation mgl.Vec3 + xRingVerticesStart int + yRingVerticesStart int + zRingVerticesStart int + vertices int +} + +// NewOrientationView returns a new instance. +func NewOrientationView(context Context, baseOrientation mgl.Mat4, rotation mgl.Vec3) *OrientationView { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, orientationViewVertexShaderSource, orientationViewFragmentShaderSource) + + if programErr != nil { + panic(opengl.NamedShaderError{Name: "OrientationViewShader", Nested: programErr}) + } + + view := &OrientationView{ + context: context, + program: program, + + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + foregroundColorUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "foregroundColor")), + backgroundColorUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "backgroundColor")), + + baseOrientation: baseOrientation, + rotation: rotation, + } + { + gl.BindBuffer(opengl.ARRAY_BUFFER, view.vertexPositionBuffer) + var vertices []float32 + radius := 0.5 + vertices = append(vertices, 0.0, 0.0, 0.0) + vertices = append(vertices, 0.4, 0.0, 0.0) + vertices = append(vertices, 0.0, 0.0, 0.0) + vertices = append(vertices, 0.0, 0.4, 0.0) + vertices = append(vertices, 0.0, 0.0, 0.0) + vertices = append(vertices, 0.0, 0.0, 0.4) + + view.xRingVerticesStart = len(vertices) + for angle := 0.0; angle < float64(toRad(360.0)); angle += float64(toRad(4.0)) { + vertices = append(vertices, 0.0, float32(radius*math.Cos(angle)), float32(radius*math.Sin(angle))) + } + vertices = append(vertices, 0.0, float32(radius*math.Cos(0)), float32(radius*math.Sin(0))) + + view.yRingVerticesStart = len(vertices) + for angle := 0.0; angle < float64(toRad(360.0)); angle += float64(toRad(4.0)) { + vertices = append(vertices, float32(radius*math.Cos(angle)), 0.0, float32(radius*math.Sin(angle))) + } + vertices = append(vertices, float32(radius*math.Cos(0)), 0.0, float32(radius*math.Sin(0))) + + view.zRingVerticesStart = len(vertices) + for angle := 0.0; angle < float64(toRad(360.0)); angle += float64(toRad(4.0)) { + vertices = append(vertices, float32(radius*math.Cos(angle)), float32(radius*math.Sin(angle)), 0.0) + } + vertices = append(vertices, float32(radius*math.Cos(0)), float32(radius*math.Sin(0)), 0.0) + + view.vertices = len(vertices) + gl.BufferData(opengl.ARRAY_BUFFER, len(vertices)*4, vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + view.vao.WithSetter(func(gl opengl.OpenGL) { + gl.EnableVertexAttribArray(uint32(view.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, view.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(view.vertexPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) + + return view +} + +// Dispose cleans up resources of the view. +func (view *OrientationView) Dispose() { + gl := view.context.OpenGL + view.vao.Dispose() + gl.DeleteBuffers([]uint32{view.vertexPositionBuffer}) + gl.DeleteProgram(view.program) +} + +func toRad(degree float32) float32 { + return (degree * math.Pi * 2.0) / 360.0 +} + +// Render renders the orientation view for given orientation. +func (view *OrientationView) Render(orientation mgl.Vec3) { + gl := view.context.OpenGL + + view.vao.OnShader(func() { + view.projectionMatrixUniform.Set(gl, &view.context.ProjectionMatrix) + view.viewMatrixUniform.Set(gl, view.context.ViewMatrix) + + view.renderRings(false) + view.renderArrow( + mgl.Ident4(). + Mul4(mgl.HomogRotate3D(toRad(orientation[0]), mgl.Vec3{view.rotation[0], 0.0, 0.0})). + Mul4(mgl.HomogRotate3D(toRad(orientation[1]), mgl.Vec3{0.0, view.rotation[1], 0.0})). + Mul4(mgl.HomogRotate3D(toRad(orientation[2]), mgl.Vec3{0.0, 0.0, view.rotation[2]}))) + view.renderRings(true) + }) +} + +func (view *OrientationView) renderRings(front bool) { + foreground := func(color [4]float32) [4]float32 { + if !front { + return [4]float32{0.0, 0.0, 0.0, 0.0} + } + return color + } + background := func(color [4]float32) [4]float32 { + if front { + return [4]float32{0.0, 0.0, 0.0, 0.0} + } + return color + } + + // draw Z-rotation ring + view.renderRing(view.zRingVerticesStart, view.vertices, + foreground([4]float32{0.0, 0.0, 1.0, 1.0}), background([4]float32{0.0, 0.0, 0.8, 0.7})) + + // draw Y-rotation ring + view.renderRing(view.yRingVerticesStart, view.zRingVerticesStart, + foreground([4]float32{0.0, 1.0, 0.0, 1.0}), background([4]float32{0.0, 0.8, 0.0, 0.7})) + + // draw X-rotation ring + view.renderRing(view.xRingVerticesStart, view.yRingVerticesStart, + foreground([4]float32{1.0, 0.0, 0.0, 1.0}), background([4]float32{0.8, 0.0, 0.0, 0.7})) +} + +func (view *OrientationView) renderRing(start, end int, foregroundColor, backgroundColor [4]float32) { + gl := view.context.OpenGL + + modelMatrix := mgl.Ident4().Mul4(view.baseOrientation) + view.foregroundColorUniform.Set(gl, &foregroundColor) + view.backgroundColorUniform.Set(gl, &backgroundColor) + view.modelMatrixUniform.Set(gl, &modelMatrix) + gl.DrawArrays(opengl.LINES, int32(start)/3, int32(end-start)/3) +} + +func (view *OrientationView) renderArrow(rotation mgl.Mat4) { + gl := view.context.OpenGL + + modelMatrix := mgl.Ident4().Mul4(view.baseOrientation).Mul4(rotation) + view.foregroundColorUniform.Set(gl, &[4]float32{0.0, 0.0, 1.0, 1.0}) + view.backgroundColorUniform.Set(gl, &[4]float32{0.0, 0.0, 0.6, 1.0}) + view.modelMatrixUniform.Set(gl, &modelMatrix) + gl.DrawArrays(opengl.LINES, 4, 2) + + modelMatrix = mgl.Ident4().Mul4(view.baseOrientation).Mul4(rotation) + view.foregroundColorUniform.Set(gl, &[4]float32{0.0, 1.0, 0.0, 1.0}) + view.backgroundColorUniform.Set(gl, &[4]float32{0.0, 0.6, 0.0, 1.0}) + view.modelMatrixUniform.Set(gl, &modelMatrix) + gl.DrawArrays(opengl.LINES, 2, 2) + + modelMatrix = mgl.Ident4().Mul4(view.baseOrientation).Mul4(rotation) + view.foregroundColorUniform.Set(gl, &[4]float32{1.0, 0.0, 0.0, 1.0}) + view.backgroundColorUniform.Set(gl, &[4]float32{0.6, 0.0, 0.0, 1.0}) + view.modelMatrixUniform.Set(gl, &modelMatrix) + gl.DrawArrays(opengl.LINES, 0, 2) +} diff --git a/modding/hacked/editor/render/TextureID.go b/modding/hacked/editor/render/TextureID.go new file mode 100644 index 0000000..b8a5ab6 --- /dev/null +++ b/modding/hacked/editor/render/TextureID.go @@ -0,0 +1,34 @@ +package render + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ui/gui" +) + +const ( + // BitmapTextureTypeResource specifies a bitmap texture directly from resources. + BitmapTextureTypeResource = 0x00 + // BitmapTextureTypeFrame specifies a bitmap texture from the frame cache. + BitmapTextureTypeFrame = 0x01 +) + +// TextureIDForBitmapTexture returns a texture ID that identifies a bitmap texture from resources. +func TextureIDForBitmapTexture(key resource.Key) imgui.TextureID { + id := imgui.TextureID(gui.ImageTypeBitmapTexture) << 56 + id |= imgui.TextureID(BitmapTextureTypeResource) << 48 + id |= imgui.TextureID(key.Lang) << 32 + id |= imgui.TextureID(key.ID) << 16 + id |= imgui.TextureID(key.Index & 0xFFFF) + return id +} + +// TextureIDForBitmapFrame returns a texture ID that identifies a bitmap texture from frame cache. +func TextureIDForBitmapFrame(key graphics.FrameCacheKey) imgui.TextureID { + id := imgui.TextureID(gui.ImageTypeBitmapTexture) << 56 + id |= imgui.TextureID(BitmapTextureTypeFrame) << 48 + id |= imgui.TextureID(key) + return id +} diff --git a/modding/hacked/editor/render/TextureImage.go b/modding/hacked/editor/render/TextureImage.go new file mode 100644 index 0000000..01f74d5 --- /dev/null +++ b/modding/hacked/editor/render/TextureImage.go @@ -0,0 +1,40 @@ +package render + +import ( + "math" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// TextureImage renders an image centered and fitted within the given size. +func TextureImage(label string, cache *graphics.TextureCache, key resource.Key, size imgui.Vec2) { + textureID := TextureIDForBitmapTexture(key) + + imgui.PushStyleColor(imgui.StyleColorChildBg, imgui.Vec4{X: 0, Y: 0, Z: 0, W: 1}) + imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{X: 0, Y: 0}) + if imgui.BeginChildV(label, size, false, + imgui.WindowFlagsNoNav|imgui.WindowFlagsNoInputs|imgui.WindowFlagsNoScrollWithMouse| + imgui.WindowFlagsNoScrollbar) { + texture, err := cache.Texture(key) + if err == nil { + var uv imgui.Vec2 + uv.X, uv.Y = texture.UV() + width, height := texture.Size() + + scaleFactor := float32(math.Min(float64(size.X/width), float64(size.Y/height))) + imageSize := imgui.Vec2{X: width * scaleFactor, Y: height * scaleFactor} + + bufferSize := imgui.Vec2{X: (size.X - imageSize.X) / 2, Y: (size.Y - imageSize.Y) / 2} + imgui.SetCursorPos(bufferSize) + + imgui.ImageV(textureID, imageSize, imgui.Vec2{}, uv, + imgui.Vec4{X: 1, Y: 1, Z: 1, W: 1}, imgui.Vec4{X: 0, Y: 0, Z: 0, W: 0}) + } + } + imgui.EndChild() + imgui.PopStyleVar() + imgui.PopStyleColor() +} diff --git a/modding/hacked/editor/render/TextureRenderer.go b/modding/hacked/editor/render/TextureRenderer.go new file mode 100644 index 0000000..8ed7f02 --- /dev/null +++ b/modding/hacked/editor/render/TextureRenderer.go @@ -0,0 +1,88 @@ +package render + +import ( + "unsafe" + + "github.com/inkyblackness/hacked/ui/opengl" +) + +// TextureRenderer is a renderer within which something can be rendered onto a texture. +// This texture can then be used to be displayed. +type TextureRenderer struct { + gl opengl.OpenGL + + framebuffer uint32 + texture uint32 + + width int32 + height int32 +} + +// NewTextureRenderer returns a new instance. +func NewTextureRenderer(gl opengl.OpenGL) *TextureRenderer { + renderer := &TextureRenderer{ + gl: gl, + + framebuffer: gl.GenFramebuffers(1)[0], + texture: gl.GenTextures(1)[0], + + width: 200, + height: 200, + } + + gl.BindTexture(opengl.TEXTURE_2D, renderer.texture) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MAG_FILTER, opengl.NEAREST) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MIN_FILTER, opengl.NEAREST) + offset := unsafe.Pointer(uintptr(0)) // nolint: govet + gl.TexImage2D(opengl.TEXTURE_2D, 0, opengl.RGBA, renderer.width, renderer.height, 0, opengl.RGBA, opengl.UNSIGNED_BYTE, offset) + // gl.GenerateMipmap(opengl.TEXTURE_2D) + gl.BindTexture(opengl.TEXTURE_2D, 0) + + renderer.onFramebuffer(func() { + gl.BindTexture(opengl.TEXTURE_2D, renderer.texture) + gl.FramebufferTexture(opengl.FRAMEBUFFER, opengl.COLOR_ATTACHMENT0, renderer.texture, 0) + gl.DrawBuffers([]uint32{opengl.COLOR_ATTACHMENT0}) + gl.BindTexture(opengl.TEXTURE_2D, 0) + + // result := gl.CheckFramebufferStatus(opengl.FRAMEBUFFER) + // fmt.Printf("status: 0x%04X\n", result) + // result == FRAMEBUFFER_COMPLETE == 0x8CD5 + }) + return renderer +} + +// Dispose cleans up resources. +func (renderer *TextureRenderer) Dispose() { + gl := renderer.gl + gl.DeleteFramebuffers([]uint32{renderer.framebuffer}) + gl.DeleteTextures([]uint32{renderer.texture}) +} + +// Handle returns the texture handle. +func (renderer *TextureRenderer) Handle() uint32 { + return renderer.texture +} + +// Size returns the dimensions of the texture, in pixels. +func (renderer *TextureRenderer) Size() (width, height float32) { + return float32(renderer.width), float32(renderer.height) +} + +// Render calls the nested function. All drawing commands within this function will end up in the texture. +func (renderer *TextureRenderer) Render(nested func()) { + gl := renderer.gl + renderer.onFramebuffer(func() { + gl.Viewport(0, 0, renderer.width, renderer.height) + gl.ClearColor(0.0, 0.0, 0.0, 0.0) + gl.Clear(opengl.COLOR_BUFFER_BIT | opengl.DEPTH_BUFFER_BIT) + + nested() + }) +} + +func (renderer *TextureRenderer) onFramebuffer(nested func()) { + gl := renderer.gl + gl.BindFramebuffer(opengl.FRAMEBUFFER, renderer.framebuffer) + nested() + gl.BindFramebuffer(opengl.FRAMEBUFFER, 0) +} diff --git a/modding/hacked/editor/render/TextureSelector.go b/modding/hacked/editor/render/TextureSelector.go new file mode 100644 index 0000000..d6e671f --- /dev/null +++ b/modding/hacked/editor/render/TextureSelector.go @@ -0,0 +1,44 @@ +package render + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// TextureSelector renders a horizontal list of game textures, with selection capability. +func TextureSelector(label string, width float32, guiScale float32, + count int, selectedIndex int, cache *graphics.TextureCache, keyResolver func(int) resource.Key, + tooltipText func(int) string, + changeCallback func(int)) { + if imgui.BeginChildV(label, imgui.Vec2{X: width * guiScale, Y: 100 * guiScale}, true, + imgui.WindowFlagsHorizontalScrollbar|imgui.WindowFlagsNoScrollWithMouse) { + for i := 0; i < count; i++ { + key := keyResolver(i) + imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{X: 0, Y: 0}) + if imgui.BeginChildV(fmt.Sprintf("%3d", i), imgui.Vec2{X: 80 * guiScale, Y: 64 * guiScale}, false, + imgui.WindowFlagsNoNav|imgui.WindowFlagsNoScrollWithMouse) { + imgui.BeginGroup() + if imgui.SelectableV("", selectedIndex == i, 0, imgui.Vec2{X: 0, Y: 64 * guiScale}) { + changeCallback(i) + } + imgui.SameLine() + TextureImage(fmt.Sprintf("%3d", i), cache, key, imgui.Vec2{X: 64 * guiScale, Y: 64 * guiScale}) + imgui.EndGroup() + if imgui.IsItemHovered() { + text := tooltipText(i) + if len(text) > 0 { + imgui.SetTooltip(text) + } + } + } + imgui.EndChild() + imgui.PopStyleVar() + imgui.SameLine() + } + } + imgui.EndChild() +} diff --git a/modding/hacked/editor/sounds/View.go b/modding/hacked/editor/sounds/View.go new file mode 100644 index 0000000..af9d98c --- /dev/null +++ b/modding/hacked/editor/sounds/View.go @@ -0,0 +1,143 @@ +package sounds + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/external" + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/edit/undoable" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +// View provides edit controls for sound effects. +type View struct { + soundEffectService undoable.SoundEffectService + + modalStateMachine gui.ModalStateMachine + guiScale float32 + + model viewModel +} + +// NewSoundEffectsView returns a new instance. +func NewSoundEffectsView(soundEffectService undoable.SoundEffectService, + modalStateMachine gui.ModalStateMachine, + guiScale float32) *View { + view := &View{ + soundEffectService: soundEffectService, + + modalStateMachine: modalStateMachine, + guiScale: guiScale, + + 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: 400 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Sound Effects", view.WindowOpen(), imgui.WindowFlagsNoCollapse) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + info, _ := ids.Info(view.model.currentKey.ID) + + imgui.BeginChildV("SoundEffects", imgui.Vec2{X: -1, Y: -60 * view.guiScale}, true, 0) + for i := 0; i < info.MaxCount; i++ { + effects := ids.SoundEffectsForAudio(i) + text := fmt.Sprintf("%3d", i) + if len(effects) > 0 { + text += " - " + for effectIndex, effect := range effects { + if effectIndex > 0 { + text += ", " + } + text += effect.Name + } + } else { + text += " (unidentified)" + } + if imgui.SelectableV(text, view.model.currentKey.Index == i, 0, imgui.Vec2{}) { + view.model.currentKey.Index = i + } + } + imgui.EndChild() + + imgui.PushItemWidth(-100 * view.guiScale) + sound := view.currentSound() + if !sound.Empty() { + imgui.LabelText("Audio", fmt.Sprintf("%.2f sec", sound.Duration())) + if imgui.Button("Export") { + view.requestExportAudio(sound) + } + imgui.SameLine() + } else { + imgui.LabelText("Audio", "(no sound)") + } + if imgui.Button("Import") { + view.requestImportAudio() + } + imgui.PopItemWidth() + imgui.SameLine() + if imgui.Button("Clear") { + view.clearAudio() + } + if view.soundEffectService.Modified(view.model.currentKey) { + imgui.SameLine() + if imgui.Button("Remove") { + view.removeAudio() + } + } +} + +func (view *View) clearAudio() { + view.soundEffectService.RequestClear(view.model.currentKey, view.restoreFunc()) +} + +func (view *View) removeAudio() { + view.soundEffectService.RequestRemove(view.model.currentKey, view.restoreFunc()) +} + +func (view *View) currentSound() (sound audio.L8) { + return view.soundEffectService.Audio(view.model.currentKey) +} + +func (view *View) requestExportAudio(sound audio.L8) { + filename := fmt.Sprintf("sfx_%03d.wav", view.model.currentKey.Index) + + external.ExportAudio(view.modalStateMachine, filename, sound) +} + +func (view *View) requestImportAudio() { + external.ImportAudio(view.modalStateMachine, func(sound audio.L8) { + view.soundEffectService.RequestSetAudio(view.model.currentKey, sound, view.restoreFunc()) + }) +} + +func (view *View) restoreFunc() func() { + oldKey := view.model.currentKey + + return func() { + view.model.restoreFocus = true + view.model.currentKey = oldKey + } +} diff --git a/modding/hacked/editor/sounds/ViewModel.go b/modding/hacked/editor/sounds/ViewModel.go new file mode 100644 index 0000000..7266882 --- /dev/null +++ b/modding/hacked/editor/sounds/ViewModel.go @@ -0,0 +1,18 @@ +package sounds + +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.SoundEffectsAudioStart, resource.LangDefault, 0), + } +} diff --git a/modding/hacked/editor/texts/View.go b/modding/hacked/editor/texts/View.go new file mode 100644 index 0000000..d920bb8 --- /dev/null +++ b/modding/hacked/editor/texts/View.go @@ -0,0 +1,198 @@ +package texts + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/external" + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/undoable" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +// View provides edit controls for texts. +type View struct { + textService undoable.AugmentedTextService + + modalStateMachine gui.ModalStateMachine + clipboard external.Clipboard + guiScale float32 + + model viewModel +} + +// NewTextsView returns a new instance. +func NewTextsView(textService undoable.AugmentedTextService, + modalStateMachine gui.ModalStateMachine, clipboard external.Clipboard, + guiScale float32) *View { + view := &View{ + textService: textService, + + modalStateMachine: modalStateMachine, + clipboard: clipboard, + guiScale: guiScale, + + 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: 400 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Texts", view.WindowOpen(), imgui.WindowFlagsNoCollapse) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + knownTexts := edit.KnownTexts() + imgui.PushItemWidth(-100 * view.guiScale) + if imgui.BeginCombo("Text Type", knownTexts.Title(view.model.currentKey.ID)) { + for _, info := range knownTexts { + if imgui.SelectableV(info.Title, info.ID == view.model.currentKey.ID, 0, imgui.Vec2{}) { + view.model.currentKey.ID = info.ID + view.model.currentKey.Index = 0 + } + } + imgui.EndCombo() + } + info, _ := ids.Info(view.model.currentKey.ID) + gui.StepSliderInt("Index", &view.model.currentKey.Index, 0, info.MaxCount-1) + + 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() + } + + if view.textIsWithAudio() { + imgui.Separator() + sound := view.currentSound() + if !sound.Empty() { + imgui.LabelText("Audio", fmt.Sprintf("%.2f sec", sound.Duration())) + if imgui.Button("Export") { + view.requestExportAudio(sound) + } + imgui.SameLine() + } else { + imgui.LabelText("Audio", "(no sound)") + } + if imgui.Button("Import") { + view.requestImportAudio() + } + } + imgui.Separator() + + imgui.PopItemWidth() + + currentText := view.currentText() + imgui.BeginChildV("Text", imgui.Vec2{X: -100 * view.guiScale, Y: 0}, true, 0) + imgui.PushTextWrapPos() + if len(currentText) == 0 { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1.0, Y: 1.0, Z: 1.0, W: 0.5}) + imgui.Text("(empty)") + imgui.PopStyleColor() + } else { + imgui.Text(currentText) + } + imgui.PopTextWrapPos() + imgui.EndChild() + + imgui.SameLine() + + imgui.BeginGroup() + if imgui.ButtonV("-> Clip", imgui.Vec2{X: -1, Y: 0}) { + view.copyTextToClipboard(currentText) + } + if imgui.ButtonV("<- Clip", imgui.Vec2{X: -1, Y: 0}) { + view.setTextFromClipboard() + } + if imgui.ButtonV("Clear", imgui.Vec2{X: -1, Y: 0}) { + view.clearText() + } + if imgui.ButtonV("Remove", imgui.Vec2{X: -1, Y: 0}) { + view.removeText() + } + imgui.EndGroup() +} + +func (view View) currentText() string { + return view.textService.Text(view.model.currentKey) +} + +func (view View) copyTextToClipboard(text string) { + if len(text) > 0 { + view.clipboard.SetString(text) + } +} + +func (view *View) setTextFromClipboard() { + value, err := view.clipboard.String() + if err != nil { + return + } + + view.textService.RequestSetText(view.model.currentKey, value, view.restoreFunc()) +} + +func (view *View) clearText() { + view.textService.RequestClear(view.model.currentKey, view.restoreFunc()) +} + +func (view *View) removeText() { + view.textService.RequestRemove(view.model.currentKey, view.restoreFunc()) +} + +func (view View) textIsWithAudio() bool { + return view.textService.WithAudio(view.model.currentKey) +} + +func (view *View) currentSound() (sound audio.L8) { + return view.textService.Sound(view.model.currentKey) +} + +func (view *View) requestExportAudio(sound audio.L8) { + if !view.textIsWithAudio() { + return + } + filename := fmt.Sprintf("%05d_%03d_%s.wav", view.model.currentKey.ID, view.model.currentKey.Index, view.model.currentKey.Lang.String()) + + external.ExportAudio(view.modalStateMachine, filename, sound) +} + +func (view *View) requestImportAudio() { + external.ImportAudio(view.modalStateMachine, func(sound audio.L8) { + view.textService.RequestSetSound(view.model.currentKey, sound, view.restoreFunc()) + }) +} + +func (view *View) restoreFunc() func() { + oldKey := view.model.currentKey + + return func() { + view.model.restoreFocus = true + view.model.currentKey = oldKey + } +} diff --git a/modding/hacked/editor/texts/ViewModel.go b/modding/hacked/editor/texts/ViewModel.go new file mode 100644 index 0000000..26f0bbb --- /dev/null +++ b/modding/hacked/editor/texts/ViewModel.go @@ -0,0 +1,18 @@ +package texts + +import ( + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/resource" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + currentKey resource.Key +} + +func freshViewModel() viewModel { + return viewModel{ + currentKey: resource.KeyOf(edit.KnownTexts()[0].ID, resource.LangDefault, 0), + } +} diff --git a/modding/hacked/editor/textures/SetTextureBitmapCommand.go b/modding/hacked/editor/textures/SetTextureBitmapCommand.go new file mode 100644 index 0000000..b8f8de9 --- /dev/null +++ b/modding/hacked/editor/textures/SetTextureBitmapCommand.go @@ -0,0 +1,50 @@ +package textures + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +type setTextureBitmapCommand struct { + model *viewModel + + textureIndex int + id resource.ID + + oldData []byte + newData []byte +} + +func (command setTextureBitmapCommand) Do(modder world.Modder) error { + return command.perform(modder, command.newData) +} + +func (command setTextureBitmapCommand) Undo(modder world.Modder) error { + return command.perform(modder, command.oldData) +} + +func (command setTextureBitmapCommand) perform(modder world.Modder, data []byte) error { + info, existing := ids.Info(command.id) + if !existing { + panic(fmt.Sprintf("unknown identifier for bitmap resource: %v", command.id)) + } + resourceID := command.id + blockIndex := command.textureIndex + if !info.List { + resourceID = resourceID.Plus(blockIndex) + blockIndex = 0 + } + + if (len(data) > 0) || info.List { + modder.SetResourceBlock(resource.LangAny, resourceID, blockIndex, data) + } else { + modder.DelResource(resource.LangAny, resourceID) + } + + command.model.restoreFocus = true + command.model.currentIndex = command.textureIndex + return nil +} diff --git a/modding/hacked/editor/textures/SetTexturePropertiesCommand.go b/modding/hacked/editor/textures/SetTexturePropertiesCommand.go new file mode 100644 index 0000000..8a9f81b --- /dev/null +++ b/modding/hacked/editor/textures/SetTexturePropertiesCommand.go @@ -0,0 +1,31 @@ +package textures + +import ( + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setTexturePropertiesCommand struct { + model *viewModel + + textureIndex int + + oldProperties texture.Properties + newProperties texture.Properties +} + +func (cmd setTexturePropertiesCommand) Do(modder world.Modder) error { + return cmd.perform(modder, cmd.newProperties) +} + +func (cmd setTexturePropertiesCommand) Undo(modder world.Modder) error { + return cmd.perform(modder, cmd.oldProperties) +} + +func (cmd setTexturePropertiesCommand) perform(modder world.Modder, properties texture.Properties) error { + modder.SetTextureProperties(cmd.textureIndex, properties) + + cmd.model.restoreFocus = true + cmd.model.currentIndex = cmd.textureIndex + return nil +} diff --git a/modding/hacked/editor/textures/SetTextureTextCommand.go b/modding/hacked/editor/textures/SetTextureTextCommand.go new file mode 100644 index 0000000..0bd9efe --- /dev/null +++ b/modding/hacked/editor/textures/SetTextureTextCommand.go @@ -0,0 +1,31 @@ +package textures + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setTextureTextCommand struct { + model *viewModel + + key resource.Key + + oldData []byte + newData []byte +} + +func (command setTextureTextCommand) Do(modder world.Modder) error { + return command.perform(modder, command.newData) +} + +func (command setTextureTextCommand) Undo(modder world.Modder) error { + return command.perform(modder, command.oldData) +} + +func (command setTextureTextCommand) perform(modder world.Modder, data []byte) error { + modder.SetResourceBlock(command.key.Lang, command.key.ID, command.key.Index, data) + command.model.restoreFocus = true + command.model.currentIndex = command.key.Index + command.model.currentLang = command.key.Lang + return nil +} diff --git a/modding/hacked/editor/textures/View.go b/modding/hacked/editor/textures/View.go new file mode 100644 index 0000000..14365e8 --- /dev/null +++ b/modding/hacked/editor/textures/View.go @@ -0,0 +1,402 @@ +package textures + +import ( + "fmt" + "math" + + "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/editor/values" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/content/texture" + "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" +) + +// View provides edit controls for textures. +type View struct { + mod *world.Mod + textCache *text.Cache + cp text.Codepage + imageCache *graphics.TextureCache + paletteCache *graphics.PaletteCache + + modalStateMachine gui.ModalStateMachine + clipboard external.Clipboard + guiScale float32 + commander cmd.Commander + + model viewModel +} + +// NewTexturesView returns a new instance. +func NewTexturesView(mod *world.Mod, textCache *text.Cache, cp text.Codepage, + imageCache *graphics.TextureCache, paletteCache *graphics.PaletteCache, + modalStateMachine gui.ModalStateMachine, + clipboard external.Clipboard, guiScale float32, commander cmd.Commander) *View { + view := &View{ + mod: mod, + textCache: textCache, + cp: cp, + 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("Textures", view.WindowOpen(), imgui.WindowFlagsNoCollapse|imgui.WindowFlagsHorizontalScrollbar) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + if imgui.BeginChildV("Properties", imgui.Vec2{X: -300 * view.guiScale, Y: 0}, false, imgui.WindowFlagsHorizontalScrollbar) { + imgui.PushItemWidth(-200 * view.guiScale) + + gui.StepSliderInt("Index", &view.model.currentIndex, 0, world.MaxWorldTextures-1) + + render.TextureSelector("###IndexBitmap", -1, view.guiScale, world.MaxWorldTextures, + view.model.currentIndex, view.imageCache, + func(index int) resource.Key { + return view.indexedResourceKey(ids.LargeTextures, index) + }, + view.textureTooltip, + func(newValue int) { + view.model.currentIndex = newValue + }) + + readOnly := !view.mod.HasModifiableTextureProperties() + + imgui.Separator() + if imgui.BeginCombo("Language", view.model.currentLang.String()) { + languages := resource.Languages() + for _, lang := range languages { + if imgui.SelectableV(lang.String(), lang == view.model.currentLang, 0, imgui.Vec2{}) { + view.model.currentLang = lang + } + } + imgui.EndCombo() + } + + nameKey := resource.KeyOf(ids.TextureNames, view.model.currentLang, view.model.currentIndex) + name, _ := view.textCache.Text(nameKey) + view.renderText(readOnly, "Name", name, view.requestSetTextureName) + + useKey := resource.KeyOf(ids.TextureUsages, view.model.currentLang, view.model.currentIndex) + use, _ := view.textCache.Text(useKey) + view.renderText(readOnly, "Use", use, view.requestSetTextureUsage) + + imgui.Separator() + view.renderTextureProperties(readOnly) + + imgui.PopItemWidth() + } + imgui.EndChild() + imgui.SameLine() + + imgui.BeginGroup() + view.renderTextureSample("Large", ids.LargeTextures, 128, "Large") + view.renderTextureSample("Medium", ids.MediumTextures, 64, "Medium") + view.renderTextureSample("Small", ids.SmallTextures, 32, "Small") + view.renderTextureSample("Icon", ids.IconTextures, 16, "Icon") + imgui.EndGroup() +} + +func (view *View) renderText(readOnly bool, label string, value string, changeCallback func(string)) { + imgui.LabelText(label, value) + view.clipboardPopup(readOnly, label, value, changeCallback) +} + +func (view *View) clipboardPopup(readOnly bool, label string, value string, changeCallback func(string)) { + if imgui.BeginPopupContextItemV(label+"-Popup", 1) { + if imgui.Selectable("Copy to Clipboard") { + view.clipboard.SetString(value) + } + if !readOnly && imgui.Selectable("Copy from Clipboard") { + newValue, err := view.clipboard.String() + if err == nil { + changeCallback(newValue) + } + } + imgui.EndPopup() + } +} + +func (view *View) renderTextureProperties(readOnly bool) { + list := view.mod.TextureProperties() + distanceUnifier := values.NewUnifier() + climbableUnifier := values.NewUnifier() + transparencyControlUnifier := values.NewUnifier() + animationGroupUnifier := values.NewUnifier() + animationIndexUnifier := values.NewUnifier() + if view.model.currentIndex < len(list) { + properties := list[view.model.currentIndex] + distanceUnifier.Add(int(properties.DistanceModifier)) + climbableUnifier.Add(properties.Climbable != 0) + transparencyControlUnifier.Add(int(properties.TransparencyControl)) + animationGroupUnifier.Add(int(properties.AnimationGroup)) + animationIndexUnifier.Add(int(properties.AnimationIndex)) + } + + values.RenderUnifiedSliderInt(readOnly, "Distance Modifier", distanceUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + math.MinInt16, math.MaxInt16, + func(newValue int) { + view.requestChangeProperties(func(prop *texture.Properties) { + prop.DistanceModifier = int16(newValue) + }) + }) + + values.RenderUnifiedCheckboxCombo(readOnly, "Climbable", climbableUnifier, + func(newValue bool) { + view.requestChangeProperties(func(prop *texture.Properties) { + prop.Climbable = 0 + if newValue { + prop.Climbable = 1 + } + }) + }) + + values.RenderUnifiedCombo(readOnly, "Transparency Control", transparencyControlUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(index int) string { return texture.TransparencyControl(index).String() }, + len(texture.TransparencyControls()), + func(newValue int) { + view.requestChangeProperties(func(prop *texture.Properties) { + prop.TransparencyControl = texture.TransparencyControl(newValue) + }) + }) + + values.RenderUnifiedSliderInt(readOnly, "Animation Group", animationGroupUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { + view.requestChangeProperties(func(prop *texture.Properties) { + prop.AnimationGroup = byte(newValue) + }) + }) + values.RenderUnifiedSliderInt(readOnly, "Animation Index", animationIndexUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { + view.requestChangeProperties(func(prop *texture.Properties) { + prop.AnimationIndex = byte(newValue) + }) + }) +} + +func (view *View) renderTextureSample(label string, id resource.ID, sideLength float32, sizeID string) { + if imgui.BeginChildV(label, imgui.Vec2{X: -1, Y: (128 + 7) * view.guiScale}, true, imgui.WindowFlagsNoScrollbar) { + key := view.indexedResourceKey(id, view.model.currentIndex) + render.TextureImage("Texture Bitmap", view.imageCache, key, + imgui.Vec2{X: sideLength * view.guiScale, Y: sideLength * view.guiScale}) + + imgui.SameLine() + imgui.BeginGroup() + + tex, err := view.imageCache.Texture(key) + + if imgui.Button("Clear") { + view.requestClear(id, view.model.currentIndex, int(sideLength)) + } + imgui.SameLine() + if imgui.Button("Import") { + view.requestImport(id, view.model.currentIndex) + } + if err == nil { + if imgui.Button("Export") { + view.requestExport(id, view.model.currentIndex, sizeID) + } + if view.hasModCurrentBitmap() { + imgui.SameLine() + if imgui.Button("Remove") { + view.requestSetBitmapData(id, view.model.currentIndex, nil) + } + } + + width, height := tex.Size() + imgui.Text(fmt.Sprintf("%d x %d px", int(width), int(height))) + } + + imgui.EndGroup() + } + imgui.EndChild() +} + +func (view *View) currentResourceKey() resource.Key { + return view.indexedResourceKey(ids.LargeTextures, view.model.currentIndex) +} + +func (view *View) indexedResourceKey(id resource.ID, index int) resource.Key { + info, _ := ids.Info(id) + if info.List { + return resource.KeyOf(id, resource.LangAny, index) + } + return resource.KeyOf(id.Plus(index), resource.LangAny, 0) +} + +func (view *View) textureTooltip(index int) string { + key := resource.KeyOf(ids.TextureNames, resource.LangDefault, index) + name, err := view.textCache.Text(key) + suffix := "" + if err == nil { + suffix = ": " + name + } + return fmt.Sprintf("%3d", index) + suffix +} + +func (view *View) hasModCurrentBitmap() bool { + key := view.currentResourceKey() + return len(view.mod.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0 +} + +func (view *View) requestChangeProperties(modifier func(*texture.Properties)) { + list := view.mod.TextureProperties() + if view.model.currentIndex < len(list) { + command := setTexturePropertiesCommand{ + model: &view.model, + textureIndex: view.model.currentIndex, + oldProperties: list[view.model.currentIndex], + newProperties: list[view.model.currentIndex], + } + modifier(&command.newProperties) + view.commander.Queue(command) + } +} + +func (view *View) requestSetTextureName(value string) { + view.requestSetTextureText(ids.TextureNames, value) +} + +func (view *View) requestSetTextureUsage(value string) { + view.requestSetTextureText(ids.TextureUsages, value) +} + +func (view *View) requestSetTextureText(id resource.ID, newValue string) { + key := resource.KeyOf(id, view.model.currentLang, view.model.currentIndex) + oldValue, _ := view.textCache.Text(key) + + if oldValue != newValue { + command := setTextureTextCommand{ + model: &view.model, + key: key, + oldData: view.cp.Encode(oldValue), + newData: view.cp.Encode(text.Blocked(newValue)[0]), + } + view.commander.Queue(command) + } +} + +func (view *View) requestExport(id resource.ID, index int, sizeID string) { + key := view.indexedResourceKey(id, index) + tex, 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("Texture_%03d_%s.png", index, sizeID) + width, height := tex.Size() + bmp := bitmap.Bitmap{ + Header: bitmap.Header{ + Width: int16(width), + Height: int16(height), + }, + Pixels: tex.PixelData(), + Palette: &rawPalette, + } + + external.ExportImage(view.modalStateMachine, filename, bmp) +} + +func (view *View) requestImport(id resource.ID, index int) { + 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(id, index, bmp) + }) +} + +func (view *View) requestClear(id resource.ID, index int, sideLength int) { + bmp := bitmap.Bitmap{ + Header: bitmap.Header{ + Width: int16(sideLength), + Height: int16(sideLength), + }, + Pixels: []byte{0x00}, + } + view.requestSetBitmap(id, index, bmp) +} + +func (view *View) requestSetBitmap(id resource.ID, index int, bmp bitmap.Bitmap) { + highestBitShift := func(value int16) (result byte) { + if value != 0 { + for (value >> result) != 1 { + result++ + } + } + return + } + + bmp.Header.Flags = 0 + bmp.Header.Type = bitmap.TypeFlat8Bit + 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(id, index, data) +} + +func (view *View) requestSetBitmapData(id resource.ID, index int, newData []byte) { + resourceKey := view.indexedResourceKey(id, index) + command := setTextureBitmapCommand{ + model: &view.model, + id: id, + textureIndex: index, + oldData: view.mod.ModifiedBlock(resource.LangAny, resourceKey.ID, resourceKey.Index), + newData: newData, + } + view.commander.Queue(command) +} diff --git a/modding/hacked/editor/textures/ViewModel.go b/modding/hacked/editor/textures/ViewModel.go new file mode 100644 index 0000000..66c59f9 --- /dev/null +++ b/modding/hacked/editor/textures/ViewModel.go @@ -0,0 +1,20 @@ +package textures + +import ( + "github.com/inkyblackness/hacked/ss1/resource" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + + currentLang resource.Language + currentIndex int +} + +func freshViewModel() viewModel { + return viewModel{ + currentIndex: 0, + currentLang: resource.LangDefault, + } +} diff --git a/modding/hacked/editor/values/InterpreterControls.go b/modding/hacked/editor/values/InterpreterControls.go new file mode 100644 index 0000000..2ed32b9 --- /dev/null +++ b/modding/hacked/editor/values/InterpreterControls.go @@ -0,0 +1,167 @@ +package values + +import ( + "fmt" + "sort" + "strings" + + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +// StandardSimplifier returns a simplifier with common property controls. +func StandardSimplifier(readOnly bool, fullKey string, unifier Unifier, + updater func(func(uint32) uint32), objTypeRenderer ObjectTypeControlRenderer) *interpreters.Simplifier { + keys := strings.Split(fullKey, ".") + key := keys[len(keys)-1] + label := key + "###" + fullKey + + simplifier := interpreters.NewSimplifier(func(minValue, maxValue int64, formatter interpreters.RawValueFormatter) { + RenderUnifiedSliderInt(readOnly, label, unifier, + func(u Unifier) int { + unifiedValue := u.Unified().(int32) + if (minValue == -1) && (maxValue == 0x7FFF) { + unifiedValue = int32(int16(unifiedValue)) + } + return int(unifiedValue) + }, + func(value int) string { + result := formatter(value) + if len(result) == 0 { + result = "%d" + } else { + result += " - raw: %d" + } + return result + }, + int(minValue), int(maxValue), + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + + simplifier.SetEnumValueHandler(func(enumValues map[uint32]string) { + valueKeys := make([]uint32, 0, len(enumValues)) + for valueKey := range enumValues { + valueKeys = append(valueKeys, valueKey) + } + sort.Slice(valueKeys, func(indexA, indexB int) bool { return valueKeys[indexA] < valueKeys[indexB] }) + + RenderUnifiedCombo(readOnly, label, unifier, + func(u Unifier) int { + unifiedValue := uint32(u.Unified().(int32)) + for index, valueKey := range valueKeys { + if valueKey == unifiedValue { + return index + } + } + return -1 + }, + func(index int) string { + if index < 0 { + return "" + } + return enumValues[valueKeys[index]] + }, + len(valueKeys), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { return valueKeys[newIndex] }) + }) + }) + + simplifier.SetBitfieldHandler(func(maskNames map[uint32]string) { + masks := make([]uint32, 0, len(maskNames)) + for mask := range maskNames { + masks = append(masks, mask) + } + sort.Slice(masks, func(indexA, indexB int) bool { return masks[indexA] < masks[indexB] }) + + addMaskedItem := func(mask uint32) { + maxValue := mask + shift := 0 + maskedLabel := key + "." + maskNames[mask] + "###" + label + "-" + maskNames[mask] + + for (maxValue & 1) == 0 { + shift++ + maxValue >>= 1 + } + + if maxValue == 1 { + booleanUnifier := NewUnifier() + if unifier.IsUnique() { + booleanUnifier.Add((uint32(unifier.Unified().(int32)) & mask) != 0) + } + RenderUnifiedCheckboxCombo(readOnly, maskedLabel, booleanUnifier, + func(newValue bool) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^mask + if newValue { + result |= mask + } + return result + }) + }) + } else { + RenderUnifiedSliderInt(readOnly, maskedLabel, unifier, + func(u Unifier) int { return int((uint32(u.Unified().(int32)) & mask) >> uint32(shift)) }, + func(value int) string { return "%d" }, + 0, int(maxValue), + func(newValue int) { + updater(func(oldValue uint32) uint32 { + return (oldValue & ^mask) | (uint32(newValue) << uint32(shift)) + }) + }) + } + } + + for _, mask := range masks { + addMaskedItem(mask) + } + }) + + simplifier.SetRotationHandler(func(minValue, maxValue int64) { + RenderUnifiedRotation(readOnly, label, unifier, + int(minValue), int(maxValue), + RotationInfo{ + // This code is specialized for Hacker-Yaw. To re-use it for other rotations, details must change. + Horizontal: true, + Positive: true, + Clockwise: false, + }, + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + + simplifier.SetSpecialHandler("ObjectTriple", func() { + var classNames [object.ClassCount]string + for index, class := range object.Classes() { + classNames[index] = class.String() + } + tripleResolver := func(u Unifier) object.Triple { return object.TripleFromInt(int(u.Unified().(int32))) } + RenderUnifiedCombo(readOnly, key+"-Class###"+fullKey+"-Class", unifier, + func(u Unifier) int { + triple := tripleResolver(u) + return int(triple.Class) + }, + func(value int) string { return fmt.Sprintf("%2d: %v", value, object.Class(value)) }, + object.ClassCount, + func(newValue int) { + triple := object.TripleFrom(newValue, 0, 0) + updater(func(oldValue uint32) uint32 { return uint32(triple.Int()) }) + }) + + objTypeRenderer.Render(readOnly, key+"###"+fullKey+"-Type", unifier, unifier, + tripleResolver, + func(newValue object.Triple) { + updater(func(oldValue uint32) uint32 { return uint32(newValue.Int()) }) + }) + }) + + simplifier.SetSpecialHandler("Mistake", func() {}) + simplifier.SetSpecialHandler("Ignored", func() {}) + simplifier.SetSpecialHandler("Unknown", func() {}) + simplifier.SetSpecialHandler("Internal", func() {}) + + return simplifier +} diff --git a/modding/hacked/editor/values/ObjectTypeControlRenderer.go b/modding/hacked/editor/values/ObjectTypeControlRenderer.go new file mode 100644 index 0000000..5dc9e3f --- /dev/null +++ b/modding/hacked/editor/values/ObjectTypeControlRenderer.go @@ -0,0 +1,71 @@ +package values + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// ObjectTypeControlRenderer renders a control to select an object. +type ObjectTypeControlRenderer struct { + // Meta describes the layout of classes, subclasses, and types. + Meta object.PropertiesTable + // TextCache is used to retrieve the name of a type. + TextCache *text.Cache +} + +// Render creates the controls according to the given parameters. +func (renderer ObjectTypeControlRenderer) Render(readOnly bool, label string, + classUnifier Unifier, typeUnifier Unifier, + tripleResolver func(Unifier) object.Triple, + changeHandler func(object.Triple)) { + switch { + case classUnifier.IsUnique(): + class := tripleResolver(classUnifier).Class + triples := renderer.Meta.TriplesInClass(class) + selectedIndex := -1 + if typeUnifier.IsUnique() { + triple := tripleResolver(typeUnifier) + for index, availableTriple := range triples { + if availableTriple == triple { + selectedIndex = index + } + } + if selectedIndex < 0 { + selectedIndex = len(triples) + triples = append(triples, triple) + } + } + RenderUnifiedCombo(readOnly, label, typeUnifier, + func(u Unifier) int { return selectedIndex }, + func(value int) string { + triple := triples[value] + return renderer.tripleName(triple) + }, + len(triples), + func(newValue int) { + triple := triples[newValue] + changeHandler(triple) + }) + case !classUnifier.IsEmpty(): + imgui.LabelText(label, "(multiple classes)") + default: + imgui.LabelText(label, "") + } +} + +func (renderer ObjectTypeControlRenderer) tripleName(triple object.Triple) string { + suffix := "???" + linearIndex := renderer.Meta.TripleIndex(triple) + if (linearIndex >= 0) && (renderer.TextCache != nil) { + key := resource.KeyOf(ids.ObjectLongNames, resource.LangDefault, linearIndex) + objName, err := renderer.TextCache.Text(key) + if err == nil { + suffix = objName + } + } + return triple.String() + ": " + suffix +} diff --git a/modding/hacked/editor/values/Properties.go b/modding/hacked/editor/values/Properties.go new file mode 100644 index 0000000..2a159e1 --- /dev/null +++ b/modding/hacked/editor/values/Properties.go @@ -0,0 +1,156 @@ +package values + +import ( + "fmt" + "image/color" + "math" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +const ( + hintMultiple = "(multiple)" + hintYes = "Yes" + hintNo = "No" +) + +// RenderUnifiedSliderInt renders a control for an unified integer value. +func RenderUnifiedSliderInt(readOnly bool, label string, unifier Unifier, + intConverter func(Unifier) int, formatter func(int) string, min, max int, changeHandler func(int)) { + var labelValue string + var selectedString string + selectedValue := -1 + if unifier.IsUnique() { + selectedValue = intConverter(unifier) + selectedString = formatter(selectedValue) + labelValue = fmt.Sprintf(selectedString, selectedValue) + } else if !unifier.IsEmpty() { + selectedString = hintMultiple + labelValue = selectedString + } + if readOnly { + imgui.LabelText(label, labelValue) + } else if gui.StepSliderIntV(label, &selectedValue, min, max, selectedString) { + changeHandler(selectedValue) + } +} + +// RenderUnifiedCombo renders a control for a unified enumeration value. +func RenderUnifiedCombo(readOnly bool, label string, unifier Unifier, + intConverter func(Unifier) int, formatter func(int) string, count int, changeHandler func(int)) { + var selectedString string + selectedIndex := -1 + if unifier.IsUnique() { + selectedIndex = intConverter(unifier) + selectedString = formatter(selectedIndex) + } else if !unifier.IsEmpty() { + selectedString = hintMultiple + } + if readOnly { + imgui.LabelText(label, selectedString) + } else if imgui.BeginCombo(label, selectedString) { + for i := 0; i < count; i++ { + entryString := formatter(i) + + if imgui.SelectableV(entryString, i == selectedIndex, 0, imgui.Vec2{}) { + changeHandler(i) + } + } + imgui.EndCombo() + } +} + +// RenderUnifiedCheckboxCombo renders a control for a unified boolean value. +func RenderUnifiedCheckboxCombo(readOnly bool, label string, unifier Unifier, changeHandler func(bool)) { + selectedString := "" + if unifier.IsUnique() { + selectedValue := unifier.Unified().(bool) + if selectedValue { + selectedString = hintYes + } else { + selectedString = hintNo + } + } else if !unifier.IsEmpty() { + selectedString = hintMultiple + } + if readOnly { + imgui.LabelText(label, selectedString) + } else if imgui.BeginCombo(label, selectedString) { + for i, text := range []string{hintNo, hintYes} { + if imgui.SelectableV(text, text == selectedString, 0, imgui.Vec2{}) { + changeHandler(i != 0) + } + } + imgui.EndCombo() + } +} + +// RotationInfo describes how the rotation is aligned in its zero position. +type RotationInfo struct { + Horizontal bool + Positive bool + Clockwise bool +} + +// RenderUnifiedRotation renders a control for rotation value. +func RenderUnifiedRotation(readOnly bool, label string, unifier Unifier, + min, max int, info RotationInfo, changeHandler func(int)) { + calcValuePercent := func(value int) float64 { + valueRange := (max - min) + 1 + return float64(value) / float64(valueRange) + } + RenderUnifiedSliderInt(readOnly, label, unifier, + func(u Unifier) int { + unifiedValue := u.Unified().(int32) + return int(unifiedValue) + }, + func(value int) string { + valuePercent := calcValuePercent(value) + result := fmt.Sprintf("%3.02f° - raw: %%d", valuePercent*360.0) + return result + }, + min, max, + changeHandler) + + if (imgui.IsItemFocused() || imgui.IsItemActive() || imgui.IsItemHovered()) && unifier.IsUnique() { + valuePercent := calcValuePercent(int(unifier.Unified().(int32))) + valueRadian := valuePercent * (math.Pi * 2) + + lineColor := imgui.Packed(color.RGBA{R: 0x21, G: 0xFF, B: 0x43, A: 0xFF}) // color copied from style. + lineThickness := float32(4.0) + imgui.BeginTooltip() + imgui.Dummy(imgui.Vec2{X: imgui.TextLineHeightWithSpacing() * 2, Y: imgui.TextLineHeightWithSpacing() * 2}) + dl := imgui.WindowDrawList() + winTopLeft := imgui.WindowPos() + winSize := imgui.WindowSize() + center := winTopLeft.Plus(winSize.Times(0.5)) + circleRadius := ((winSize.X / 2) * 5) / 6 + dl.AddCircleV(center, circleRadius, lineColor, 100, lineThickness) + + targetX := 0.0 + targetY := 0.0 + if info.Horizontal { + targetX = 1.0 + if !info.Positive { + targetX = -1.0 + } + } else { + targetY = -1.0 + if !info.Positive { + targetY = 1.0 + } + } + var target imgui.Vec2 + if info.Clockwise { + target.X = center.X - float32((float64(circleRadius)*targetX)*math.Cos(valueRadian)+(float64(circleRadius)*targetY)*math.Sin(valueRadian)) + target.Y = center.Y + float32((float64(circleRadius)*targetY)*math.Cos(valueRadian)-(float64(circleRadius)*targetX)*math.Sin(valueRadian)) + } else { + target.X = center.X + float32((float64(circleRadius)*targetX)*math.Cos(valueRadian)-(float64(circleRadius)*targetY)*math.Sin(valueRadian)) + target.Y = center.Y - float32((float64(circleRadius)*targetY)*math.Cos(valueRadian)+(float64(circleRadius)*targetX)*math.Sin(valueRadian)) + } + dl.AddLineV(center, target, lineColor, lineThickness) + imgui.EndTooltip() + } +} diff --git a/modding/hacked/editor/values/Unifier.go b/modding/hacked/editor/values/Unifier.go new file mode 100644 index 0000000..2e3173e --- /dev/null +++ b/modding/hacked/editor/values/Unifier.go @@ -0,0 +1,107 @@ +package values + +// Unifier can be used to verify whether a list of values has equal entries. +type Unifier struct { + state unifierState +} + +// NewUnifier returns a new instance. +func NewUnifier() Unifier { + return Unifier{state: unifierInitState{}} +} + +// UnifierFor returns a unifier for a single value. +func UnifierFor(value interface{}) Unifier { + unifier := NewUnifier() + unifier.Add(value) + return unifier +} + +// Add unifies the given value to the current state. +// The given value must be comparable. +func (u *Unifier) Add(value interface{}) { + u.state = u.state.add(value) +} + +// Unified returns the result of the unification. +// If all values that were added to the unifier were equal, then the first +// added value will be returned. Otherwise, nil will be returned. +func (u *Unifier) Unified() interface{} { + return u.state.unified() +} + +// IsUnique returns true if the unifier has received only equal values. +func (u Unifier) IsUnique() bool { + return u.state.isUnique() +} + +// IsEmpty returns true if the unifier has not received any value. +func (u Unifier) IsEmpty() bool { + return (u.state == nil) || u.state.isEmpty() +} + +type unifierState interface { + add(value interface{}) unifierState + unified() interface{} + isUnique() bool + isEmpty() bool +} + +type unifierInitState struct{} + +func (state unifierInitState) add(value interface{}) unifierState { + return unifierMatchedState{value: value} +} + +func (state unifierInitState) unified() interface{} { + return nil +} + +func (state unifierInitState) isUnique() bool { + return false +} + +func (state unifierInitState) isEmpty() bool { + return true +} + +type unifierMatchedState struct { + value interface{} +} + +func (state unifierMatchedState) add(value interface{}) unifierState { + if state.value == value { + return state + } + return unifierMismatchedState{} +} + +func (state unifierMatchedState) unified() interface{} { + return state.value +} + +func (state unifierMatchedState) isUnique() bool { + return true +} + +func (state unifierMatchedState) isEmpty() bool { + return false +} + +type unifierMismatchedState struct{} + +func (state unifierMismatchedState) add(value interface{}) unifierState { + return state +} + +func (state unifierMismatchedState) unified() interface{} { + return nil +} + +func (state unifierMismatchedState) isUnique() bool { + return false +} + +func (state unifierMismatchedState) isEmpty() bool { + return false +} diff --git a/modding/hacked/editor/values/Unifier_test.go b/modding/hacked/editor/values/Unifier_test.go new file mode 100644 index 0000000..c658f7c --- /dev/null +++ b/modding/hacked/editor/values/Unifier_test.go @@ -0,0 +1,84 @@ +package values_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/editor/values" + + "github.com/stretchr/testify/assert" +) + +func TestUnifier_Integers(t *testing.T) { + tt := []struct { + values []int + expectedValue interface{} + expectedUnified bool + }{ + {[]int{1, 1, 1}, 1, true}, + {[]int{}, nil, false}, + {[]int{1, 2, 3}, nil, false}, + } + + for tn, tc := range tt { + u := values.NewUnifier() + for _, value := range tc.values { + u.Add(value) + } + resultValue := u.Unified() + resultUnified := u.IsUnique() + assert.Equal(t, tc.expectedValue, resultValue, fmt.Sprintf("Wrong result for test number %d", tn)) + assert.Equal(t, tc.expectedUnified, resultUnified, fmt.Sprintf("Wrong unified for test number %d", tn)) + } +} + +func TestUnifier_Strings(t *testing.T) { + tt := []struct { + values []string + expectedValue interface{} + expectedUnified bool + }{ + {[]string{"a", "a", "a"}, "a", true}, + {[]string{}, nil, false}, + {[]string{"a", "b", "c"}, nil, false}, + } + + for tn, tc := range tt { + u := values.NewUnifier() + for _, value := range tc.values { + u.Add(value) + } + resultValue := u.Unified() + resultUnified := u.IsUnique() + assert.Equal(t, tc.expectedValue, resultValue, fmt.Sprintf("Wrong result for test number %d", tn)) + assert.Equal(t, tc.expectedUnified, resultUnified, fmt.Sprintf("Wrong unified for test number %d", tn)) + } +} + +func TestUnifier_Types(t *testing.T) { + type simpleType struct { + value int + text string + } + tt := []struct { + values []simpleType + expectedValue interface{} + expectedUnified bool + }{ + {[]simpleType{{value: 1, text: "a"}, {value: 1, text: "a"}, {value: 1, text: "a"}}, simpleType{value: 1, text: "a"}, true}, + {[]simpleType{}, nil, false}, + {[]simpleType{{value: 1, text: "a"}, {value: 1, text: "a"}, {value: 2, text: "a"}}, nil, false}, + {[]simpleType{{value: 1, text: "a"}, {value: 1, text: "a"}, {value: 1, text: "b"}}, nil, false}, + } + + for tn, tc := range tt { + u := values.NewUnifier() + for _, value := range tc.values { + u.Add(value) + } + resultValue := u.Unified() + resultUnified := u.IsUnique() + assert.Equal(t, tc.expectedValue, resultValue, fmt.Sprintf("Wrong result for test number %d", tn)) + assert.Equal(t, tc.expectedUnified, resultUnified, fmt.Sprintf("Wrong unified for test number %d", tn)) + } +} diff --git a/modding/hacked/go.mod b/modding/hacked/go.mod new file mode 100644 index 0000000..7523cbc --- /dev/null +++ b/modding/hacked/go.mod @@ -0,0 +1,26 @@ +module github.com/inkyblackness/hacked + +go 1.25.0 + +require ( + github.com/asticode/go-astisub v0.12.1 + github.com/go-gl/gl v0.0.0-20210315015930-ae072cafe09d + github.com/go-gl/glfw v0.0.0-20210410170116-ea3d685f79fb + github.com/go-gl/mathgl v1.0.0 + github.com/inkyblackness/imgui-go/v3 v3.1.0 + github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d + github.com/stretchr/testify v1.4.0 + golang.org/x/image v0.41.0 +) + +require ( + github.com/TheTitanrain/w32 v0.0.0-20200114052255-2654d97dbd3d // indirect + github.com/asticode/go-astikit v0.20.0 // indirect + github.com/asticode/go-astits v1.8.0 // indirect + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/text v0.37.0 // indirect + gopkg.in/yaml.v2 v2.2.8 // indirect +) diff --git a/modding/hacked/go.sum b/modding/hacked/go.sum new file mode 100644 index 0000000..162807f --- /dev/null +++ b/modding/hacked/go.sum @@ -0,0 +1,52 @@ +github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= +github.com/TheTitanrain/w32 v0.0.0-20200114052255-2654d97dbd3d h1:2xp1BQbqcDDaikHnASWpVZRjibOxu7y9LhAv04whugI= +github.com/TheTitanrain/w32 v0.0.0-20200114052255-2654d97dbd3d/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= +github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8= +github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= +github.com/asticode/go-astisub v0.12.1 h1:eAXddrpYLUcFAv+/PhibQbfhscZVT4dSG6UkPglF5g8= +github.com/asticode/go-astisub v0.12.1/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8= +github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg= +github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ= +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/go-gl/gl v0.0.0-20210315015930-ae072cafe09d h1:o81yRlBATU4PRn97lydmsq8hTRNXI4wlR/VvUQhFRVY= +github.com/go-gl/gl v0.0.0-20210315015930-ae072cafe09d/go.mod h1:482civXOzJJCPzJ4ZOX/pwvXBWSnzD4OKMdH4ClKGbk= +github.com/go-gl/glfw v0.0.0-20210410170116-ea3d685f79fb h1:yMlfD+jmoG8wwBVLaFwtcgNBY207iWsYwtNC9u+T2yk= +github.com/go-gl/glfw v0.0.0-20210410170116-ea3d685f79fb/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/mathgl v1.0.0 h1:t9DznWJlXxxjeeKLIdovCOVJQk/GzDEL7h/h+Ro2B68= +github.com/go-gl/mathgl v1.0.0/go.mod h1:yhpkQzEiH9yPyxDUGzkmgScbaBVlhC06qodikEM0ZwQ= +github.com/inkyblackness/imgui-go/v3 v3.1.0 h1:KPUUEmt4I/Q1VGqBVpvHKOPZklltuHJ+WpTBZzUkNQc= +github.com/inkyblackness/imgui-go/v3 v3.1.0/go.mod h1:WdM5LZHuTrjd1d+yd2fA19t3mG538kJ6zOLB2sAXF6U= +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/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= +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/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d h1:Chay1rwJnXxI27H+pzu7P81BKf647un9GOoRPTdXN18= +github.com/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/modding/hacked/main.go b/modding/hacked/main.go new file mode 100644 index 0000000..38d28c6 --- /dev/null +++ b/modding/hacked/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "flag" + "fmt" + _ "image/gif" + _ "image/png" + "os" + "path/filepath" + "runtime/pprof" + "time" + + _ "golang.org/x/image/bmp" + + "github.com/inkyblackness/hacked/crash" + "github.com/inkyblackness/hacked/editor" + "github.com/inkyblackness/hacked/ui/native" +) + +var version string + +func main() { + scale := flag.Float64("scale", 1.0, "factor for scaling the UI (0.5 .. 10.0). 1080p displays should use default. 4K most likely 2.0.") + fontFile := flag.String("fontfile", "", "Path to font file (.TTF) to use instead of the default font. Useful for HiDPI displays.") + fontSize := flag.Float64("fontsize", 0.0, "Size of the font to use. If not specified, a default height will be used.") + cpuProfile := flag.String("cpuprofile", "", "write cpu profile to file") + flag.Parse() + var app editor.Application + app.FontFile = *fontFile + app.FontSize = float32(*fontSize) + app.GuiScale = float32(*scale) + if len(version) > 0 { + app.Version = version + } else { + app.Version = fmt.Sprintf("(manual build %v)", time.Now().Format("2006-01-02")) + } + + configDir, err := configDir() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Failed to determine config dir: %v", err) + os.Exit(-1) + } + app.ConfigDir = configDir + + versionInfo := "InkyBlackness - HackEd - " + app.Version + defer crash.Handler(versionInfo) + + profileFin, err := initProfiling(*cpuProfile) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Failed to start CPU profiling: %v\n", err) + } + defer profileFin() + + err = native.Run(app.InitializeWindow, versionInfo, 30.0) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Failed to run application: %v\n", err) + } +} + +func initProfiling(filename string) (func(), error) { + if filename == "" { + return func() {}, nil + } + f, err := os.Create(filename) + if err != nil { + return func() {}, err + } + err = pprof.StartCPUProfile(f) + return func() { + pprof.StopCPUProfile() + _ = f.Close() + }, err +} + +func configDir() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", err + } + fullPath := filepath.Join(base, "InkyBlackness", "HackEd") + err = os.MkdirAll(fullPath, os.ModeDir|0750) + if err != nil { + return "", err + } + return fullPath, nil +} diff --git a/modding/hacked/ss1/StringError.go b/modding/hacked/ss1/StringError.go new file mode 100644 index 0000000..d002463 --- /dev/null +++ b/modding/hacked/ss1/StringError.go @@ -0,0 +1,10 @@ +package ss1 + +// StringError is a string-based type implementing the error interface. +// It is used to cover all errors based on static strings. +type StringError string + +// Error returns the string interpretation of the error. +func (err StringError) Error() string { + return string(err) +} diff --git a/modding/hacked/ss1/content/archive/Constants.go b/modding/hacked/ss1/content/archive/Constants.go new file mode 100644 index 0000000..be6e441 --- /dev/null +++ b/modding/hacked/ss1/content/archive/Constants.go @@ -0,0 +1,6 @@ +package archive + +const ( + // MaxLevels describes how many levels the world can hold. + MaxLevels = 16 +) diff --git a/modding/hacked/ss1/content/archive/GameState.go b/modding/hacked/ss1/content/archive/GameState.go new file mode 100644 index 0000000..20f02fd --- /dev/null +++ b/modding/hacked/ss1/content/archive/GameState.go @@ -0,0 +1,825 @@ +package archive + +import ( + "fmt" + "math" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/text" +) + +// GameStateSize specifies the byte count of a the GameState in an archive. +// Note that in the original archive.dat file, the resource only has 0x054D bytes. +// This does not matter much, as in the original engine the resource was ignored. +const GameStateSize = 0x0595 + +const ( + stateHackerNameSize = 20 + // InventoryWeaponSlots is the number of weapons available to the character. + InventoryWeaponSlots = 7 + weaponSlotsStartOffset = 0x048B + weaponSlotSize = 5 + + // GrenadeTypeCount is the number of grenades (explosives) available to the character. + GrenadeTypeCount = 7 + grenadeCountStartOffset = 0x0350 + grenadeTimerStartOffset = 0x04FF + grenadeTimerDefault = 70 + // GrenadeTimerMaximum is the maximum time that can be set for a grenade in game. + GrenadeTimerMaximum = 571 + + // PatchTypeCount is the number of patches available to the character. + PatchTypeCount = 7 + patchCountStartOffset = 0x0349 + + // AmmoTypeCount is the number of types of ammo available to the character. + AmmoTypeCount = 15 + ammoFullClipCountStartOffset = 0x032B + ammoExtraRoundsCountStartOffset = 0x033A + + // GeneralInventorySlotCount is the number of general inventory slots available. + GeneralInventorySlotCount = 14 + generalInventorySlotStartOffset = 0x007A + + // HardwareTypeCount is the number of available hardware. + HardwareTypeCount = 15 + hardwareVersionStartOffset = 0x0309 + hardwareStatusStartOffset = 0x04AE + + // SoftwareOffenseTypeCount is the number of available offense software. + SoftwareOffenseTypeCount = 7 + // SoftwareDefenseTypeCount is the number of available defense software. + SoftwareDefenseTypeCount = 3 + // SoftwareOneshotTypeCount is the number of available one-shot software. + SoftwareOneshotTypeCount = 4 + + installedOffenseSoftwareStartOffset = 0x0318 + installedDefenseSoftwareStartOffset = 0x031F + installedOneshotSoftwareStartOffset = 0x0322 + + engineTicksPerSecond = 280 + secondsPerMinute = 60 + engineTicksPerMinute = secondsPerMinute * engineTicksPerSecond + cyberspaceMinTime = 90 * engineTicksPerSecond + cyberspaceMaxTime = 30 * secondsPerMinute * engineTicksPerSecond + + // EMailCount is the number of available EMail status slots. + EMailCount = 47 + // FragmentCount is the number of available fragment (data) status slots. + FragmentCount = 23 + // PossibleLogsPerLevel is the number of logs slots available per level. + PossibleLogsPerLevel = 16 + // LevelsWithLogs is the number of levels that can hold logs. + LevelsWithLogs = 14 + // LogCount is the total number of logs available. + LogCount = PossibleLogsPerLevel * LevelsWithLogs + + messageStatusReceived byte = 0x80 + messageStatusRead byte = 0x40 + + messageStatusStartOffset = 0x0357 + emailStatusStartOffset = messageStatusStartOffset + logStatusStartOffset = emailStatusStartOffset + EMailCount + fragmentStatusStartOffset = logStatusStartOffset + LogCount + + // BooleanVarCount is the number of available boolean variables. + BooleanVarCount = 512 + booleanVarStartOffset = 0x00B6 + // IntegerVarCount is the number of available integer variables. + IntegerVarCount = 64 + integerVarStartOffset = 0x00F6 +) + +// GameState contains all the necessary information for the current progress of the game. +// It is a combination of world information, character information, and configuration values. +type GameState struct { + *interpreters.Instance +} + +// InventoryWeaponSlot describes one entry for weapons in the inventory. +type InventoryWeaponSlot struct { + Index int + State *GameState +} + +func freeWeaponSlot() []byte { + return []byte{0xFF, 0x00, 0x00, 0x00, 0x00} +} + +func (slot InventoryWeaponSlot) rawData() []byte { + if (slot.Index < 0) || (slot.Index >= InventoryWeaponSlots) { + return freeWeaponSlot() + } + startIndex := weaponSlotsStartOffset + (weaponSlotSize * slot.Index) + return slot.State.Raw()[startIndex : startIndex+weaponSlotSize] +} + +// IsInUse returns true if the slot is currently holding a weapon. +func (slot InventoryWeaponSlot) IsInUse() bool { + return slot.rawData()[0] != 0xFF +} + +// SetFree clears the weapon slot. +func (slot InventoryWeaponSlot) SetFree() { + rawData := slot.rawData() + copy(rawData, freeWeaponSlot()) +} + +// SetInUse inserts the given weapon in this slot. Other fields are reset to zero. +func (slot InventoryWeaponSlot) SetInUse(subclass object.Subclass, objType object.Type) { + rawData := slot.rawData() + copy(rawData, freeWeaponSlot()) + rawData[0] = byte(subclass) + rawData[1] = byte(objType) +} + +// Triple returns the type information of the weapon in this slot. +func (slot InventoryWeaponSlot) Triple() object.Triple { + rawData := slot.rawData() + return object.TripleFrom(int(object.ClassGun), int(rawData[0]), int(rawData[1])) +} + +// WeaponState returns the two state values. Their interpretation is subclass specific. +func (slot InventoryWeaponSlot) WeaponState() (byte, byte) { + rawData := slot.rawData() + return rawData[2], rawData[3] +} + +// SetWeaponState updates the two state values. +func (slot InventoryWeaponSlot) SetWeaponState(a, b byte) { + rawData := slot.rawData() + rawData[2] = a + rawData[3] = b +} + +// InventoryGrenade describes properties of grenades by type. +type InventoryGrenade struct { + Index int + State *GameState +} + +func (grenade InventoryGrenade) isValid() bool { + return (grenade.Index >= 0) && (grenade.Index < GrenadeTypeCount) && (grenade.State != nil) +} + +// Count returns how many explosives the character is carrying. +func (grenade InventoryGrenade) Count() int { + if !grenade.isValid() { + return 0 + } + return int(grenade.State.Raw()[grenadeCountStartOffset+grenade.Index]) +} + +// SetCount updates the amount of explosives the character is carrying. Invalid values are clamped. +func (grenade InventoryGrenade) SetCount(value int) { + if !grenade.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + + grenade.State.Raw()[grenadeCountStartOffset+grenade.Index] = count +} + +// GrenadeTimerSetting is the value for explosive timers. +type GrenadeTimerSetting uint16 + +// InSeconds returns the amount in unit of seconds. +func (setting GrenadeTimerSetting) InSeconds() float32 { + return float32(setting) / 10.0 +} + +func (grenade InventoryGrenade) rawTimer() []byte { + if !grenade.isValid() { + return []byte{0x00, 0x00} + } + startIndex := grenadeTimerStartOffset + (2 * grenade.Index) + return grenade.State.Raw()[startIndex : startIndex+2] +} + +// TimerSetting returns the generic timer used for this type of grenade. +func (grenade InventoryGrenade) TimerSetting() GrenadeTimerSetting { + raw := grenade.rawTimer() + return GrenadeTimerSetting((uint16(raw[1]) << 8) | (uint16(raw[0]) << 0)) +} + +// SetTimerSetting updates the generic timer used for this type of grenade. +func (grenade InventoryGrenade) SetTimerSetting(value GrenadeTimerSetting) { + raw := grenade.rawTimer() + raw[0] = byte(value >> 0) + raw[1] = byte(value >> 8) +} + +// PatchState describes properties of patches by type. +type PatchState struct { + Index int + State *GameState +} + +func (patch PatchState) isValid() bool { + return (patch.Index >= 0) && (patch.Index < PatchTypeCount) && (patch.State != nil) +} + +// Count returns how many patches the character is carrying. +func (patch PatchState) Count() int { + if !patch.isValid() { + return 0 + } + return int(patch.State.Raw()[patchCountStartOffset+patch.Index]) +} + +// SetCount updates the amount of patches the character is carrying. Invalid values are clamped. +func (patch PatchState) SetCount(value int) { + if !patch.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + + patch.State.Raw()[patchCountStartOffset+patch.Index] = count +} + +// InventoryAmmo describes properties of ammo by type. +type InventoryAmmo struct { + Index int + State *GameState +} + +func (ammo InventoryAmmo) isValid() bool { + return (ammo.Index >= 0) && (ammo.Index < AmmoTypeCount) && (ammo.State != nil) +} + +// FullClipCount returns how many full clips the character is carrying. +func (ammo InventoryAmmo) FullClipCount() int { + if !ammo.isValid() { + return 0 + } + return int(ammo.State.Raw()[ammoFullClipCountStartOffset+ammo.Index]) +} + +// SetFullClipCount updates the amount of full clips the character is carrying. Invalid values are clamped. +func (ammo InventoryAmmo) SetFullClipCount(value int) { + if !ammo.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + + ammo.State.Raw()[ammoFullClipCountStartOffset+ammo.Index] = count +} + +// ExtraRoundsCount returns how many extra rounds the character is carrying. +func (ammo InventoryAmmo) ExtraRoundsCount() int { + if !ammo.isValid() { + return 0 + } + return int(ammo.State.Raw()[ammoExtraRoundsCountStartOffset+ammo.Index]) +} + +// SetExtraRoundsCount updates the amount of extra rounds the character is carrying. Invalid values are clamped. +func (ammo InventoryAmmo) SetExtraRoundsCount(value int) { + if !ammo.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + + ammo.State.Raw()[ammoExtraRoundsCountStartOffset+ammo.Index] = count +} + +// GeneralInventorySlot describes properties of a general inventory slot. +type GeneralInventorySlot struct { + Index int + State *GameState +} + +func (slot GeneralInventorySlot) isValid() bool { + return (slot.Index >= 0) && (slot.Index < GeneralInventorySlotCount) && (slot.State != nil) +} + +func (slot GeneralInventorySlot) rawID() []byte { + if !slot.isValid() { + return []byte{0x00, 0x00} + } + startIndex := generalInventorySlotStartOffset + (2 * slot.Index) + return slot.State.Raw()[startIndex : startIndex+2] +} + +// ObjectID returns the ID of the object in this inventory slot. +func (slot GeneralInventorySlot) ObjectID() level.ObjectID { + raw := slot.rawID() + return level.ObjectID((uint16(raw[1]) << 8) | (uint16(raw[0]) << 0)) +} + +// SetObjectID updates the ID in this inventory slot. +func (slot GeneralInventorySlot) SetObjectID(id level.ObjectID) { + raw := slot.rawID() + raw[0] = byte(id >> 0) + raw[1] = byte(id >> 8) +} + +// HardwareState describes properties of hardware by type. +type HardwareState struct { + Index int + State *GameState +} + +func (hardware HardwareState) isValid() bool { + return (hardware.Index >= 0) && (hardware.Index < HardwareTypeCount) && (hardware.State != nil) +} + +// Version returns which version of the hardware is installed. Zero means not installed. +func (hardware HardwareState) Version() int { + if !hardware.isValid() { + return 0 + } + return int(hardware.State.Raw()[hardwareVersionStartOffset+hardware.Index]) +} + +// SetVersion updates the version the character has installed. +func (hardware HardwareState) SetVersion(value int) { + if !hardware.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + + hardware.State.Raw()[hardwareVersionStartOffset+hardware.Index] = count +} + +// IsActive returns whether the hardware is currently active. +func (hardware HardwareState) IsActive() bool { + if !hardware.isValid() { + return false + } + return hardware.State.Raw()[hardwareStatusStartOffset+hardware.Index] != 0 +} + +// SetActive updates the active state of a hardware. +func (hardware HardwareState) SetActive(on bool) { + if !hardware.isValid() { + return + } + var newValue byte + if on { + newValue = 1 + } + + hardware.State.Raw()[hardwareStatusStartOffset+hardware.Index] = newValue +} + +// VersionedSoftwareState describes software that is upgradeable (exists only once). +type VersionedSoftwareState struct { + startOffset int + limit int + + Index int + State *GameState +} + +func (sw VersionedSoftwareState) isValid() bool { + return (sw.Index >= 0) && (sw.Index < sw.limit) && (sw.State != nil) +} + +// Version returns the installed version number of the software. Zero means not installed. +func (sw VersionedSoftwareState) Version() int { + if !sw.isValid() { + return 0 + } + return int(sw.State.Raw()[sw.startOffset+sw.Index]) +} + +// SetVersion sets the installed version number of the software. +func (sw VersionedSoftwareState) SetVersion(value int) { + if !sw.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + sw.State.Raw()[sw.startOffset+sw.Index] = count +} + +// CountedSoftwareState describes software that is expended upon use. +type CountedSoftwareState struct { + startOffset int + limit int + + Index int + State *GameState +} + +func (sw CountedSoftwareState) isValid() bool { + return (sw.Index >= 0) && (sw.Index < sw.limit) && (sw.State != nil) +} + +// Count returns the available amount of the software. +func (sw CountedSoftwareState) Count() int { + if !sw.isValid() { + return 0 + } + return int(sw.State.Raw()[sw.startOffset+sw.Index]) +} + +// SetCount sets the installed amount of the software. +func (sw CountedSoftwareState) SetCount(value int) { + if !sw.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + sw.State.Raw()[sw.startOffset+sw.Index] = count +} + +// MessageState describes properties of message by index. +type MessageState struct { + startOffset int + limit int + + Index int + State *GameState +} + +func (message MessageState) isValid() bool { + return (message.Index >= 0) && (message.Index < message.limit) && (message.State != nil) +} + +// Received returns whether the given message was received. +func (message MessageState) Received() bool { + return message.hasFlag(messageStatusReceived) +} + +// SetReceived marks the receive status of the message. +func (message MessageState) SetReceived(value bool) { + message.setFlag(messageStatusReceived, value) +} + +// Read returns whether the given message was read. +func (message MessageState) Read() bool { + return message.hasFlag(messageStatusRead) +} + +// SetRead marks the read status of the message. +func (message MessageState) SetRead(value bool) { + message.setFlag(messageStatusRead, value) +} + +func (message MessageState) hasFlag(flag byte) bool { + if !message.isValid() { + return false + } + return (message.State.Raw()[message.startOffset+message.Index] & flag) != 0 +} + +func (message MessageState) setFlag(flag byte, on bool) { + if !message.isValid() { + return + } + offset := message.startOffset + message.Index + newState := message.State.Raw()[offset] + newState &= ^flag + if on { + newState |= flag + } + message.State.Raw()[offset] = newState +} + +var gameStateDesc = interpreters.New(). + With("Difficulty: Combat", 0x0015, 1).As(interpreters.RangedValue(0, 3)). + With("Difficulty: Mission", 0x0016, 1).As(interpreters.RangedValue(0, 3)). + With("Difficulty: Puzzle", 0x0017, 1).As(interpreters.RangedValue(0, 3)). + With("Difficulty: Cyber", 0x0018, 1).As(interpreters.RangedValue(0, 3)). + With("Game time", 0x001C, 4).As(interpreters.SpecialValue("Internal")). + With("Current Level", 0x0039, 1).As(interpreters.RangedValue(0, 15)). + With("Health", 0x009C, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) string { + return fmt.Sprintf("%.2f%%%%", float64(value*100)/255.0) + })). + With("Cyberspace integrity", 0x009D, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) string { + return fmt.Sprintf("%.2f%%%%", float64(value*100)/255.0) + })). + With("Health regeneration per minute", 0x009E, 2).As(interpreters.RangedValue(0, 1000)). + With("Power", 0x00AC, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) string { + return fmt.Sprintf("%.2f%%%%", float64(value*100)/255.0) + })). + With("Power Usage", 0x00AD, 1).As(interpreters.RangedValue(0, 0xFF)). + With("Out-of-power flag", 0x00AF, 1).As(interpreters.EnumValue( + map[uint32]string{ + 0: "Powered", + 1: "Out-of-power", + })). + With("Cyberspace base time", 0x00B2, 4).As(interpreters.FormattedRangedValue(cyberspaceMinTime, cyberspaceMaxTime, + func(value int) string { + minutes := value / engineTicksPerMinute + return fmt.Sprintf("%2dm %2.2fs", minutes, float64(value-(minutes*engineTicksPerMinute))/(engineTicksPerSecond)) + })). + With("Fatigue regeneration", 0x0181, 2).As(interpreters.RangedValue(0, 400)). + With("Fatigue regeneration base", 0x0183, 2).As(interpreters.RangedValue(0, 400)). + With("Fatigue regeneration max", 0x0185, 2).As(interpreters.RangedValue(0, 400)). + With("Accuracy", 0x0187, 1).As(interpreters.RangedValue(0, 255)). + With("Shield absorb rate", 0x0188, 1).As(interpreters.RangedValue(0, 255)). + With("Hacker Position X", 0x053F, 4).As(interpreters.FormattedRangedValue(0x010000, 0x3FFFFF, + func(value int) string { + return fmt.Sprintf("%2.03f", float32(value)/0x010000) + })). + With("Hacker Position Y", 0x0543, 4).As(interpreters.FormattedRangedValue(0x010000, 0x3FFFFF, + func(value int) string { + return fmt.Sprintf("%2.03f", float32(value)/0x010000) + })). + With("Hacker Position Z", 0x0547, 4).As(interpreters.FormattedRangedValue(0x010000, 0x1FFFFF, + func(value int) string { + return fmt.Sprintf("%2.03f tile(s)", float32(value)/0x010000) + })). + With("Hacker Yaw", 0x054B, 4).As(interpreters.RotationValue(0, edmsFullCircle())) + +func edmsFullCircle() int64 { + full := math.Pi * 2 * float64(0x10000) + return int64(full) - 1 +} + +// NewGameState returns a GameState instance for given raw data. +func NewGameState(raw []byte) *GameState { + return &GameState{Instance: gameStateDesc.For(raw)} +} + +// IsSavegame returns true if the state describes one during a running game. +func (state *GameState) IsSavegame() bool { + // Picking the right property is tricky. Most properties could be pre-initialized + // by an archive as well. + // For now, assume the "game time" will not be touched by initial archives. + // A possible other approach would be to take the "version" field, which + // is always set when a savegame is created, but is zero in the original. Yet, + // it is not clear whether or not a (pedantic) engine would respect this field and + // ignore data when it's zero. + return state.Get("Game time") > 0 +} + +// IsDefaulting returns true if the state describes an archive from which the engine +// should initialize the state for a new game. +func (state *GameState) IsDefaulting() bool { + return state.Get("Hacker Position X") == 0 +} + +// HackerName returns the name interpreted with given codepage. +func (state *GameState) HackerName(cp text.Codepage) string { + return cp.Decode(state.Raw()[:stateHackerNameSize]) +} + +// SetHackerName stores the given name using given codepage, up to the internal available amount of bytes. +func (state *GameState) SetHackerName(name string, cp text.Codepage) { + raw := state.Raw() + var zeroName [stateHackerNameSize]byte + copy(raw[:stateHackerNameSize], zeroName[:]) + copy(raw[:stateHackerNameSize], cp.Encode(name)) + raw[stateHackerNameSize-1] = 0 +} + +// CurrentLevel returns the number of the level hacker is currently in. +func (state GameState) CurrentLevel() int { + return int(state.Get("Current Level")) +} + +// HackerMapPosition returns the rough X/Y location on the map. +func (state GameState) HackerMapPosition() (level.Coordinate, level.Coordinate) { + x := state.Get("Hacker Position X") + y := state.Get("Hacker Position Y") + return level.CoordinateAt(byte(x>>16), byte(x>>8)), level.CoordinateAt(byte(y>>16), byte(y>>8)) +} + +// BooleanVar returns the state of the boolean variable at given index. Unsupported indices return 0. +func (state GameState) BooleanVar(index int) bool { + if (index < 0) || (index >= BooleanVarCount) { + return false + } + return state.Raw()[booleanVarStartOffset+(index/8)]&(0x01<<(uint(index%8))) != 0 +} + +// SetBooleanVar sets the state of the boolean variable at given index. Unsupported indices are ignored. +func (state *GameState) SetBooleanVar(index int, set bool) { + if (index < 0) || (index >= BooleanVarCount) { + return + } + byteIndex := booleanVarStartOffset + (index / 8) + bitMask := byte(0x01 << uint(index%8)) + temp := state.Raw()[byteIndex] + temp &= ^bitMask + if set { + temp |= bitMask + } + state.Raw()[byteIndex] = temp +} + +// IntegerVar returns the value of the integer variable at given index. Unsupported indices return 0. +func (state *GameState) IntegerVar(index int) int16 { + if (index < 0) || (index >= IntegerVarCount) { + return 0 + } + startOffset := integerVarStartOffset + (2 * index) + val := uint16(state.Raw()[startOffset+1])<<8 | uint16(state.Raw()[startOffset+0]) + return int16(val) +} + +// SetIntegerVar sets the value of the integer variable at given index. Unsupported indices are ignored. +func (state *GameState) SetIntegerVar(index int, value int16) { + if (index < 0) || (index >= IntegerVarCount) { + return + } + startOffset := integerVarStartOffset + (2 * index) + state.Raw()[startOffset+0] = byte((value >> 0) & 0xFF) + state.Raw()[startOffset+1] = byte((value >> 8) & 0xFF) +} + +// InventoryWeaponSlot returns an accessor for the identified weapon slot. +// Index should be within the range of [0..InventoryWeaponSlots[. +func (state *GameState) InventoryWeaponSlot(index int) InventoryWeaponSlot { + return InventoryWeaponSlot{ + Index: index, + State: state, + } +} + +// InventoryGrenade returns an accessor for the identified grenade type index. +// Index should be within the range of [0..GrenadeTypeCount[ . +func (state *GameState) InventoryGrenade(index int) InventoryGrenade { + return InventoryGrenade{ + Index: index, + State: state, + } +} + +// PatchState returns an accessor for the identified patch type index. +// Index should be within the range of [0..PatchTypeCount[ . +func (state *GameState) PatchState(index int) PatchState { + return PatchState{ + Index: index, + State: state, + } +} + +// InventoryAmmo returns an accessor for the identified ammo type index. +// Index should be within the range of [0..AmmoTypeCount[ . +func (state *GameState) InventoryAmmo(index int) InventoryAmmo { + return InventoryAmmo{ + Index: index, + State: state, + } +} + +// GeneralInventorySlot returns an accessor for the identified general slot. +// Index should be within the range of [0..GeneralInventorySlotCount[ . +func (state *GameState) GeneralInventorySlot(index int) GeneralInventorySlot { + return GeneralInventorySlot{ + Index: index, + State: state, + } +} + +// HardwareState returns an accessor for the identified hardware type index. +// Index should be within the range of [0..HardwareTypeCount[ . +func (state *GameState) HardwareState(index int) HardwareState { + return HardwareState{ + Index: index, + State: state, + } +} + +// VersionedOffenseSoftwareState returns an accessor for the identified versioned software status. +// Index should be within the range of [0..SoftwareOffenseTypeCount[ . +func (state *GameState) VersionedOffenseSoftwareState(index int) VersionedSoftwareState { + return VersionedSoftwareState{ + startOffset: installedOffenseSoftwareStartOffset, + limit: SoftwareOffenseTypeCount, + Index: index, + State: state, + } +} + +// VersionedDefenseSoftwareState returns an accessor for the identified versioned software status. +// Index should be within the range of [0..SoftwareDefenseTypeCount[ . +func (state *GameState) VersionedDefenseSoftwareState(index int) VersionedSoftwareState { + return VersionedSoftwareState{ + startOffset: installedDefenseSoftwareStartOffset, + limit: SoftwareDefenseTypeCount, + Index: index, + State: state, + } +} + +// OneshotSoftwareState returns an accessor for the identified software status. +// Index should be within the range of [0..SoftwareOneshotTypeCount[ . +func (state *GameState) OneshotSoftwareState(index int) CountedSoftwareState { + return CountedSoftwareState{ + startOffset: installedOneshotSoftwareStartOffset, + limit: SoftwareOneshotTypeCount, + Index: index, + State: state, + } +} + +// EMailState returns an accessor for the identified EMail status. +// Index should be within the range of [0..EMailCount[ . +func (state *GameState) EMailState(index int) MessageState { + return MessageState{ + startOffset: emailStatusStartOffset, + limit: EMailCount, + Index: index, + State: state, + } +} + +// LogState returns an accessor for the identified log status. +// Index should be within the range of [0..LogCount[ . +func (state *GameState) LogState(index int) MessageState { + return MessageState{ + startOffset: logStatusStartOffset, + limit: LogCount, + Index: index, + State: state, + } +} + +// FragmentState returns an accessor for the identified fragment status. +// Index should be within the range of [0..FragmentCount[ . +func (state *GameState) FragmentState(index int) MessageState { + return MessageState{ + startOffset: fragmentStatusStartOffset, + limit: FragmentCount, + Index: index, + State: state, + } +} + +// ZeroGameStateData returns the state block reset for default engine behaviour. +func ZeroGameStateData() []byte { + return make([]byte, GameStateSize) +} + +// DefaultGameState returns a GameState that can be used for any mission. +func DefaultGameState() *GameState { + data := ZeroGameStateData() + state := NewGameState(data) + + state.Set("Health", 212) + state.Set("Cyberspace integrity", 255) + state.Set("Health regeneration per minute", 0) + state.Set("Power", 255) + state.Set("Cyberspace base time", 30*60*engineTicksPerSecond) + state.Set("Shield absorb rate", 0) + state.Set("Fatigue regeneration", 0) + state.Set("Fatigue regeneration base", 100) + state.Set("Fatigue regeneration max", 400) + state.Set("Accuracy", 100) + + for i := 0; i < InventoryWeaponSlots; i++ { + state.InventoryWeaponSlot(i).SetFree() + } + for i := 0; i < GrenadeTypeCount; i++ { + state.InventoryGrenade(i).SetTimerSetting(grenadeTimerDefault) + } + + for index, info := range engineBooleanVariables { + if info.InitValue == nil { + continue + } + state.SetBooleanVar(index, *info.InitValue != 0) + } + for index, info := range engineIntegerVariables { + if info.InitValue == nil { + continue + } + state.SetIntegerVar(index, *info.InitValue) + } + + return state +} diff --git a/modding/hacked/ss1/content/archive/IsSavegame.go b/modding/hacked/ss1/content/archive/IsSavegame.go new file mode 100644 index 0000000..41b66b0 --- /dev/null +++ b/modding/hacked/ss1/content/archive/IsSavegame.go @@ -0,0 +1,28 @@ +package archive + +import ( + "io/ioutil" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +// IsSavegame returns true for resources that most likely identify a savegame. +// This function must be called with a resource from ID 0x0FA1. +func IsSavegame(res resource.BlockProvider) bool { + if res == nil { + return false + } + if res.BlockCount() < 1 { + return false + } + dataReader, err := res.Block(0) + if err != nil { + return false + } + data, err := ioutil.ReadAll(dataReader) + if err != nil { + return false + } + state := NewGameState(data) + return state.IsSavegame() +} diff --git a/modding/hacked/ss1/content/archive/IsSavegame_test.go b/modding/hacked/ss1/content/archive/IsSavegame_test.go new file mode 100644 index 0000000..249f51c --- /dev/null +++ b/modding/hacked/ss1/content/archive/IsSavegame_test.go @@ -0,0 +1,69 @@ +package archive_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/resource" +) + +func TestIsSavegameTrueForActualSavegame(t *testing.T) { + stateData := make([]byte, archive.GameStateSize) + stateData[0x001C+2] = 0x10 + res := resource.Resource{ + Properties: resource.Properties{ + Compressed: false, + ContentType: resource.Archive, + Compound: false, + }, + Blocks: resource.BlocksFrom([][]byte{stateData}), + } + + result := archive.IsSavegame(res) + assert.True(t, result) +} + +func TestIsSavegameFalseForWrongResourceContent(t *testing.T) { + res := resource.Resource{ + Properties: resource.Properties{ + Compressed: false, + ContentType: resource.Archive, + Compound: true, + }, + Blocks: resource.BlocksFrom([][]byte{}), + } + + result := archive.IsSavegame(res) + assert.False(t, result) +} + +func TestIsSavegameFalseForTooShortData(t *testing.T) { + res := resource.Resource{ + Properties: resource.Properties{ + Compressed: false, + ContentType: resource.Archive, + Compound: true, + }, + Blocks: resource.BlocksFrom([][]byte{make([]byte, 0x10)}), + } + + result := archive.IsSavegame(res) + assert.False(t, result) +} + +func TestIsSavegameFalseForZeroData(t *testing.T) { + stateData := make([]byte, archive.GameStateSize) + res := resource.Resource{ + Properties: resource.Properties{ + Compressed: false, + ContentType: resource.Archive, + Compound: true, + }, + Blocks: resource.BlocksFrom([][]byte{stateData}), + } + + result := archive.IsSavegame(res) + assert.False(t, result) +} diff --git a/modding/hacked/ss1/content/archive/Variables.go b/modding/hacked/ss1/content/archive/Variables.go new file mode 100644 index 0000000..91e06eb --- /dev/null +++ b/modding/hacked/ss1/content/archive/Variables.go @@ -0,0 +1,253 @@ +package archive + +import "math" + +// GameVariableLimits describes limits of a game variable. +type GameVariableLimits struct { + Minimum int16 + Maximum int16 +} + +// GameVariableInfo describes a variable in the game state of the archive. +type GameVariableInfo struct { + // InitValue is nil if the initial value is system dependent. + InitValue *int16 + // Name is the short identifier for the variable. + Name string + // Description is an optional text with further information on the variable. + Description string + // Limits may be provided to describe the range of possible values. + Limits *GameVariableLimits + // ValueNames may be set for enumerated values. + ValueNames map[int16]string + // Hardcoded variables are fixed for a new game. + Hardcoded bool +} + +// ResetValueInt returns the expected reset value for integer variables. +func (info GameVariableInfo) ResetValueInt() int16 { + if info.InitValue == nil { + return 0 + } + return *info.InitValue +} + +// ResetValueBool returns the expected reset value for boolean variables. +func (info GameVariableInfo) ResetValueBool() bool { + if info.InitValue == nil { + return false + } + return *info.InitValue != 0 +} + +// GameVariableInfoProvider provides lookup functionality for integer and boolean variables. +type GameVariableInfoProvider interface { + IntegerVariable(index int) GameVariableInfo + BooleanVariable(index int) GameVariableInfo +} + +// GameVariableInfoFor returns a new instance with given name. +func GameVariableInfoFor(name string) GameVariableInfo { + return GameVariableInfo{Name: name} +} + +// At returns an information based on the current one, with the given initial value. +func (info GameVariableInfo) At(value int16) GameVariableInfo { + info.InitValue = &value + return info +} + +// HardcodedConfig returns an information that is marked as being a hardcoded initialized configuration value. +func (info GameVariableInfo) HardcodedConfig() GameVariableInfo { + info.Hardcoded = true + return info +} + +// HardcodedAt returns an information that is marked as being a hardcoded variable with given initial value. +func (info GameVariableInfo) HardcodedAt(value int16) GameVariableInfo { + info.InitValue = &value + info.Hardcoded = true + return info +} + +// Enumerated returns an information with given value names. +func (info GameVariableInfo) Enumerated(names map[int16]string) GameVariableInfo { + info.ValueNames = names + return info +} + +// Boolean returns an enumerated information with No/Yes as possible values. +func (info GameVariableInfo) Boolean() GameVariableInfo { + return info.Enumerated(map[int16]string{ + 0: "No", + 1: "Yes", + }) +} + +// LimitedBy returns an information with given minimum and maximum values. +func (info GameVariableInfo) LimitedBy(min, max int16) GameVariableInfo { + info.Limits = &GameVariableLimits{ + Minimum: min, + Maximum: max, + } + return info +} + +// DescribedAs returns an information with given text as description. +func (info GameVariableInfo) DescribedAs(text string) GameVariableInfo { + info.Description = text + return info +} + +// GameVariables is a lookup map for information on game variables. +type GameVariables map[int]GameVariableInfo + +// Lookup returns the information for given index. If the index is not known, nil is returned. +func (vars GameVariables) Lookup(index int) *GameVariableInfo { + info, known := vars[index] + if !known { + return nil + } + return &info +} + +const ( + securityValueDescription = "The current security value is re-calculated whenever a level is loaded up." + randomCodeDescription = "If both codes are equal at the start of a new game, they will be randomized." + highscoreCodeDescription = "The highscore values are combined as one 32-bit integer, used for the MFD game MCOM." +) + +var engineIntegerVariables = GameVariables{ + 2: GameVariableInfoFor("Plastique explosion counter").At(0).LimitedBy(0, 100). + DescribedAs("This value is incremented for each exploding plastique."), + + 9: GameVariableInfoFor("Plot counter").At(0), + + 13: GameVariableInfoFor("Difficulty: Mission").HardcodedConfig().LimitedBy(0, 3), + 14: GameVariableInfoFor("Difficulty: Cyber").HardcodedConfig().LimitedBy(0, 3), + 15: GameVariableInfoFor("Difficulty: Combat").HardcodedConfig().LimitedBy(0, 3), + + 16: GameVariableInfoFor("Security Value: Level 0").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 17: GameVariableInfoFor("Security Value: Level 1").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 18: GameVariableInfoFor("Security Value: Level 2").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 19: GameVariableInfoFor("Security Value: Level 3").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 20: GameVariableInfoFor("Security Value: Level 4").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 21: GameVariableInfoFor("Security Value: Level 5").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 22: GameVariableInfoFor("Security Value: Level 6").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 23: GameVariableInfoFor("Security Value: Level 7").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 24: GameVariableInfoFor("Security Value: Level 8").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 25: GameVariableInfoFor("Security Value: Level 9").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 26: GameVariableInfoFor("Security Value: Level 10").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 27: GameVariableInfoFor("Security Value: Level 11").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 28: GameVariableInfoFor("Security Value: Level 12").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 29: GameVariableInfoFor("Security Value: Level 13").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + + 30: GameVariableInfoFor("Difficulty: Puzzle").HardcodedConfig().LimitedBy(0, 3), + 31: GameVariableInfoFor("Random Code 1").At(0).DescribedAs(randomCodeDescription), + 32: GameVariableInfoFor("Random Code 2").At(0).DescribedAs(randomCodeDescription), + + 41: GameVariableInfoFor("Music Volume").HardcodedConfig().LimitedBy(0, 100), + 42: GameVariableInfoFor("Video Gamma").HardcodedConfig().LimitedBy(0, math.MaxInt16), + 43: GameVariableInfoFor("SFX Volume").HardcodedConfig().LimitedBy(0, 100), + 44: GameVariableInfoFor("Mouse Handedness").HardcodedConfig().Enumerated( + map[int16]string{ + 0: "Right Handed", + 1: "Left Handed", + }), + 45: GameVariableInfoFor("Game: Highscore (pt1)").At(0).DescribedAs(highscoreCodeDescription), + 46: GameVariableInfoFor("Game: Highscore (pt2)").At(0).DescribedAs(highscoreCodeDescription), + 47: GameVariableInfoFor("Double-Click Speed").HardcodedConfig().LimitedBy(0, math.MaxInt16), + 48: GameVariableInfoFor("Language").HardcodedConfig().Enumerated(map[int16]string{ + 0: "Default", + 1: "French", + 2: "German", + }), + 49: GameVariableInfoFor("Audiolog Volume").HardcodedConfig().LimitedBy(0, 100), + 50: GameVariableInfoFor("Screen Mode").HardcodedConfig(), + 51: GameVariableInfoFor("Joystick Sensitivity").At(0x100).LimitedBy(0, 0x100), + 52: GameVariableInfoFor("Show Fullscreen Icons").HardcodedConfig().Boolean(), + 53: GameVariableInfoFor("Audio Messages").HardcodedConfig().Enumerated(map[int16]string{ + 0: "Text Only", + 1: "Speech Only", + 2: "Text and Speech", + }), + 54: GameVariableInfoFor("Show Fullscreen Vitals").HardcodedConfig().Boolean(), + 55: GameVariableInfoFor("Show Map Notes").HardcodedConfig().Boolean(), + 56: GameVariableInfoFor("Game: Wing Level").At(0).LimitedBy(0, 100), + 57: GameVariableInfoFor("HUD Color Bank").HardcodedConfig().LimitedBy(0, 2), + 58: GameVariableInfoFor("Audio Channels").HardcodedConfig().Enumerated(map[int16]string{ + 0: "2", + 1: "4", + 2: "8", + }), +} + +var engineBooleanVariables = GameVariables{ + 0: GameVariableInfoFor("Always False").At(0). + DescribedAs("This variable should always stay at 'False'.\n" + + "Default conditions assume boolean var 0 is zero to not block.\n" + + "For example, default doors would all become locked if it were 'True'."), + + 10: GameVariableInfoFor("Status HW: Delta Launch Enable").At(0), + 11: GameVariableInfoFor("Status HW: Alpha Launch Enable").At(0), + 12: GameVariableInfoFor("Status HW: Beta Launch Enable").At(0), + 15: GameVariableInfoFor("Status HW: Beta Launched").At(0), + + 20: GameVariableInfoFor("Reactor on Destruct").At(0). + DescribedAs("Note: Rumble will stop if boolean var 152 is 'True'."), + + 94: GameVariableInfoFor("Unopenable door"). + DescribedAs("Doors that are 'broken beyond repair' or such."), + + 145: GameVariableInfoFor("On-Line Help"), + + 152: GameVariableInfoFor("Self-Destruct Rumble Stop").At(0). + DescribedAs("Disables rumble caused by boolean var 20."), + 153: GameVariableInfoFor("Four or more plastique exploded").At(0). + DescribedAs("Set to 'True' if integer variable 2 reached 4 (or higher)."), + + 231: GameVariableInfoFor("Unopenable door"). + DescribedAs("Doors that are 'broken beyond repair' or such."), + + 300: GameVariableInfoFor("New Message Flag").At(0), +} + +// EngineIntegerVariable returns a variable info for integer variables. +// If the given index is not used by the engine, nil is returned. +func EngineIntegerVariable(index int) *GameVariableInfo { + return engineIntegerVariables.Lookup(index) +} + +// EngineBooleanVariable returns a variable info for boolean variables. +// If the given index is not used by the engine, nil is returned. +func EngineBooleanVariable(index int) *GameVariableInfo { + return engineBooleanVariables.Lookup(index) +} + +// IsRandomIntegerVariable returns true for the special variables that are randomized. +func IsRandomIntegerVariable(index int) bool { + return (index == 31) || (index == 32) +} + +// EngineVariables is a collector of engine-specific variable accessors. +type EngineVariables struct{} + +var unusedVar = GameVariableInfoFor("(unused)").At(0) + +// IntegerVariable returns a variable info for given index. +func (vars EngineVariables) IntegerVariable(index int) GameVariableInfo { + varInfo := EngineIntegerVariable(index) + if varInfo == nil { + return unusedVar + } + return *varInfo +} + +// BooleanVariable returns a variable info for given index. +func (vars EngineVariables) BooleanVariable(index int) GameVariableInfo { + varInfo := EngineBooleanVariable(index) + if varInfo == nil { + return unusedVar + } + return *varInfo +} diff --git a/modding/hacked/ss1/content/archive/doc.go b/modding/hacked/ss1/content/archive/doc.go new file mode 100644 index 0000000..b4a7ac9 --- /dev/null +++ b/modding/hacked/ss1/content/archive/doc.go @@ -0,0 +1,2 @@ +// Package archive contains all the information that is stored in an archive file. +package archive diff --git a/modding/hacked/ss1/content/archive/level/BaseInfo.go b/modding/hacked/ss1/content/archive/level/BaseInfo.go new file mode 100644 index 0000000..0a78582 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/BaseInfo.go @@ -0,0 +1,43 @@ +package level + +const ( + defaultMapXShift = 6 + defaultMapYShift = 6 + defaultMapHeightShift = 3 +) + +// BaseInfo describes the basic parameters of a level map. +type BaseInfo struct { + // XSize is the horizontal extent of the map (West to East). + XSize int32 + // YSize is the vertical extent of the map (South to North). + YSize int32 + // XShift is the base value of XSize (XSize <= 1 << XShift). + XShift int32 + // YShift is the base value of YSize (YSize <= 1 << YShift). + YShift int32 + // ZShift is the base value of the height of the map. + ZShift HeightShift + _ [4]byte + // Cyberspace indicates whether the level is cyberspace. + Cyberspace byte + _ [12]byte + // Scheduler contains base information about the schedule table of the level. + Scheduler SchedulerInfo +} + +// DefaultBaseInfo returns an initialized instance. +func DefaultBaseInfo(cyberspace bool) BaseInfo { + info := BaseInfo{ + XSize: 1 << defaultMapXShift, + YSize: 1 << defaultMapYShift, + XShift: defaultMapXShift, + YShift: defaultMapYShift, + ZShift: defaultMapHeightShift, + Scheduler: DefaultSchedulerInfo(), + } + if cyberspace { + info.Cyberspace = 1 + } + return info +} diff --git a/modding/hacked/ss1/content/archive/level/BaseInfo_test.go b/modding/hacked/ss1/content/archive/level/BaseInfo_test.go new file mode 100644 index 0000000..e89f688 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/BaseInfo_test.go @@ -0,0 +1,26 @@ +package level_test + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultBaseInfoSerialized(t *testing.T) { + buf := bytes.NewBuffer(nil) + info := level.DefaultBaseInfo(true) + _ = binary.Write(buf, binary.LittleEndian, &info) + + data := buf.Bytes() + assert.Equal(t, 58, len(data), "length mismatch") + assert.Equal(t, []byte{ + 0x40, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, data, "data mismatch") +} diff --git a/modding/hacked/ss1/content/archive/level/BlockPuzzleState.go b/modding/hacked/ss1/content/archive/level/BlockPuzzleState.go new file mode 100644 index 0000000..ff8bf35 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/BlockPuzzleState.go @@ -0,0 +1,75 @@ +package level + +// BlockPuzzleState describes the state of a block puzzle. +type BlockPuzzleState struct { + width int + height int + data []byte +} + +// NewBlockPuzzleState returns a new instance of a block puzzle state modifier. +// The returned instance works with the passed data slice directly. +func NewBlockPuzzleState(data []byte, height, width int) *BlockPuzzleState { + return &BlockPuzzleState{ + data: data, + width: width, + height: height} +} + +// CellValue returns the value of the identified cell. +func (state *BlockPuzzleState) CellValue(row, col int) int { + scratch := uint16(0) + + if state.positionOk(row, col) { + byteOffset, byteShift := state.byteOffset(row, col) + bitsAvailable := 8 - byteShift + + scratch = uint16(state.data[state.mappedByteIndex(byteOffset)]) >> byteShift + if bitsAvailable < 3 { + scratch |= uint16(state.data[state.mappedByteIndex(byteOffset+1)]) << bitsAvailable + } + } + + return int(scratch & 7) +} + +// SetCellValue sets the value of the identified cell. +func (state *BlockPuzzleState) SetCellValue(row, col int, value int) { + if state.positionOk(row, col) { + byteOffset, byteShift := state.byteOffset(row, col) + needsSecondByte := (8 - byteShift) < 3 + scratch := uint16(state.data[state.mappedByteIndex(byteOffset)]) + mask := uint16(0x0007) << byteShift + + if needsSecondByte { + scratch |= uint16(state.data[state.mappedByteIndex(byteOffset+1)]) << 8 + } + scratch = (scratch & ^mask) | (uint16(value) << byteShift) + state.data[state.mappedByteIndex(byteOffset)] = byte((scratch >> 0) & 0xFF) + if needsSecondByte { + state.data[state.mappedByteIndex(byteOffset+1)] = byte((scratch >> 8) & 0xFF) + } + } +} + +func (state *BlockPuzzleState) bitOffset(row, col int) int { + return (row*state.width + col) * 3 +} + +func (state *BlockPuzzleState) positionOk(row, col int) bool { + bitOffset := state.bitOffset(row, col) + + return (row < state.height) && (col < state.width) && (bitOffset <= (128 - 3)) +} + +func (state *BlockPuzzleState) byteOffset(row, col int) (byteOffset int, byteShift uint) { + bitOffset := state.bitOffset(row, col) + byteOffset = bitOffset / 8 + byteShift = uint(bitOffset % 8) + return +} + +func (state *BlockPuzzleState) mappedByteIndex(index int) int { + remainder := index % 4 + return 15 - (index - remainder + (3 - remainder)) +} diff --git a/modding/hacked/ss1/content/archive/level/BlockPuzzleState_test.go b/modding/hacked/ss1/content/archive/level/BlockPuzzleState_test.go new file mode 100644 index 0000000..0877cd3 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/BlockPuzzleState_test.go @@ -0,0 +1,131 @@ +package level_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestBlockPuzzleCellValueReturnsTheCellValue_A(t *testing.T) { + var data [16]byte + state := level.NewBlockPuzzleState(data[:], 1, 1) + + assert.Equal(t, 0, state.CellValue(0, 0)) +} + +func TestBlockPuzzleCellValueReturnsTheCellValue_B(t *testing.T) { + var data [16]byte + data[16-4] = 2 + state := level.NewBlockPuzzleState(data[:], 1, 1) + + assert.Equal(t, 2, state.CellValue(0, 0)) +} + +func TestBlockPuzzleCellValueReturnsTheCellValue_C(t *testing.T) { + var data [16]byte + data[16-4] = 0x18 + state := level.NewBlockPuzzleState(data[:], 2, 1) + + assert.Equal(t, 3, state.CellValue(1, 0)) +} + +func TestBlockPuzzleCellValueReturnsTheCellValue_D(t *testing.T) { + var data [16]byte + data[15] = 0x40 + data[8] = 0x01 + state := level.NewBlockPuzzleState(data[:], 4, 3) + + assert.Equal(t, 5, state.CellValue(3, 1)) +} + +func TestBlockPuzzleCellValueReturnsTheCellValue_E(t *testing.T) { + var data [16]byte + data[3] = 0x38 + state := level.NewBlockPuzzleState(data[:], 6, 7) + + assert.Equal(t, 7, state.CellValue(5, 6)) +} + +func TestBlockPuzzleCellValueReturnsZeroOutOfBounds_A(t *testing.T) { + var data [16]byte + for index := 0; index < len(data); index++ { + data[index] = 0xFF + } + state := level.NewBlockPuzzleState(data[:], 1, 1) + + assert.Equal(t, 0, state.CellValue(0, 1)) + assert.Equal(t, 0, state.CellValue(1, 0)) + assert.Equal(t, 0, state.CellValue(5, 5)) + assert.Equal(t, 0, state.CellValue(8, 8)) +} + +func TestBlockPuzzleCellValueReturnsZeroOutOfBounds_B(t *testing.T) { + var data [16]byte + for index := 0; index < len(data); index++ { + data[index] = 0xFF + } + state := level.NewBlockPuzzleState(data[:], 7, 6) + + assert.Equal(t, 0, state.CellValue(6, 7)) + assert.Equal(t, 0, state.CellValue(10, 10)) +} + +func TestBlockPuzzleCellValueReturnsZeroOutOfBounds_C(t *testing.T) { + var data [16]byte + for index := 0; index < len(data); index++ { + data[index] = 0xFF + } + state := level.NewBlockPuzzleState(data[:], 7, 7) + + assert.Equal(t, 0, state.CellValue(6, 0)) +} + +func TestBlockPuzzleSetCellValue_A(t *testing.T) { + var data [16]byte + state := level.NewBlockPuzzleState(data[:], 1, 1) + + state.SetCellValue(0, 0, 1) + assert.Equal(t, [16]byte{ + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00}, data) +} + +func TestBlockPuzzleSetCellValue_B(t *testing.T) { + var data [16]byte + state := level.NewBlockPuzzleState(data[:], 2, 2) + + state.SetCellValue(1, 0, 7) + assert.Equal(t, [16]byte{ + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0xC0, 0x01, 0x00, 0x00}, data) +} + +func TestBlockPuzzleSetCellValue_C(t *testing.T) { + var data [16]byte + state := level.NewBlockPuzzleState(data[:], 6, 7) + + state.SetCellValue(5, 6, 5) + assert.Equal(t, [16]byte{ + 0x00, 0x00, 0x00, 0x28, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00}, data) +} + +func TestBlockPuzzleSetCellValueIgnoredOutOfBounds(t *testing.T) { + var data [16]byte + state := level.NewBlockPuzzleState(data[:], 6, 7) + + state.SetCellValue(8, 8, 7) + assert.Equal(t, [16]byte{ + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00}, data) +} diff --git a/modding/hacked/ss1/content/archive/level/Constants.go b/modding/hacked/ss1/content/archive/level/Constants.go new file mode 100644 index 0000000..9bc0123 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Constants.go @@ -0,0 +1,20 @@ +package level + +const ( + // MapVersion is the version of the map structures. + MapVersion int32 = 11 + // ObjectVersion is the version of object structures. + ObjectVersion int32 = 27 + // SavefileVersion is the version of the save. + SavefileVersion int32 = 13 + + // InventorySize specifies how many items the inventory could hold. + InventorySize int = 20 + + // GradesOfShadow is the number of how much shadow (light) is possible. + GradesOfShadow = 16 +) + +var mapVersionValue = MapVersion +var objectVersionValue = ObjectVersion +var savefileVersionValue = SavefileVersion diff --git a/modding/hacked/ss1/content/archive/level/Coordinate.go b/modding/hacked/ss1/content/archive/level/Coordinate.go new file mode 100644 index 0000000..e1fb3b0 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Coordinate.go @@ -0,0 +1,22 @@ +package level + +// FineCoordinatesPerTileSide is the number of possible positions along an axis within a tile. +const FineCoordinatesPerTileSide = 256 + +// Coordinate describes a tile position in one dimension. +type Coordinate uint16 + +// CoordinateAt returns a coordinate with given parameters. +func CoordinateAt(tile, fine byte) Coordinate { + return Coordinate(uint16(tile)<<8 | uint16(fine)) +} + +// Tile returns the tile part of the coordinate. +func (coord Coordinate) Tile() byte { + return byte(coord >> 8) +} + +// Fine returns the fine part of the coordinate. +func (coord Coordinate) Fine() byte { + return byte(coord & 0xFF) +} diff --git a/modding/hacked/ss1/content/archive/level/CyberspaceFlightPull.go b/modding/hacked/ss1/content/archive/level/CyberspaceFlightPull.go new file mode 100644 index 0000000..cdf89d1 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/CyberspaceFlightPull.go @@ -0,0 +1,92 @@ +package level + +import "fmt" + +// CyberspaceFlightPull describes a directed pull of hacker in cyberspace. +type CyberspaceFlightPull byte + +// String returns a textual representation. +func (pull CyberspaceFlightPull) String() string { + switch pull { + case CyberspaceFlightPullNone: + return "None" + case CyberspaceFlightPullWeakEast: + return "Weak East" + case CyberspaceFlightPullWeakWest: + return "Weak West" + case CyberspaceFlightPullWeakNorth: + return "Weak North" + case CyberspaceFlightPullWeakSouth: + return "Weak South" + case CyberspaceFlightPullMediumEast: + return "Medium East" + case CyberspaceFlightPullMediumWest: + return "Medium West" + case CyberspaceFlightPullMediumNorth: + return "Medium North" + case CyberspaceFlightPullMediumSouth: + return "Medium South" + case CyberspaceFlightPullStrongEast: + return "Strong East" + case CyberspaceFlightPullStrongWest: + return "Strong West" + case CyberspaceFlightPullStrongNorth: + return "Strong North" + case CyberspaceFlightPullStrongSouth: + return "Strong South" + case CyberspaceFlightPullMediumCeiling: + return "Medium Ceiling" + case CyberspaceFlightPullMediumFloor: + return "Medium Floor" + case CyberspaceFlightPullStrongCeiling: + return "Strong Ceiling" + case CyberspaceFlightPullStrongFloor: + return "Strong Floor" + default: + return fmt.Sprintf("Unknown%02X", int(pull)) + } +} + +// CyberspaceFlightPull constants are listed below. +const ( + CyberspaceFlightPullNone CyberspaceFlightPull = 0 + CyberspaceFlightPullWeakEast CyberspaceFlightPull = 1 + CyberspaceFlightPullWeakWest CyberspaceFlightPull = 2 + CyberspaceFlightPullWeakNorth CyberspaceFlightPull = 3 + CyberspaceFlightPullWeakSouth CyberspaceFlightPull = 4 + CyberspaceFlightPullMediumEast CyberspaceFlightPull = 5 + CyberspaceFlightPullMediumWest CyberspaceFlightPull = 6 + CyberspaceFlightPullMediumNorth CyberspaceFlightPull = 7 + CyberspaceFlightPullMediumSouth CyberspaceFlightPull = 8 + CyberspaceFlightPullStrongEast CyberspaceFlightPull = 9 + CyberspaceFlightPullStrongWest CyberspaceFlightPull = 10 + CyberspaceFlightPullStrongNorth CyberspaceFlightPull = 11 + CyberspaceFlightPullStrongSouth CyberspaceFlightPull = 12 + CyberspaceFlightPullMediumCeiling CyberspaceFlightPull = 13 + CyberspaceFlightPullMediumFloor CyberspaceFlightPull = 14 + CyberspaceFlightPullStrongCeiling CyberspaceFlightPull = 15 + CyberspaceFlightPullStrongFloor CyberspaceFlightPull = 16 +) + +// CyberspaceFlightPulls returns all constants of CyberspaceFlightPull. +func CyberspaceFlightPulls() []CyberspaceFlightPull { + return []CyberspaceFlightPull{ + CyberspaceFlightPullNone, + CyberspaceFlightPullWeakEast, + CyberspaceFlightPullWeakWest, + CyberspaceFlightPullWeakNorth, + CyberspaceFlightPullWeakSouth, + CyberspaceFlightPullMediumEast, + CyberspaceFlightPullMediumWest, + CyberspaceFlightPullMediumNorth, + CyberspaceFlightPullMediumSouth, + CyberspaceFlightPullStrongEast, + CyberspaceFlightPullStrongWest, + CyberspaceFlightPullStrongNorth, + CyberspaceFlightPullStrongSouth, + CyberspaceFlightPullMediumCeiling, + CyberspaceFlightPullMediumFloor, + CyberspaceFlightPullStrongCeiling, + CyberspaceFlightPullStrongFloor, + } +} diff --git a/modding/hacked/ss1/content/archive/level/Direction.go b/modding/hacked/ss1/content/archive/level/Direction.go new file mode 100644 index 0000000..0ad3fd8 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Direction.go @@ -0,0 +1,41 @@ +package level + +// Direction is an enumeration of cardinal and ordinal directions. +// Cardinal directions describe sides, ordinal directions describe corners. +type Direction byte + +// Plus is a shortcut for dir.AsMask().Plus(other). +func (dir Direction) Plus(other Direction) DirectionMask { + return dir.AsMask().Plus(other) +} + +// AsMask returns the direction as a mask. +func (dir Direction) AsMask() DirectionMask { + return DirectionMask(1 << dir) +} + +// Offset returns a direction that you reach by offsetting this one by the given value. +// The direction goes in a circle, and both positive and negative offsets are allowed. +func (dir Direction) Offset(offset int) Direction { + return Direction((int(dir) + ((8 + (offset % 8)) % 8)) % 8) +} + +// DirectionMask is a bitfield combination of directions. +type DirectionMask byte + +// Plus adds the mask of the given direction to this mask and returns the result. +func (mask DirectionMask) Plus(dir Direction) DirectionMask { + return mask | dir.AsMask() +} + +// Direction constants are listed below. +const ( + DirNorth Direction = iota + DirNorthEast + DirEast + DirSouthEast + DirSouth + DirSouthWest + DirWest + DirNorthWest +) diff --git a/modding/hacked/ss1/content/archive/level/Direction_test.go b/modding/hacked/ss1/content/archive/level/Direction_test.go new file mode 100644 index 0000000..0c77b1a --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Direction_test.go @@ -0,0 +1,40 @@ +package level_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestDirectionOffset(t *testing.T) { + tt := []struct { + start level.Direction + offset int + expected level.Direction + }{ + {level.DirNorth, 0, level.DirNorth}, + {level.DirSouth, 0, level.DirSouth}, + + {level.DirNorth, 1, level.DirNorthEast}, + {level.DirNorth, -1, level.DirNorthWest}, + + {level.DirEast, 2, level.DirSouth}, + {level.DirWest, 3, level.DirNorthEast}, + + {level.DirNorth, 8, level.DirNorth}, + {level.DirNorth, -8, level.DirNorth}, + + {level.DirEast, 16, level.DirEast}, + {level.DirSouthEast, 4, level.DirNorthWest}, + + {level.DirSouth, -15, level.DirSouthWest}, + } + + for _, tc := range tt { + result := tc.start.Offset(tc.offset) + assert.Equal(t, tc.expected, result, fmt.Sprintf("Wrong for %d+%d", int(tc.start), tc.offset)) + } +} diff --git a/modding/hacked/ss1/content/archive/level/EmptyLevelData.go b/modding/hacked/ss1/content/archive/level/EmptyLevelData.go new file mode 100644 index 0000000..835bf6d --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/EmptyLevelData.go @@ -0,0 +1,94 @@ +package level + +import ( + "bytes" + "math/bits" + + "github.com/inkyblackness/hacked/ss1/content/archive/level/lvlids" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// EmptyLevelParameters contain the values to create an empty level. +type EmptyLevelParameters struct { + // XSize specifies the expected width, in tiles. 0 (or less) will default. + XSize int32 + // YSize specifies the expected height, in tiles. 0 (or less) will default. + YSize int32 + // Cyberspace indicates whether the level should be marked as cyberspace. + Cyberspace bool + // MapModifier is called to make initial changes to the map before serializing. + // This can be used to empty out starter tile on starter level. + MapModifier func(x, y int, entry *TileMapEntry) +} + +// EmptyLevelData returns an array of serialized data for an empty level. +func EmptyLevelData(param EmptyLevelParameters) [lvlids.PerLevel][]byte { + var levelData [lvlids.PerLevel][]byte + baseInfo := DefaultBaseInfo(param.Cyberspace) + if param.XSize > 0 { + baseInfo.XSize = param.XSize + baseInfo.XShift = int32(bits.Len32(uint32(param.XSize))) + if (1 << (baseInfo.XShift - 1)) == baseInfo.XSize { + baseInfo.XShift-- + } + } + if param.YSize > 0 { + baseInfo.YSize = param.YSize + baseInfo.YShift = int32(bits.Len32(uint32(param.YSize))) + if (1 << (baseInfo.YShift - 1)) == baseInfo.YSize { + baseInfo.YShift-- + } + } + + levelData[lvlids.MapVersionNumber] = encode(mapVersionValue) + levelData[lvlids.ObjectVersionNumber] = encode(objectVersionValue) + levelData[lvlids.Information] = encode(&baseInfo) + + tileMap := NewTileMap(1<> 5 +} + +// WithTextureRotations returns a floor info that specifies the given the rotation steps. +// The provided value is normalized to the valid range. +func (info FloorInfo) WithTextureRotations(value int) FloorInfo { + normalized := (4 + (value % 4)) % 4 + return FloorInfo(byte(info&^0x60) | byte(normalized)<<5) +} + +// HasHazard returns true if the hazard flag is set. +func (info FloorInfo) HasHazard() bool { + return (byte(info) & 0x80) != 0 +} + +// WithHazard returns a floor info with the hazard flag set according to given value. +func (info FloorInfo) WithHazard(value bool) FloorInfo { + var flag byte + if value { + flag = 0x80 + } + return FloorInfo(byte(info&^0x80) | flag) +} + +// CeilingInfo describes the properties of a ceiling. +type CeilingInfo byte + +// AbsoluteHeight returns the height (from minimum floor height zero) of the ceiling. +// Internally, this value is stored "from maximum ceiling height 32" down. +// The range is [1..TileHeightUnitMax]. +func (info CeilingInfo) AbsoluteHeight() TileHeightUnit { + value := info & 0x1F + return TileHeightUnit(byte(TileHeightUnitMax) - byte(value)) +} + +// WithAbsoluteHeight returns a ceiling info that specifies the given absolute height. +// Internally, this value is stored "from maximum ceiling height 32" down. +// The allowed range is [1..TileHeightUnitMax]. +func (info CeilingInfo) WithAbsoluteHeight(value TileHeightUnit) CeilingInfo { + return CeilingInfo(byte(info&0xE0) | (byte(TileHeightUnitMax-value) & 0x1F)) +} + +// TextureRotations returns the rotation steps to apply for the ceiling texture. Valid range: [0..3]. +func (info CeilingInfo) TextureRotations() int { + return int(info&0x60) >> 5 +} + +// WithTextureRotations returns a ceiling info that specifies the given the rotation steps. +// The provided value is normalized to the valid range. +func (info CeilingInfo) WithTextureRotations(value int) CeilingInfo { + normalized := (4 + (value % 4)) % 4 + return CeilingInfo(byte(info&^0x60) | byte(normalized)<<5) +} + +// HasHazard returns true if the hazard flag is set. +func (info CeilingInfo) HasHazard() bool { + return (byte(info) & 0x80) != 0 +} + +// WithHazard returns a ceiling info with the hazard flag set according to given value. +func (info CeilingInfo) WithHazard(value bool) CeilingInfo { + var flag byte + if value { + flag = 0x80 + } + return CeilingInfo(byte(info&^0x80) | flag) +} diff --git a/modding/hacked/ss1/content/archive/level/FloorCeilingInfo_test.go b/modding/hacked/ss1/content/archive/level/FloorCeilingInfo_test.go new file mode 100644 index 0000000..cd5813f --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/FloorCeilingInfo_test.go @@ -0,0 +1,41 @@ +package level_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +func TestFloorCeilingInfoPermutations(t *testing.T) { + for height := level.TileHeightUnit(0); height < level.TileHeightUnitMax; height++ { + for rotations := -4; rotations < 6; rotations++ { + for _, hazard := range []bool{false, true} { + floor1 := level.FloorInfo(0).WithAbsoluteHeight(height).WithTextureRotations(rotations).WithHazard(hazard) + floor2 := level.FloorInfo(0).WithTextureRotations(rotations).WithHazard(hazard).WithAbsoluteHeight(height) + floor3 := level.FloorInfo(0).WithHazard(hazard).WithAbsoluteHeight(height).WithTextureRotations(rotations) + + assert.Equal(t, floor1, floor2, "Floor1/2 mismatch") + assert.Equal(t, floor1, floor3, "Floor1/3 mismatch") + assert.Equal(t, floor2, floor3, "Floor2/3 mismatch") + + assert.Equal(t, height, floor1.AbsoluteHeight(), "floor.AbsoluteHeight mismatch") + assert.Equal(t, (4+rotations)%4, floor1.TextureRotations(), "floor.TextureRotations mismatch") + assert.Equal(t, hazard, floor1.HasHazard(), "floor.HasHazard mismatch") + + ceiling1 := level.CeilingInfo(0).WithAbsoluteHeight(height + 1).WithTextureRotations(rotations).WithHazard(hazard) + ceiling2 := level.CeilingInfo(0).WithTextureRotations(rotations).WithHazard(hazard).WithAbsoluteHeight(height + 1) + ceiling3 := level.CeilingInfo(0).WithHazard(hazard).WithAbsoluteHeight(height + 1).WithTextureRotations(rotations) + + assert.Equal(t, ceiling1, ceiling2, "Floor1/2 mismatch") + assert.Equal(t, ceiling1, ceiling3, "Floor1/3 mismatch") + assert.Equal(t, ceiling2, ceiling3, "Floor2/3 mismatch") + + assert.Equal(t, height+1, ceiling1.AbsoluteHeight(), "ceiling.AbsoluteHeight mismatch") + assert.Equal(t, (4+rotations)%4, ceiling1.TextureRotations(), "ceiling.TextureRotations mismatch") + assert.Equal(t, hazard, ceiling1.HasHazard(), "ceiling.HasHazard mismatch") + } + } + } +} diff --git a/modding/hacked/ss1/content/archive/level/HeightSemaphores.go b/modding/hacked/ss1/content/archive/level/HeightSemaphores.go new file mode 100644 index 0000000..593cc3c --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/HeightSemaphores.go @@ -0,0 +1,4 @@ +package level + +// HeightSemaphores is a placeholder for storing height transitions. +type HeightSemaphores [0x40]byte diff --git a/modding/hacked/ss1/content/archive/level/HeightShift.go b/modding/hacked/ss1/content/archive/level/HeightShift.go new file mode 100644 index 0000000..dacb168 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/HeightShift.go @@ -0,0 +1,39 @@ +package level + +import ( + "math" +) + +var tileHeights = []float64{32.0, 16.0, 8.0, 4.0, 2.0, 1.0, 0.5, 0.25} + +// HeightShift indicates the vertical scale of a level. +type HeightShift int32 + +// ValueFromTileHeight returns a floating point value in tiles based on a tile height. +func (shift HeightShift) ValueFromTileHeight(raw TileHeightUnit) (float32, error) { + return shift.valueFromScale(float32(raw), float64(TileHeightUnitMax)) +} + +// ValueFromObjectHeight returns a floating point value in tiles based on an object height. +func (shift HeightShift) ValueFromObjectHeight(raw HeightUnit) (float32, error) { + return shift.valueFromScale(float32(raw), float64(0x100)) +} + +// ValueToObjectHeight returns an object height value based on given floating point value. +func (shift HeightShift) ValueToObjectHeight(value float32) HeightUnit { + return HeightUnit(math.Min(0xFF, math.Max(0, shift.valueToScale(value, float64(0x100))))) +} + +func (shift HeightShift) valueFromScale(raw float32, scale float64) (float32, error) { + if (shift < 0) || (int(shift) >= len(tileHeights)) { + return 0.0, errInvalidHeightShift + } + return float32((float64(raw) * tileHeights[int(shift)]) / scale), nil +} + +func (shift HeightShift) valueToScale(value float32, scale float64) float64 { + if (shift < 0) || (int(shift) >= len(tileHeights)) { + return 0.0 + } + return (float64(value) * scale) / tileHeights[shift] +} diff --git a/modding/hacked/ss1/content/archive/level/HeightUnit.go b/modding/hacked/ss1/content/archive/level/HeightUnit.go new file mode 100644 index 0000000..5437014 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/HeightUnit.go @@ -0,0 +1,4 @@ +package level + +// HeightUnit describes a height in a range of 0..255. +type HeightUnit byte diff --git a/modding/hacked/ss1/content/archive/level/Level.go b/modding/hacked/ss1/content/archive/level/Level.go new file mode 100644 index 0000000..19f5454 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Level.go @@ -0,0 +1,581 @@ +package level + +import ( + "bytes" + "encoding/binary" + "io" + "io/ioutil" + + "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/object" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// Level is the complete structure defining all necessary data for a level. +type Level struct { + id int + localizer resource.Localizer + + resStart resource.ID + resEnd resource.ID + + baseInfo BaseInfo + tileMap TileMap + wallHeightsMap WallHeightsMap + textureAtlas TextureAtlas + + objectMainTable ObjectMainTable + objectCrossRefTable ObjectCrossReferenceTable + objectClassTables [object.ClassCount]ObjectClassTable + + textureAnimations []TextureAnimationEntry + surveillanceSources [SurveillanceObjectCount]ObjectID + surveillanceSurrogates [SurveillanceObjectCount]ObjectID + parameters Parameters +} + +// NewLevel returns a new instance. +func NewLevel(resourceBase resource.ID, id int, localizer resource.Localizer) *Level { + lvl := &Level{ + id: id, + + localizer: localizer, + + resStart: resourceBase.Plus(lvlids.PerLevel * id), + } + lvl.resEnd = lvl.resStart.Plus(lvlids.PerLevel) + + lvl.reloadBaseInfo() + lvl.reloadTileMap() + lvl.reloadTextureAtlas() + lvl.reloadObjectMainTable() + lvl.reloadObjectCrossRefTable() + lvl.reloadObjectClassTables() + lvl.reloadTextureAnimations() + lvl.reloadSurveillanceSources() + lvl.reloadSurveillanceSurrogates() + lvl.reloadParameters() + + return lvl +} + +// ID returns the identifier of the level. +func (lvl *Level) ID() int { + return lvl.id +} + +// InvalidateResources resets all internally cached data. +func (lvl *Level) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + if (id >= lvl.resStart) && (id < lvl.resEnd) { + lvl.onLevelResourceDataChanged(int(id.Value() - lvl.resStart.Value())) + } + } +} + +// Size returns the dimensions of the level. +func (lvl *Level) Size() (x, y int, z HeightShift) { + return int(lvl.baseInfo.XSize), int(lvl.baseInfo.YSize), lvl.baseInfo.ZShift +} + +// SetHeightShift changes the vertical scale of the level. +func (lvl *Level) SetHeightShift(value HeightShift) { + lvl.baseInfo.ZShift = value +} + +// TextureAnimations returns the list of animations. +func (lvl *Level) TextureAnimations() []TextureAnimationEntry { + return lvl.textureAnimations +} + +// SurveillanceSources returns the current sources of surveillance. +func (lvl *Level) SurveillanceSources() [SurveillanceObjectCount]ObjectID { + return lvl.surveillanceSources +} + +// SetSurveillanceSource sets a surveillance source. +func (lvl *Level) SetSurveillanceSource(index int, id ObjectID) { + if (index >= 0) && (index < SurveillanceObjectCount) { + lvl.surveillanceSources[index] = id + } +} + +// SurveillanceSurrogates returns the current surrogates of surveillance. +func (lvl *Level) SurveillanceSurrogates() [SurveillanceObjectCount]ObjectID { + return lvl.surveillanceSurrogates +} + +// SetSurveillanceSurrogate sets a surveillance source. +func (lvl *Level) SetSurveillanceSurrogate(index int, id ObjectID) { + if (index >= 0) && (index < SurveillanceObjectCount) { + lvl.surveillanceSurrogates[index] = id + } +} + +// IsCyberspace returns true if the level describes a cyberspace. +func (lvl *Level) IsCyberspace() bool { + return lvl.baseInfo.Cyberspace != 0 +} + +// Parameters returns the extra properties the level has. +func (lvl *Level) Parameters() *Parameters { + return &lvl.parameters +} + +// TextureAtlas returns the atlas for textures. +func (lvl *Level) TextureAtlas() TextureAtlas { + return lvl.textureAtlas +} + +// SetTextureAtlasEntry changes the entry of the atlas. +func (lvl *Level) SetTextureAtlasEntry(index int, textureIndex TextureIndex) { + if (index >= 0) && (index < len(lvl.textureAtlas)) { + lvl.textureAtlas[index] = textureIndex + } +} + +// Tile returns the tile entry at given position. +func (lvl *Level) Tile(pos TilePosition) *TileMapEntry { + return lvl.tileMap.Tile(pos, int(lvl.baseInfo.XShift)) +} + +// MapGridInfo returns the information necessary to draw a 2D map. +func (lvl *Level) MapGridInfo(pos TilePosition) (TileType, TileSlopeControl, WallHeights) { + tile := lvl.Tile(pos) + if tile == nil { + return TileTypeSolid, TileSlopeControlCeilingInverted, WallHeights{} + } + return tile.Type, tile.Flags.SlopeControl(), *lvl.wallHeightsMap.Tile(pos) +} + +// ObjectCapacity returns the number of objects that can be stored in this level. +func (lvl *Level) ObjectCapacity() int { + return lvl.objectMainTable.Capacity() +} + +// ObjectClassStats returns the number of used and total possible entries of given class. +func (lvl *Level) ObjectClassStats(class object.Class) (active, capacity int) { + if int(class) >= len(lvl.objectClassTables) { + return 0, 0 + } + objectClassTable := lvl.objectClassTables[class] + return objectClassTable.AllocatedCount(), objectClassTable.Capacity() +} + +// ForEachObject iterates over all active objects and calls the given handler. +func (lvl *Level) ForEachObject(handler func(ObjectID, ObjectMainEntry)) { + tableSize := len(lvl.objectMainTable) + if tableSize > 0 { + id := ObjectID(lvl.objectMainTable[0].CrossReferenceTableIndex) + for (id > 0) && (int(id) < tableSize) { + entry := lvl.objectMainTable[id] + handler(id, entry) + id = entry.Next + } + } +} + +// HasRoomForObjectOf returns true if the level can hold one object of given class. +func (lvl *Level) HasRoomForObjectOf(class object.Class) bool { + classActive, classCapacity := lvl.ObjectClassStats(class) + levelActive := lvl.objectMainTable.AllocatedCount() + levelCapacity := lvl.objectMainTable.Capacity() + return (levelActive < levelCapacity) && (classActive < classCapacity) +} + +// NewObject attempts to allocate a new object for given class. +// The created object has no place in the world and must be placed. +// Returns 0 and an error if not possible. +func (lvl *Level) NewObject(class object.Class) (ObjectID, error) { + if int(class) >= len(lvl.objectClassTables) { + return 0, errInvalidClass + } + classTable := lvl.objectClassTables[class] + classIndex := classTable.Allocate() + if classIndex == 0 { + return 0, errNoMoreRoomForClass + } + id := lvl.objectMainTable.Allocate() + if id == 0 { + classTable.Release(classIndex) + return 0, errNoMoreRoomForObjects + } + obj := &lvl.objectMainTable[id] + classEntry := &classTable[classIndex] + classEntry.ObjectID = id + obj.ClassTableIndex = int16(classIndex) + obj.Class = class + + lvl.addCrossReferenceTo(id, obj, offMapReferencePosition()) + + return id, nil +} + +// UpdateObjectLocation updates the reference table between object and tiles based on its current location. +func (lvl *Level) UpdateObjectLocation(id ObjectID) { + obj := lvl.Object(id) + if (obj == nil) || (obj.InUse == 0) { + return + } + lvl.removeCrossReferences(int(obj.CrossReferenceTableIndex), func(entry ObjectCrossReferenceEntry) int { return int(entry.NextTileForObj) }) + lvl.addCrossReferenceTo(id, obj, obj.TilePosition()) +} + +// DelObject removes the identified object from the level. +func (lvl *Level) DelObject(id ObjectID) { + obj := lvl.Object(id) + if (obj == nil) || (obj.InUse == 0) { + return + } + lvl.removeCrossReferences(int(obj.CrossReferenceTableIndex), func(entry ObjectCrossReferenceEntry) int { return int(entry.NextTileForObj) }) + if int(obj.Class) < len(lvl.objectClassTables) { + lvl.objectClassTables[obj.Class].Release(int(obj.ClassTableIndex)) + } + lvl.objectMainTable.Release(id) +} + +func (lvl *Level) addCrossReferenceTo(id ObjectID, obj *ObjectMainEntry, pos TilePosition) { + newIndex := lvl.objectCrossRefTable.Allocate() + if newIndex == 0 { + return + } + entry := &lvl.objectCrossRefTable[newIndex] + entry.ObjectID = id + entry.SetTilePosition(pos) + + tile := lvl.Tile(pos) + if tile != nil { + entry.NextInTile = tile.FirstObjectIndex + tile.FirstObjectIndex = int16(newIndex) + } + if obj.CrossReferenceTableIndex != 0 { + entry.NextTileForObj = obj.CrossReferenceTableIndex + endIndex := obj.CrossReferenceTableIndex + for lvl.objectCrossRefTable[endIndex].NextTileForObj != obj.CrossReferenceTableIndex { + endIndex = lvl.objectCrossRefTable[endIndex].NextTileForObj + } + lvl.objectCrossRefTable[endIndex].NextTileForObj = int16(newIndex) + } else { + entry.NextTileForObj = int16(newIndex) + } + obj.CrossReferenceTableIndex = int16(newIndex) +} + +func (lvl *Level) removeCrossReferences(start int, next func(ObjectCrossReferenceEntry) int) { + index := start + for (index != 0) && (index < len(lvl.objectCrossRefTable)) { + entry := lvl.objectCrossRefTable[index] + + tile := lvl.Tile(entry.TilePosition()) + if tile != nil { + if tile.FirstObjectIndex == int16(index) { + tile.FirstObjectIndex = entry.NextInTile + } else { + otherIndex := tile.FirstObjectIndex + for otherIndex != 0 { + otherEntry := &lvl.objectCrossRefTable[otherIndex] + otherIndex = otherEntry.NextInTile + if otherEntry.NextInTile == int16(index) { + otherEntry.NextInTile = entry.NextInTile + } + } + } + } + + obj := lvl.Object(entry.ObjectID) + if obj != nil { + switch { + case entry.NextTileForObj == int16(start): + obj.CrossReferenceTableIndex = 0 + case obj.CrossReferenceTableIndex == int16(index): + obj.CrossReferenceTableIndex = entry.NextTileForObj + default: + otherIndex := obj.CrossReferenceTableIndex + for otherIndex != 0 { + otherEntry := &lvl.objectCrossRefTable[otherIndex] + otherIndex = otherEntry.NextTileForObj + if otherEntry.NextTileForObj == int16(index) { + otherEntry.NextTileForObj = entry.NextTileForObj + } + } + } + } + + lvl.objectCrossRefTable.Release(index) + + index = next(entry) + if index == start { + index = 0 + } + } +} + +// Object returns the main entry for the identified object. +func (lvl *Level) Object(id ObjectID) *ObjectMainEntry { + var entry *ObjectMainEntry + if (id > 0) && (int(id) < len(lvl.objectMainTable)) { + entry = &lvl.objectMainTable[id] + } + return entry +} + +// ObjectClassData returns the raw class data for the given object. +func (lvl *Level) ObjectClassData(obj *ObjectMainEntry) *interpreters.Instance { + if (obj == nil) || (obj.InUse == 0) { + return interpreters.New().For(nil) + } + if int(obj.Class) >= len(lvl.objectClassTables) { + return interpreters.New().For(nil) + } + classTable := lvl.objectClassTables[obj.Class] + if (obj.ClassTableIndex < 1) || (int(obj.ClassTableIndex) >= len(classTable)) { + return interpreters.New().For(nil) + } + + interpreterFactory := lvlobj.ForRealWorld + if lvl.IsCyberspace() { + interpreterFactory = lvlobj.ForCyberspace + } + return interpreterFactory(obj.Triple(), classTable[obj.ClassTableIndex].Data) +} + +// ObjectExtraData returns the raw extra data for the given object. +func (lvl *Level) ObjectExtraData(obj *ObjectMainEntry) *interpreters.Instance { + if (obj == nil) || (obj.InUse == 0) { + return interpreters.New().For(nil) + } + interpreterFactory := lvlobj.RealWorldExtra + if lvl.IsCyberspace() { + interpreterFactory = lvlobj.CyberspaceExtra + } + return interpreterFactory(obj.Triple(), obj.Extra[:]) +} + +// EncodeState returns a subset of encoded level data, which only includes +// data that is loaded (modified) by the level structure. +// For any data block that is not relevant, a zero length slice is returned. +func (lvl *Level) EncodeState() [lvlids.PerLevel][]byte { + var levelData [lvlids.PerLevel][]byte + + levelData[lvlids.Information] = encode(&lvl.baseInfo) + + levelData[lvlids.TextureAtlas] = encode(lvl.textureAtlas) + levelData[lvlids.TileMap] = encode(lvl.tileMap) + levelData[lvlids.ObjectMainTable] = encode(lvl.objectMainTable) + levelData[lvlids.ObjectCrossRefTable] = encode(lvl.objectCrossRefTable) + for class := 0; class < len(lvl.objectClassTables); class++ { + levelData[lvlids.ObjectClassTablesStart+class] = encode(lvl.objectClassTables[class]) + } + + levelData[lvlids.TextureAnimations] = encode(lvl.textureAnimations) + levelData[lvlids.SurveillanceSources] = encode(&lvl.surveillanceSources) + levelData[lvlids.SurveillanceSurrogates] = encode(&lvl.surveillanceSurrogates) + levelData[lvlids.Parameters] = encode(&lvl.parameters) + + return levelData +} + +func (lvl *Level) onLevelResourceDataChanged(id int) { + switch id { + case lvlids.Information: + lvl.reloadBaseInfo() + case lvlids.TextureAtlas: + lvl.reloadTextureAtlas() + case lvlids.TileMap: + lvl.reloadTileMap() + case lvlids.ObjectMainTable: + lvl.reloadObjectMainTable() + case lvlids.ObjectCrossRefTable: + lvl.reloadObjectCrossRefTable() + case lvlids.TextureAnimations: + lvl.reloadTextureAnimations() + case lvlids.SurveillanceSources: + lvl.reloadSurveillanceSources() + case lvlids.SurveillanceSurrogates: + lvl.reloadSurveillanceSurrogates() + case lvlids.Parameters: + lvl.reloadParameters() + } + if (id >= lvlids.ObjectClassTablesStart) && (id < (lvlids.ObjectClassTablesStart + len(lvl.objectClassTables))) { + lvl.reloadObjectClassTable(object.Class(id - lvlids.ObjectClassTablesStart)) + } +} + +func (lvl *Level) reloadBaseInfo() { + reader, err := lvl.reader(lvlids.Information) + if err != nil { + lvl.baseInfo = BaseInfo{} + return + } + err = binary.Read(reader, binary.LittleEndian, &lvl.baseInfo) + if err != nil { + lvl.baseInfo = BaseInfo{} + } +} + +func (lvl *Level) reloadTextureAtlas() { + reader, err := lvl.reader(lvlids.TextureAtlas) + if err != nil { + lvl.textureAtlas = nil + return + } + data, err := ioutil.ReadAll(reader) + if err != nil { + lvl.textureAtlas = nil + return + } + lvl.textureAtlas = make([]TextureIndex, len(data)/2) + err = binary.Read(bytes.NewReader(data), binary.LittleEndian, lvl.textureAtlas) + if err != nil { + lvl.textureAtlas = nil + } +} + +func (lvl *Level) reloadTileMap() { + lvl.tileMap = NewTileMap(1<> 4) +} + +// WithCeiling returns a new instance with the given ceiling value. +func (info LightDeltaInfo) WithCeiling(value int) LightDeltaInfo { + if (value < 0) || (value > 15) { + return info + } + return (info & 0x0F) | LightDeltaInfo(value<<4) +} + +// OfFloor returns the delta value for the floor. +func (info LightDeltaInfo) OfFloor() int { + return int(info & 0x0F) +} + +// WithFloor returns a new instance with the given floor value. +func (info LightDeltaInfo) WithFloor(value int) LightDeltaInfo { + if (value < 0) || (value > 15) { + return info + } + return (info & 0xF0) | LightDeltaInfo(value) +} diff --git a/modding/hacked/ss1/content/archive/level/LoopConfigEntry.go b/modding/hacked/ss1/content/archive/level/LoopConfigEntry.go new file mode 100644 index 0000000..4f9baa8 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/LoopConfigEntry.go @@ -0,0 +1,8 @@ +package level + +const ( + // LoopConfigEntryCount describes how many loop entries a level has. + LoopConfigEntryCount = 64 + // LoopConfigEntrySize describes the size, in bytes, of a loop config entry. + LoopConfigEntrySize = 15 +) diff --git a/modding/hacked/ss1/content/archive/level/MapNotes.go b/modding/hacked/ss1/content/archive/level/MapNotes.go new file mode 100644 index 0000000..df5d50e --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/MapNotes.go @@ -0,0 +1,9 @@ +package level + +const ( + // MapNotesSize is the size, in bytes, of the map notes resource. + MapNotesSize = 0x0800 +) + +// MapNotesPointer is an offset into the map notes resource. +type MapNotesPointer uint32 diff --git a/modding/hacked/ss1/content/archive/level/ObjectClassInfo.go b/modding/hacked/ss1/content/archive/level/ObjectClassInfo.go new file mode 100644 index 0000000..c6988f1 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectClassInfo.go @@ -0,0 +1,34 @@ +package level + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" +) + +// ObjectClassInfo contains meta information about a level object class in an archive. +type ObjectClassInfo struct { + DataSize int + EntryCount int +} + +var objectClassInfoList = []ObjectClassInfo{ + {DataSize: 2, EntryCount: 16}, + {DataSize: 0, EntryCount: 32}, + {DataSize: 34, EntryCount: 32}, + {DataSize: 6, EntryCount: 32}, + {DataSize: 0, EntryCount: 32}, + {DataSize: 1, EntryCount: 8}, + {DataSize: 3, EntryCount: 16}, + {DataSize: 10, EntryCount: 176}, + {DataSize: 10, EntryCount: 128}, + {DataSize: 24, EntryCount: 64}, + {DataSize: 8, EntryCount: 64}, + {DataSize: 4, EntryCount: 32}, + {DataSize: 22, EntryCount: 160}, + {DataSize: 15, EntryCount: 64}, + {DataSize: 40, EntryCount: 64}, +} + +// ObjectClassInfoFor returns the info entry for the corresponding object class. +func ObjectClassInfoFor(class object.Class) ObjectClassInfo { + return objectClassInfoList[int(class)] +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectClassTable.go b/modding/hacked/ss1/content/archive/level/ObjectClassTable.go new file mode 100644 index 0000000..5b67378 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectClassTable.go @@ -0,0 +1,156 @@ +package level + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/serial" +) + +const ( + // ObjectClassEntryHeaderSize is the size, in bytes, of the header prefix for each class entry. + ObjectClassEntryHeaderSize = 6 +) + +// ObjectClassEntry describes an entry in a object-class specific list. +type ObjectClassEntry struct { + ObjectID ObjectID + Next int16 + Prev int16 + Data []byte +} + +// NewObjectClassEntry returns an instance with the given amount of bytes initialized for data. +func NewObjectClassEntry(dataSize int) *ObjectClassEntry { + return &ObjectClassEntry{ + Data: make([]byte, dataSize), + } +} + +// Code serializes the entry with given coder, including the data array. +func (entry *ObjectClassEntry) Code(coder serial.Coder) { + coder.Code(&entry.ObjectID) + coder.Code(&entry.Next) + coder.Code(&entry.Prev) + coder.Code(entry.Data) +} + +// Reset sets all members of the entry to zero, including all bytes of the data array. +func (entry *ObjectClassEntry) Reset() { + entry.ObjectID = 0 + entry.Next = 0 + entry.Prev = 0 + for i := 0; i < len(entry.Data); i++ { + entry.Data[i] = 0 + } +} + +// ObjectClassTable is a list of entries. +// +// The first entry is reserved for internal use. For the reserved entry, +// the Next field identifies the head of the single-linked free chain, +// and the ObjectID field identifies the head of the double-linked used chain. +type ObjectClassTable []ObjectClassEntry + +// DefaultObjectClassTable returns an initialized table for given object class. +func DefaultObjectClassTable(class object.Class) ObjectClassTable { + info := ObjectClassInfoFor(class) + table := make(ObjectClassTable, info.EntryCount) + table.AllocateData(info.DataSize) + table.Reset() + return table +} + +// AllocateData prepares each entry to be able to store the given amount of bytes. +// This function drops any previously assigned data. +func (table ObjectClassTable) AllocateData(size int) { + for i := 0; i < len(table); i++ { + table[i].Data = make([]byte, size) + } +} + +// Reset wipes the entire table and initializes all links. +// The bytes of the data members of each entry are reset to zero. +func (table ObjectClassTable) Reset() { + tableLen := len(table) + for i := 0; i < tableLen; i++ { + entry := &table[i] + entry.Reset() + entry.Next = int16(i + 1) + } + if tableLen > 0 { + table[tableLen-1].Next = 0 + } +} + +// Code serializes the table with the provided coder. +func (table ObjectClassTable) Code(coder serial.Coder) { + for i := 0; i < len(table); i++ { + table[i].Code(coder) + } +} + +// Capacity returns the number of how many objects the table can hold. +func (table ObjectClassTable) Capacity() int { + size := len(table) + if size < 2 { + return 0 + } + return size - 1 +} + +// AllocatedCount returns the number of how many objects are currently allocated in the table. +func (table ObjectClassTable) AllocatedCount() int { + capacity := table.Capacity() + if capacity == 0 { + return 0 + } + index := int(table[0].ObjectID) + count := 0 + for (index != 0) && (count < capacity) { + count++ + index = int(table[index].Next) + } + return count +} + +// Allocate attempts to reserve an entry in the table and returns the corresponding index. +// returns 0 if exhausted. +func (table ObjectClassTable) Allocate() int { + if len(table) < 2 { + return 0 + } + start := &table[0] + if start.Next == 0 { + return 0 + } + index := start.Next + entry := &table[index] + start.Next = entry.Next + + entry.Reset() + entry.Next = int16(start.ObjectID) + table[start.ObjectID].Prev = index + entry.Prev = 0 + start.ObjectID = ObjectID(index) + + return int(index) +} + +// Release frees the identified entry. +func (table ObjectClassTable) Release(index int) { + if (index < 1) || (index >= len(table)) { + return + } + start := &table[0] + entry := &table[index] + + if entry.Prev == 0 { + start.ObjectID = ObjectID(entry.Next) + } else { + table[entry.Prev].Next = entry.Next + } + table[entry.Next].Prev = entry.Prev + + entry.Reset() + entry.Next = start.Next + start.Next = int16(index) +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectClassTable_test.go b/modding/hacked/ss1/content/archive/level/ObjectClassTable_test.go new file mode 100644 index 0000000..b957e81 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectClassTable_test.go @@ -0,0 +1,144 @@ +package level_test + +import ( + "bytes" + "crypto/rand" + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewObjectClassEntrySetsData(t *testing.T) { + entry := level.NewObjectClassEntry(20) + assert.Equal(t, 20, len(entry.Data)) +} + +func TestObjectClassEntryCanBeSerializedWithCoder(t *testing.T) { + entry := level.NewObjectClassEntry(10) + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + encoder.Code(entry) + data := buf.Bytes() + assert.Equal(t, 10+6, len(data)) +} + +func TestObjectClassTableCanBeSerializedWithCoder(t *testing.T) { + table := level.ObjectClassTable([]level.ObjectClassEntry{*level.NewObjectClassEntry(5), *level.NewObjectClassEntry(5)}) + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + encoder.Code(table) + data := buf.Bytes() + assert.Equal(t, (5+6)*2, len(data)) +} + +func TestObjectClassTableCanAllocateData(t *testing.T) { + table := level.ObjectClassTable([]level.ObjectClassEntry{{}, {}}) + table.AllocateData(4) + assert.Equal(t, 4, len(table[0].Data)) + assert.Equal(t, 4, len(table[1].Data)) +} + +func TestObjectClassTableResetClearsEntryData(t *testing.T) { + table := level.ObjectClassTable([]level.ObjectClassEntry{aRandomObjectClassEntry(), aRandomObjectClassEntry()}) + table.Reset() + + for index, entry := range table { + expected := make([]byte, len(entry.Data)) + assert.Equal(t, expected, entry.Data, fmt.Sprintf("Data not zero for %d", index)) + } +} + +func TestObjectClassTableResetInitializesChainPointers(t *testing.T) { + table := level.ObjectClassTable([]level.ObjectClassEntry{aRandomObjectClassEntry(), aRandomObjectClassEntry(), aRandomObjectClassEntry()}) + table.Reset() + + start := table[0] + assert.Equal(t, int16(1), start.Next, "start.Next should be 1, the first free entry.") + assert.Equal(t, level.ObjectID(0), start.ObjectID, "start.ObjectID should be 0, the list is empty.") + free0 := table[1] + assert.Equal(t, int16(2), free0.Next, "free0.Next should be 2, the second free entry.") + free1 := table[2] + assert.Equal(t, int16(0), free1.Next, "free1.Next should be 0, the start entry.") +} + +func TestDefaultObjectClassTable(t *testing.T) { + table := level.DefaultObjectClassTable(0) + assert.Equal(t, 16, len(table), "16 entries for class 0 expected") + assert.Equal(t, 2, len(table[0].Data), "2 bytes for class 0 data expected") + assert.Equal(t, int16(1), table[0].Next, "free list expected to be initialized") +} + +func TestObjectClassTableAllocate(t *testing.T) { + tt := []int{0, 1, 2, 3, 100} + + for _, tc := range tt { + table := make(level.ObjectClassTable, tc) + table.Reset() + possible := tc - 1 + + if possible < 0 { + possible = 0 + } + assert.Equal(t, possible, table.Capacity(), "table should have proper capacity") + for attempt := 0; attempt < possible; attempt++ { + assert.Equal(t, attempt, table.AllocatedCount(), "table should have proper allocation count") + index := table.Allocate() + assert.NotEqual(t, 0, index, "could not allocate at attempt %d for size %d", attempt, tc) + } + assert.Equal(t, possible, table.AllocatedCount(), "table should be full") + last := table.Allocate() + assert.Equal(t, 0, last, "table was not exhausted although it should be") + } +} + +func TestObjectClassTableRelease(t *testing.T) { + stats := func(table level.ObjectClassTable) (used, free int) { + for next := int16(table[0].ObjectID); next != 0; next = table[next].Next { + used++ + } + for next := table[0].Next; next != 0; next = table[next].Next { + free++ + } + return + } + + table := make(level.ObjectClassTable, 10) + table.Reset() + var allocated []int + for i := 0; i < 4; i++ { + id := table.Allocate() + allocated = append(allocated, id) + } + used, free := stats(table) + require.Equal(t, 4, used, "invalid amount of used entries") + require.Equal(t, 5, free, "invalid amount of free entries") + + for _, id := range allocated { + table.Release(id) + } + + used, free = stats(table) + assert.Equal(t, 0, used, "invalid amount of used entries after release") + assert.Equal(t, 9, free, "invalid amount of free entries after release") + + table.Release(0) + table.Release(20) + for i := 0; i < len(table)-1; i++ { + id := table.Allocate() + assert.NotEqual(t, level.ObjectID(0), id, "should have been able to re-allocate") + } +} + +func aRandomObjectClassEntry() level.ObjectClassEntry { + decoder := serial.NewDecoder(rand.Reader) + var dataSize byte + decoder.Code(&dataSize) + entry := level.NewObjectClassEntry(int(dataSize) + 1) + decoder.Code(entry) + return *entry +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable.go b/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable.go new file mode 100644 index 0000000..2870b77 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable.go @@ -0,0 +1,103 @@ +package level + +const ( + // ObjectCrossReferenceEntrySize describes the size, in bytes, of a ObjectCrossReferenceEntry. + ObjectCrossReferenceEntrySize = 10 + + defaultObjectCrossReferenceEntryCount = 1600 +) + +func offMapReferencePosition() TilePosition { + return TilePosition{X: 0xFF, Y: 0} +} + +// ObjectCrossReferenceEntry links objects and tiles. +type ObjectCrossReferenceEntry struct { + TileX int16 + TileY int16 + + ObjectID ObjectID + NextInTile int16 + NextTileForObj int16 +} + +// TilePosition returns the position for that entry. +func (entry ObjectCrossReferenceEntry) TilePosition() TilePosition { + return TilePosition{ + X: byte(entry.TileX), + Y: byte(entry.TileY), + } +} + +// SetTilePosition sets the position for that entry. +func (entry *ObjectCrossReferenceEntry) SetTilePosition(pos TilePosition) { + if pos == offMapReferencePosition() { + entry.TileX = -1 + entry.TileY = 0 + } else { + entry.TileX = int16(pos.X) + entry.TileY = int16(pos.Y) + } +} + +// Reset clears the members of the entry. +func (entry *ObjectCrossReferenceEntry) Reset() { + *entry = ObjectCrossReferenceEntry{} +} + +// ObjectCrossReferenceTable is a list of entries. +// The first entry is reserved for internal use. For the reserved entry, +// The NextInTile member refers to the head of the single-linked free chain. +type ObjectCrossReferenceTable []ObjectCrossReferenceEntry + +// DefaultObjectCrossReferenceTable returns an initialized table with a default size. +func DefaultObjectCrossReferenceTable() ObjectCrossReferenceTable { + table := make(ObjectCrossReferenceTable, defaultObjectCrossReferenceEntryCount) + table.Reset() + return table +} + +// Reset wipes the entire table and initializes all links. +func (table ObjectCrossReferenceTable) Reset() { + tableLen := len(table) + for i := 0; i < tableLen; i++ { + entry := &table[i] + entry.Reset() + entry.NextInTile = int16(i + 1) + } + if tableLen > 0 { + table[tableLen-1].NextInTile = 0 + } +} + +// Allocate attempts to reserve a free entry in the table and return its index. +// Returns 0 if exhausted. +func (table ObjectCrossReferenceTable) Allocate() int { + if len(table) < 2 { + return 0 + } + start := &table[0] + if start.NextInTile == 0 { + return 0 + } + index := start.NextInTile + entry := &table[index] + start.NextInTile = entry.NextInTile + + entry.Reset() + + return int(index) +} + +// Release frees the entry with given index. +func (table ObjectCrossReferenceTable) Release(index int) { + if (index < 1) || (index >= len(table)) { + return + } + start := &table[0] + entry := &table[index] + + entry.Reset() + entry.NextInTile = start.NextInTile + start.NextInTile = int16(index) +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable_test.go b/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable_test.go new file mode 100644 index 0000000..9b27879 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable_test.go @@ -0,0 +1,114 @@ +package level_test + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestObjectCrossReferenceEntrySerializedSize(t *testing.T) { + var entry level.ObjectCrossReferenceEntry + size := binary.Size(&entry) + assert.Equal(t, level.ObjectCrossReferenceEntrySize, size) +} + +func TestObjectCrossReferenceTableCanBeSerializedWithCoder(t *testing.T) { + table := level.ObjectCrossReferenceTable([]level.ObjectCrossReferenceEntry{{}, {}}) + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + encoder.Code(table) + data := buf.Bytes() + assert.Equal(t, level.ObjectCrossReferenceEntrySize*2, len(data)) +} + +func TestObjectCrossReferenceTableResetInitializesChainPointers(t *testing.T) { + table := level.ObjectCrossReferenceTable([]level.ObjectCrossReferenceEntry{ + aRandomObjectCrossReferenceEntry(), aRandomObjectCrossReferenceEntry(), aRandomObjectCrossReferenceEntry()}) + table.Reset() + + start := table[0] + assert.Equal(t, level.ObjectID(0), start.ObjectID, "start.ObjectID should be 0, it is unused.") + assert.Equal(t, int16(1), start.NextInTile, "start.NextInTile should be 1, the first free.") + free0 := table[1] + assert.Equal(t, level.ObjectID(0), free0.ObjectID, "free0.ObjectID should be 0, it is unused.") + assert.Equal(t, int16(2), free0.NextInTile, "free0.NextInTile should be 2, the second free.") + free1 := table[2] + assert.Equal(t, level.ObjectID(0), free1.ObjectID, "free1.ObjectID should be 0, it is unused.") + assert.Equal(t, int16(0), free1.NextInTile, "free1.NextInTile should be 0, the start.") +} + +func TestDefaultObjectCrossReferenceTable(t *testing.T) { + table := level.DefaultObjectCrossReferenceTable() + + assert.Equal(t, 1600, len(table), "Wrong default size") + assert.Equal(t, int16(1), table[0].NextInTile, "start entry should point to first free.") +} + +func TestObjectCrossReferenceTableAllocate(t *testing.T) { + tt := []int{0, 1, 2, 3, 100} + + for _, tc := range tt { + table := make(level.ObjectCrossReferenceTable, tc) + table.Reset() + possible := tc - 1 + + for attempt := 0; attempt < possible; attempt++ { + index := table.Allocate() + assert.NotEqual(t, 0, index, "could not allocate at attempt %d for size %d", attempt, tc) + } + last := table.Allocate() + assert.Equal(t, 0, last, "table was not exhausted although it should be") + } +} + +func TestObjectCrossReferenceTableRelease(t *testing.T) { + stats := func(table level.ObjectCrossReferenceTable) (used, free int) { + for next := table[0].NextInTile; next != 0; next = table[next].NextInTile { + free++ + } + used = len(table) - 1 - free + return + } + + table := make(level.ObjectCrossReferenceTable, 10) + table.Reset() + var allocated []int + for i := 0; i < 4; i++ { + id := table.Allocate() + allocated = append(allocated, id) + } + used, free := stats(table) + require.Equal(t, 4, used, "invalid amount of used entries") + require.Equal(t, 5, free, "invalid amount of free entries") + + for _, id := range allocated { + table.Release(id) + } + + used, free = stats(table) + assert.Equal(t, 0, used, "invalid amount of used entries after release") + assert.Equal(t, 9, free, "invalid amount of free entries after release") + + table.Release(0) + table.Release(20) + for i := 0; i < len(table)-1; i++ { + id := table.Allocate() + assert.NotEqual(t, level.ObjectID(0), id, "should have been able to re-allocate") + } +} + +func aRandomObjectCrossReferenceEntry() level.ObjectCrossReferenceEntry { + decoder := serial.NewDecoder(rand.Reader) + var dataSize byte + decoder.Code(&dataSize) + var entry level.ObjectCrossReferenceEntry + decoder.Code(&entry) + return entry +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectID.go b/modding/hacked/ss1/content/archive/level/ObjectID.go new file mode 100644 index 0000000..d4c9ce8 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectID.go @@ -0,0 +1,5 @@ +package level + +// ObjectID identifies one object in a level. +// It is also the index into the main object table. +type ObjectID int16 diff --git a/modding/hacked/ss1/content/archive/level/ObjectMainTable.go b/modding/hacked/ss1/content/archive/level/ObjectMainTable.go new file mode 100644 index 0000000..8089855 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectMainTable.go @@ -0,0 +1,160 @@ +package level + +import "github.com/inkyblackness/hacked/ss1/content/object" + +const ( + // ObjectMainEntrySize describes the size, in bytes, of a ObjectMainEntry. + ObjectMainEntrySize = 27 + + defaultObjectMainEntryCount = 872 +) + +// ObjectMainEntryModifier is a function that changes properties. +type ObjectMainEntryModifier func(*ObjectMainEntry) + +// ObjectMainEntry describes an object in the level. +type ObjectMainEntry struct { + InUse byte + + Class object.Class + Subclass object.Subclass + + ClassTableIndex int16 + CrossReferenceTableIndex int16 + Next ObjectID + Prev ObjectID + + X Coordinate + Y Coordinate + Z HeightUnit + XRotation RotationUnit + ZRotation RotationUnit + YRotation RotationUnit + + _ byte + + Type object.Type + + Hitpoints int16 + + Extra [4]byte +} + +// Triple returns the unique identifier of the entry. +func (entry ObjectMainEntry) Triple() object.Triple { + return object.TripleFrom(int(entry.Class), int(entry.Subclass), int(entry.Type)) +} + +// TilePosition returns the tile coordinates of the object. +func (entry ObjectMainEntry) TilePosition() TilePosition { + return TilePosition{X: entry.X.Tile(), Y: entry.Y.Tile()} +} + +// FinePosition returns the coordinates of the object within the tile. +func (entry ObjectMainEntry) FinePosition() FinePosition { + return FinePosition{X: entry.X.Fine(), Y: entry.Y.Fine()} +} + +// Reset clears the entry and resets all members. +func (entry *ObjectMainEntry) Reset() { + *entry = ObjectMainEntry{} +} + +// ObjectMainTable is a list of entries. +// The first entry is reserved for internal use. For the reserved entry, +// the Next field refers to the head of the single-linked free chain, +// and the CrossReferenceTableIndex refers to the head of the double-linked used chain. +type ObjectMainTable []ObjectMainEntry + +// DefaultObjectMainTable returns an initialized table with a default size. +func DefaultObjectMainTable() ObjectMainTable { + table := make(ObjectMainTable, defaultObjectMainEntryCount) + table.Reset() + return table +} + +// Capacity returns the number of how many objects the table can hold. +func (table ObjectMainTable) Capacity() int { + size := len(table) + if size < 2 { + return 0 + } + return size - 1 +} + +// AllocatedCount returns the number of how many objects are currently allocated in the table. +func (table ObjectMainTable) AllocatedCount() int { + capacity := table.Capacity() + if capacity == 0 { + return 0 + } + index := int(table[0].CrossReferenceTableIndex) + count := 0 + for (index != 0) && (count < capacity) { + count++ + index = int(table[index].Next) + } + return count +} + +// Reset wipes the entire table and initializes all links. +func (table ObjectMainTable) Reset() { + tableLen := len(table) + for i := 0; i < tableLen; i++ { + entry := &table[i] + entry.Reset() + entry.Next = ObjectID(i + 1) + } + if tableLen > 0 { + table[tableLen-1].Next = 0 + } +} + +// Allocate attempts to find an available entry in the table and activates it. +// Returns 0 if not possible (exhausted). +func (table ObjectMainTable) Allocate() ObjectID { + if len(table) < 2 { + return 0 + } + start := &table[0] + if start.Next == 0 { + return 0 + } + id := start.Next + entry := &table[id] + start.Next = entry.Next + + entry.Reset() + entry.Next = ObjectID(start.CrossReferenceTableIndex) + table[start.CrossReferenceTableIndex].Prev = id + entry.Prev = 0 + start.CrossReferenceTableIndex = int16(id) + + entry.InUse = 1 + + return id +} + +// Release deactivates the entry with given ID. +func (table ObjectMainTable) Release(id ObjectID) { + if (id < 1) || (int(id) >= len(table)) { + return + } + start := &table[0] + entry := &table[id] + + if entry.InUse == 0 { + return + } + + if entry.Prev == 0 { + start.CrossReferenceTableIndex = int16(entry.Next) + } else { + table[entry.Prev].Next = entry.Next + } + table[entry.Next].Prev = entry.Prev + + entry.Reset() + entry.Next = start.Next + start.Next = id +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectMainTable_test.go b/modding/hacked/ss1/content/archive/level/ObjectMainTable_test.go new file mode 100644 index 0000000..d434e30 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectMainTable_test.go @@ -0,0 +1,116 @@ +package level_test + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestObjectMainEntrySerializedSize(t *testing.T) { + var entry level.ObjectMainEntry + size := binary.Size(&entry) + assert.Equal(t, level.ObjectMainEntrySize, size) +} + +func TestObjectMainTableCanBeSerializedWithCoder(t *testing.T) { + table := level.ObjectMainTable([]level.ObjectMainEntry{{}, {}}) + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + encoder.Code(table) + data := buf.Bytes() + assert.Equal(t, level.ObjectMainEntrySize*2, len(data)) +} + +func TestObjectMainTableResetClearsInUseFlags(t *testing.T) { + table := level.ObjectMainTable([]level.ObjectMainEntry{{InUse: 1}, {InUse: 1}}) + table.Reset() + + assert.Equal(t, byte(0), table[0].InUse, "table[0].InUse should be zero.") + assert.Equal(t, byte(0), table[1].InUse, "table[1].InUse should be zero.") +} + +func TestObjectMainTableInitializesLists(t *testing.T) { + table := level.ObjectMainTable([]level.ObjectMainEntry{{Next: 20, CrossReferenceTableIndex: 10}, {Next: 30}, {Next: 40}}) + table.Reset() + + assert.Equal(t, level.ObjectID(1), table[0].Next, "table[0].Next should be 1, the first free entry.") + assert.Equal(t, int16(0), table[0].CrossReferenceTableIndex, "table[0].CrossReferenceTableIndex should be 0, the used list is empty.") + assert.Equal(t, level.ObjectID(2), table[1].Next, "table[1].Next should be 2, the second free entry.") + assert.Equal(t, level.ObjectID(0), table[2].Next, "table[2].Next should be 0, the start entry.") +} + +func TestDefaultObjectMainTable(t *testing.T) { + table := level.DefaultObjectMainTable() + + assert.Equal(t, 872, len(table), "Table length mismatch") + assert.Equal(t, level.ObjectID(1), table[0].Next, "table[0].Next should be 1, the first free entry.") +} + +func TestObjectMainTableAllocate(t *testing.T) { + tt := []int{0, 1, 2, 3, 100} + + for _, tc := range tt { + table := make(level.ObjectMainTable, tc) + table.Reset() + possible := tc - 1 + + if possible < 0 { + possible = 0 + } + assert.Equal(t, possible, table.Capacity(), "table should have proper capacity") + for attempt := 0; attempt < possible; attempt++ { + assert.Equal(t, attempt, table.AllocatedCount(), "table should have expected allocation count") + id := table.Allocate() + assert.NotEqual(t, level.ObjectID(0), id, "could not allocate at attempt %d for size %d", attempt, tc) + } + assert.Equal(t, possible, table.AllocatedCount(), "table should be full") + last := table.Allocate() + assert.Equal(t, level.ObjectID(0), last, "table was not exhausted although it should be") + } +} + +func TestObjectMainTableRelease(t *testing.T) { + stats := func(table level.ObjectMainTable) (used, free int) { + for i := 1; i < len(table); i++ { + entry := &table[i] + if entry.InUse != 0 { + used++ + } else { + free++ + } + } + return + } + + table := make(level.ObjectMainTable, 10) + table.Reset() + var allocated []level.ObjectID + for i := 0; i < 4; i++ { + id := table.Allocate() + allocated = append(allocated, id) + } + used, free := stats(table) + require.Equal(t, 4, used, "invalid amount of used entries") + require.Equal(t, 5, free, "invalid amount of free entries") + + for _, id := range allocated { + table.Release(id) + } + + used, free = stats(table) + assert.Equal(t, 0, used, "invalid amount of used entries after release") + assert.Equal(t, 9, free, "invalid amount of free entries after release") + + table.Release(0) + table.Release(20) + for i := 0; i < len(table)-1; i++ { + id := table.Allocate() + assert.NotEqual(t, level.ObjectID(0), id, "should have been able to re-allocate") + } +} diff --git a/modding/hacked/ss1/content/archive/level/Parameters.go b/modding/hacked/ss1/content/archive/level/Parameters.go new file mode 100644 index 0000000..39867ad --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Parameters.go @@ -0,0 +1,29 @@ +package level + +const ( + // ParametersSize is the serialized size, in bytes, of the Parameters structure. + ParametersSize = 94 +) + +// Parameters describes level-global properties. +// It also contains information on the auto-maps, which, as per original code, should have been in the game state. +type Parameters struct { + Size int16 + _ [2]byte + + CeilingHazardLevel byte + FloorHazardLevel byte + FloorHazardIsGravity byte + BiohazardRegister byte + RadiationRegister byte + + _ [4]byte + _ [3][27]byte +} + +// DefaultParameters returns a new instance of parameters. +func DefaultParameters() Parameters { + return Parameters{ + Size: ParametersSize, + } +} diff --git a/modding/hacked/ss1/content/archive/level/Parameters_test.go b/modding/hacked/ss1/content/archive/level/Parameters_test.go new file mode 100644 index 0000000..cf3554a --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Parameters_test.go @@ -0,0 +1,28 @@ +package level_test + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultParametersSerialized(t *testing.T) { + buf := bytes.NewBuffer(nil) + param := level.DefaultParameters() + _ = binary.Write(buf, binary.LittleEndian, ¶m) + + data := buf.Bytes() + assert.Equal(t, level.ParametersSize, len(data), "length mismatch") + assert.Equal(t, []byte{ + 0x5e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, data, "data mismatch") +} diff --git a/modding/hacked/ss1/content/archive/level/RotationUnit.go b/modding/hacked/ss1/content/archive/level/RotationUnit.go new file mode 100644 index 0000000..b270af0 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/RotationUnit.go @@ -0,0 +1,9 @@ +package level + +// RotationUnit describes a rotation in the range 0..255. +type RotationUnit byte + +// ToDegrees returns the value converted to degrees [0..360). +func (unit RotationUnit) ToDegrees() float64 { + return (float64(unit) * 360.0) / 256.0 +} diff --git a/modding/hacked/ss1/content/archive/level/SchedulerInfo.go b/modding/hacked/ss1/content/archive/level/SchedulerInfo.go new file mode 100644 index 0000000..2fde233 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/SchedulerInfo.go @@ -0,0 +1,29 @@ +package level + +const ( + schedulerDefaultSize = 64 + schedulerDefaultElementSize = 8 +) + +// SchedulerInfo describes the basic parameters of a level scheduler. +// Its entries are stored in the Schedules table. +type SchedulerInfo struct { + // Size describes the maximum amount of schedule entries in the table. + Size int32 + // ScheduleCount is the current amount of active schedules. + ScheduleCount int32 + // ElementSize is the size of an schedule entry, in bytes. + ElementSize int32 + // GrowFlag indicates whether the schedule table can be resized. + GrowFlag byte + _ [4]byte + _ [4]byte +} + +// DefaultSchedulerInfo returns a new scheduler info structure with default values. +func DefaultSchedulerInfo() SchedulerInfo { + return SchedulerInfo{ + Size: schedulerDefaultSize, + ElementSize: schedulerDefaultElementSize, + } +} diff --git a/modding/hacked/ss1/content/archive/level/Surveillance.go b/modding/hacked/ss1/content/archive/level/Surveillance.go new file mode 100644 index 0000000..5f5b98a --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Surveillance.go @@ -0,0 +1,6 @@ +package level + +const ( + // SurveillanceObjectCount describes how many surveillance objects a level can have. + SurveillanceObjectCount = 8 +) diff --git a/modding/hacked/ss1/content/archive/level/TextureAnimationEntry.go b/modding/hacked/ss1/content/archive/level/TextureAnimationEntry.go new file mode 100644 index 0000000..60be867 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TextureAnimationEntry.go @@ -0,0 +1,55 @@ +package level + +import "fmt" + +const ( + // TextureAnimationCount describes how many entries of texture animations a level can have. + TextureAnimationCount = 4 + + // TextureAnimationEntrySize is the size, in bytes, of one animation entry. + TextureAnimationEntrySize = 7 +) + +// TextureAnimationEntry describes one entry of the texture animation table. +type TextureAnimationEntry struct { + FrameTime uint16 + CurrentFrameTime uint16 + CurrentFrameIndex byte + FrameCount byte + LoopType TextureAnimationLoopType +} + +// TextureAnimationLoopType describes how a texture animation loop should advance. +type TextureAnimationLoopType byte + +// String returns the textual representation. +func (loopType TextureAnimationLoopType) String() string { + switch loopType { + case TextureAnimationForward: + return "Forward" + case TextureAnimationForthAndBack: + return "Forth-And-Back" + case TextureAnimationBackAndForth: + return "Back-And-Forth" + default: + return fmt.Sprintf("Unknown%02X", int(loopType)) + } +} + +const ( + // TextureAnimationForward has the texture animation run in a linear loop. + TextureAnimationForward = TextureAnimationLoopType(0x00) + // TextureAnimationForthAndBack reverses the sequence at every end. + TextureAnimationForthAndBack = TextureAnimationLoopType(0x01) + // TextureAnimationBackAndForth reverses the sequence at every end. + TextureAnimationBackAndForth = TextureAnimationLoopType(0x81) +) + +// TextureAnimationLoopTypes returns all constants. +func TextureAnimationLoopTypes() []TextureAnimationLoopType { + return []TextureAnimationLoopType{ + TextureAnimationForward, + TextureAnimationForthAndBack, + TextureAnimationBackAndForth, + } +} diff --git a/modding/hacked/ss1/content/archive/level/TextureIndex.go b/modding/hacked/ss1/content/archive/level/TextureIndex.go new file mode 100644 index 0000000..5bb585d --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TextureIndex.go @@ -0,0 +1,17 @@ +package level + +const ( + // DefaultTextureAtlasSize is the amount of textures a level can hold. + DefaultTextureAtlasSize = 54 + // FloorCeilingTextureLimit describes the amount of textures available for floors/ceilings. + FloorCeilingTextureLimit = 32 +) + +// TextureIndex identifies one game texture. +type TextureIndex int16 + +// AtlasIndex is an index into the TextureAtlas. +type AtlasIndex byte + +// TextureAtlas is a selection of textures for the level. +type TextureAtlas []TextureIndex diff --git a/modding/hacked/ss1/content/archive/level/TileFlag.go b/modding/hacked/ss1/content/archive/level/TileFlag.go new file mode 100644 index 0000000..ab82868 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileFlag.go @@ -0,0 +1,227 @@ +package level + +import "fmt" + +// TileFlag describes simple properties of a map tile. +type TileFlag uint32 + +// RealWorldFlag describes simple properties of a map tile in the real world. +type RealWorldFlag TileFlag + +// CyberspaceFlag describes simple properties of a map tile in cyberspace. +type CyberspaceFlag TileFlag + +// ForRealWorld interprets the flag value for the real world. +func (flag TileFlag) ForRealWorld() RealWorldFlag { + return RealWorldFlag(flag) +} + +// ForCyberspace interprets the flag value for cyberspace. +func (flag TileFlag) ForCyberspace() CyberspaceFlag { + return CyberspaceFlag(flag) +} + +// MusicZone returns the music zone. +func (flag TileFlag) MusicZone() TileMusicZone { + return TileMusicZone((flag & 0x0000E000) >> 13) +} + +// WithMusicZone returns a new flag value with the given music index set. +func (flag TileFlag) WithMusicZone(value TileMusicZone) TileFlag { + return TileFlag(uint32(flag&^0x0000E000) | (uint32(value) << 13)) +} + +// Peril returns whether the tile is marked as peril (may play peril music). +func (flag TileFlag) Peril() bool { + return (flag & 0x00001000) != 0 +} + +// WithPeril returns a flag with the given peril set. +func (flag TileFlag) WithPeril(value bool) TileFlag { + var valueFlag uint32 + if value { + valueFlag = 0x00001000 + } + return TileFlag(uint32(flag&^0x00001000) | valueFlag) +} + +// SlopeControl returns the slope control as per flags. +func (flag TileFlag) SlopeControl() TileSlopeControl { + return TileSlopeControl((flag & 0x00000C00) >> 10) +} + +// WithSlopeControl returns a new flag value with the given slope control set. +func (flag TileFlag) WithSlopeControl(ctrl TileSlopeControl) TileFlag { + return TileFlag(uint32(flag&^0x00000C00) | (uint32(ctrl&0x3) << 10)) +} + +// AsTileFlag returns the flags as regular tile flag value. +func (flag RealWorldFlag) AsTileFlag() TileFlag { + return TileFlag(flag) +} + +// WallTextureOffset returns the vertical offset (in tile height units) to apply for wall textures. +func (flag RealWorldFlag) WallTextureOffset() TileHeightUnit { + return TileHeightUnit(flag & 0x0000001F) +} + +// WithWallTextureOffset returns a flag value with the given wall texture offset. +func (flag RealWorldFlag) WithWallTextureOffset(value TileHeightUnit) RealWorldFlag { + return RealWorldFlag(uint32(flag&^0x0000001F) | uint32(value&0x1F)) +} + +// WallTexturePattern returns the pattern to apply for walls. +func (flag RealWorldFlag) WallTexturePattern() WallTexturePattern { + return WallTexturePattern(byte(flag&0x00000060) >> 5) +} + +// WithWallTexturePattern returns a flag with the given pattern set. +func (flag RealWorldFlag) WithWallTexturePattern(value WallTexturePattern) RealWorldFlag { + return RealWorldFlag(uint32(flag&^0x00000060) | (uint32(value&0x3) << 5)) +} + +// UseAdjacentWallTexture returns whether the wall texture from the adjacent tile should be used for each side. +func (flag RealWorldFlag) UseAdjacentWallTexture() bool { + return (flag & 0x00000100) != 0 +} + +// WithUseAdjacentWallTexture returns a flag with the given usage set. +func (flag RealWorldFlag) WithUseAdjacentWallTexture(value bool) RealWorldFlag { + var valueFlag uint32 + if value { + valueFlag = 0x00000100 + } + return RealWorldFlag(uint32(flag&^0x00000100) | valueFlag) +} + +// Deconstructed returns whether the tile is marked as heavily deconstructed (should play spooky music). +func (flag RealWorldFlag) Deconstructed() bool { + return (flag & 0x00000200) != 0 +} + +// WithDeconstructed returns a flag with the given deconstruction set. +func (flag RealWorldFlag) WithDeconstructed(value bool) RealWorldFlag { + var valueFlag uint32 + if value { + valueFlag = 0x00000200 + } + return RealWorldFlag(uint32(flag&^0x00000200) | valueFlag) +} + +// FloorShadow returns the floor shadow value. Range: [0..GradesOfShadow]. +func (flag RealWorldFlag) FloorShadow() int { + return int((flag & 0x000F0000) >> 16) +} + +// WithFloorShadow returns a new flag value with the given floor shadow set. Values beyond allowed range are ignored. +func (flag RealWorldFlag) WithFloorShadow(value int) RealWorldFlag { + if (value < 0) || (value >= GradesOfShadow) { + return flag + } + return RealWorldFlag(uint32(flag&^0x000F0000) | (uint32(value) << 16)) +} + +// CeilingShadow returns the ceiling shadow value. Range: [0..GradesOfShadow]. +func (flag RealWorldFlag) CeilingShadow() int { + return int((flag & 0x0F000000) >> 24) +} + +// WithCeilingShadow returns a new flag value with the given ceiling shadow set. Values beyond allowed range are ignored. +func (flag RealWorldFlag) WithCeilingShadow(value int) RealWorldFlag { + if (value < 0) || (value >= GradesOfShadow) { + return flag + } + return RealWorldFlag(uint32(flag&^0x0F000000) | (uint32(value) << 24)) +} + +// TileVisited returns whether the tile is marked as being visited (seen). +func (flag RealWorldFlag) TileVisited() bool { + return (flag & 0x80000000) != 0 +} + +// WithTileVisited returns a flag with the given visited set. +func (flag RealWorldFlag) WithTileVisited(value bool) RealWorldFlag { + var valueFlag uint32 + if value { + valueFlag = 0x80000000 + } + return RealWorldFlag(uint32(flag&^0x80000000) | valueFlag) +} + +// AsTileFlag returns the flags as regular tile flag value. +func (flag CyberspaceFlag) AsTileFlag() TileFlag { + return TileFlag(flag) +} + +// GameOfLifeState returns the current game-of-life state. +func (flag CyberspaceFlag) GameOfLifeState() int { + return int(byte(flag&0x00000060) >> 5) +} + +// WithGameOfLifeState returns a flag with the given game-of-life state set. +func (flag CyberspaceFlag) WithGameOfLifeState(value int) CyberspaceFlag { + return CyberspaceFlag(uint32(flag&^0x00000060) | (uint32(value&0x3) << 5)) +} + +// FlightPull returns the pull applying in a tile. +func (flag CyberspaceFlag) FlightPull() CyberspaceFlightPull { + return CyberspaceFlightPull((uint32(flag&0x01000000) >> 20) | (uint32(flag&0x000F0000) >> 16)) +} + +// WithFlightPull returns a flag instance with the given pull applied. +func (flag CyberspaceFlag) WithFlightPull(value CyberspaceFlightPull) CyberspaceFlag { + newFlag := uint32(flag &^ 0x010F0000) + newFlag |= uint32(value&0x0F) << 16 + newFlag |= uint32(value&0x10) << 20 + return CyberspaceFlag(newFlag) +} + +type TileMusicZone byte + +const ( + TileMusicZoneNoMusic TileMusicZone = 0 + TileMusicZoneHospital TileMusicZone = 1 + TileMusicZoneExecutive TileMusicZone = 2 + TileMusicZoneIndustrial TileMusicZone = 3 + TileMusicZoneMetal TileMusicZone = 4 + TileMusicZonePark TileMusicZone = 5 + TileMusicZoneBridge TileMusicZone = 6 + TileMusicZoneElevator TileMusicZone = 7 +) + +// TileSlopeControls returns all control values. +func TileMusicZones() []TileMusicZone { + return []TileMusicZone{ + TileMusicZoneNoMusic, + TileMusicZoneHospital, + TileMusicZoneExecutive, + TileMusicZoneIndustrial, + TileMusicZoneMetal, + TileMusicZonePark, + TileMusicZoneBridge, + TileMusicZoneElevator, + } +} + +func (ctrl TileMusicZone) String() string { + switch ctrl { + case TileMusicZoneNoMusic: + return "No Music" + case TileMusicZoneHospital: + return "Hospital" + case TileMusicZoneExecutive: + return "Executive" + case TileMusicZoneIndustrial: + return "Industrial" + case TileMusicZoneMetal: + return "Metal" + case TileMusicZonePark: + return "Park" + case TileMusicZoneBridge: + return "Bridge" + case TileMusicZoneElevator: + return "Elevator" + default: + return fmt.Sprintf("Unknown%02X", int(ctrl)) + } +} diff --git a/modding/hacked/ss1/content/archive/level/TileHeightUnit.go b/modding/hacked/ss1/content/archive/level/TileHeightUnit.go new file mode 100644 index 0000000..5fb99ff --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileHeightUnit.go @@ -0,0 +1,10 @@ +package level + +// TileHeightUnit constants are listed below. +const ( + TileHeightUnitMin TileHeightUnit = 0 + TileHeightUnitMax TileHeightUnit = 32 +) + +// TileHeightUnit describes a height in a range of 0..32. +type TileHeightUnit byte diff --git a/modding/hacked/ss1/content/archive/level/TileMap.go b/modding/hacked/ss1/content/archive/level/TileMap.go new file mode 100644 index 0000000..0ac78a4 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileMap.go @@ -0,0 +1,93 @@ +package level + +import "github.com/inkyblackness/hacked/ss1/serial" + +// TileMapEntry describes one tile of the map. +type TileMapEntry struct { + // Type indicates what kind of tile this is. + Type TileType + // Floor describes floor properties. + Floor FloorInfo + // Ceiling describes ceiling properties. + Ceiling CeilingInfo + // SlopeHeight indicates for non-flat tiles the height offset. + SlopeHeight TileHeightUnit + + // FirstObjectIndex points into the level object cross reference table to the first object in this tile. + FirstObjectIndex int16 + // TextureInfo describes tile texturing. + TextureInfo TileTextureInfo + // Flags contains arbitrary additional information. + Flags TileFlag + + // SubClip is always 0xFF. + SubClip byte + _ [2]byte + // LightDelta describes the light offsets of this tile. + LightDelta LightDeltaInfo +} + +// Reset puts the tile into an initial state. +func (tile *TileMapEntry) Reset() { + *tile = TileMapEntry{} + tile.SubClip = 0xFF +} + +// FloorTileHeightAt returns the height of the floor (in tile unit) at the given fine position. +func (tile TileMapEntry) FloorTileHeightAt(pos FinePosition, height HeightShift) float32 { + floorHeight, _ := height.ValueFromTileHeight(tile.Floor.AbsoluteHeight()) + if tile.Flags.SlopeControl() == TileSlopeControlFloorFlat { + return floorHeight + } + slopeHeight, _ := height.ValueFromTileHeight(tile.SlopeHeight) + fineHeight := slopeHeight * pos.SlopeFactorFor(tile.Type) + return floorHeight + fineHeight +} + +// CeilingTileHeightAt returns the height of the ceiling (in tile unit) at the given fine position. +func (tile TileMapEntry) CeilingTileHeightAt(pos FinePosition, height HeightShift) float32 { + ceilingHeight, _ := height.ValueFromTileHeight(tile.Ceiling.AbsoluteHeight()) + slopeControl := tile.Flags.SlopeControl() + if slopeControl == TileSlopeControlCeilingFlat { + return ceilingHeight + } + slopeHeight, _ := height.ValueFromTileHeight(tile.SlopeHeight) + var slopeFactor float32 + if slopeControl == TileSlopeControlCeilingMirrored { + slopeFactor = pos.SlopeFactorFor(tile.Type) + } else { + slopeFactor = pos.SlopeFactorFor(tile.Type.Info().SlopeInvertedType) + } + fineHeight := slopeHeight * slopeFactor + return ceilingHeight - fineHeight +} + +// TileMap is a rectangular set of tiles. +// The first index is the Y-axis, the second index the X-axis. This way the map can be serialized quicker. +type TileMap struct { + entries []TileMapEntry +} + +// NewTileMap returns a new, initialized map. +func NewTileMap(width, height int) TileMap { + entries := make([]TileMapEntry, width*height) + for i := 0; i < len(entries); i++ { + entries[i].Reset() + } + return TileMap{entries: entries} +} + +// Tile returns a pointer to the tile within the map for given position. +// Nil is returned for a coordinate outside the boundaries. +func (m TileMap) Tile(pos TilePosition, xShift int) *TileMapEntry { + index := int(pos.X) + (int(pos.Y) << xShift) + if (int(pos.X) >= (1 << xShift)) || (index >= len(m.entries)) { + return nil + } + return &m.entries[index] +} + +// Code serializes the map. +func (m TileMap) Code(coder serial.Coder) { + coder.Code(m.entries) +} diff --git a/modding/hacked/ss1/content/archive/level/TileMap_test.go b/modding/hacked/ss1/content/archive/level/TileMap_test.go new file mode 100644 index 0000000..f84d944 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileMap_test.go @@ -0,0 +1,16 @@ +package level_test + +import ( + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestTileMapEntrySerializedSize(t *testing.T) { + var entry level.TileMapEntry + size := binary.Size(&entry) + assert.Equal(t, 16, size, "Size mismatch") +} diff --git a/modding/hacked/ss1/content/archive/level/TilePosition.go b/modding/hacked/ss1/content/archive/level/TilePosition.go new file mode 100644 index 0000000..980ddcf --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TilePosition.go @@ -0,0 +1,62 @@ +package level + +// TilePosition contains the coordinates of a tile in a level. +type TilePosition struct { + X byte + Y byte +} + +// FinePosition contains the coordinates of an object within a tile. +type FinePosition struct { + X byte + Y byte +} + +// SlopeFactorFor returns the factor of how much the slope of given tile type would apply at the fine position. +func (pos FinePosition) SlopeFactorFor(tileType TileType) float32 { + southToNorth := float32(pos.Y) / 255 + westToEast := float32(pos.X) / 255 + swneDiag := func(northWest, southEast float32) float32 { + if pos.X < pos.Y { + return northWest + } + return southEast + } + nwseDiag := func(southWest, northEast float32) float32 { + if (255 - pos.X) < pos.Y { + return northEast + } + return southWest + } + + switch tileType { + case TileTypeSlopeSouthToNorth: + return southToNorth + case TileTypeSlopeWestToEast: + return westToEast + case TileTypeSlopeNorthToSouth: + return 1 - southToNorth + case TileTypeSlopeEastToWest: + return 1 - westToEast + + case TileTypeValleySouthEastToNorthWest: + return nwseDiag(1-westToEast, southToNorth) + case TileTypeValleySouthWestToNorthEast: + return swneDiag(southToNorth, westToEast) + case TileTypeValleyNorthWestToSouthEast: + return nwseDiag(1-southToNorth, westToEast) + case TileTypeValleyNorthEastToSouthWest: + return swneDiag(1-westToEast, 1-southToNorth) + + case TileTypeRidgeNorthWestToSouthEast: + return nwseDiag(westToEast, 1-southToNorth) + case TileTypeRidgeNorthEastToSouthWest: + return swneDiag(1-southToNorth, 1-westToEast) + case TileTypeRidgeSouthEastToNorthWest: + return nwseDiag(southToNorth, 1-westToEast) + case TileTypeRidgeSouthWestToNorthEast: + return swneDiag(westToEast, southToNorth) + } + + return 0.0 +} diff --git a/modding/hacked/ss1/content/archive/level/TilePosition_test.go b/modding/hacked/ss1/content/archive/level/TilePosition_test.go new file mode 100644 index 0000000..2898b9e --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TilePosition_test.go @@ -0,0 +1,28 @@ +package level_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +func TestFinePositionSlopeFactorFor(t *testing.T) { + tt := []struct { + pos level.FinePosition + tileType level.TileType + expected float32 + }{ + {level.FinePosition{X: 127, Y: 127}, level.TileTypeOpen, 0.0}, + {level.FinePosition{X: 127, Y: 127}, level.TileTypeSlopeNorthToSouth, 0.5}, + {level.FinePosition{X: 127, Y: 127}, level.TileTypeRidgeNorthWestToSouthEast, 0.5}, + {level.FinePosition{X: 0, Y: 127}, level.TileTypeRidgeNorthWestToSouthEast, 0.0}, + {level.FinePosition{X: 127, Y: 0}, level.TileTypeRidgeNorthWestToSouthEast, 0.5}, + } + + for _, tc := range tt { + result := tc.pos.SlopeFactorFor(tc.tileType) + assert.InDeltaf(t, tc.expected, result, 0.01, "Type: %v, Pos: %v", tc.tileType, tc.pos) + } +} diff --git a/modding/hacked/ss1/content/archive/level/TileSlopeControl.go b/modding/hacked/ss1/content/archive/level/TileSlopeControl.go new file mode 100644 index 0000000..66cc551 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileSlopeControl.go @@ -0,0 +1,59 @@ +package level + +import "fmt" + +// TileSlopeControl defines how the floor and ceiling of a sloped tile should be processed. +type TileSlopeControl byte + +// String returns the textual representation of the value. +func (ctrl TileSlopeControl) String() string { + switch ctrl { + case TileSlopeControlCeilingInverted: + return "CeilingInverted" + case TileSlopeControlCeilingMirrored: + return "CeilingMirrored" + case TileSlopeControlCeilingFlat: + return "CeilingFlat" + case TileSlopeControlFloorFlat: + return "FloorFlat" + default: + return fmt.Sprintf("Unknown%02X", int(ctrl)) + } +} + +// FloorSlopeFactors returns the slope factors for the given tile type as per control constant. +func (ctrl TileSlopeControl) FloorSlopeFactors(tileType TileType) SlopeFactors { + if ctrl == TileSlopeControlFloorFlat { + return SlopeFactors{} + } + return tileType.Info().SlopeFloorFactors +} + +// CeilingSlopeFactors returns the slope factors for the given tile type as per control constant. +func (ctrl TileSlopeControl) CeilingSlopeFactors(tileType TileType) SlopeFactors { + if ctrl == TileSlopeControlCeilingFlat { + return SlopeFactors{} + } + if ctrl == TileSlopeControlCeilingMirrored { + return tileType.Info().SlopeFloorFactors + } + return tileType.Info().SlopeInvertedType.Info().SlopeFloorFactors +} + +// TileSlopeControl constants. +const ( + TileSlopeControlCeilingInverted TileSlopeControl = 0 + TileSlopeControlCeilingMirrored TileSlopeControl = 1 + TileSlopeControlCeilingFlat TileSlopeControl = 2 + TileSlopeControlFloorFlat TileSlopeControl = 3 +) + +// TileSlopeControls returns all control values. +func TileSlopeControls() []TileSlopeControl { + return []TileSlopeControl{ + TileSlopeControlCeilingInverted, + TileSlopeControlCeilingMirrored, + TileSlopeControlCeilingFlat, + TileSlopeControlFloorFlat, + } +} diff --git a/modding/hacked/ss1/content/archive/level/TileTextureInfo.go b/modding/hacked/ss1/content/archive/level/TileTextureInfo.go new file mode 100644 index 0000000..b6bcd13 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileTextureInfo.go @@ -0,0 +1,72 @@ +package level + +// TileTextureInfo describes the textures used for a map tile. +type TileTextureInfo uint16 + +// WallTextureIndex returns the texture index into the texture atlas for the walls. +// Valid range [0..63]. +// This property is only valid in real world. +func (info TileTextureInfo) WallTextureIndex() AtlasIndex { + return AtlasIndex(info & 0x003F) +} + +// WithWallTextureIndex returns an info with given index set. +// Values outside valid range are ignored. +func (info TileTextureInfo) WithWallTextureIndex(value AtlasIndex) TileTextureInfo { + if value >= 64 { + return info + } + return TileTextureInfo(uint16(info&^0x003F) | uint16(value&0x003F)) +} + +// CeilingTextureIndex returns the texture index into the texture atlas for the ceiling. +// Valid range [0..31]. +// This property is only valid in real world. +func (info TileTextureInfo) CeilingTextureIndex() AtlasIndex { + return AtlasIndex((info & 0x07C0) >> 6) +} + +// WithCeilingTextureIndex returns an info with given index set. +// Values outside valid range are ignored. +func (info TileTextureInfo) WithCeilingTextureIndex(value AtlasIndex) TileTextureInfo { + if value >= FloorCeilingTextureLimit { + return info + } + return TileTextureInfo(uint16(info&^0x07C0) | (uint16(value&0x001F) << 6)) +} + +// FloorTextureIndex returns the texture index into the texture atlas for the floor. +// Valid range [0..31]. +// This property is only valid in real world. +func (info TileTextureInfo) FloorTextureIndex() AtlasIndex { + return AtlasIndex((info & 0xF800) >> 11) +} + +// WithFloorTextureIndex returns an info with given index set. +// Values outside valid range are ignored. +func (info TileTextureInfo) WithFloorTextureIndex(value AtlasIndex) TileTextureInfo { + if value >= FloorCeilingTextureLimit { + return info + } + return TileTextureInfo(uint16(info&^0xF800) | (uint16(value&0x001F) << 11)) +} + +// FloorPaletteIndex returns the palette index for the floor in cyberspace. +func (info TileTextureInfo) FloorPaletteIndex() byte { + return byte(info) +} + +// WithFloorPaletteIndex returns an info with given index set. +func (info TileTextureInfo) WithFloorPaletteIndex(value byte) TileTextureInfo { + return TileTextureInfo(uint16(info&0xFF00) | uint16(value)) +} + +// CeilingPaletteIndex returns the palette index for the ceiling in cyberspace. +func (info TileTextureInfo) CeilingPaletteIndex() byte { + return byte(info >> 8) +} + +// WithCeilingPaletteIndex returns an info with given index set. +func (info TileTextureInfo) WithCeilingPaletteIndex(value byte) TileTextureInfo { + return TileTextureInfo(uint16(info&0x00FF) | (uint16(value) << 8)) +} diff --git a/modding/hacked/ss1/content/archive/level/TileType.go b/modding/hacked/ss1/content/archive/level/TileType.go new file mode 100644 index 0000000..d54b1e4 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileType.go @@ -0,0 +1,66 @@ +package level + +import "fmt" + +// TileType describes the general type of a map tile. +type TileType byte + +// Info returns the information associated with the tile type. +func (t TileType) Info() TileTypeInfo { + if int(t) < len(tileTypeInfoList) { + return tileTypeInfoList[t] + } + info := tileTypeInfoList[TileTypeSolid] + info.Name = t.String() + info.SlopeInvertedType = t + return info +} + +// String returns the textual representation of the type. +func (t TileType) String() string { + if int(t) < len(tileTypeInfoList) { + return tileTypeInfoList[t].Name + } + return fmt.Sprintf("Unknown%02X", int(t)) +} + +// Tiles come in different forms: +// Solid tiles can not be entered, Open tiles are regular tiles with a flat floor and a flat ceiling. +// DiagonalOpen tiles are those with flat floors and ceilings, and two walls cut off by one diagonal wall. +// Slope tiles have a sloped floor (or ceiling). Valley tiles have one floor vertex lower while Ridge tiles have one +// floor vertex higher than the other three. +const ( + TileTypeSolid TileType = 0x00 + TileTypeOpen TileType = 0x01 + + TileTypeDiagonalOpenSouthEast TileType = 0x02 + TileTypeDiagonalOpenSouthWest TileType = 0x03 + TileTypeDiagonalOpenNorthWest TileType = 0x04 + TileTypeDiagonalOpenNorthEast TileType = 0x05 + + TileTypeSlopeSouthToNorth TileType = 0x06 + TileTypeSlopeWestToEast TileType = 0x07 + TileTypeSlopeNorthToSouth TileType = 0x08 + TileTypeSlopeEastToWest TileType = 0x09 + + TileTypeValleySouthEastToNorthWest TileType = 0x0A + TileTypeValleySouthWestToNorthEast TileType = 0x0B + TileTypeValleyNorthWestToSouthEast TileType = 0x0C + TileTypeValleyNorthEastToSouthWest TileType = 0x0D + + TileTypeRidgeNorthWestToSouthEast TileType = 0x0E + TileTypeRidgeNorthEastToSouthWest TileType = 0x0F + TileTypeRidgeSouthEastToNorthWest TileType = 0x10 + TileTypeRidgeSouthWestToNorthEast TileType = 0x11 +) + +// TileTypes returns a list of all known tile types. +func TileTypes() []TileType { + return []TileType{ + TileTypeSolid, TileTypeOpen, + TileTypeDiagonalOpenSouthEast, TileTypeDiagonalOpenSouthWest, TileTypeDiagonalOpenNorthWest, TileTypeDiagonalOpenNorthEast, + TileTypeSlopeSouthToNorth, TileTypeSlopeWestToEast, TileTypeSlopeNorthToSouth, TileTypeSlopeEastToWest, + TileTypeValleySouthEastToNorthWest, TileTypeValleySouthWestToNorthEast, TileTypeValleyNorthWestToSouthEast, TileTypeValleyNorthEastToSouthWest, + TileTypeRidgeNorthWestToSouthEast, TileTypeRidgeNorthEastToSouthWest, TileTypeRidgeSouthEastToNorthWest, TileTypeRidgeSouthWestToNorthEast, + } +} diff --git a/modding/hacked/ss1/content/archive/level/TileTypeInfo.go b/modding/hacked/ss1/content/archive/level/TileTypeInfo.go new file mode 100644 index 0000000..6a2e6cd --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileTypeInfo.go @@ -0,0 +1,53 @@ +package level + +// SlopeFactors is a list of multipliers for each direction of a tile. +type SlopeFactors [8]float32 + +// Negated returns the factors multiplied by -1. +func (factors SlopeFactors) Negated() SlopeFactors { + return SlopeFactors{ + factors[0] * -1, factors[1] * -1, factors[2] * -1, factors[3] * -1, + factors[4] * -1, factors[5] * -1, factors[6] * -1, factors[7] * -1, + } +} + +// TileTypeInfo is the meta information about a tile type. +type TileTypeInfo struct { + // Name is the textual representation of the tile type. + Name string + + // SolidSides is a bitfield of cardinal directions describing solid walls. + SolidSides DirectionMask + + // SlopeFloorFactors defines how a slope affects the floor in each direction of a tile [0.0 .. 1.0]. + SlopeFloorFactors SlopeFactors + + // SlopeInvertedType is the type that inverts the slope to form a solid tile if merged (e.g. floor & ceiling). + // Types that have no slope invert themselves. + SlopeInvertedType TileType +} + +var tileTypeInfoList = []TileTypeInfo{ + {"Solid", DirNorth.Plus(DirEast).Plus(DirSouth).Plus(DirWest), SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeSolid}, + {"Open", 0, SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeOpen}, + + {"DiagonalOpenSouthEast", DirNorth.Plus(DirWest), SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeDiagonalOpenSouthEast}, + {"DiagonalOpenSouthWest", DirNorth.Plus(DirEast), SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeDiagonalOpenSouthWest}, + {"DiagonalOpenNorthWest", DirSouth.Plus(DirEast), SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeDiagonalOpenNorthWest}, + {"DiagonalOpenNorthEast", DirSouth.Plus(DirWest), SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeDiagonalOpenNorthEast}, + + {"SlopeSouthToNorth", 0, SlopeFactors{1.0, 1.0, 0.5, 0.0, 0.0, 0.0, 0.5, 1.0}, TileTypeSlopeNorthToSouth}, + {"SlopeWestToEast", 0, SlopeFactors{0.5, 1.0, 1.0, 1.0, 0.5, 0.0, 0.0, 0.0}, TileTypeSlopeEastToWest}, + {"SlopeNorthToSouth", 0, SlopeFactors{0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 0.5, 0.0}, TileTypeSlopeSouthToNorth}, + {"SlopeEastToWest", 0, SlopeFactors{0.5, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0}, TileTypeSlopeWestToEast}, + + {"ValleySouthEastToNorthWest", 0, SlopeFactors{1.0, 1.0, 0.5, 0.0, 0.5, 1.0, 1.0, 1.0}, TileTypeRidgeNorthWestToSouthEast}, + {"ValleySouthWestToNorthEast", 0, SlopeFactors{1.0, 1.0, 1.0, 1.0, 0.5, 0.0, 0.5, 1.0}, TileTypeRidgeNorthEastToSouthWest}, + {"ValleyNorthWestToSouthEast", 0, SlopeFactors{0.5, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5, 0.0}, TileTypeRidgeSouthEastToNorthWest}, + {"ValleyNorthEastToSouthWest", 0, SlopeFactors{0.5, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0, 1.0}, TileTypeRidgeSouthWestToNorthEast}, + + {"RidgeNorthWestToSouthEast", 0, SlopeFactors{0.0, 0.0, 0.5, 1.0, 0.5, 0.0, 0.0, 0.0}, TileTypeValleySouthEastToNorthWest}, + {"RidgeNorthEastToSouthWest", 0, SlopeFactors{0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 0.5, 0.0}, TileTypeValleySouthWestToNorthEast}, + {"RidgeSouthEastToNorthWest", 0, SlopeFactors{0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 1.0}, TileTypeValleyNorthWestToSouthEast}, + {"RidgeSouthWestToNorthEast", 0, SlopeFactors{0.5, 1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0}, TileTypeValleyNorthEastToSouthWest}, +} diff --git a/modding/hacked/ss1/content/archive/level/TileType_test.go b/modding/hacked/ss1/content/archive/level/TileType_test.go new file mode 100644 index 0000000..7405e15 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileType_test.go @@ -0,0 +1,49 @@ +package level_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestTileTypeInfoSlopeFactorsSealTileWithInversion(t *testing.T) { + tt := []level.TileType{ + level.TileTypeSlopeSouthToNorth, + level.TileTypeSlopeWestToEast, + level.TileTypeSlopeNorthToSouth, + level.TileTypeSlopeEastToWest, + + level.TileTypeValleySouthEastToNorthWest, + level.TileTypeValleySouthWestToNorthEast, + level.TileTypeValleyNorthWestToSouthEast, + level.TileTypeValleyNorthEastToSouthWest, + + level.TileTypeRidgeNorthWestToSouthEast, + level.TileTypeRidgeNorthEastToSouthWest, + level.TileTypeRidgeSouthEastToNorthWest, + level.TileTypeRidgeSouthWestToNorthEast, + } + for _, tc := range tt { + info := tc.Info() + mirroredInfo := info.SlopeInvertedType.Info() + var factors level.SlopeFactors + for i := 0; i < 8; i++ { + factors[i] = info.SlopeFloorFactors[i] + mirroredInfo.SlopeFloorFactors[i] + } + assert.Equal(t, level.SlopeFactors{1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0}, factors, fmt.Sprintf("Factors don't seal tile for type 0x%02X", tc)) + } +} + +func TestTileTypeInfoDefaultsToSolidEquivalent(t *testing.T) { + tileType := level.TileType(0x77) + expected := level.TileTypeInfo{ + Name: "Unknown77", + SolidSides: level.DirNorth.Plus(level.DirEast).Plus(level.DirSouth).Plus(level.DirWest), + SlopeFloorFactors: level.SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, + SlopeInvertedType: tileType, + } + assert.Equal(t, expected, tileType.Info()) +} diff --git a/modding/hacked/ss1/content/archive/level/Unknowns.go b/modding/hacked/ss1/content/archive/level/Unknowns.go new file mode 100644 index 0000000..ce76888 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Unknowns.go @@ -0,0 +1,16 @@ +package level + +// UnknownL41 is the unknown data of L41. +type UnknownL41 [1]byte + +// UnknownL48 is the unknown data of L48. +type UnknownL48 [0x30]byte + +// UnknownL49 is the unknown data of L49. +type UnknownL49 [0x1C0]byte + +// UnknownL50 is the unknown data of L50. +type UnknownL50 [2]byte + +// UnknownL52 is the unknown data of L52. +type UnknownL52 [2]byte diff --git a/modding/hacked/ss1/content/archive/level/WallHeights.go b/modding/hacked/ss1/content/archive/level/WallHeights.go new file mode 100644 index 0000000..1b86c6a --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/WallHeights.go @@ -0,0 +1,119 @@ +package level + +// WallHeights describes the height delta, in HeightUnits, one would take to +// cross to the other tile. A value of 0 means equal floor, a negative value a fall down. +// A value of 0x20 (= maximum of height units) means a solid wall. +// +// For each cardinal direction, facing to the specified direction, +// the first entry is the left side, the second the center, and the third the right side. +type WallHeights struct { + North [3]float32 + East [3]float32 + South [3]float32 + West [3]float32 +} + +// Reset sets all the heights to zero. +func (heights *WallHeights) Reset() { + *heights = WallHeights{} +} + +// WallHeightsMap is rectangular table of wall heights, typically mirroring a tile map. +type WallHeightsMap [][]WallHeights + +// NewWallHeightsMap returns a new, initialized map. +func NewWallHeightsMap(width, height int) WallHeightsMap { + m := make([][]WallHeights, height) + for y := 0; y < height; y++ { + m[y] = make([]WallHeights, width) + for x := 0; x < width; x++ { + m[y][x].Reset() + } + } + return m +} + +// Tile returns a pointer to the tile within the map for given position. +func (m WallHeightsMap) Tile(pos TilePosition) *WallHeights { + return &m[pos.Y][pos.X] +} + +// CalculateFrom updates all the wall heights according to the specified map. +func (m *WallHeightsMap) CalculateFrom(tileMap interface { + Tile(pos TilePosition) *TileMapEntry +}) { + posAt := func(x, y int) TilePosition { + return TilePosition{X: byte(x), Y: byte(y)} + } + for y, row := range *m { + for x := 0; x < len(row); x++ { + tile := tileMap.Tile(posAt(x, y)) + if tile == nil { + continue + } + heights := &row[x] + heights.North = calculateWallHeight(tile, DirNorth, tileMap.Tile(posAt(x, y+1)), DirSouth) + heights.East = calculateWallHeight(tile, DirEast, tileMap.Tile(posAt(x+1, y)), DirWest) + heights.South = calculateWallHeight(tile, DirSouth, tileMap.Tile(posAt(x, y-1)), DirNorth) + heights.West = calculateWallHeight(tile, DirWest, tileMap.Tile(posAt(x-1, y)), DirEast) + } + } +} + +func calculateWallHeight(entry *TileMapEntry, entrySide Direction, other *TileMapEntry, otherSide Direction) [3]float32 { + var result [3]float32 + baseHeights := func(tile *TileMapEntry, side Direction) (floor TileHeightUnit, slope TileHeightUnit, ceiling TileHeightUnit) { + floor = TileHeightUnitMax + slope = TileHeightUnitMin + ceiling = TileHeightUnitMax + if (tile != nil) && ((tile.Type.Info().SolidSides & side.AsMask()) == 0) { + floor = tile.Floor.AbsoluteHeight() + slope = tile.SlopeHeight + ceiling = tile.Ceiling.AbsoluteHeight() + } + return + } + + otherFloorHeight, otherSlopeHeight, otherCeilingHeight := baseHeights(other, otherSide) + var otherFloorFactors SlopeFactors + var otherCeilingFactors SlopeFactors + if other != nil { + otherSlopeControl := other.Flags.SlopeControl() + otherFloorFactors = otherSlopeControl.FloorSlopeFactors(other.Type) + otherCeilingFactors = otherSlopeControl.CeilingSlopeFactors(other.Type) + } + + entryFloorHeight, entrySlopeHeight, entryCeilingHeight := baseHeights(entry, entrySide) + entrySlopeControl := entry.Flags.SlopeControl() + entryFloorFactors := entrySlopeControl.FloorSlopeFactors(entry.Type) + entryCeilingFactors := entrySlopeControl.CeilingSlopeFactors(entry.Type) + + sideHeights := func(side Direction, sideOffset int, factors SlopeFactors, slopeHeight TileHeightUnit, absoluteHeight TileHeightUnit) [3]float32 { + return [3]float32{ + factors[side.Offset(-sideOffset)]*float32(slopeHeight) + float32(absoluteHeight), + factors[side.Offset(0)]*float32(slopeHeight) + float32(absoluteHeight), + factors[side.Offset(+sideOffset)]*float32(slopeHeight) + float32(absoluteHeight), + } + } + entryFloorSideHeights := sideHeights(entrySide, 1, entryFloorFactors, entrySlopeHeight, entryFloorHeight) + entryCeilingSideHeights := sideHeights(entrySide, 1, entryCeilingFactors.Negated(), entrySlopeHeight, entryCeilingHeight) + otherFloorSideHeights := sideHeights(otherSide, -1, otherFloorFactors, otherSlopeHeight, otherFloorHeight) + otherCeilingSideHeights := sideHeights(otherSide, -1, otherCeilingFactors.Negated(), otherSlopeHeight, otherCeilingHeight) + + for i := 0; i < 3; i++ { + switch { + case (otherFloorSideHeights[i] == entryFloorSideHeights[i]) && (entryFloorSideHeights[i] == float32(TileHeightUnitMax)): + // shortcut for solid-to-solid - no "wall" to see here + result[i] = 0 + case (otherFloorSideHeights[i] >= otherCeilingSideHeights[i]) || // other ceiling side seals off at its floor + (otherCeilingSideHeights[i] <= entryFloorSideHeights[i]) || // other ceiling side seals off at this floor + (otherFloorSideHeights[i] >= entryCeilingSideHeights[i]) || // other floor side seals off at this ceiling + (entryFloorSideHeights[i] >= entryCeilingSideHeights[i]): // own ceiling side seals off at floor + result[i] = float32(TileHeightUnitMax) + default: + result[i] = otherFloorSideHeights[i] - entryFloorSideHeights[i] + } + } + + return result +} diff --git a/modding/hacked/ss1/content/archive/level/WallHeights_test.go b/modding/hacked/ss1/content/archive/level/WallHeights_test.go new file mode 100644 index 0000000..427dfc2 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/WallHeights_test.go @@ -0,0 +1,208 @@ +package level_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +type WallHeightsSuite struct { + suite.Suite + + tileMap level.TileMap + heightsMap level.WallHeightsMap + + tile *level.TileMapEntry +} + +func TestWallHeightsSuite(t *testing.T) { + suite.Run(t, new(WallHeightsSuite)) +} + +func (suite *WallHeightsSuite) SetupTest() { + suite.tileMap = level.NewTileMap(4, 4) + suite.heightsMap = level.NewWallHeightsMap(3, 3) + suite.tile = nil +} + +func (suite *WallHeightsSuite) TestCalculateFromResetsToZeroForAllSolids() { + suite.givenARandomHeightsMap() + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightsShouldBeForTile(1, 1, 0.0) +} + +func (suite *WallHeightsSuite) TestCalculateFromSetsToMaximumHeightUnitFromOpenToSolid() { + suite.givenTile(1, 1).typed(level.TileTypeOpen) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightsShouldBeForTile(1, 1, 32.0) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(1, 0, level.DirNorth, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(0, 1, level.DirEast, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(2, 1, level.DirWest, 32, 32, 32) +} + +func (suite *WallHeightsSuite) TestCalculateFromSetsToMaximumHeightUnitFromOpenToSolidSide() { + suite.givenTile(1, 1).typed(level.TileTypeOpen) + suite.givenTile(2, 1).typed(level.TileTypeDiagonalOpenNorthEast) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightsShouldBeForTile(1, 1, 32) + suite.thenHeightShouldBeForTileAt(2, 1, level.DirWest, 32, 32, 32) +} + +func (suite *WallHeightsSuite) TestCalculateFromSetsHeightDifferencesForFloors() { + suite.givenTile(1, 1).typed(level.TileTypeOpen).floorAt(0) + suite.givenTile(1, 2).typed(level.TileTypeOpen).floorAt(10) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 10, 10, 10) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, -10, -10, -10) +} + +func (suite *WallHeightsSuite) TestCalculateFromSetsHeightDifferencesForFloorsRespectingSlope() { + suite.givenTile(1, 1).typed(level.TileTypeSlopeEastToWest).floorAt(0).sloped(level.TileSlopeControlCeilingFlat, 2) + suite.givenTile(1, 2).typed(level.TileTypeOpen).floorAt(10) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 8, 9, 10) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, -10, -9, -8) +} + +func (suite *WallHeightsSuite) TestCalculateFromSetsHeightDifferencesForFloorsRespectingSlopeSameFloorHeight() { + suite.givenTile(1, 1).typed(level.TileTypeOpen).floorAt(0) + suite.givenTile(1, 2).typed(level.TileTypeSlopeEastToWest).floorAt(0).sloped(level.TileSlopeControlCeilingFlat, 4) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 4, 2, 0) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 0, -2, -4) +} + +func (suite *WallHeightsSuite) TestCalculateFromConsidersCeilingHeightsIfWallingOff() { + suite.givenTile(1, 1).typed(level.TileTypeOpen).floorAt(0) + suite.givenTile(1, 2).typed(level.TileTypeSlopeSouthToNorth).floorAt(0).sloped(level.TileSlopeControlFloorFlat, 8).ceilingAt(8) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 32, 32, 32) +} + +func (suite *WallHeightsSuite) TestCalculateFromConsidersMirroredSlopesWallingOff() { + suite.givenTile(1, 1).typed(level.TileTypeOpen).floorAt(0) + suite.givenTile(1, 2).typed(level.TileTypeSlopeNorthToSouth).floorAt(0).sloped(level.TileSlopeControlCeilingMirrored, 16) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 32, 32, 32) +} + +func (suite *WallHeightsSuite) TestCalculateFromWithFloorCrossingOtherCeiling() { + suite.givenTile(1, 1).typed(level.TileTypeOpen).floorAt(20) + suite.givenTile(1, 2).typed(level.TileTypeOpen).floorAt(0).ceilingAt(8) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 32, 32, 32) +} + +func (suite *WallHeightsSuite) TestCalculateFromWithSameSlopes() { + suite.givenTile(1, 1).typed(level.TileTypeSlopeEastToWest).sloped(level.TileSlopeControlCeilingInverted, 8) + suite.givenTile(1, 2).typed(level.TileTypeSlopeEastToWest).sloped(level.TileSlopeControlCeilingInverted, 8) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 0, 0, 0) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 0, 0, 0) +} + +func (suite *WallHeightsSuite) givenTile(x, y int) *WallHeightsSuite { + suite.tile = suite.Tile(level.TilePosition{X: byte(x), Y: byte(y)}) + return suite +} + +func (suite *WallHeightsSuite) typed(tileType level.TileType) *WallHeightsSuite { + suite.tile.Type = tileType + return suite +} + +func (suite *WallHeightsSuite) sloped(ctrl level.TileSlopeControl, height level.TileHeightUnit) *WallHeightsSuite { + suite.tile.Flags = suite.tile.Flags.WithSlopeControl(ctrl) + suite.tile.SlopeHeight = height + return suite +} + +func (suite *WallHeightsSuite) floorAt(value level.TileHeightUnit) *WallHeightsSuite { + suite.tile.Floor = suite.tile.Floor.WithAbsoluteHeight(value) + return suite +} + +func (suite *WallHeightsSuite) ceilingAt(value level.TileHeightUnit) *WallHeightsSuite { + suite.tile.Ceiling = suite.tile.Ceiling.WithAbsoluteHeight(value) + return suite +} + +func (suite *WallHeightsSuite) givenARandomHeightsMap() { + var random float32 + randomizeSide := func(side *[3]float32) { + for i := 0; i < 3; i++ { + random += 0.5 + if random > 32 { + random = -32 + } + side[i] = random + } + } + randomizeTile := func(heights *level.WallHeights) { + randomizeSide(&heights.North) + randomizeSide(&heights.East) + randomizeSide(&heights.South) + randomizeSide(&heights.West) + } + + for _, row := range suite.heightsMap { + for i := 0; i < len(row); i++ { + heights := &row[i] + randomizeTile(heights) + } + } +} + +func (suite *WallHeightsSuite) whenHeightsAreCalculatedFromMap() { + suite.heightsMap.CalculateFrom(suite) +} + +func (suite *WallHeightsSuite) thenHeightsShouldBeForTile(x int, y int, expected float32) { + tile := suite.heightsMap.Tile(level.TilePosition{X: byte(x), Y: byte(y)}) + + verifySide := func(name string, values [3]float32) { + for i := 0; i < len(values); i++ { + assert.Equal(suite.T(), expected, values[i], fmt.Sprintf("%s[%d] mismatch", name, i)) + } + } + verifySide("North", tile.North) + verifySide("East", tile.East) + verifySide("South", tile.South) + verifySide("West", tile.West) +} + +func (suite *WallHeightsSuite) thenHeightShouldBeForTileAt(x int, y int, side level.Direction, + expectedLeft float32, expectedCenter float32, expectedRight float32) { + tile := suite.heightsMap.Tile(level.TilePosition{X: byte(x), Y: byte(y)}) + var values [3]float32 + + switch side { + case level.DirNorth: + values = tile.North + case level.DirEast: + values = tile.East + case level.DirSouth: + values = tile.South + case level.DirWest: + values = tile.West + default: + require.Fail(suite.T(), "Invalid side specified") + } + + assert.Equal(suite.T(), expectedLeft, values[0], "left mismatch") + assert.Equal(suite.T(), expectedCenter, values[1], "center mismatch") + assert.Equal(suite.T(), expectedRight, values[2], "right mismatch") +} + +func (suite *WallHeightsSuite) Tile(pos level.TilePosition) *level.TileMapEntry { + return suite.tileMap.Tile(pos, 2) +} diff --git a/modding/hacked/ss1/content/archive/level/WallTexturePattern.go b/modding/hacked/ss1/content/archive/level/WallTexturePattern.go new file mode 100644 index 0000000..55ab344 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/WallTexturePattern.go @@ -0,0 +1,40 @@ +package level + +import "fmt" + +// WallTexturePattern describes how a wall texture should be applied for a tile. +type WallTexturePattern byte + +// String returns the textual information. +func (pattern WallTexturePattern) String() string { + switch pattern { + case WallTexturePatternRegular: + return "Regular" + case WallTexturePatternFlipHorizontal: + return "FlipHorizontal" + case WallTexturePatternFlipAlternating: + return "FlipAlternating" + case WallTexturePatternFlipAlternatingInverted: + return "FlipAlternatingInverted" + default: + return fmt.Sprintf("Unknown%02X", int(pattern)) + } +} + +// WallTexturePattern constants are listed below. +const ( + WallTexturePatternRegular WallTexturePattern = 0 + WallTexturePatternFlipHorizontal WallTexturePattern = 1 + WallTexturePatternFlipAlternating WallTexturePattern = 2 + WallTexturePatternFlipAlternatingInverted WallTexturePattern = 3 +) + +// WallTexturePatterns returns all available texture patterns. +func WallTexturePatterns() []WallTexturePattern { + return []WallTexturePattern{ + WallTexturePatternRegular, + WallTexturePatternFlipHorizontal, + WallTexturePatternFlipAlternating, + WallTexturePatternFlipAlternatingInverted, + } +} diff --git a/modding/hacked/ss1/content/archive/level/lvlids/Identifier.go b/modding/hacked/ss1/content/archive/level/lvlids/Identifier.go new file mode 100644 index 0000000..6e2d11d --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlids/Identifier.go @@ -0,0 +1,45 @@ +package lvlids + +const ( + // PerLevel is the amount of resources reserved per level. + PerLevel = 100 + + // FirstUsed identifies the first level-specific resource in a level-range. + FirstUsed = 2 + // FirstUnused identifies the first unused level-specific resource in a level-range. + FirstUnused = 54 +) + +// Level resource identifier are listed below. +const ( + MapVersionNumber = 2 + ObjectVersionNumber = 3 + Information = 4 + + TileMap = 5 + Schedules = 6 + TextureAtlas = 7 + ObjectMainTable = 8 + ObjectCrossRefTable = 9 + ObjectClassTablesStart = 10 + ObjectDefaultTablesStart = 25 + + SavefileVersion = 40 + Unused41 = 41 + + TextureAnimations = 42 + SurveillanceSources = 43 + SurveillanceSurrogates = 44 + Parameters = 45 + MapNotes = 46 + MapNotesPointer = 47 + + Unknown48 = 48 + Unknown49 = 49 + Unknown50 = 50 + + LoopConfiguration = 51 + + Unknown52 = 52 + HeightSemaphores = 53 +) diff --git a/modding/hacked/ss1/content/archive/level/lvlobj/Animating.go b/modding/hacked/ss1/content/archive/level/lvlobj/Animating.go new file mode 100644 index 0000000..09ee5d2 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlobj/Animating.go @@ -0,0 +1,7 @@ +package lvlobj + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var baseAnimation = interpreters.New() diff --git a/modding/hacked/ss1/content/archive/level/lvlobj/BigStuff.go b/modding/hacked/ss1/content/archive/level/lvlobj/BigStuff.go new file mode 100644 index 0000000..f52d97e --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlobj/BigStuff.go @@ -0,0 +1,122 @@ +package lvlobj + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var baseBigStuff = interpreters.New() + +var multiAnimation = interpreters.New(). + With("LoopType", 0, 2).As(interpreters.Bitfield(map[uint32]string{0x01: "Forward/Backward", 0x02: "Backward"})). + With("Alternation", 2, 2).As(interpreters.SpecialValue("MultiAnimation")). + With("Picture", 4, 2).As(interpreters.SpecialValue("PictureSource")). + With("Alternate", 6, 2).As(interpreters.SpecialValue("PictureSource")) + +var displayScenery = baseBigStuff. + With("FrameCount", 0, 2).As(interpreters.RangedValue(0, 8)). + Refining("", 2, 8, multiAnimation, interpreters.Always) + +var displayControlPedestal = baseBigStuff. + With("FrameCount", 0, 2).As(interpreters.RangedValue(0, 8)). + With("TriggerObjectID1", 2, 2).As(interpreters.ObjectID()). + With("TriggerObjectID2", 4, 2).As(interpreters.ObjectID()). + Refining("", 2, 8, multiAnimation, interpreters.Always) + +var cabinetFurniture = baseBigStuff. + With("Object1ID", 2, 2).As(interpreters.ObjectID()). + With("Object2ID", 4, 2).As(interpreters.ObjectID()) + +var texturableFurniture = baseBigStuff. + With("Texture", 6, 2).As(interpreters.SpecialValue("PictureSource")) + +var wordScenery = baseBigStuff. + With("TextIndex", 0, 2).As(interpreters.RangedValue(0, 511)). + With("Font", 2, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x0F: "Face", + 0xF0: "Size"})). + With("Color", 4, 1).As(interpreters.RangedValue(0, 255)) + +var textureMapScenery = baseBigStuff. + With("TextureIndex", 6, 2).As(interpreters.SpecialValue("LevelTexture")) + +var buttonControlPedestal = baseBigStuff. + With("TriggerObjectID1", 2, 2).As(interpreters.ObjectID()). + With("TriggerObjectID2", 4, 2).As(interpreters.ObjectID()) + +var surgicalMachine = baseBigStuff. + With("BrokenState", 2, 1).As(interpreters.EnumValue(map[uint32]string{0x00: "OK", 0xE7: "Broken"})). + With("BrokenMessageIndex", 5, 1) + +var securityCamera = baseBigStuff. + With("PanningSwitch", 2, 1).As(interpreters.EnumValue(map[uint32]string{0: "Stationary", 1: "Panning"})) + +var solidBridge = baseBigStuff. + With("Size", 2, 1).As(interpreters.Bitfield(map[uint32]string{0x0F: "X", 0xF0: "Y"})). + With("Height", 3, 1).As(interpreters.SpecialValue("ObjectHeight")). + With("TopBottomTexture", 4, 1).As(interpreters.SpecialValue("MaterialOrLevelTexture")). + With("SideTexture", 5, 1).As(interpreters.SpecialValue("MaterialOrLevelTexture")) + +var forceBridge = baseBigStuff. + With("Size", 2, 1).As(interpreters.Bitfield(map[uint32]string{0x0F: "X", 0xF0: "Y"})). + With("Height", 3, 1).As(interpreters.SpecialValue("ObjectHeight")). + With("Color", 6, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) (result string) { + return forceColors[value] + })) + +func initBigStuff() interpreterRetriever { + displays := newInterpreterLeaf(displayScenery) + textureable := newInterpreterLeaf(texturableFurniture) + + electronics := newInterpreterEntry(baseBigStuff) + electronics.set(6, displays) + electronics.set(7, displays) + + furniture := newInterpreterEntry(baseBigStuff) + furniture.set(2, newInterpreterLeaf(cabinetFurniture)) + furniture.set(5, textureable) + furniture.set(7, textureable) + furniture.set(8, textureable) + + surfaces := newInterpreterEntry(baseBigStuff) + surfaces.set(3, newInterpreterLeaf(wordScenery)) + surfaces.set(6, displays) + surfaces.set(7, newInterpreterLeaf(textureMapScenery)) + surfaces.set(8, displays) + surfaces.set(9, displays) + + lighting := newInterpreterEntry(baseBigStuff) + + medicalEquipment := newInterpreterEntry(baseBigStuff) + medicalEquipment.set(0, newInterpreterLeaf(buttonControlPedestal)) + medicalEquipment.set(3, newInterpreterLeaf(surgicalMachine)) + medicalEquipment.set(5, textureable) + + scienceSecurityEquipment := newInterpreterEntry(baseBigStuff) + scienceSecurityEquipment.set(4, newInterpreterLeaf(securityCamera)) + scienceSecurityEquipment.set(5, newInterpreterLeaf(buttonControlPedestal)) + scienceSecurityEquipment.set(6, newInterpreterLeaf(displayControlPedestal)) + + gardenScenery := newInterpreterEntry(baseBigStuff) + + bridges := newInterpreterEntry(baseBigStuff) + solidBridges := newInterpreterLeaf(solidBridge) + forceBridges := newInterpreterLeaf(forceBridge) + bridges.set(0, solidBridges) + bridges.set(1, solidBridges) + bridges.set(7, forceBridges) + bridges.set(8, forceBridges) + bridges.set(9, forceBridges) + + class := newInterpreterEntry(baseBigStuff) + class.set(0, electronics) + class.set(1, furniture) + class.set(2, surfaces) + class.set(3, lighting) + class.set(4, medicalEquipment) + class.set(5, scienceSecurityEquipment) + class.set(6, gardenScenery) + class.set(7, bridges) + + return class +} diff --git a/modding/hacked/ss1/content/archive/level/lvlobj/Containers.go b/modding/hacked/ss1/content/archive/level/lvlobj/Containers.go new file mode 100644 index 0000000..f2d5844 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlobj/Containers.go @@ -0,0 +1,36 @@ +package lvlobj + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var baseContainer = interpreters.New() + +var standardContainer = baseContainer. + With("ObjectID1", 0, 2).As(interpreters.ObjectID()). + With("ObjectID2", 2, 2).As(interpreters.ObjectID()). + With("ObjectID3", 4, 2).As(interpreters.ObjectID()). + With("ObjectID4", 6, 2).As(interpreters.ObjectID()) + +var crate = standardContainer. + With("Width", 8, 1). + With("Depth", 9, 1). + With("Height", 10, 1). + With("TopBottomTexture", 11, 1). + With("SideTexture", 12, 1) + +func initContainers() interpreterRetriever { + standardContainers := newInterpreterLeaf(standardContainer) + crates := newInterpreterLeaf(crate) + + class := newInterpreterEntry(baseContainer) + class.set(0, crates) + class.set(1, standardContainers) + class.set(2, standardContainers) + class.set(3, standardContainers) + class.set(4, standardContainers) + class.set(5, standardContainers) + class.set(6, standardContainers) + + return class +} diff --git a/modding/hacked/ss1/content/archive/level/lvlobj/Critters.go b/modding/hacked/ss1/content/archive/level/lvlobj/Critters.go new file mode 100644 index 0000000..2914eef --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlobj/Critters.go @@ -0,0 +1,40 @@ +package lvlobj + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var baseCritter = interpreters.New(). + With("PendingZRotation", 0, 4). + With("ForwardVelocityFraction", 4, 2). + With("ForwardVelocity", 6, 2). + With("Hastiness", 0x0A, 2). + With("StateTimeout", 0x0C, 2).As(interpreters.RangedValue(0, 300)). + With("Unknown000E", 0x0E, 2).As(interpreters.SpecialValue("Unknown")). + With("Unknown0010", 0x10, 2).As(interpreters.SpecialValue("Unknown")). + With("RoamingState", 0x14, 1). + With("PrimaryState", 0x15, 1).As(interpreters.EnumValue(map[uint32]string{ + 0: "docile", + 1: "cautious", + 2: "hostile", + 3: "cautious (?)", + 4: "attacking", + 5: "sleeping", + 6: "tranquilized", + 7: "confused"})). + With("SecondaryState", 0x16, 1). + With("TertiaryState", 0x17, 1). + With("AICoordinates0018", 0x18, 2).As(interpreters.SpecialValue("Unknown")). + With("AICoordinates001A", 0x1A, 2).As(interpreters.SpecialValue("Unknown")). + With("AICoordinates001C", 0x1C, 2).As(interpreters.SpecialValue("Unknown")). + With("Always255", 0x1E, 1).As(interpreters.RangedValue(0, 255)). + With("AICoordinates001F", 0x1F, 1).As(interpreters.SpecialValue("Unknown")). + With("LootObjectID1", 0x20, 2).As(interpreters.ObjectID()). + With("LootObjectID2", 0x22, 2).As(interpreters.ObjectID()). + With("Unknown0026", 0x26, 2).As(interpreters.SpecialValue("Unknown")) + +func initCritters() interpreterRetriever { + class := newInterpreterEntry(baseCritter) + + return class +} diff --git a/modding/hacked/ss1/content/archive/level/lvlobj/Doors.go b/modding/hacked/ss1/content/archive/level/lvlobj/Doors.go new file mode 100644 index 0000000..ef156f0 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlobj/Doors.go @@ -0,0 +1,38 @@ +package lvlobj + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var baseDoor = interpreters.New(). + With("LockVariableIndex", 0, 2).As(interpreters.SpecialValue("LockVariable")). + With("LockMessageIndex", 2, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) (result string) { + return fmt.Sprintf("%d", value+7) + })). + With("ForceDoorColor", 3, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) (result string) { + return forceColors[value] + })). + With("RequiredAccessLevel", 4, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) (result string) { + if value == 255 { + result = "SHODAN" + } else if accessLevel, known := accessLevelMasks[1<> 8), byte(dataBytes >> 16)}) + return err +} + +func writeBasicSoundData(writer io.Writer, sampleRate float32, samples []byte) error { + sampleType := byte(0) + + err := writeBlockHeader(writer, soundData, len(samples)+2) + if err != nil { + return err + } + _, err = writer.Write([]byte{sampleRateToDivisor(sampleRate), sampleType}) + if err != nil { + return err + } + _, err = writer.Write(samples) + return err +} + +func writeEndOfFile(writer io.Writer) error { + _, err := writer.Write([]byte{byte(terminator)}) + return err +} diff --git a/modding/hacked/ss1/content/audio/voc/Utility.go b/modding/hacked/ss1/content/audio/voc/Utility.go new file mode 100644 index 0000000..6f61c2d --- /dev/null +++ b/modding/hacked/ss1/content/audio/voc/Utility.go @@ -0,0 +1,22 @@ +package voc + +const ( + // FileHeader identifies VOC files. + FileHeader string = "Creative Voice File\u001A" + standardHeaderSize uint16 = 0x1A + baseVersion uint16 = 0x010A + versionCheckValue uint16 = 0x1234 + rateBase float32 = 1000000.0 +) + +func lengthFromBlockStart(blockStart []byte) int { + return int(blockStart[3])<<16 + int(blockStart[2])<<8 + int(blockStart[1]) +} + +func divisorToSampleRate(divisor byte) float32 { + return rateBase / float32(256-int(divisor)) +} + +func sampleRateToDivisor(sampleRate float32) byte { + return byte(256 - int(rateBase/sampleRate)) +} diff --git a/modding/hacked/ss1/content/audio/voc/Utility_test.go b/modding/hacked/ss1/content/audio/voc/Utility_test.go new file mode 100644 index 0000000..4e4f549 --- /dev/null +++ b/modding/hacked/ss1/content/audio/voc/Utility_test.go @@ -0,0 +1,23 @@ +package voc // nolint: testpackage + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFrequencyDivisorFor11111(t *testing.T) { + divisor := byte(0xA6) + sampleRate := divisorToSampleRate(divisor) + result := sampleRateToDivisor(sampleRate) + + assert.Equal(t, divisor, result) +} + +func TestFrequencyDivisorFor222222(t *testing.T) { + divisor := byte(0x2D) + sampleRate := divisorToSampleRate(divisor) + result := sampleRateToDivisor(sampleRate) + + assert.Equal(t, divisor, result) +} diff --git a/modding/hacked/ss1/content/audio/wav/Load.go b/modding/hacked/ss1/content/audio/wav/Load.go new file mode 100644 index 0000000..614f3c0 --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/Load.go @@ -0,0 +1,32 @@ +package wav + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/content/audio" +) + +const ( + errSourceIsNil ss1.StringError = "source is nil" + errNotASupportedWave ss1.StringError = "not a supported WAV" +) + +// Load reads from the provided source and returns the data. +func Load(source io.Reader) (data audio.L8, err error) { + if source == nil { + return data, errSourceIsNil + } + + var loader waveLoader + + loader.load(source) + if loader.err != nil { + return data, loader.err + } + + data.SampleRate = loader.sampleRate + data.Samples = loader.samples + + return +} diff --git a/modding/hacked/ss1/content/audio/wav/Load_test.go b/modding/hacked/ss1/content/audio/wav/Load_test.go new file mode 100644 index 0000000..fbdc930 --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/Load_test.go @@ -0,0 +1,65 @@ +package wav_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/audio/wav" +) + +func TestLoadReturnsErrorOnNil(t *testing.T) { + _, err := wav.Load(nil) + + assert.NotNil(t, err) +} + +func TestLoadExtractsDataOfL8(t *testing.T) { + input := []byte{ + 0x52, 0x49, 0x46, 0x46, // "RIFF" + 0x29, 0x00, 0x00, 0x00, // len(RIFF) + 0x57, 0x41, 0x56, 0x45, // "WAVE" + 0x66, 0x6d, 0x74, 0x20, // "fmt " + 0x10, 0x00, 0x00, 0x00, // len(fmt) + 0x01, 0x00, // fmt:type + 0x01, 0x00, // fmt:channels + 0x22, 0x56, 0x00, 0x00, // fmt:samples/sec + 0x22, 0x56, 0x00, 0x00, // fmt:avgBytes/sec + 0x01, 0x00, // fmt:blockAlign + 0x08, 0x00, // fmt:bits/sample + 0x64, 0x61, 0x74, 0x61, // "data" + 0x05, 0x00, 0x00, 0x00, // len(data) + 0x00, 0x40, 0x80, 0xC0, 0xFF} // data + + data, err := wav.Load(bytes.NewReader(input)) + + require.Nil(t, err) + assert.Equal(t, float32(22050), data.SampleRate) + assert.Equal(t, []byte{0x00, 0x40, 0x80, 0xC0, 0xFF}, data.Samples) +} + +func TestLoadExtractsDataOfL16(t *testing.T) { + input := []byte{ + 0x52, 0x49, 0x46, 0x46, // "RIFF" + 0x2E, 0x00, 0x00, 0x00, // len(RIFF) + 0x57, 0x41, 0x56, 0x45, // "WAVE" + 0x66, 0x6d, 0x74, 0x20, // "fmt " + 0x10, 0x00, 0x00, 0x00, // len(fmt) + 0x01, 0x00, // fmt:type + 0x01, 0x00, // fmt:channels + 0x22, 0x56, 0x00, 0x00, // fmt:samples/sec + 0x44, 0xAC, 0x00, 0x00, // fmt:avgBytes/sec + 0x02, 0x00, // fmt:blockAlign + 0x10, 0x00, // fmt:bits/sample + 0x64, 0x61, 0x74, 0x61, // "data" + 0x0A, 0x00, 0x00, 0x00, // len(data) + 0xAA, 0x00, 0xAA, 0x40, 0xAA, 0x7F, 0xAA, 0xC0, 0xAA, 0xFF} // data + + data, err := wav.Load(bytes.NewReader(input)) + + require.Nil(t, err) + assert.Equal(t, float32(22050), data.SampleRate) + assert.Equal(t, []byte{0x80, 0xC0, 0xFF, 0x40, 0x7F}, data.Samples) +} diff --git a/modding/hacked/ss1/content/audio/wav/Save.go b/modding/hacked/ss1/content/audio/wav/Save.go new file mode 100644 index 0000000..c5baddb --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/Save.go @@ -0,0 +1,35 @@ +package wav + +import ( + "encoding/binary" + "io" +) + +// Save encodes the provided samples into the given writer. +func Save(writer io.Writer, sampleRate float32, samples []byte) error { + dataSize := uint32(len(samples)) + var fmt formatHeader + fmtSize := fmt.size() + contentType := riffContentTypeWave + contentTypeSize := uint32(4) + tagSize := uint32(8) + tagSizes := tagSize * 2 + + fmt.base.FormatType = waveFormatTypePcm + fmt.base.Channels = 1 + fmt.base.SamplesPerSec = uint32(sampleRate) + fmt.base.AvgBytesPerSec = fmt.base.SamplesPerSec + fmt.base.BlockAlign = 1 + fmt.extension.BitsPerSample = 8 + + riffTag := riffChunkTag{ChunkType: riffChunkTypeRiff, Size: tagSizes + contentTypeSize + fmtSize + dataSize} + + _ = binary.Write(writer, binary.LittleEndian, &riffTag) + _ = binary.Write(writer, binary.LittleEndian, &contentType) + _ = binary.Write(writer, binary.LittleEndian, &riffChunkTag{ChunkType: riffChunkTypeFmt, Size: fmtSize}) + _ = binary.Write(writer, binary.LittleEndian, &fmt.base) + _ = binary.Write(writer, binary.LittleEndian, &fmt.extension) + _ = binary.Write(writer, binary.LittleEndian, &riffChunkTag{ChunkType: riffChunkTypeData, Size: dataSize}) + _, err := writer.Write(samples) + return err +} diff --git a/modding/hacked/ss1/content/audio/wav/Save_test.go b/modding/hacked/ss1/content/audio/wav/Save_test.go new file mode 100644 index 0000000..b71f575 --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/Save_test.go @@ -0,0 +1,37 @@ +package wav_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/audio/wav" +) + +func TestSaveStoresBytes(t *testing.T) { + sampleRate := float32(22050.0) + samples := []byte{0x00, 0x40, 0x80, 0xC0, 0xFF} + buf := bytes.NewBuffer(nil) + + err := wav.Save(buf, sampleRate, samples) + + require.Nil(t, err) + assert.Equal(t, []byte{ + 0x52, 0x49, 0x46, 0x46, // "RIFF" + 0x2B, 0x00, 0x00, 0x00, // len(RIFF) + 0x57, 0x41, 0x56, 0x45, // "WAVE" + 0x66, 0x6d, 0x74, 0x20, // "fmt " + 0x12, 0x00, 0x00, 0x00, // len(fmt) + 0x01, 0x00, // fmt:type + 0x01, 0x00, // fmt:channels + 0x22, 0x56, 0x00, 0x00, // fmt:samples/sec + 0x22, 0x56, 0x00, 0x00, // fmt:avgBytes/sec + 0x01, 0x00, // fmt:blockAlign + 0x08, 0x00, // fmt:bits/sample + 0x00, 0x00, // fmt:extensionSize + 0x64, 0x61, 0x74, 0x61, // "data" + 0x05, 0x00, 0x00, 0x00, // len(data) + 0x00, 0x40, 0x80, 0xC0, 0xFF}, buf.Bytes()) +} diff --git a/modding/hacked/ss1/content/audio/wav/WaveLoader.go b/modding/hacked/ss1/content/audio/wav/WaveLoader.go new file mode 100644 index 0000000..8ee411f --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/WaveLoader.go @@ -0,0 +1,133 @@ +package wav + +import ( + "bytes" + "encoding/binary" + "io" + + "github.com/inkyblackness/hacked/ss1" +) + +const ( + errUnsupportedFormat ss1.StringError = "unsupported WAVE format" +) + +func l8FromL8(input []byte) []byte { + return input +} + +func l8FromL16(input []byte) []byte { + samples := len(input) / 2 + output := make([]byte, samples) + + for i := 0; i < samples; i++ { + output[i] = byte(int(input[i*2+1]) + 0x80) + } + + return output +} + +type waveLoader struct { + dataRead bool + formatRead bool + samples []byte + sampleRate float32 + + reader io.Reader + err error + + dataConverter func([]byte) []byte +} + +func (loader *waveLoader) load(reader io.Reader) { + loader.reader = reader + loader.dataConverter = l8FromL8 + loader.loadRiff() +} + +func (loader *waveLoader) read(data interface{}) bool { + if loader.err == nil { + loader.err = binary.Read(loader.reader, binary.LittleEndian, data) + } + return loader.err == nil +} + +func (loader *waveLoader) readBytes(size uint32) (data []byte) { + data = make([]byte, int(size)) + _, loader.err = loader.reader.Read(data) + return +} + +func (loader *waveLoader) loadChunk(handler func(riffChunkType, uint32)) { + var tag riffChunkTag + + if loader.read(&tag) { + handler(tag.ChunkType, tag.Size) + } +} + +func (loader *waveLoader) loadRiff() { + loader.loadChunk(loader.handleRiff) +} + +func (loader *waveLoader) handleRiff(chunkType riffChunkType, size uint32) { + if chunkType == riffChunkTypeRiff { + var contentType riffContentType + if loader.read(&contentType) && (contentType == riffContentTypeWave) { + loader.loadWave() + } else if loader.err == nil { + loader.err = errNotASupportedWave + } + } else { + loader.err = errNotASupportedWave + } +} + +func (loader *waveLoader) loadWave() { + for !loader.isDone() { + loader.loadFormatOrData() + } + loader.samples = loader.dataConverter(loader.samples) +} + +func (loader *waveLoader) loadFormatOrData() { + loader.loadChunk(loader.handleFormatOrData) +} + +func (loader *waveLoader) handleFormatOrData(chunkType riffChunkType, size uint32) { + if chunkType == riffChunkTypeFmt { + loader.loadFormat(size) + } else if chunkType == riffChunkTypeData { + loader.loadData(size) + } +} + +func (loader *waveLoader) loadFormat(size uint32) { + headerData := loader.readBytes(size) + headerReader := bytes.NewReader(headerData) + var header formatHeader + + loader.formatRead = true + loader.err = binary.Read(headerReader, binary.LittleEndian, &header.base) + loader.err = binary.Read(headerReader, binary.LittleEndian, &header.extension.BitsPerSample) + loader.sampleRate = float32(header.base.SamplesPerSec) + + if header.extension.BitsPerSample == 16 { + loader.dataConverter = l8FromL16 + } + + if (header.base.FormatType != waveFormatTypePcm) || + (header.base.Channels != 1) || + ((header.extension.BitsPerSample != 8) && (header.extension.BitsPerSample != 16)) { + loader.err = errUnsupportedFormat + } +} + +func (loader *waveLoader) loadData(size uint32) { + loader.dataRead = true + loader.samples = loader.readBytes(size) +} + +func (loader *waveLoader) isDone() bool { + return (loader.err != nil) || (loader.dataRead && loader.formatRead) +} diff --git a/modding/hacked/ss1/content/audio/wav/formatHeader.go b/modding/hacked/ss1/content/audio/wav/formatHeader.go new file mode 100644 index 0000000..089e769 --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/formatHeader.go @@ -0,0 +1,33 @@ +package wav + +import ( + "encoding/binary" +) + +type waveFormatType uint16 + +const ( + waveFormatTypePcm = 1 +) + +type waveFormat struct { + FormatType waveFormatType + Channels uint16 + SamplesPerSec uint32 + AvgBytesPerSec uint32 + BlockAlign uint16 +} + +type waveFormatExtension struct { + BitsPerSample uint16 + ExtensionSize uint16 +} + +type formatHeader struct { + base waveFormat + extension waveFormatExtension +} + +func (header formatHeader) size() uint32 { + return uint32(binary.Size(&header.base) + binary.Size(&header.extension)) +} diff --git a/modding/hacked/ss1/content/audio/wav/riff.go b/modding/hacked/ss1/content/audio/wav/riff.go new file mode 100644 index 0000000..7fd564a --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/riff.go @@ -0,0 +1,19 @@ +package wav + +type riffChunkType uint32 +type riffContentType uint32 + +const ( + riffChunkTypeRiff = riffChunkType(0x46464952) + riffChunkTypeFmt = riffChunkType(0x20746d66) + riffChunkTypeData = riffChunkType(0x61746164) +) + +const ( + riffContentTypeWave = riffContentType(0x45564157) +) + +type riffChunkTag struct { + ChunkType riffChunkType + Size uint32 +} diff --git a/modding/hacked/ss1/content/bitmap/Animation.go b/modding/hacked/ss1/content/bitmap/Animation.go new file mode 100644 index 0000000..dc5aa55 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Animation.go @@ -0,0 +1,98 @@ +package bitmap + +import ( + "fmt" + "io" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial" +) + +const ( + animationEndTag = 0x0C + animationEndTagData = 0x01 + + animationEntryTag = 0x04 + + errEndTagDataWrong ss1.StringError = "end tag data wrong" +) + +type unknownEntryTagError struct { + tag byte +} + +func (err unknownEntryTagError) Error() string { + return fmt.Sprintf("unknown entry tag 0x%02X", int(err.tag)) +} + +// Animation describes a sequence of bitmaps to form a small movie. +type Animation struct { + Width int16 + Height int16 + ResourceID resource.ID + IntroFlag uint16 + Entries []AnimationEntry +} + +// AnimationEntry describes one part of an animation with common frame time. +type AnimationEntry struct { + FirstFrame byte + LastFrame byte + FrameTime int16 +} + +// ReadAnimation extracts an animation from a serialized stream. +func ReadAnimation(reader io.Reader) (anim Animation, err error) { + decoder := serial.NewDecoder(reader) + codeAnimationBase(decoder, &anim) + done := false + err = decoder.FirstError() + for !done && (err == nil) { + var tag byte + decoder.Code(&tag) + err = decoder.FirstError() + switch tag { + case animationEntryTag: + var entry AnimationEntry + decoder.Code(&entry) + anim.Entries = append(anim.Entries, entry) + err = decoder.FirstError() + case animationEndTag: + var endTagData byte + decoder.Code(&endTagData) + if endTagData != animationEndTagData { + err = errEndTagDataWrong + } + done = true + default: + if err == nil { + err = unknownEntryTagError{tag: tag} + } + } + } + + return +} + +// WriteAnimation serializes an animation to a writer. +func WriteAnimation(writer io.Writer, anim Animation) error { + encoder := serial.NewEncoder(writer) + codeAnimationBase(encoder, &anim) + for _, entry := range anim.Entries { + encoder.Code([]byte{animationEntryTag}) + encoder.Code(&entry) // nolint: scopelint + } + encoder.Code([]byte{animationEndTag, animationEndTagData}) + + return encoder.FirstError() +} + +func codeAnimationBase(coder serial.Coder, anim *Animation) { + coder.Code(&anim.Width) + coder.Code(&anim.Height) + coder.Code(&anim.ResourceID) + var unknown [6]byte + coder.Code(&unknown) + coder.Code(&anim.IntroFlag) +} diff --git a/modding/hacked/ss1/content/bitmap/AnimationCache.go b/modding/hacked/ss1/content/bitmap/AnimationCache.go new file mode 100644 index 0000000..ffbde1f --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/AnimationCache.go @@ -0,0 +1,58 @@ +package bitmap + +import ( + "github.com/inkyblackness/hacked/ss1/resource" +) + +// AnimationCache retrieves animations from a localizer and keeps them decoded until they are invalidated. +type AnimationCache struct { + localizer resource.Localizer + + animations map[resource.Key]Animation +} + +// NewAnimationCache returns a new instance. +func NewAnimationCache(localizer resource.Localizer) *AnimationCache { + cache := &AnimationCache{ + localizer: localizer, + animations: make(map[resource.Key]Animation), + } + return cache +} + +// InvalidateResources lets the cache remove any animations from resources that are specified in the given slice. +func (cache *AnimationCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.animations { + if key.ID == id { + delete(cache.animations, key) + } + } + } +} + +// Animation tries to look up given animation. +func (cache *AnimationCache) Animation(key resource.Key) (anim Animation, err error) { + anim, existing := cache.animations[key] + if existing { + return anim, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID) + if err != nil { + return + } + if (view.ContentType() != resource.Animation) || (view.BlockCount() != 1) { + return anim, resource.ErrWrongType(key, resource.Animation) + } + reader, err := view.Block(0) + if err != nil { + return + } + anim, err = ReadAnimation(reader) + if err != nil { + return + } + cache.animations[key] = anim + return anim, nil +} diff --git a/modding/hacked/ss1/content/bitmap/AnimationCache_test.go b/modding/hacked/ss1/content/bitmap/AnimationCache_test.go new file mode 100644 index 0000000..ba634b6 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/AnimationCache_test.go @@ -0,0 +1,176 @@ +package bitmap_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/resource" +) + +const ( + baseAnimationResourceID = 0x0A4C +) + +type AnimationCacheSuite struct { + suite.Suite + + localizedResources resource.LocalizedResourcesList + + instance *bitmap.AnimationCache +} + +func TestAnimationCacheSuite(t *testing.T) { + suite.Run(t, new(AnimationCacheSuite)) +} + +func (suite *AnimationCacheSuite) SetupTest() { + suite.instance = nil +} + +func (suite *AnimationCacheSuite) TestAnimationReturnsValueIfOK() { + anim := suite.someAnimation(0) + suite.givenAnInstance() + suite.whenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, anim))) + suite.thenAnimationShouldReturn(anim, 0) +} + +func (suite *AnimationCacheSuite) TestAnimationReturnsErrorIfResourceNotExisting() { + suite.givenAnInstance() + suite.whenResourcesAre() + suite.thenAnimationShouldReturnError(0) +} + +func (suite *AnimationCacheSuite) TestTextTriesToReloadWhenCacheInvalidated() { + suite.givenAnInstance() + suite.givenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, suite.someAnimation(0)))) + suite.givenAnimationWasRetrieved(0) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(baseAnimationResourceID) + suite.thenAnimationShouldReturnError(0) +} + +func (suite *AnimationCacheSuite) TestInvalidationConsidersOnlyAffectedIDs() { + suite.givenAnInstance() + suite.givenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, suite.someAnimation(0)), suite.storing(1, suite.someAnimation(1)))) + suite.givenAnimationWasRetrieved(0) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x02BD) + suite.thenAnimationShouldReturn(suite.someAnimation(0), 0) +} + +func (suite *AnimationCacheSuite) TestAnimationReturnsErrorIfResourceIsNotAnAnimation() { + suite.givenAnInstance() + suite.whenResourcesAre(suite.someLocalizedResources(suite.storingNonAnimation(0))) + suite.thenAnimationShouldReturnError(0) +} + +func (suite *AnimationCacheSuite) givenAnInstance() { + suite.instance = bitmap.NewAnimationCache(suite) +} + +func (suite *AnimationCacheSuite) givenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *AnimationCacheSuite) givenAnimationWasRetrieved(index int) { + _, err := suite.instance.Animation(suite.keyed(index)) + require.Nil(suite.T(), err, "No error expected setting up") +} + +func (suite *AnimationCacheSuite) whenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *AnimationCacheSuite) whenCacheResourcesAreInvalidated(ids ...resource.ID) { + suite.instance.InvalidateResources(ids) +} + +func (suite *AnimationCacheSuite) thenAnimationShouldReturn(expected bitmap.Animation, index int) { + result, err := suite.instance.Animation(suite.keyed(index)) + require.Nil(suite.T(), err, "No error expected") + assert.Equal(suite.T(), expected, result, "Animations don't match") +} + +func (suite *AnimationCacheSuite) thenAnimationShouldReturnError(index int) { + _, err := suite.instance.Animation(suite.keyed(index)) + require.NotNil(suite.T(), err, "Error expected") +} + +func (suite AnimationCacheSuite) keyed(index int) resource.Key { + return resource.KeyOf(baseAnimationResourceID, resource.LangAny, index) +} + +func (suite *AnimationCacheSuite) someLocalizedResources(modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: resource.LangAny, + Viewer: store, + } +} + +func (suite *AnimationCacheSuite) storing(id int, anim bitmap.Animation) func(*resource.Store) { + buf := bytes.NewBuffer(nil) + _ = bitmap.WriteAnimation(buf, anim) + return func(store *resource.Store) { + _ = store.Put(resource.ID(baseAnimationResourceID).Plus(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Animation, + Compound: true, + }, + Blocks: resource.BlocksFrom([][]byte{buf.Bytes()}), + }) + } +} + +func (suite *AnimationCacheSuite) someAnimation(seed uint8) bitmap.Animation { + anim := bitmap.Animation{ + Width: int16(seed) * 4, + Height: int16(seed) * 3, + IntroFlag: uint16(seed % 2), + ResourceID: resource.ID(seed), + Entries: make([]bitmap.AnimationEntry, seed+1), + } + + for i := 0; i < len(anim.Entries); i++ { + anim.Entries[i] = bitmap.AnimationEntry{ + FirstFrame: byte(i), + LastFrame: byte(i), + FrameTime: 100, + } + } + return anim +} + +func (suite *AnimationCacheSuite) storingNonAnimation(id int) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(baseAnimationResourceID).Plus(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Sound, + Compound: true, + }, + Blocks: resource.BlocksFrom([][]byte{{}}), + }) + } +} + +func (suite *AnimationCacheSuite) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: suite.localizedResources, + Lang: lang, + } +} diff --git a/modding/hacked/ss1/content/bitmap/Animation_test.go b/modding/hacked/ss1/content/bitmap/Animation_test.go new file mode 100644 index 0000000..d0e3bb1 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Animation_test.go @@ -0,0 +1,130 @@ +package bitmap_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/resource" +) + +func TestReadAnimationErrorsLength(t *testing.T) { + for i := 0; i < 20; i++ { + data := make([]byte, i) + _, err := bitmap.ReadAnimation(bytes.NewReader(data)) + assert.Error(t, err, fmt.Sprintf("Error expected for %d bytes", i)) + } +} + +func TestReadAnimationErrorsTags(t *testing.T) { + tt := []struct { + info string + data []byte + }{ + { + info: "end tag data missing", + data: []byte{ + 0x00, 0x00, + 0x02, 0x02, + 0x04, 0x04, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x0C, 0x0C, + 0x0C, + }, + }, + { + info: "end tag data wrong", + data: []byte{ + 0x00, 0x00, + 0x02, 0x02, + 0x04, 0x04, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x0C, 0x0C, + 0x0C, 0x00, + }, + }, + { + info: "unknown tag", + data: []byte{ + 0x00, 0x00, + 0x02, 0x02, + 0x04, 0x04, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x0C, 0x0C, + 0x0B, + }, + }, + } + for _, tc := range tt { + _, err := bitmap.ReadAnimation(bytes.NewReader(tc.data)) + assert.Error(t, err, fmt.Sprintf("Error expected for %s", tc.info)) + } +} + +func TestReadWriteAnimationEntries(t *testing.T) { + tt := []struct { + info string + data []byte + expected bitmap.Animation + }{ + { + info: "empty list of entries", + data: []byte{ + 0x12, 0x34, + 0x56, 0x78, + 0xAA, 0xBB, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xCC, 0xDD, + 0x0C, 0x01, + }, + expected: bitmap.Animation{ + Width: 0x3412, + Height: 0x7856, + ResourceID: resource.ID(0xBBAA), + IntroFlag: 0xDDCC, + Entries: nil, + }, + }, + + { + info: "two entries", + data: []byte{ + 0x12, 0x34, + 0x56, 0x78, + 0xAA, 0xBB, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xCC, 0xDD, + 0x04, 0x00, 0x0A, 0x10, 0x20, + 0x04, 0x0B, 0x10, 0x30, 0x40, + 0x0C, 0x01, + }, + expected: bitmap.Animation{ + Width: 0x3412, + Height: 0x7856, + ResourceID: resource.ID(0xBBAA), + IntroFlag: 0xDDCC, + Entries: []bitmap.AnimationEntry{ + {FirstFrame: 0x00, LastFrame: 0x0A, FrameTime: 0x2010}, + {FirstFrame: 0x0B, LastFrame: 0x10, FrameTime: 0x4030}, + }, + }, + }, + } + for _, tc := range tt { + td := tc + t.Run(tc.info, func(t *testing.T) { + anim, err := bitmap.ReadAnimation(bytes.NewReader(td.data)) + require.Nil(t, err, "No error expected") + assert.Equal(t, td.expected, anim, "Animation data not as expected after reading") + + output := bytes.NewBuffer(nil) + err = bitmap.WriteAnimation(output, anim) + require.Nil(t, err, "No error expected") + assert.Equal(t, td.data, output.Bytes(), "Data mismatch after re-encoding") + }) + } +} diff --git a/modding/hacked/ss1/content/bitmap/Bitmap.go b/modding/hacked/ss1/content/bitmap/Bitmap.go new file mode 100644 index 0000000..affc350 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Bitmap.go @@ -0,0 +1,101 @@ +package bitmap + +import ( + "bytes" + "encoding/binary" + "io" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/serial/rle" +) + +const ( + errReaderIsNil ss1.StringError = "reader is nil" + errStrideNotValid ss1.StringError = "stride not equal to width for compressed bitmap" + errDataCouldNotBeRead ss1.StringError = "data could not be read" + errPaletteCouldNotBeRead ss1.StringError = "palette could not be read" +) + +// Bitmap describes a palette based image. +type Bitmap struct { + Header Header + Pixels []byte + Palette *Palette +} + +// Decode tries to read a bitmap from given reader. +// Should the bitmap be a compressed bitmap, then a reference image with pixel data all 0x00 is assumed. +func Decode(reader io.Reader) (*Bitmap, error) { + return DecodeReferenced(reader, func(width, height int16) ([]byte, error) { + return make([]byte, int(width)*int(height)), nil + }) +} + +// DecodeReferenced tries to read a bitmap from given reader. +// If the serialized bitmap describes a compressed bitmap, then the pixels from the reference are used as a basis for the result. +// The returned byte array from the provider will be used as pixel buffer for the new bitmap. +func DecodeReferenced(reader io.Reader, provider func(width, height int16) ([]byte, error)) (*Bitmap, error) { + if reader == nil { + return nil, errReaderIsNil + } + + var bmp Bitmap + + err := binary.Read(reader, binary.LittleEndian, &bmp.Header) + if err != nil { + return nil, err + } + if bmp.Header.Type == TypeCompressed8Bit { + if bmp.Header.Stride != uint16(bmp.Header.Width) { + return nil, errStrideNotValid + } + bmp.Pixels, err = provider(bmp.Header.Width, bmp.Header.Height) + if err != nil { + return nil, err + } + err = rle.Decompress(reader, bmp.Pixels) + } else { + bmp.Pixels = make([]byte, int(bmp.Header.Height)*int(bmp.Header.Stride)) + _, err = reader.Read(bmp.Pixels) + } + if err != nil { + return nil, errDataCouldNotBeRead + } + + if bmp.Header.PaletteOffset != 0 { + paletteFlag := uint32(0) + _ = binary.Read(reader, binary.LittleEndian, &paletteFlag) + bmp.Palette = new(Palette) + err = binary.Read(reader, binary.LittleEndian, bmp.Palette) + if err != nil { + return nil, errPaletteCouldNotBeRead + } + } + + return &bmp, nil +} + +// Encode writes the bitmap to a byte array and returns it. +// Compressed bitmaps will be compressed with a reference image with pixel data all 0x00. +func Encode(bmp *Bitmap, offsetBase int) []byte { + rawData := bmp.Pixels + if bmp.Header.Type == TypeCompressed8Bit { + buf := bytes.NewBuffer(nil) + _ = rle.Compress(buf, rawData, nil) + rawData = buf.Bytes() + } + header := bmp.Header + if bmp.Palette != nil { + header.PaletteOffset = int32(offsetBase + HeaderSize + len(rawData)) + } + + buf := bytes.NewBuffer(nil) + _ = binary.Write(buf, binary.LittleEndian, &header) + _ = binary.Write(buf, binary.LittleEndian, rawData) + if bmp.Palette != nil { + _ = binary.Write(buf, binary.LittleEndian, paletteMarker) + _ = binary.Write(buf, binary.LittleEndian, bmp.Palette) + } + + return buf.Bytes() +} diff --git a/modding/hacked/ss1/content/bitmap/Bitmap_test.go b/modding/hacked/ss1/content/bitmap/Bitmap_test.go new file mode 100644 index 0000000..2ce3d98 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Bitmap_test.go @@ -0,0 +1,114 @@ +package bitmap_test + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeReturnsErrorOnNilSource(t *testing.T) { + _, err := bitmap.Decode(nil) + + assert.Error(t, err, "error expected") +} + +func TestDecodeOfUncompressedDataReturnsBitmap(t *testing.T) { + data := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, false) + bmp, err := bitmap.Decode(bytes.NewReader(data)) + + require.Nil(t, err, "no error expected") + require.NotNil(t, bmp, "bitmap expected") + assert.Equal(t, int16(1), bmp.Header.Width) + assert.Equal(t, int16(1), bmp.Header.Height) + assert.Equal(t, []byte{0xAA}, bmp.Pixels) +} + +func TestDecodeOfCompressedDataReturnsBitmap(t *testing.T) { + data := getTestData(bitmap.TypeCompressed8Bit, []byte{0x00, 0x01, 0xBB, 0x80, 0x00, 0x00}, false) + bmp, err := bitmap.Decode(bytes.NewReader(data)) + + require.Nil(t, err, "no error expected") + require.NotNil(t, bmp, "bitmap expected") + assert.Equal(t, int16(1), bmp.Header.Width) + assert.Equal(t, int16(1), bmp.Header.Height) + assert.Equal(t, []byte{0xBB}, bmp.Pixels) +} + +func TestDecodeWithPrivatePaletteReturnsPalette(t *testing.T) { + data := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, true) + bmp, err := bitmap.Decode(bytes.NewReader(data)) + + require.Nil(t, err, "no error expected") + require.NotNil(t, bmp, "bitmap expected") + assert.NotNil(t, bmp.Palette, "palette expected") +} + +func TestWriteUncompressedWithoutPalette(t *testing.T) { + sourceData := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, false) + bmp, _ := bitmap.Decode(bytes.NewReader(sourceData)) + + result := bitmap.Encode(bmp, 0) + assert.Equal(t, sourceData, result) +} + +func TestWriteCompressedWithoutPalette(t *testing.T) { + sourceData := getTestData(bitmap.TypeCompressed8Bit, []byte{0x01, 0xBB, 0x80, 0x00, 0x00}, false) + bmp, _ := bitmap.Decode(bytes.NewReader(sourceData)) + + result := bitmap.Encode(bmp, 0) + assert.Equal(t, sourceData, result) +} + +func TestWriteUncompressedWithPalette(t *testing.T) { + sourceData := getTestData(bitmap.TypeFlat8Bit, []byte{0xCC}, true) + bmp, _ := bitmap.Decode(bytes.NewReader(sourceData)) + + result := bitmap.Encode(bmp, 0) + assert.Equal(t, sourceData, result) +} + +func TestWriteCompressedWithPalette(t *testing.T) { + sourceData := getTestData(bitmap.TypeCompressed8Bit, []byte{0x01, 0xEE, 0x80, 0x00, 0x00}, true) + bmp, _ := bitmap.Decode(bytes.NewReader(sourceData)) + + result := bitmap.Encode(bmp, 0) + assert.Equal(t, sourceData, result) +} + +func TestWriteWithForceTransparency(t *testing.T) { + sourceData := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, false) + sourceData[6] = 1 + bmp, _ := bitmap.Decode(bytes.NewReader(sourceData)) + + result := bitmap.Encode(bmp, 0) + assert.Equal(t, sourceData, result) +} + +func getTestData(bmpType bitmap.Type, data []byte, withPalette bool) []byte { + var header bitmap.Header + buf := bytes.NewBuffer(nil) + + header.Type = bmpType + header.Width = 1 + header.Stride = 1 + header.Height = 1 + header.WidthFactor = 1 + header.HeightFactor = 1 + if withPalette { + header.PaletteOffset = int32(binary.Size(header) + len(data)) + } + _ = binary.Write(buf, binary.LittleEndian, &header) + buf.Write(data) + if withPalette { + var pal bitmap.Palette + _ = binary.Write(buf, binary.LittleEndian, uint32(0x01000000)) + _ = binary.Write(buf, binary.LittleEndian, pal) + } + + return buf.Bytes() +} diff --git a/modding/hacked/ss1/content/bitmap/Bitmapper.go b/modding/hacked/ss1/content/bitmap/Bitmapper.go new file mode 100644 index 0000000..e749c2d --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Bitmapper.go @@ -0,0 +1,105 @@ +package bitmap + +import ( + "image" + "image/color" + "math" +) + +// d64 is the reference white point. +var d65 = [3]float64{0.95047, 1.00000, 1.08883} + +func labF(t float64) float64 { + if t > 6.0/29.0*6.0/29.0*6.0/29.0 { + return math.Cbrt(t) + } + return t/3.0*29.0/6.0*29.0/6.0 + 4.0/29.0 +} + +func square(value float64) float64 { + return value * value +} + +type labEntry struct { + l float64 + a float64 + b float64 +} + +func labEntryFromColor(clr color.Color) labEntry { + rLinear, gLinear, bLinear, _ := clr.RGBA() + r, g, b := float64(rLinear)/float64(0xFFFF), float64(gLinear)/float64(0xFFFF), float64(bLinear)/float64(0xFFFF) + x := 0.4124564*r + 0.3575761*g + 0.1804375*b + y := 0.2126729*r + 0.7151522*g + 0.0721750*b + z := 0.0193339*r + 0.1191920*g + 0.9503041*b + whiteRef := d65 + fy := labF(y / whiteRef[1]) + entry := labEntry{ + l: 1.16*fy - 0.16, + a: 5.0 * (labF(x/whiteRef[0]) - fy), + b: 2.0 * (fy - labF(z/whiteRef[2]))} + + return entry +} + +func (entry labEntry) distanceTo(other labEntry) float64 { + return math.Sqrt(square(entry.l-other.l) + square(entry.a-other.a) + square(entry.b-other.b)) +} + +// Bitmapper creates bitmap images from generic images. +type Bitmapper struct { + pal []labEntry +} + +// NewBitmapper returns a new bitmapper instance based on the given palette. +func NewBitmapper(palette *Palette) *Bitmapper { + bitmapper := &Bitmapper{} + + for _, clr := range palette { + bitmapper.pal = append(bitmapper.pal, labEntryFromColor(clr.Color(0xFF))) + } + + return bitmapper +} + +// Map maps the provided image to a bitmap based on the internal palette. +func (bitmapper *Bitmapper) Map(img image.Image) Bitmap { + var bmp Bitmap + bounds := img.Bounds() + + bmp.Header.Width = int16(math.Max(0, math.Min(float64(bounds.Dx()), math.MaxInt16))) + bmp.Header.Height = int16(math.Max(0, math.Min(float64(bounds.Dy()), math.MaxInt16))) + bmp.Pixels = make([]byte, int(bmp.Header.Width)*int(bmp.Header.Height)) + for row := 0; row < int(bmp.Header.Height); row++ { + for column := 0; column < int(bmp.Header.Width); column++ { + bmp.Pixels[row*int(bmp.Header.Width)+column] = bitmapper.MapColor(img.At(column, row)) + } + } + + return bmp +} + +// MapColor maps the provided color to the nearest index in the palette. +func (bitmapper *Bitmapper) MapColor(clr color.Color) (palIndex byte) { + _, _, _, a := clr.RGBA() // nolint:dogsled + indexWithin := func(index, from, to int) bool { + return (index >= from) && (index <= to) + } + + if a > 0 { + clrEntry := labEntryFromColor(clr) + palDistance := 1000.0 + + for colorIndex, palEntry := range bitmapper.pal { + isRegularColor := indexWithin(colorIndex, 0x01, 0x02) || indexWithin(colorIndex, 0x08, 0x0A) || indexWithin(colorIndex, 0x20, 0xFF) + if isRegularColor { + distance := palEntry.distanceTo(clrEntry) + if distance < palDistance { + palDistance = distance + palIndex = byte(colorIndex) + } + } + } + } + return +} diff --git a/modding/hacked/ss1/content/bitmap/Constants.go b/modding/hacked/ss1/content/bitmap/Constants.go new file mode 100644 index 0000000..034d0a0 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Constants.go @@ -0,0 +1,5 @@ +package bitmap + +const ( + paletteMarker uint32 = 0x01000000 +) diff --git a/modding/hacked/ss1/content/bitmap/Header.go b/modding/hacked/ss1/content/bitmap/Header.go new file mode 100644 index 0000000..b58c8d7 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Header.go @@ -0,0 +1,43 @@ +package bitmap + +// Type describes the data layout of a bitmap. +type Type byte + +// Type constants are listed below. +const ( + // TypeFlat8Bit bitmaps are 8-bit paletted bitmaps that have their pixel stored in a flat layout. + TypeFlat8Bit Type = 2 + // TypeCompressed8Bit bitmaps are 8-bit paletted bitmaps that have their pixel compressed in storage. + // Compression is using run-length-encoding (RLE); See package rle. + TypeCompressed8Bit Type = 4 +) + +// Flag adds further properties. +type Flag uint16 + +// Flag constants are listed below. +const ( + // FlagTransparent is set for bitmaps that shall treat palette index 0x00 as fully transparent. + FlagTransparent Flag = 0x0001 +) + +// HeaderSize is the size of the Header structure, in bytes. +const HeaderSize = 28 + +// Area is a placeholder for either a rectangle, or an anchoring point (first two entries). +type Area [4]int16 + +// Header contains the meta information for a bitmap. +type Header struct { + _ [4]byte + Type Type + _ byte + Flags Flag + Width int16 + Height int16 + Stride uint16 + WidthFactor byte + HeightFactor byte + Area Area + PaletteOffset int32 +} diff --git a/modding/hacked/ss1/content/bitmap/Palette.go b/modding/hacked/ss1/content/bitmap/Palette.go new file mode 100644 index 0000000..8637c97 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Palette.go @@ -0,0 +1,65 @@ +package bitmap + +import ( + "bytes" + "image/color" + + "github.com/lucasb-eyer/go-colorful" +) + +// RGB is describing Red, Green, and Blue intensities with 8-bit resolution. +type RGB struct { + Red uint8 + Green uint8 + Blue uint8 +} + +// Color returns the RGB data as a regular Color entry. +func (col RGB) Color(alpha byte) color.Color { + return color.RGBA{ + R: col.Red, + G: col.Green, + B: col.Blue, + A: alpha, + } +} + +// PaletteSize indicates how many colors a palette can hold with a byte index. +const PaletteSize = 256 + +// Palette describes a list of colors to be used by bitmaps. +// It is an array of 256 RGB values. +type Palette [PaletteSize]RGB + +// ColorPalette returns a palette usable for the image packages. +func (pal Palette) ColorPalette(firstIndexTransparent bool) color.Palette { + result := make(color.Palette, len(pal)) + for index, col := range pal { + alpha := byte(0xFF) + if (index == 0) && firstIndexTransparent { + alpha = 0x00 + } + result[index] = col.Color(alpha) + } + return result +} + +// IndexClosestTo returns the index into this palette that matches the given color the closest. +// This search excludes the provided indices. +func (pal Palette) IndexClosestTo(rgb RGB, excluding []byte) byte { + targetColor, _ := colorful.MakeColor(rgb.Color(0xFF)) + closestDist := float64(-1) + closestIndex := byte(0) + for index, other := range pal { + if bytes.IndexByte(excluding, byte(index)) >= 0 { + continue + } + otherColor, _ := colorful.MakeColor(other.Color(0xFF)) + dist := targetColor.DistanceLab(otherColor) + if (closestDist < 0) || (dist < closestDist) { + closestDist = dist + closestIndex = byte(index) + } + } + return closestIndex +} diff --git a/modding/hacked/ss1/content/bitmap/PaletteCache.go b/modding/hacked/ss1/content/bitmap/PaletteCache.go new file mode 100644 index 0000000..88bdda4 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/PaletteCache.go @@ -0,0 +1,67 @@ +package bitmap + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// PaletteCache retrieves palettes from a localizer and keeps them decoded until they are invalidated. +type PaletteCache struct { + localizer resource.Localizer + + defaultPalette Palette + palettes map[resource.Key]Palette +} + +// NewPaletteCache returns a new instance. +func NewPaletteCache(localizer resource.Localizer) *PaletteCache { + cache := &PaletteCache{ + localizer: localizer, + palettes: make(map[resource.Key]Palette), + } + for i := 0; i < len(cache.defaultPalette); i++ { + col := &cache.defaultPalette[i] + col.Red = uint8(i) + col.Green = uint8(i) + col.Blue = uint8(i) + } + return cache +} + +// InvalidateResources lets the cache remove any palettes from resources that are specified in the given slice. +func (cache *PaletteCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.palettes { + if key.ID == id { + delete(cache.palettes, key) + } + } + } +} + +// Palette tries to look up given palette. +func (cache *PaletteCache) Palette(key resource.Key) (pal Palette, err error) { + pal, existing := cache.palettes[key] + if existing { + return pal, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID) + if err != nil { + return cache.defaultPalette, err + } + if (view.ContentType() != resource.Palette) || (view.BlockCount() != 1) { + return cache.defaultPalette, resource.ErrWrongType(key, resource.Palette) + } + reader, err := view.Block(0) + if err != nil { + return cache.defaultPalette, err + } + decoder := serial.NewDecoder(reader) + decoder.Code(&pal) + if decoder.FirstError() != nil { + return cache.defaultPalette, decoder.FirstError() + } + cache.palettes[key] = pal + return pal, nil +} diff --git a/modding/hacked/ss1/content/bitmap/PaletteCache_test.go b/modding/hacked/ss1/content/bitmap/PaletteCache_test.go new file mode 100644 index 0000000..2c83cc6 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/PaletteCache_test.go @@ -0,0 +1,169 @@ +package bitmap_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const ( + basePaletteResourceID = 0x02BC +) + +type PaletteCacheSuite struct { + suite.Suite + + localizedResources resource.LocalizedResourcesList + + instance *bitmap.PaletteCache +} + +func TestPaletteCacheSuite(t *testing.T) { + suite.Run(t, new(PaletteCacheSuite)) +} + +func (suite *PaletteCacheSuite) SetupTest() { + suite.instance = nil +} + +func (suite *PaletteCacheSuite) TestPaletteReturnsValueIfOK() { + pal := suite.somePalette(0) + suite.givenAnInstance() + suite.whenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, pal))) + suite.thenPaletteShouldReturn(pal, 0) +} + +func (suite *PaletteCacheSuite) TestPaletteReturnsErrorIfResourceNotExisting() { + suite.givenAnInstance() + suite.whenResourcesAre() + suite.thenPaletteShouldReturnError(0) +} + +func (suite *PaletteCacheSuite) TestTextTriesToReloadWhenCacheInvalidated() { + suite.givenAnInstance() + suite.givenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, suite.somePalette(0)))) + suite.givenPaletteWasRetrieved(0) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(basePaletteResourceID) + suite.thenPaletteShouldReturnError(0) +} + +func (suite *PaletteCacheSuite) TestInvalidationConsidersOnlyAffectedIDs() { + suite.givenAnInstance() + suite.givenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, suite.somePalette(0)), suite.storing(1, suite.somePalette(1)))) + suite.givenPaletteWasRetrieved(0) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x02BD) + suite.thenPaletteShouldReturn(suite.somePalette(0), 0) +} + +func (suite *PaletteCacheSuite) TestPaletteReturnsErrorIfResourceIsNotAPalette() { + suite.givenAnInstance() + suite.whenResourcesAre(suite.someLocalizedResources(suite.storingNonPalette(0))) + suite.thenPaletteShouldReturnError(0) +} + +func (suite *PaletteCacheSuite) givenAnInstance() { + suite.instance = bitmap.NewPaletteCache(suite) +} + +func (suite *PaletteCacheSuite) givenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *PaletteCacheSuite) givenPaletteWasRetrieved(index int) { + _, err := suite.instance.Palette(suite.keyed(index)) + require.Nil(suite.T(), err, "No error expected setting up") +} + +func (suite *PaletteCacheSuite) whenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *PaletteCacheSuite) whenCacheResourcesAreInvalidated(ids ...resource.ID) { + suite.instance.InvalidateResources(ids) +} + +func (suite *PaletteCacheSuite) thenPaletteShouldReturn(expected bitmap.Palette, index int) { + result, err := suite.instance.Palette(suite.keyed(index)) + require.Nil(suite.T(), err, "No error expected") + assert.Equal(suite.T(), expected, result, "Palettes don't match") +} + +func (suite *PaletteCacheSuite) thenPaletteShouldReturnError(index int) { + _, err := suite.instance.Palette(suite.keyed(index)) + require.NotNil(suite.T(), err, "Error expected") +} + +func (suite PaletteCacheSuite) keyed(index int) resource.Key { + return resource.KeyOf(basePaletteResourceID, resource.LangAny, index) +} + +func (suite *PaletteCacheSuite) someLocalizedResources(modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: resource.LangAny, + Viewer: store, + } +} + +func (suite *PaletteCacheSuite) storing(id int, palette bitmap.Palette) func(*resource.Store) { + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + encoder.Code(palette) + return func(store *resource.Store) { + _ = store.Put(resource.ID(basePaletteResourceID).Plus(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Palette, + Compound: false, + }, + Blocks: resource.BlocksFrom([][]byte{buf.Bytes()}), + }) + } +} + +func (suite *PaletteCacheSuite) somePalette(seed uint8) bitmap.Palette { + var pal bitmap.Palette + for i := 0; i < len(pal); i++ { + pal[i].Red = seed + uint8(i) + pal[i].Green = seed + uint8(255-i) + pal[i].Blue = seed + uint8(i*2) + } + return pal +} + +func (suite *PaletteCacheSuite) storingNonPalette(id int) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(basePaletteResourceID).Plus(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Sound, + Compound: false, + }, + Blocks: resource.BlocksFrom([][]byte{{}}), + }) + } +} + +func (suite *PaletteCacheSuite) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: suite.localizedResources, + Lang: lang, + } +} diff --git a/modding/hacked/ss1/content/doc.go b/modding/hacked/ss1/content/doc.go new file mode 100644 index 0000000..d401fcb --- /dev/null +++ b/modding/hacked/ss1/content/doc.go @@ -0,0 +1,4 @@ +/* +Package content and its sub-packages provide resource primitives and their serialization. +*/ +package content diff --git a/modding/hacked/ss1/content/interpreters/Description.go b/modding/hacked/ss1/content/interpreters/Description.go new file mode 100644 index 0000000..89d3b32 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Description.go @@ -0,0 +1,86 @@ +package interpreters + +// Predicate is a function that specifies whether a certain description +// is active within another. +type Predicate func(inst *Instance) bool + +// Always is a predicate that returns true for every call. +func Always(inst *Instance) bool { + return true +} + +// Never is a predicate that returns false for every call. +func Never(inst *Instance) bool { + return false +} + +// Description is the meta information about a data interpreter. A description +// is an immutable object that provides new descriptions with any modification. +type Description struct { + fields map[string]*entry + refinements map[string]*refinement + lastField *entry +} + +var empty = newDescription() + +// New returns an empty description. +func New() *Description { + return empty +} + +func newDescription() *Description { + return &Description{ + fields: make(map[string]*entry), + refinements: make(map[string]*refinement)} +} + +func (desc *Description) clone() *Description { + cloned := newDescription() + + for key, e := range desc.fields { + cloned.fields[key] = e + } + for key, r := range desc.refinements { + cloned.refinements[key] = r + } + + return cloned +} + +// For returns an instance of an interpreter with the given data slice +// as backing buffer. +func (desc *Description) For(data []byte) *Instance { + return &Instance{desc: desc, data: data} +} + +// With extends the given description with a new field. +// The returned description is a new, separated object from the originating one. +func (desc *Description) With(key string, byteStart int, byteCount int) *Description { + cloned := desc.clone() + cloned.lastField = &entry{start: byteStart, count: byteCount} + cloned.fields[key] = cloned.lastField + + return cloned +} + +// As sets the value range for the previously added field. +func (desc *Description) As(fieldRange FieldRange) *Description { + if desc.lastField == nil { + panic("No field active") + } + desc.lastField.via = fieldRange + return desc +} + +// Refining adds another description within the given one. The provided predicate +// determines whether the refined description is active. +func (desc *Description) Refining(key string, byteStart int, byteCount int, refined *Description, predicate Predicate) *Description { + cloned := desc.clone() + cloned.refinements[key] = &refinement{ + entry: entry{start: byteStart, count: byteCount}, + desc: refined, + predicate: predicate} + + return cloned +} diff --git a/modding/hacked/ss1/content/interpreters/Description_test.go b/modding/hacked/ss1/content/interpreters/Description_test.go new file mode 100644 index 0000000..e179f58 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Description_test.go @@ -0,0 +1,87 @@ +package interpreters_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/interpreters" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDescriptionWithReturnsANewDescription(t *testing.T) { + second := interpreters.New().With("test", 0, 32) + + require.NotNil(t, second) + assert.NotEqual(t, interpreters.New(), second) +} + +func TestDescriptionWithCopiesPreviousFields(t *testing.T) { + first := interpreters.New().With("field1", 0, 32) + second := first.With("field2", 32, 32) + onlyField2 := interpreters.New().With("field2", 32, 32) + + assert.NotEqual(t, onlyField2, second) +} + +func TestDescriptionWithLeavesOriginalAlone(t *testing.T) { + first := interpreters.New().With("field1", 0, 32) + onlyField1 := interpreters.New().With("field1", 0, 32) + + first.With("field2", 32, 32) + + assert.Equal(t, onlyField1, first) +} + +func TestDescriptionForCreatesNewInstance(t *testing.T) { + data := make([]byte, 10) + inst := interpreters.New().For(data) + + assert.NotNil(t, inst) +} + +func TestDescriptionRefiningReturnsANewDescription(t *testing.T) { + refined := interpreters.New().With("field1", 0, 16) + second := interpreters.New().Refining("test", 0, 4, refined, interpreters.Always) + + require.NotNil(t, second) + assert.NotEqual(t, interpreters.New(), second) +} + +func TestDescriptionRefiningCopiesPreviousFields(t *testing.T) { + first := interpreters.New().With("fieldA", 0, 8) + refined := interpreters.New().With("field1", 0, 16) + second := first.Refining("test", 0, 4, refined, interpreters.Always) + secondMissing := interpreters.New().Refining("test", 0, 4, refined, interpreters.Always) + + assert.NotEqual(t, secondMissing, second) +} + +func TestDescriptionRefiningCopiesPreviousRefinements(t *testing.T) { + first := interpreters.New().Refining("sub1", 0, 1, interpreters.New(), interpreters.Always) + second := first.Refining("sub2", 0, 1, interpreters.New(), interpreters.Always) + + assert.NotContains(t, first.For(nil).ActiveRefinements(), "sub2") + assert.Contains(t, second.For(nil).ActiveRefinements(), "sub1") +} + +func TestDescriptionAsPanicsIfNoFieldIsActive(t *testing.T) { + assert.PanicsWithValue(t, "No field active", func() { + interpreters.New().As(func(simpl *interpreters.Simplifier) bool { return false }) + }) +} + +func TestDescriptionAsRegistersRangeFunctionForLastField(t *testing.T) { + rangeFuncCalled := false + rangeFunc := func(simpl *interpreters.Simplifier) bool { + rangeFuncCalled = true + return true + } + desc := interpreters.New().With("fieldA", 0, 2).As(rangeFunc) + + inst := desc.For([]byte{0x00, 0x01}) + simplifier := interpreters.NewSimplifier(func(minValue, maxValue int64, formatter interpreters.RawValueFormatter) {}) + inst.Describe("fieldA", simplifier) + + assert.True(t, rangeFuncCalled) +} diff --git a/modding/hacked/ss1/content/interpreters/Entry.go b/modding/hacked/ss1/content/interpreters/Entry.go new file mode 100644 index 0000000..4b703e4 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Entry.go @@ -0,0 +1,16 @@ +package interpreters + +// FieldRange is a function specializing the range of a field to a simplifier. +type FieldRange func(*Simplifier) bool + +type entry struct { + start int + count int + via FieldRange +} + +func (e *entry) describe(simplifier *Simplifier) { + if (e.via == nil) || !e.via(simplifier) { + simplifier.rawValue(e) + } +} diff --git a/modding/hacked/ss1/content/interpreters/Instance.go b/modding/hacked/ss1/content/interpreters/Instance.go new file mode 100644 index 0000000..27b4d91 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Instance.go @@ -0,0 +1,138 @@ +package interpreters + +import ( + "bytes" + "sort" +) + +// Instance is one instantiated interpreter for a data block, +// based on a predetermined description. +type Instance struct { + desc *Description + data []byte +} + +// Raw returns the raw array. +func (inst *Instance) Raw() []byte { + return inst.data +} + +// Undefined returns a byte array that specifies which bits were not +// specified. The array has the same length as the underlying buffer and +// has all bits set to 1 where no field or active refinement is present. +func (inst *Instance) Undefined() []byte { + mask := bytes.Repeat([]byte{0xFF}, len(inst.data)) + + for _, e := range inst.desc.fields { + if inst.isValidRange(e) { + copy(mask[e.start:e.start+e.count], bytes.Repeat([]byte{0x00}, e.count)) + } + } + for key, r := range inst.desc.refinements { + if r.predicate(inst) { + refined := inst.Refined(key) + subMask := refined.Undefined() + + for index, m := range subMask { + mask[r.start+index] &= m + } + } + } + + return mask +} + +// Keys returns an array of all registered keys, sorted by start index. +func (inst *Instance) Keys() []string { + return sortKeys(inst.desc.fields) +} + +// ActiveRefinements returns an array of all keys where the corresponding refinement +// is active (The corresponding predicate returns true). +func (inst *Instance) ActiveRefinements() (keys []string) { + entries := make(map[string]*entry) + for key, r := range inst.desc.refinements { + entries[key] = &r.entry + } + sortedKeys := sortKeys(entries) + for _, key := range sortedKeys { + if inst.desc.refinements[key].predicate(inst) { + keys = append(keys, key) + } + } + + return +} + +// Get returns the value associated with the given key. Should there be no +// value for the requested key, the function returns 0. +func (inst *Instance) Get(key string) uint32 { + e := inst.desc.fields[key] + value := uint32(0) + + if e != nil && inst.isValidRange(e) { + for i := 0; i < e.count; i++ { + value = (value << 8) | uint32(inst.data[e.start+e.count-1-i]) + } + } + + return value +} + +// Describe returns the description of a value key. +func (inst *Instance) Describe(key string, simplifier *Simplifier) { + e := inst.desc.fields[key] + + if e != nil && inst.isValidRange(e) { + e.describe(simplifier) + } +} + +// Set stores the provided value with the given key. Should there be no +// registration for the key, the function does nothing. +func (inst *Instance) Set(key string, value uint32) { + e := inst.desc.fields[key] + + if e != nil && inst.isValidRange(e) { + for i := 0; i < e.count; i++ { + inst.data[e.start+i] = byte(value >> uint32(i*8)) + } + } +} + +// Refined returns an instance that was nested according to the description. +// This method returns an instance for registered refinements even if their predicate +// would not specify them being active. +// Should the refinement not exist, an instance without any fields will be returned. +func (inst *Instance) Refined(key string) (refined *Instance) { + r := inst.desc.refinements[key] + + if r != nil && inst.isValidRange(&r.entry) { + refined = &Instance{ + desc: r.desc, + data: inst.data[r.start : r.start+r.count]} + } else { + refined = New().For(nil) + } + + return +} + +func (inst *Instance) isValidRange(e *entry) bool { + available := len(inst.data) + + return (e.start + e.count) <= available +} + +func sortKeys(entries map[string]*entry) []string { + keys := make([]string, 0, len(entries)) + for key := range entries { + keys = append(keys, key) + } + sort.Slice(keys, func(a, b int) bool { + entryA := entries[keys[a]] + entryB := entries[keys[b]] + return entryA.start < entryB.start + }) + return keys +} diff --git a/modding/hacked/ss1/content/interpreters/Instance_test.go b/modding/hacked/ss1/content/interpreters/Instance_test.go new file mode 100644 index 0000000..a3643b0 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Instance_test.go @@ -0,0 +1,146 @@ +package interpreters_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/interpreters" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type InstanceSuite struct { + suite.Suite + + data []byte + inst *interpreters.Instance +} + +func TestInstanceSuite(t *testing.T) { + suite.Run(t, new(InstanceSuite)) +} + +func (suite *InstanceSuite) SetupTest() { + sub1 := interpreters.New(). + With("subField0", 0, 1). + With("subField1", 1, 2) + + sub2 := interpreters.New(). + With("subFieldA", 0, 2). + With("subFieldB", 2, 1) + + desc := interpreters.New(). + With("field0", 0, 1). + With("field1", 1, 1). + With("field2", 2, 2). + With("field3", 4, 4). + With("misaligned", 9, 2). + With("beyond", 256, 16). + Refining("sub1", 3, 3, sub1, interpreters.Always). + Refining("sub2", 6, 3, sub2, func(inst *interpreters.Instance) bool { return inst.Get("field0") == 0 }). + Refining("sub3", 7, 1, interpreters.New(), interpreters.Always) + + suite.data = []byte{0x01, 0x5A, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A} + suite.inst = desc.For(suite.data) +} + +func (suite *InstanceSuite) TestGetReturnsZeroForUnknownKey() { + result := suite.inst.Get("unknown") + + assert.Equal(suite.T(), uint32(0), result) +} + +func (suite *InstanceSuite) TestGetReturnsValueLittleEndian() { + result := suite.inst.Get("field2") + + assert.Equal(suite.T(), uint32(0x0403), result) +} + +func (suite *InstanceSuite) TestGetReturnsZeroForKeyBeyondSize() { + result := suite.inst.Get("beyond") + + assert.Equal(suite.T(), uint32(0), result) +} + +func (suite *InstanceSuite) TestSetIgnoresMisalignedFields() { + suite.inst.Set("misaligned", 0xEEFF) + + assert.Equal(suite.T(), []byte{0x09, 0x0A}, suite.data[8:10]) +} + +func (suite *InstanceSuite) TestSetStoresValue() { + suite.inst.Set("field3", 0xAABBCCDD) + + assert.Equal(suite.T(), []byte{0xDD, 0xCC, 0xBB, 0xAA}, suite.data[4:8]) +} + +func (suite *InstanceSuite) TestRefinedForUnknownKeyReturnsNullObject() { + refined := suite.inst.Refined("unknown") + + require.NotNil(suite.T(), refined) + assert.Equal(suite.T(), uint32(0), refined.Get("something")) +} + +func (suite *InstanceSuite) TestRefinedReturnsInstanceForSubsection() { + refined := suite.inst.Refined("sub1") + + assert.Equal(suite.T(), uint32(0x0605), refined.Get("subField1")) +} + +func (suite *InstanceSuite) TestRefinedAllowsModificationOfOriginalData() { + refined := suite.inst.Refined("sub1") + refined.Set("subField0", 0xAB) + + assert.Equal(suite.T(), byte(0xAB), suite.data[3]) +} + +func (suite *InstanceSuite) TestRefinedReturnsInstanceEvenIfNotActive() { + refined := suite.inst.Refined("sub2") + + assert.Equal(suite.T(), uint32(0x0807), refined.Get("subFieldA")) +} + +func (suite *InstanceSuite) TestKeysReturnsListOfKeysSortedByStartIndex() { + keys := suite.inst.Keys() + + assert.Equal(suite.T(), []string{"field0", "field1", "field2", "field3", "misaligned", "beyond"}, keys) +} + +func (suite *InstanceSuite) TestActiveRefinementsReturnsListOfActiveKeysSortedByStartIndex() { + keys := suite.inst.ActiveRefinements() + + assert.Equal(suite.T(), []string{"sub1", "sub3"}, keys) +} + +func (suite *InstanceSuite) TestActiveRefinementsCanChange() { + suite.inst.Set("field0", 0) + keys := suite.inst.ActiveRefinements() + + assert.Equal(suite.T(), []string{"sub1", "sub2", "sub3"}, keys) +} + +func (suite *InstanceSuite) TestRawReturnsData() { + data := suite.inst.Refined("sub2").Raw() + + assert.Equal(suite.T(), []byte{0x07, 0x08, 0x09}, data) +} + +func (suite *InstanceSuite) TestRawAllowsModificationOfOriginalData() { + suite.inst.Refined("sub2").Raw()[1] = 0xEF + + assert.Equal(suite.T(), byte(0xEF), suite.data[7]) +} + +func (suite *InstanceSuite) TestUndefinedReturnsUndefinedBits() { + data := suite.inst.Undefined() + + assert.Equal(suite.T(), []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF}, data) +} + +func (suite *InstanceSuite) TestUndefinedConsidersActiveRefinements() { + suite.inst.Set("field0", 0) + data := suite.inst.Undefined() + + assert.Equal(suite.T(), []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, data) +} diff --git a/modding/hacked/ss1/content/interpreters/Refinement.go b/modding/hacked/ss1/content/interpreters/Refinement.go new file mode 100644 index 0000000..9f930f9 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Refinement.go @@ -0,0 +1,8 @@ +package interpreters + +type refinement struct { + entry + + desc *Description + predicate Predicate +} diff --git a/modding/hacked/ss1/content/interpreters/Simplifier.go b/modding/hacked/ss1/content/interpreters/Simplifier.go new file mode 100644 index 0000000..7ab00ad --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Simplifier.go @@ -0,0 +1,193 @@ +package interpreters + +import ( + "math" +) + +// RawValueFormatter provides the format function for given value. +// If nothing special is to be displayed, an empty string should be returned. +// The handler is responsible for adding the number value representation. +type RawValueFormatter func(value int) string + +// RawValueHandler is for a simple value range. +type RawValueHandler func(minValue, maxValue int64, formatter RawValueFormatter) + +// EnumValueHandler is for enumerated (mapped) values. +type EnumValueHandler func(values map[uint32]string) + +// BitfieldHandler is for bitfields. +type BitfieldHandler func(values map[uint32]string) + +// ObjectIDHandler is for object identifier in a level. +type ObjectIDHandler func() + +// SpecialHandler is for rare occasions. +type SpecialHandler func() + +// RotationHandler is for circular values. +type RotationHandler func(minValue, maxValue int64) + +func basicToString(value int) string { + return "" +} + +// RangedValue creates a field range for specific minimum and maximum values. +func RangedValue(minValue, maxValue int64) FieldRange { + return FormattedRangedValue(minValue, maxValue, basicToString) +} + +// FormattedRangedValue is similar to RangedValue, and adds a formatting function. +func FormattedRangedValue(minValue, maxValue int64, formatter RawValueFormatter) FieldRange { + return func(simpl *Simplifier) bool { + return simpl.rangedValue(minValue, maxValue, formatter) + } +} + +// EnumValue creates a field range describing enumerated values. +func EnumValue(values map[uint32]string) FieldRange { + return func(simpl *Simplifier) bool { + return simpl.enumValue(values) + } +} + +// Bitfield creates a field range describing bitfield values. +func Bitfield(values map[uint32]string) FieldRange { + return func(simpl *Simplifier) bool { + return simpl.bitfield(values) + } +} + +// ObjectID creates a field range describing object identifier. +func ObjectID() FieldRange { + return func(simpl *Simplifier) bool { + return simpl.objectIndex() + } +} + +// RotationValue creates a field range for a rotational property. +func RotationValue(minValue, maxValue int64) FieldRange { + return func(simpl *Simplifier) bool { + return simpl.rotation(minValue, maxValue) + } +} + +// SpecialValue creates a field range for special fields. +// Currently known special values: +// * BinaryCodedDecimal - for keypads storing their number as BCD +// * LevelTexture - index value into level texture list +// * MaterialOrLevelTexture - index value into level texture list, or material (bit 7 toggles) +// * VariableKey - for actions +// * VariableCondition - for action conditions +// * ObjectTriple - for 0x00CCSSTT selection +// * ObjectHeight - for level height value 0..255 +// * MoveTileHeight - for change tile height action +// * TileHeight - tile heights +// * TileType - tile types +// * Unknown - It is unclear whether this field would have any effect, none identified so far +// * Ignored - Although values have been found in this field, they don't appear to have any effect +// * Mistake - It is assumed that these values should have been placed somewhere else. Typical example: Container content +// * Internal - Fields that are handled mainly by the engine and shouldn't be modified. +func SpecialValue(specialType string) FieldRange { + return func(simpl *Simplifier) bool { + return simpl.specialValue(specialType) + } +} + +// Simplifier forwards descriptions in a way the requester can use. +type Simplifier struct { + rawValueHandler RawValueHandler + enumValueHandler EnumValueHandler + bitfieldHandler BitfieldHandler + objectIDHandler ObjectIDHandler + rotationHandler RotationHandler + specialHandler map[string]SpecialHandler +} + +// NewSimplifier returns a new instance of a simplifier, with the minimal +// handler set. +func NewSimplifier(rawValueHandler RawValueHandler) *Simplifier { + return &Simplifier{ + rawValueHandler: rawValueHandler, + specialHandler: make(map[string]SpecialHandler)} +} + +func (simpl *Simplifier) rawValue(e *entry) { + max := int64(math.Pow(2, float64(e.count*8))) + if max == 256 { + simpl.rawValueHandler(0, 255, basicToString) + } else { + half := max / 2 + simpl.rawValueHandler(-1, half-1, basicToString) + } +} + +func (simpl *Simplifier) rangedValue(minValue, maxValue int64, formatter RawValueFormatter) bool { + simpl.rawValueHandler(minValue, maxValue, formatter) + return true +} + +// SetEnumValueHandler registers the handler for enumerations. +func (simpl *Simplifier) SetEnumValueHandler(handler EnumValueHandler) { + simpl.enumValueHandler = handler +} + +func (simpl *Simplifier) enumValue(values map[uint32]string) (result bool) { + if simpl.enumValueHandler != nil { + simpl.enumValueHandler(values) + result = true + } + return +} + +// SetBitfieldHandler registers the handler for bitfields. +func (simpl *Simplifier) SetBitfieldHandler(handler BitfieldHandler) { + simpl.bitfieldHandler = handler +} + +func (simpl *Simplifier) bitfield(values map[uint32]string) (result bool) { + if simpl.bitfieldHandler != nil { + simpl.bitfieldHandler(values) + result = true + } + return +} + +// SetObjectIDHandler registers the handler for object indices. +func (simpl *Simplifier) SetObjectIDHandler(handler ObjectIDHandler) { + simpl.objectIDHandler = handler +} + +func (simpl *Simplifier) objectIndex() (result bool) { + if simpl.objectIDHandler != nil { + simpl.objectIDHandler() + result = true + } + return +} + +// SetSpecialHandler registers the handler for special values. +func (simpl *Simplifier) SetSpecialHandler(specialType string, handler SpecialHandler) { + simpl.specialHandler[specialType] = handler +} + +func (simpl *Simplifier) specialValue(specialType string) (result bool) { + handler, existing := simpl.specialHandler[specialType] + if existing && (handler != nil) { + handler() + result = true + } + return +} + +// SetRotationHandler registers the handler for rotation values. +func (simpl *Simplifier) SetRotationHandler(handler RotationHandler) { + simpl.rotationHandler = handler +} + +func (simpl *Simplifier) rotation(minValue, maxValue int64) (result bool) { + if simpl.rotationHandler != nil { + simpl.rotationHandler(minValue, maxValue) + result = true + } + return +} diff --git a/modding/hacked/ss1/content/interpreters/Simplifier_test.go b/modding/hacked/ss1/content/interpreters/Simplifier_test.go new file mode 100644 index 0000000..f3d3464 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Simplifier_test.go @@ -0,0 +1,42 @@ +package interpreters // nolint: testpackage + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSimplifierRawValueCallsHandler(t *testing.T) { + var calledMinValue int64 + var calledMaxValue int64 + rawHandler := func(minValue, maxValue int64, formatter RawValueFormatter) { + calledMinValue, calledMaxValue = minValue, maxValue + } + simpl := NewSimplifier(rawHandler) + + simpl.rawValue(&entry{count: 2}) + + assert.Equal(t, int64(-1), calledMinValue) + assert.Equal(t, int64(32767), calledMaxValue) +} + +func TestSimplifierEnumValueReturnsFalseIfNoHandlerRegistered(t *testing.T) { + simpl := NewSimplifier(func(minValue, maxValue int64, formatter RawValueFormatter) {}) + + result := simpl.enumValue(map[uint32]string{}) + + assert.False(t, result) +} + +func TestSimplifierEnumValueCallsRegisteredHandler(t *testing.T) { + result := map[uint32]string{} + simpl := NewSimplifier(func(minValue, maxValue int64, formatter RawValueFormatter) {}) + simpl.SetEnumValueHandler(func(values map[uint32]string) { + result = values + }) + + expected := map[uint32]string{1: "value"} + simpl.enumValue(expected) + + assert.Equal(t, expected, result) +} diff --git a/modding/hacked/ss1/content/movie/Audio.go b/modding/hacked/ss1/content/movie/Audio.go new file mode 100644 index 0000000..710e229 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Audio.go @@ -0,0 +1,53 @@ +package movie + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" +) + +const audioEntrySize = 0x2000 + +// Audio represents the sound part of a movie. +type Audio struct { + Sound audio.L8 +} + +func (a Audio) duration() format.Timestamp { + if a.Sound.SampleRate <= 0 { + return format.Timestamp{} + } + return format.TimestampFromSeconds(a.Sound.Duration()) +} + +func (a Audio) encode() []format.EntryBucket { + buckets := make([]format.EntryBucket, 0, (len(a.Sound.Samples)/audioEntrySize)+1) + addBucket := func(ts format.Timestamp, samples []byte) { + buckets = append(buckets, + format.EntryBucket{ + Priority: format.EntryBucketPriorityAudio, + Timestamp: ts, + Entries: []format.Entry{ + { + Timestamp: ts, + Data: format.AudioEntryData{ + Samples: samples, + }, + }, + }, + }) + } + + startOffset := 0 + for (startOffset + audioEntrySize) <= len(a.Sound.Samples) { + ts := format.TimestampFromSeconds(float32(startOffset) / a.Sound.SampleRate) + endOffset := startOffset + audioEntrySize + addBucket(ts, a.Sound.Samples[startOffset:endOffset]) + startOffset = endOffset + } + if startOffset < len(a.Sound.Samples) { + ts := format.TimestampFromSeconds(float32(startOffset) / a.Sound.SampleRate) + addBucket(ts, a.Sound.Samples[startOffset:]) + } + + return buckets +} diff --git a/modding/hacked/ss1/content/movie/Cache.go b/modding/hacked/ss1/content/movie/Cache.go new file mode 100644 index 0000000..a10bc72 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Cache.go @@ -0,0 +1,132 @@ +package movie + +import ( + "bytes" + "io/ioutil" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" +) + +const sizeLimit = 0xFFFFFF + +// Cache retrieves movie container from a localizer and keeps them decoded until they are invalidated. +type Cache struct { + cp text.Codepage + + localizer resource.Localizer + + movies map[resource.Key]*cachedMovie +} + +type cachedMovie struct { + sizeWarning bool + container Container + scenes []Scene +} + +func (cached *cachedMovie) video() []Scene { + if len(cached.scenes) > 0 { + return cached.scenes + } + cached.scenes, _ = cached.container.Video.Decompress() + return cached.scenes +} + +// NewCache returns a new instance. +func NewCache(cp text.Codepage, localizer resource.Localizer) *Cache { + cache := &Cache{ + cp: cp, + localizer: localizer, + + movies: make(map[resource.Key]*cachedMovie), + } + return cache +} + +// InvalidateResources lets the cache remove any movies from resources that are specified in the given slice. +func (cache *Cache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.movies { + if key.ID == id { + delete(cache.movies, key) + } + } + } +} + +func (cache *Cache) cached(key resource.Key) (*cachedMovie, error) { + value, existing := cache.movies[key] + if existing { + return value, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID.Plus(key.Index)) + if err != nil { + return nil, err + } + if (view.ContentType() != resource.Movie) || view.Compound() || (view.BlockCount() != 1) { + return nil, resource.ErrWrongType(key, resource.Movie) + } + reader, err := view.Block(0) + if err != nil { + return nil, err + } + data, err := ioutil.ReadAll(reader) + if err != nil { + return nil, err + } + container, err := Read(bytes.NewReader(data), cache.cp) + if err != nil { + return nil, err + } + cached := &cachedMovie{ + sizeWarning: len(data) > sizeLimit, + container: container, + } + cache.movies[key] = cached + return cached, nil +} + +// SizeWarning returns true if the given movie is cached and is larger than the underlying archive would support. +func (cache *Cache) SizeWarning(key resource.Key) bool { + cached, existing := cache.movies[key] + return existing && cached.sizeWarning +} + +// Container retrieves and caches the underlying movie, and returns the complete container. +func (cache *Cache) Container(key resource.Key) (Container, error) { + cached, err := cache.cached(key) + if err != nil { + return Container{}, err + } + return cached.container, nil +} + +// Audio retrieves and caches the underlying movie, and returns the audio only. +func (cache *Cache) Audio(key resource.Key) (sound audio.L8, err error) { + cached, err := cache.cached(key) + if err != nil { + return + } + return cached.container.Audio.Sound, nil +} + +// Video retrieves and caches the underlying movie, and returns the complete list of decoded scenes. +func (cache *Cache) Video(key resource.Key) ([]Scene, error) { + cached, err := cache.cached(key) + if err != nil { + return nil, err + } + return cached.video(), nil +} + +// Subtitles retrieves and caches the underlying movie, and returns the subtitles for given language. +func (cache *Cache) Subtitles(key resource.Key, language resource.Language) (SubtitleList, error) { + cached, err := cache.cached(key) + if err != nil { + return SubtitleList{}, err + } + return cached.container.Subtitles.PerLanguage[language], nil +} diff --git a/modding/hacked/ss1/content/movie/ContainSoundData.go b/modding/hacked/ss1/content/movie/ContainSoundData.go new file mode 100644 index 0000000..f8885e1 --- /dev/null +++ b/modding/hacked/ss1/content/movie/ContainSoundData.go @@ -0,0 +1,18 @@ +package movie + +import ( + "bytes" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/text" +) + +// ContainSoundData packs a sound data into a container and encodes it. +func ContainSoundData(soundData audio.L8) []byte { + var container Container + container.Audio.Sound = soundData + + buffer := bytes.NewBuffer(nil) + _ = Write(buffer, container, text.DefaultCodepage()) + return buffer.Bytes() +} diff --git a/modding/hacked/ss1/content/movie/Container.go b/modding/hacked/ss1/content/movie/Container.go new file mode 100644 index 0000000..fff7921 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Container.go @@ -0,0 +1,20 @@ +package movie + +import "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" + +// Container wraps the information and data of a MOVI container. +type Container struct { + Audio Audio + Video Video + Subtitles Subtitles +} + +func (container Container) duration() format.Timestamp { + var max format.Timestamp + for _, ts := range []format.Timestamp{container.Audio.duration(), container.Video.duration(), container.Subtitles.duration()} { + if max.IsBefore(ts) { + max = ts + } + } + return max +} diff --git a/modding/hacked/ss1/content/movie/Read.go b/modding/hacked/ss1/content/movie/Read.go new file mode 100644 index 0000000..7105a97 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Read.go @@ -0,0 +1,211 @@ +package movie + +import ( + "bytes" + "encoding/binary" + "io" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial" +) + +const ( + errSourceIsNil ss1.StringError = "source is nil" + errInvalidFormat ss1.StringError = "not a MOVI format" + errLowResVideoNotSupported ss1.StringError = "low-res video not supported" +) + +// Read tries to extract a MOVI container from the provided reader. +// On success the position of the reader is past the last data entry. +// On failure the position of the reader is undefined. +func Read(source io.ReadSeeker, cp text.Codepage) (Container, error) { + if source == nil { + return Container{}, errSourceIsNil + } + + var header format.Header + startPos, _ := source.Seek(0, io.SeekCurrent) + var container Container + err := binary.Read(source, binary.LittleEndian, &header) + if err != nil { + return Container{}, err + } + err = verifyAndExtractHeader(&header, &container) + if err != nil { + return Container{}, err + } + var startPalette bitmap.Palette + err = readPalette(source, &startPalette) + if err != nil { + return Container{}, err + } + entries, err := readIndexAndEntries(source, startPos, &header) + if err != nil { + return Container{}, err + } + err = parseEntries(entries, &container, + cp, startPalette, format.Timestamp{Second: byte(header.Duration.Number), Fraction: header.Duration.Fraction}) + if err != nil { + return Container{}, err + } + + return container, nil +} + +func verifyAndExtractHeader(header *format.Header, container *Container) error { + if !bytes.Equal(header.Tag[:], bytes.NewBufferString(format.Tag).Bytes()) { + return errInvalidFormat + } + + container.Video.Height = header.VideoHeight + container.Video.Width = header.VideoWidth + container.Audio.Sound.SampleRate = header.AudioSampleRate.ToFloat() + return nil +} + +func readPalette(source io.Reader, pal *bitmap.Palette) error { + decoder := serial.NewDecoder(source) + decoder.Code(pal) + return decoder.FirstError() +} + +func readIndexAndEntries(source io.ReadSeeker, startPos int64, header *format.Header) ([]format.Entry, error) { + entries := make([]format.Entry, 0, header.IndexEntryCount) + indexEntries := make([]format.IndexTableEntry, header.IndexEntryCount) + err := binary.Read(source, binary.LittleEndian, indexEntries) + if err != nil { + return nil, err + } + for index, indexEntry := range indexEntries { + entryType := format.DataType(indexEntry.Type) + + if entryType != format.DataTypeEndOfMedia { + timestamp := format.Timestamp{ + Second: indexEntry.TimestampSecond, + Fraction: indexEntry.TimestampFraction, + } + length := int(indexEntries[index+1].DataOffset - indexEntry.DataOffset) + + _, err = source.Seek(startPos+int64(indexEntry.DataOffset), io.SeekStart) + if err != nil { + return nil, err + } + + entryData, err := readEntry(entryType, source, length) + if err != nil { + return nil, err + } + entries = append(entries, format.Entry{ + Timestamp: timestamp, + Data: entryData, + }) + } + } + return entries, nil +} + +func readEntry(entryType format.DataType, source io.Reader, dataSize int) (format.EntryData, error) { + limited := io.LimitReader(source, int64(dataSize)) + switch entryType { + case format.DataTypeLowResVideo: + return format.LowResVideoEntryFrom(limited, dataSize) + case format.DataTypeHighResVideo: + return format.HighResVideoEntryFrom(limited, dataSize) + case format.DataTypeAudio: + return format.AudioEntryFrom(limited, dataSize) + case format.DataTypeSubtitle: + return format.SubtitleEntryFrom(limited, dataSize) + case format.DataTypePalette: + return format.PaletteEntryFrom(limited) + case format.DataTypePaletteReset: + return format.PaletteResetEntryFrom() + case format.DataTypePaletteLookupList: + return format.PaletteLookupEntryFrom(limited, dataSize) + case format.DataTypeControlDictionary: + return format.ControlDictionaryEntryFrom(limited, dataSize) + default: + return format.UnknownEntryFrom(entryType, limited, dataSize) + } +} + +func parseEntries(entries []format.Entry, container *Container, + cp text.Codepage, startPalette bitmap.Palette, endTimestamp format.Timestamp) error { + palette := startPalette + var paletteLookup []byte + var controlDictionary []compression.ControlWord + var highResScene *HighResScene + sceneChanging := true + finishScene := func() { + if highResScene != nil { + container.Video.Scenes = append(container.Video.Scenes, *highResScene) + } + highResScene = nil + } + var frame *HighResFrame + finishFrame := func(timestamp format.Timestamp) { + if frame != nil { + frame.displayTime = timestamp.Minus(frame.displayTime) + highResScene.frames = append(highResScene.frames, *frame) + } + frame = nil + } + for _, entry := range entries { + switch data := entry.Data.(type) { + case format.AudioEntryData: + container.Audio.Sound.Samples = append(container.Audio.Sound.Samples, data.Samples...) + case format.SubtitleEntryData: + subText := cp.Decode(data.Text) + switch data.Control { + case format.SubtitleTextStd: + container.Subtitles.add(resource.LangDefault, entry.Timestamp.ToDuration(), subText) + case format.SubtitleTextGer: + container.Subtitles.add(resource.LangGerman, entry.Timestamp.ToDuration(), subText) + case format.SubtitleTextFrn: + container.Subtitles.add(resource.LangFrench, entry.Timestamp.ToDuration(), subText) + case format.SubtitleArea: + default: + } + case format.LowResVideoEntryData: + // ignored for now + return errLowResVideoNotSupported + case format.PaletteLookupEntryData: + sceneChanging = true + paletteLookup = data.List + case format.ControlDictionaryEntryData: + sceneChanging = true + controlDictionary = data.Words + case format.PaletteResetEntryData: + sceneChanging = true + palette = bitmap.Palette{} + case format.PaletteEntryData: + sceneChanging = true + palette = data.Colors + case format.HighResVideoEntryData: + finishFrame(entry.Timestamp) + if sceneChanging { + finishScene() + highResScene = &HighResScene{ + palette: palette, + paletteLookup: paletteLookup, + controlWords: controlDictionary, + } + sceneChanging = false + } + frame = &HighResFrame{ + bitstream: data.Bitstream, + maskstream: data.Maskstream, + displayTime: entry.Timestamp, + } + default: + } + } + finishFrame(endTimestamp) + finishScene() + + return nil +} diff --git a/modding/hacked/ss1/content/movie/Read_test.go b/modding/hacked/ss1/content/movie/Read_test.go new file mode 100644 index 0000000..bcff65f --- /dev/null +++ b/modding/hacked/ss1/content/movie/Read_test.go @@ -0,0 +1,87 @@ +package movie_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" + "github.com/inkyblackness/hacked/ss1/content/text" +) + +func TestReadReturnsErrorOnNil(t *testing.T) { + _, err := movie.Read(nil, text.DefaultCodepage()) + + assert.Errorf(t, err, "source is nil") +} + +func TestReadReturnsContainerOnEmptyFile(t *testing.T) { + buffer := bytes.NewBufferString(format.Tag) + buffer.Write(make([]byte, 0x100+0x300-len(format.Tag))) + emptyFile := buffer.Bytes() + source := bytes.NewReader(emptyFile) + container, _ := movie.Read(source, text.DefaultCodepage()) + + assert.NotNil(t, container) +} + +func TestReadReturnsErrorOnMissingTag(t *testing.T) { + emptyFile := make([]byte, 0x100+0x300) + source := bytes.NewReader(emptyFile) + _, err := movie.Read(source, text.DefaultCodepage()) + + assert.Errorf(t, err, "Not a MOVI format") +} + +func TestReadReturnsContainerWithBasicPropertiesSet(t *testing.T) { + buffer := bytes.NewBufferString(format.Tag) + buffer.Write(make([]byte, 0x100+0x300-len(format.Tag))) + emptyFile := buffer.Bytes() + + emptyFile[0x10] = 0x80 + emptyFile[0x11] = 0x40 + emptyFile[0x12] = 0x03 + emptyFile[0x18] = 0x80 + emptyFile[0x19] = 0x02 + emptyFile[0x1A] = 0xE0 + emptyFile[0x1B] = 0x01 + emptyFile[0x26] = 0x22 + emptyFile[0x27] = 0x56 + + source := bytes.NewReader(emptyFile) + container, _ := movie.Read(source, text.DefaultCodepage()) + + assert.Equal(t, uint16(640), container.Video.Width) + assert.Equal(t, uint16(480), container.Video.Height) + assert.Equal(t, uint16(22050), uint16(container.Audio.Sound.SampleRate)) +} + +func TestReadReturnsContainerWithAudioData(t *testing.T) { + testData := []byte{0x01, 0x02, 0x03, 0x04, 0x05} + buffer := bytes.NewBufferString(format.Tag) + buffer.Write(make([]byte, 0x100+0x300-len(format.Tag))) + buffer.Write(make([]byte, 0xC00)) + buffer.Write(testData) + raw := buffer.Bytes() + + raw[0x04] = 2 + // size of index table + raw[0x08] = 0x00 + raw[0x09] = 0x0C + + // index entry 0 + raw[0x0400+3] = 0x02 + raw[0x0400+4] = 0x00 + raw[0x0400+5] = 0x10 + // index entry 1 + raw[0x0408+3] = 0x00 + raw[0x0408+4] = byte(len(testData)) + raw[0x0408+5] = 0x10 + + source := bytes.NewReader(raw) + container, _ := movie.Read(source, text.DefaultCodepage()) + + assert.Equal(t, testData, container.Audio.Sound.Samples) +} diff --git a/modding/hacked/ss1/content/movie/Scene.go b/modding/hacked/ss1/content/movie/Scene.go new file mode 100644 index 0000000..2322c08 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Scene.go @@ -0,0 +1,20 @@ +package movie + +import ( + "time" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" +) + +// Scene describes a series of frames that share a common palette. +// Ideally, they also share the same framerate. +type Scene struct { + Palette bitmap.Palette + Frames []Frame +} + +// Frame describes a bitmap and how long it shall be displayed. +type Frame struct { + Pixels []byte + DisplayTime time.Duration +} diff --git a/modding/hacked/ss1/content/movie/Subtitles.go b/modding/hacked/ss1/content/movie/Subtitles.go new file mode 100644 index 0000000..873090b --- /dev/null +++ b/modding/hacked/ss1/content/movie/Subtitles.go @@ -0,0 +1,103 @@ +package movie + +import ( + "time" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// Subtitles contains all the subtitles in all languages. +type Subtitles struct { + PerLanguage [resource.LanguageCount]SubtitleList +} + +func (sub *Subtitles) add(lang resource.Language, timestamp time.Duration, text string) { + sub.PerLanguage[lang].Entries = append(sub.PerLanguage[lang].Entries, Subtitle{ + Timestamp: timestamp, + Text: text, + }) +} + +// ArePresent returns true if at least one language makes use of subtitles. +func (sub Subtitles) ArePresent() bool { + for _, lang := range sub.PerLanguage { + if len(lang.Entries) > 0 { + return true + } + } + return false +} + +func (sub Subtitles) duration() format.Timestamp { + var highest time.Duration + for _, list := range sub.PerLanguage { + for _, sub := range list.Entries { + if highest < sub.Timestamp { + highest = sub.Timestamp + } + } + } + return format.TimestampFromDuration(highest) +} + +func (sub Subtitles) encode(cp text.Codepage) [][]format.EntryBucket { + if !sub.ArePresent() { + return nil + } + bucketsPerLanguage := make([][]format.EntryBucket, len(sub.PerLanguage)+1) + + // Ensure a subtitle area is defined. + // The area is hardcoded. While the engine respects any area, placing the text in the + // frame area will have the pixels become overwritten. As such, there are many "wrong" options, + // and only a few right ones. There's no need to make them editable. + bucketsPerLanguage[0] = []format.EntryBucket{{ + Priority: format.EntryBucketPrioritySubtitleControl, + Timestamp: format.Timestamp{}, + Entries: []format.Entry{{ + Timestamp: format.Timestamp{}, + Data: format.SubtitleEntryData{ + Control: format.SubtitleArea, + Text: cp.Encode("20 365 620 395 CLR"), + }, + }}, + }} + for index, lang := range sub.PerLanguage { + bucketsPerLanguage[index+1] = lang.encode(format.SubtitleControlForLanguage(resource.Language(index)), cp) + } + return bucketsPerLanguage +} + +// SubtitleList describes the textual representation of a movie in one language. +type SubtitleList struct { + Entries []Subtitle +} + +func (sub SubtitleList) encode(control format.SubtitleControl, cp text.Codepage) []format.EntryBucket { + buckets := make([]format.EntryBucket, 0, len(sub.Entries)) + for _, entry := range sub.Entries { + buckets = append(buckets, entry.encode(control, cp)) + } + return buckets +} + +// Subtitle is a timestamped text for subtitles. +type Subtitle struct { + Timestamp time.Duration + Text string +} + +func (sub Subtitle) encode(control format.SubtitleControl, cp text.Codepage) format.EntryBucket { + return format.EntryBucket{ + Priority: format.EntryBucketPrioritySubtitle, + Timestamp: format.TimestampFromDuration(sub.Timestamp), + Entries: []format.Entry{{ + Timestamp: format.TimestampFromDuration(sub.Timestamp), + Data: format.SubtitleEntryData{ + Control: control, + Text: cp.Encode(sub.Text), + }, + }}, + } +} diff --git a/modding/hacked/ss1/content/movie/Video.go b/modding/hacked/ss1/content/movie/Video.go new file mode 100644 index 0000000..3b9e249 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Video.go @@ -0,0 +1,208 @@ +package movie + +import ( + "context" + "time" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" +) + +// Constants for video. +const ( + HighResDefaultWidth = 600 + HighResDefaultHeight = 300 +) + +// Video describes the visual part of a movie. +type Video struct { + // Width is the width of the video in pixel. + Width uint16 + // Height is the height of the video in pixel. + Height uint16 + // Scenes contain the frames of the video. + Scenes []HighResScene +} + +// StartPalette returns the palette of the first scene. If no scene is present, a black palette is returned. +func (video Video) StartPalette() bitmap.Palette { + if len(video.Scenes) == 0 { + return bitmap.Palette{} + } + return video.Scenes[0].palette +} + +func (video Video) duration() format.Timestamp { + var sum format.Timestamp + for _, scene := range video.Scenes { + sum = sum.Plus(scene.duration()) + } + return sum +} + +func (video Video) encode() []format.EntryBucket { + var sceneTime format.Timestamp + var buckets []format.EntryBucket + for index, scene := range video.Scenes { + buckets = append(buckets, scene.encode(sceneTime, index != 0)...) + sceneTime = sceneTime.Plus(scene.duration()) + } + return buckets +} + +// Decompress unpacks all the frames of all scenes. +func (video Video) Decompress() ([]Scene, error) { + var scenes []Scene + width := int(video.Width) + height := int(video.Height) + frameBuffer := make([]byte, width*height) + decoderBuilder := compression.NewFrameDecoderBuilder(width, height) + decoderBuilder.ForStandardFrame(frameBuffer, width) + + cloneFramebuffer := func() []byte { + bufferCopy := make([]byte, len(frameBuffer)) + copy(bufferCopy, frameBuffer) + return bufferCopy + } + + for _, compressedScene := range video.Scenes { + decoderBuilder.WithControlWords(compressedScene.controlWords) + decoderBuilder.WithPaletteLookupList(compressedScene.paletteLookup) + decoder := decoderBuilder.Build() + var scene Scene + scene.Palette = compressedScene.palette + for _, compressedFrame := range compressedScene.frames { + err := decoder.Decode(compressedFrame.bitstream, compressedFrame.maskstream) + if err != nil { + return nil, err + } + scene.Frames = append(scene.Frames, Frame{ + Pixels: cloneFramebuffer(), + DisplayTime: compressedFrame.displayTime.ToDuration(), + }) + } + scenes = append(scenes, scene) + } + return scenes, nil +} + +// HighResScene is a set of frames with high-resolution compression. +type HighResScene struct { + palette bitmap.Palette + paletteLookup []byte + controlWords []compression.ControlWord + frames []HighResFrame +} + +// HighResSceneFrom compresses given scene and returns the compression result. +func HighResSceneFrom(ctx context.Context, scene Scene, width, height int) (HighResScene, error) { + encoder := compression.NewSceneEncoder(width, height) + for _, frame := range scene.Frames { + err := encoder.AddFrame(frame.Pixels) + if err != nil { + return HighResScene{}, err + } + if ctx.Err() != nil { + return HighResScene{}, ctx.Err() + } + } + words, paletteLookup, frames, err := encoder.Encode(ctx) + if err != nil { + return HighResScene{}, err + } + compressedScene := HighResScene{ + palette: scene.Palette, + paletteLookup: paletteLookup, + controlWords: words, + frames: make([]HighResFrame, len(frames)), + } + for index, frame := range frames { + compressedScene.frames[index] = HighResFrame{ + bitstream: frame.Bitstream, + maskstream: frame.Maskstream, + displayTime: format.TimestampFromDuration(scene.Frames[index].DisplayTime), + } + } + + return compressedScene, nil +} + +// WithFrameDisplayTime returns a new scene instance with the given display time set for all frames. +func (scene HighResScene) WithFrameDisplayTime(displayTime time.Duration) HighResScene { + newScene := scene + newFrames := make([]HighResFrame, len(scene.frames)) + for index, frame := range scene.frames { + newFrames[index] = frame.WithDisplayTime(displayTime) + } + newScene.frames = newFrames + return newScene +} + +func (scene HighResScene) duration() format.Timestamp { + var sum format.Timestamp + for _, frame := range scene.frames { + sum = sum.Plus(frame.duration()) + } + return sum +} + +func (scene HighResScene) encode(start format.Timestamp, withPalette bool) []format.EntryBucket { + buckets := make([]format.EntryBucket, 0, len(scene.frames)+1) + controlEntries := []format.Entry{ + {Timestamp: format.Timestamp{}, Data: format.PaletteLookupEntryData{List: scene.paletteLookup}}, + {Timestamp: format.Timestamp{}, Data: format.ControlDictionaryEntryData{Words: scene.controlWords}}, + } + if withPalette { + controlEntries = append(controlEntries, + format.Entry{Timestamp: start, Data: format.PaletteResetEntryData{}}, + format.Entry{Timestamp: start, Data: format.PaletteEntryData{Colors: scene.palette}}) + } + buckets = append(buckets, + format.EntryBucket{ + Priority: format.EntryBucketPriorityVideoControl, + Timestamp: start, + Entries: controlEntries, + }) + frameTime := start + for _, frame := range scene.frames { + buckets = append(buckets, frame.encode(frameTime)) + frameTime = frameTime.Plus(frame.displayTime) + } + + return buckets +} + +// HighResFrame contains the compressed information of a high-resolution picture in a scene. +type HighResFrame struct { + bitstream []byte + maskstream []byte + displayTime format.Timestamp +} + +// WithDisplayTime returns a new instance with the given display time set. +func (frame HighResFrame) WithDisplayTime(displayTime time.Duration) HighResFrame { + newFrame := frame + newFrame.displayTime = format.TimestampFromDuration(displayTime) + return newFrame +} + +func (frame HighResFrame) duration() format.Timestamp { + return frame.displayTime +} + +func (frame HighResFrame) encode(start format.Timestamp) format.EntryBucket { + return format.EntryBucket{ + Priority: format.EntryBucketPriorityFrame, + Timestamp: start, + Entries: []format.Entry{ + { + Timestamp: start, + Data: format.HighResVideoEntryData{ + Bitstream: frame.bitstream, + Maskstream: frame.maskstream, + }, + }, + }, + } +} diff --git a/modding/hacked/ss1/content/movie/Write.go b/modding/hacked/ss1/content/movie/Write.go new file mode 100644 index 0000000..1f2f355 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Write.go @@ -0,0 +1,143 @@ +package movie + +import ( + "bytes" + "encoding/binary" + "io" + "sort" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" + "github.com/inkyblackness/hacked/ss1/content/text" +) + +// Write encodes the provided container into the given writer. +func Write(dest io.Writer, container Container, cp text.Codepage) error { + var indexEntries []format.IndexTableEntry + var header format.Header + palette := paletteDataFromContainer(container) + + // setup header + copy(header.Tag[:], bytes.NewBufferString(format.Tag).Bytes()) + endTimestamp := container.duration() + header.Duration.Number = int16(endTimestamp.Second) + header.Duration.Fraction = endTimestamp.Fraction + header.VideoWidth = container.Video.Width + header.VideoHeight = container.Video.Height + + if header.VideoWidth != 0 { + header.VideoBitsPerPixel = 8 + header.VideoPalettePresent = 1 + header.VideoFrameRate = format.FixFromFloat(12.5) // take a good guess on what a typical framerate would be. + } + if !container.Audio.Sound.Empty() { + header.AudioChannelCount = 1 + header.AudioBytesPerSample = 1 + header.AudioSampleRate = format.FixFromFloat(container.Audio.Sound.SampleRate) + } + + var buckets []format.EntryBucket + buckets = append(buckets, container.Audio.encode()...) + buckets = append(buckets, container.Video.encode()...) + subtitleBucketsList := container.Subtitles.encode(cp) + for _, subtitleBuckets := range subtitleBucketsList { + buckets = append(buckets, subtitleBuckets...) + } + sort.Slice(buckets, func(a, b int) bool { + bucketA := buckets[a] + bucketB := buckets[b] + if bucketA.Timestamp.IsBefore(bucketB.Timestamp) { + return true + } + if bucketB.Timestamp.IsBefore(bucketA.Timestamp) { + return false + } + return bucketA.Priority < bucketB.Priority + }) + + // create index + var entries []format.Entry + for _, bucket := range buckets { + entries = append(entries, bucket.Entries...) + } + moveSubtitlesAfterSceneChange(entries) + + for _, dataEntry := range entries { + indexEntry := format.IndexTableEntry{ + Type: byte(dataEntry.Data.Type()), + DataOffset: header.ContentSize, + TimestampSecond: dataEntry.Timestamp.Second, + TimestampFraction: dataEntry.Timestamp.Fraction, + } + + header.ContentSize += int32(len(dataEntry.Data.Bytes())) + indexEntries = append(indexEntries, indexEntry) + } + // calculate size fields + header.IndexSize = int32(format.IndexTableSizeFor(len(indexEntries) + 1)) + dataStartOffset := format.HeaderSize + int32(len(palette)) + header.IndexSize + for i := range indexEntries { + indexEntries[i].DataOffset += dataStartOffset + } + + // determine end + lastEntry := format.IndexTableEntry{ + TimestampSecond: byte(header.Duration.Number), + TimestampFraction: header.Duration.Fraction, + Type: byte(format.DataTypeEndOfMedia), + DataOffset: dataStartOffset + header.ContentSize} + indexEntries = append(indexEntries, lastEntry) + header.IndexEntryCount = int32(len(indexEntries)) + + // write data + err := binary.Write(dest, binary.LittleEndian, &header) + if err != nil { + return err + } + _, err = dest.Write(palette) + if err != nil { + return err + } + err = binary.Write(dest, binary.LittleEndian, indexEntries) + if err != nil { + return err + } + _, err = dest.Write(make([]byte, int(header.IndexSize)-int(header.IndexEntryCount)*format.IndexTableEntrySize)) + if err != nil { + return err + } + for _, dataEntry := range entries { + _, err = dest.Write(dataEntry.Data.Bytes()) + if err != nil { + return err + } + } + return nil +} + +func moveSubtitlesAfterSceneChange(entries []format.Entry) { + nextSceneChangeIndex := -1 + var nextSceneChangeTimestamp format.Timestamp + delta := format.TimestampFromSeconds(0.5) + for i := len(entries) - 1; i >= 0; i-- { + entry := entries[i] + + switch entry.Data.Type() { + case format.DataTypePalette: + nextSceneChangeIndex = i + nextSceneChangeTimestamp = entry.Timestamp + case format.DataTypeSubtitle: + if (nextSceneChangeIndex > 0) && nextSceneChangeTimestamp.IsBefore(entry.Timestamp.Plus(delta)) { + copy(entries[i:nextSceneChangeIndex], entries[i+1:nextSceneChangeIndex+1]) + entries[nextSceneChangeIndex] = entry + nextSceneChangeIndex-- + } + } + } +} + +func paletteDataFromContainer(container Container) []byte { + palette := container.Video.StartPalette() + buf := bytes.NewBuffer(nil) + _ = binary.Write(buf, binary.LittleEndian, &palette) + return buf.Bytes() +} diff --git a/modding/hacked/ss1/content/movie/Write_test.go b/modding/hacked/ss1/content/movie/Write_test.go new file mode 100644 index 0000000..b0ecd2d --- /dev/null +++ b/modding/hacked/ss1/content/movie/Write_test.go @@ -0,0 +1,50 @@ +package movie_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/content/text" +) + +func TestWriteOfEmptyContainerCreatesMinimumSizeData(t *testing.T) { + var container movie.Container + buffer := bytes.NewBuffer(nil) + + err := movie.Write(buffer, container, text.DefaultCodepage()) + require.Nil(t, err) + assert.Equal(t, 0x0800, len(buffer.Bytes())) +} + +func TestWriteCanSaveEmptyContainer(t *testing.T) { + var container movie.Container + buffer := bytes.NewBuffer(nil) + + err := movie.Write(buffer, container, text.DefaultCodepage()) + require.Nil(t, err) + + _, err = movie.Read(bytes.NewReader(buffer.Bytes()), text.DefaultCodepage()) + + require.Nil(t, err) +} + +func TestWriteSavesAudio(t *testing.T) { + dataBytes := []byte{0x01, 0x02, 0x03} + var container movie.Container + container.Audio.Sound.SampleRate = 22050.0 + container.Audio.Sound.Samples = dataBytes + buffer := bytes.NewBuffer(nil) + + err := movie.Write(buffer, container, text.DefaultCodepage()) + require.Nil(t, err) + + result, err := movie.Read(bytes.NewReader(buffer.Bytes()), text.DefaultCodepage()) + + require.Nil(t, err) + require.NotNil(t, result) + assert.Equal(t, dataBytes, result.Audio.Sound.Samples) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader.go b/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader.go new file mode 100644 index 0000000..ac287be --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader.go @@ -0,0 +1,87 @@ +package compression + +// BitstreamReader is a utility to read big-endian integer values of arbitrary bit size from a bitstream. +type BitstreamReader struct { + source []byte + nextSourceIndex int + sourceBitLen uint64 + + currentBitPos uint64 + + buffer uint64 + bitsBuffered uint64 +} + +// NewBitstreamReader returns a new instance of a bistream reader using the provided byte slice as source. +func NewBitstreamReader(data []byte) *BitstreamReader { + return &BitstreamReader{ + source: data, + sourceBitLen: uint64(len(data)) * 8, + } +} + +func (reader *BitstreamReader) bufferNextByte() { + reader.buffer = (reader.buffer << 8) | uint64(reader.source[reader.nextSourceIndex]) + reader.bitsBuffered += 8 + reader.nextSourceIndex++ +} + +// Exhausted returns true if the current position is at or beyond the possible length. +// Reading an exhausted stream yields only zeroes. +func (reader *BitstreamReader) Exhausted() bool { + return reader.currentBitPos >= reader.sourceBitLen +} + +// Read returns a value with the requested bit size, right aligned, as a uint32. +// Reading does not advance the current position. A successful read of a certain size will return the same +// value when called repeatedly with the same parameter. +// Reading is possible beyond the available size. For the missing bits, a value of 0 is provided. +// +// The function panics when reading more than 32 bits. +func (reader *BitstreamReader) Read(bits int) (result uint32) { + if bits > 32 { + panic("Limit of bit count: 32") + } + + available := reader.sourceBitLen - reader.currentBitPos + toRead := uint64(bits) + if toRead > available { + toRead = available + } + + for reader.bitsBuffered < toRead { + reader.bufferNextByte() + } + result = uint32(reader.buffer >> (reader.bitsBuffered - toRead) & ^(uint64(0xFFFFFFFFFFFFFFFF) << toRead)) + result <<= uint64(bits) - toRead + + return +} + +// Advance skips the provided amount of bits and puts the current position there. +// Successive read operations will return values from the new position. +// It is possible to advance beyond the available length. +// +// This function panics when advancing with negative values. +func (reader *BitstreamReader) Advance(bits int) { + if bits < 0 { + panic("Can only advance forward") + } + + newIndex := reader.currentBitPos + uint64(bits) + if newIndex > reader.sourceBitLen { + newIndex = reader.sourceBitLen + } + reader.currentBitPos = newIndex + if uint64(bits) > reader.bitsBuffered { + reader.bitsBuffered = 0 + reader.nextSourceIndex = int(reader.currentBitPos / 8) + remainder := reader.currentBitPos % 8 + if remainder > 0 { + reader.bufferNextByte() + reader.bitsBuffered -= remainder + } + } else { + reader.bitsBuffered -= uint64(bits) + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader_test.go b/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader_test.go new file mode 100644 index 0000000..a8f3297 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader_test.go @@ -0,0 +1,181 @@ +package compression_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + + "github.com/stretchr/testify/assert" +) + +func TestBitstreamReadPanicsForMoreThan32Bits(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0x11, 0x22, 0x33, 0x44}) + + assert.Panicsf(t, func() { reader.Read(33) }, "Limit of bit count: 32") +} + +func TestBitstreamReadReturnsValueOfRequestedBitSize(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xAF}) + + result := reader.Read(3) + + assert.Equal(t, uint32(5), result) +} + +func TestBitstreamRepeatedReadReturnsSameValue(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xAF}) + + result1 := reader.Read(3) + result2 := reader.Read(3) + + assert.Equal(t, uint32(5), result1) + assert.Equal(t, result1, result2) +} + +func TestBitstreamReadReturnsZeroesForBitsBeyondEndA(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xAF}) + + result := reader.Read(9) + + assert.Equal(t, uint32(0x15E), result) +} + +func TestBitstreamReadReturnsZeroesForBitsBeyondEndB(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xBF}) + + result := reader.Read(32) + + assert.Equal(t, uint32(0xBF000000), result) +} + +func TestBitstreamAdvancePanicsForNegativeValues(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0x11, 0x22, 0x33, 0x44}) + + assert.Panicsf(t, func() { reader.Advance(-10) }, "Can only advance forward") +} + +func TestBitstreamAdvanceLetsReadFurtherBits(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xAF}) + + reader.Advance(2) + result := reader.Read(4) + + assert.Equal(t, uint32(0x0B), result) +} + +func TestBitstreamAdvanceToEndIsPossible(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xAF}) + + reader.Advance(8) + result := reader.Read(8) + + assert.Equal(t, uint32(0), result) +} + +func TestBitstreamInternalBufferDoesNotLoseData(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0x7F, 0xFF, 0xFF, 0xFF, 0x80}) + + reader.Advance(1) + result := reader.Read(32) + + assert.Equal(t, uint32(0xFFFFFFFF), result) +} + +func TestBitstreamAdvanceCanJumpToLastBit(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0x00, 0x00, 0x01}) + + reader.Advance(23) + result := reader.Read(1) + + assert.Equal(t, uint32(1), result) +} + +func TestBitstreamReadAdvanceBeyondFirstRead(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF, 0x00, 0xFA}) + + reader.Read(10) + reader.Advance(20) + result := reader.Read(4) + + assert.Equal(t, uint32(0x0A), result) +} + +func TestBitstreamReadAdvanceWithinFirstRead(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF, 0x00, 0xFA}) + + reader.Read(10) + reader.Advance(4) + result := reader.Read(20) + + assert.Equal(t, uint32(0xF00FA), result) +} + +func TestBitstreamReadOfZeroBitsIsPossibleMidStream(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF}) + + reader.Read(4) + result := reader.Read(0) + + assert.Equal(t, uint32(0), result) +} + +func TestBitstreamReadOfZeroBitsIsPossibleAtEnd(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF}) + + reader.Advance(8) + result := reader.Read(0) + + assert.Equal(t, uint32(0), result) +} + +func TestBitstreamReadOfZeroBitsIsPossibleWithEmptySource(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{}) + + result := reader.Read(0) + + assert.Equal(t, uint32(0), result) +} + +func TestBitstreamAdvanceBeyondEndIsPossible(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF}) + + reader.Advance(9) + result := reader.Read(32) + + assert.Equal(t, uint32(0), result) +} + +func TestBitstreamExhaustedReturnsFalseForAvailableBits(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF}) + + result := reader.Exhausted() + + assert.False(t, result) +} + +func TestBitstreamExhaustedReturnsFalseForStillOneAvailableBit(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF}) + + reader.Advance(7) + result := reader.Exhausted() + + assert.False(t, result) +} + +func TestBitstreamExhaustedReturnsTrueAfterAdvancingToEnd(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF, 0xFF}) + + reader.Advance(16) + result := reader.Exhausted() + + assert.True(t, result) +} + +func TestBitstreamExhaustedReturnsTrueAfterAdvancingBeyondEnd(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF, 0xFF}) + + reader.Advance(30) + result := reader.Exhausted() + + assert.True(t, result) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter.go b/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter.go new file mode 100644 index 0000000..761972e --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter.go @@ -0,0 +1,34 @@ +package compression + +// BitstreamWriter is a utility to write big-endian integer values of arbitrary bit size to a bitstream. +type BitstreamWriter struct { + buf []byte + + offset uint +} + +// Buffer returns the currently stored buffer. +func (w BitstreamWriter) Buffer() []byte { + return w.buf +} + +// Write stores the given amount of bits from the provided value. +// Trying to write more than 32 bits will cause panic. +func (w *BitstreamWriter) Write(bits uint, value uint32) { + if bits > 32 { + panic("maximum of 32 bits are possible to be written") + } + for bits > 0 { + temp := (uint64(value) & ^(^uint64(0) << bits)) << (40 - bits - w.offset) + if w.offset == 0 { + w.buf = append(w.buf, 0x00) + } + w.buf[len(w.buf)-1] |= byte(temp >> 32) + written := 8 - w.offset + if written > bits { + written = bits + } + bits -= written + w.offset = (w.offset + written) % 8 + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter_test.go b/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter_test.go new file mode 100644 index 0000000..08be2b3 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter_test.go @@ -0,0 +1,99 @@ +package compression_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type BitstreamWriterSuite struct { + suite.Suite + + writer compression.BitstreamWriter +} + +func TestBitstreamWriter(t *testing.T) { + suite.Run(t, new(BitstreamWriterSuite)) +} + +func (suite *BitstreamWriterSuite) SetupTest() { + suite.writer = compression.BitstreamWriter{} +} + +func (suite *BitstreamWriterSuite) TestWriteMoreThan32BitsPanics() { + assert.Panics(suite.T(), func() { suite.whenWriting(33, 0) }) +} + +func (suite *BitstreamWriterSuite) TestWriteZeroBitsIsIgnored() { + suite.whenWriting(0, 1) + suite.thenBufferShouldBe([]byte{}) +} + +func (suite *BitstreamWriterSuite) TestWriteOneBit() { + suite.whenWriting(1, 1) + suite.thenBufferShouldBe([]byte{0x80}) +} + +func (suite *BitstreamWriterSuite) TestWriteEightBits() { + suite.whenWriting(8, 0xEF) + suite.thenBufferShouldBe([]byte{0xEF}) +} + +func (suite *BitstreamWriterSuite) TestWriteTwelveBits() { + suite.whenWriting(12, 0xFEF) + suite.thenBufferShouldBe([]byte{0xFE, 0xF0}) +} + +func (suite *BitstreamWriterSuite) TestWriteOneBitTwice() { + suite.givenWritten(1, 1) + suite.whenWriting(1, 1) + suite.thenBufferShouldBe([]byte{0xC0}) +} + +func (suite *BitstreamWriterSuite) TestWriteArbitrary1() { + suite.givenWritten(1, 1) + suite.givenWritten(10, 0x2AA) + suite.whenWriting(16, 0xFFFF) + suite.thenBufferShouldBe([]byte{0xD5, 0x5F, 0xFF, 0xE0}) // [1 10 10101] [010 11111] [11111111] [111 00000] +} + +func (suite *BitstreamWriterSuite) TestWriteArbitrary2() { + suite.givenWritten(1, 1) + suite.givenWritten(10, 0x2AA) + suite.whenWriting(24, 0xFFFFFF) + suite.thenBufferShouldBe([]byte{0xD5, 0x5F, 0xFF, 0xFF, 0xE0}) // extension of Arbitrary1 +} + +func (suite *BitstreamWriterSuite) TestWriteLessBitsThanGiven() { + suite.givenWritten(4, 0x0) + suite.whenWriting(8, 0xFFF) + suite.thenBufferShouldBe([]byte{0x0F, 0xF0}) +} + +func (suite *BitstreamWriterSuite) TestWriteArbitrary3() { + suite.givenWritten(1, 1) + suite.givenWritten(10, 0x2AA) + suite.whenWriting(32, 0xFFFFFFFF) + suite.thenBufferShouldBe([]byte{0xD5, 0x5F, 0xFF, 0xFF, 0xFF, 0xE0}) // extension of Arbitrary1+2 +} + +func (suite *BitstreamWriterSuite) givenWritten(bits uint, value uint32) { + suite.writer.Write(bits, value) +} + +func (suite *BitstreamWriterSuite) whenWriting(bits uint, value uint32) { + suite.writer.Write(bits, value) +} + +func (suite *BitstreamWriterSuite) thenBufferShouldBe(expected []byte) { + suite.T().Helper() + buf := suite.writer.Buffer() + if len(expected) == 0 { + assert.Equal(suite.T(), 0, len(buf)) + } else { + assert.Equal(suite.T(), expected, buf) + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/Compression_test.go b/modding/hacked/ss1/content/movie/internal/compression/Compression_test.go new file mode 100644 index 0000000..a229cdd --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/Compression_test.go @@ -0,0 +1,241 @@ +package compression_test + +import ( + "context" + "fmt" + "io/ioutil" + "math/rand" + "path/filepath" + "testing" + "time" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type CompressionSuite struct { + suite.Suite +} + +func TestTileSideLength(t *testing.T) { + assert.Equal(t, 4, compression.TileSideLength, "The following tests assume a tile side length of 4") +} + +func TestCompressionTest(t *testing.T) { + suite.Run(t, new(CompressionSuite)) +} + +func (suite *CompressionSuite) SetupTest() { + +} + +func (suite *CompressionSuite) TestOneFrameOneTile() { + frame0 := []byte{ + 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, + 0x0D, 0x0E, 0x0F, 0x10, + } + suite.verifyCompression(4, 4, frame0) +} + +func (suite *CompressionSuite) TestTwoFramesOneTile() { + frame0 := []byte{ + 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, + 0x0D, 0x0E, 0x0F, 0x10, + } + frame1 := []byte{ + 0x20, 0x21, 0x22, 0x23, + 0x24, 0x25, 0x26, 0x27, + 0x28, 0x29, 0x2A, 0x2B, + 0x2C, 0x2D, 0x2E, 0x2F, + } + suite.verifyCompression(4, 4, frame0, frame1) +} + +func (suite *CompressionSuite) TestTwoTiles() { + frame0 := []byte{ + 0x01, 0x02, 0x03, 0x04, 0x20, 0x21, 0x22, 0x23, + 0x05, 0x06, 0x07, 0x08, 0x24, 0x25, 0x26, 0x27, + 0x09, 0x0A, 0x0B, 0x0C, 0x28, 0x29, 0x2A, 0x2B, + 0x0D, 0x0E, 0x0F, 0x10, 0x2C, 0x2D, 0x2E, 0x2F, + } + suite.verifyCompression(8, 4, frame0) +} + +func (suite *CompressionSuite) verifyCompression(width, height int, inFrames ...[]byte) { + suite.T().Helper() + verifyCompression(suite.T(), width, height, inFrames...) +} + +func BenchmarkRecompressionDeth00(b *testing.B) { + recompress(b, "deth", "scene00") +} + +func BenchmarkRecompressionDeth01(b *testing.B) { + recompress(b, "deth", "scene01") +} + +func BenchmarkRecompressionDeth02(b *testing.B) { + recompress(b, "deth", "scene02") +} + +func recompress(tb testing.TB, dir1, dir2 string) { + tb.Helper() + scenepath := filepath.Join(".", "_testdata", dir1, dir2) + read := func(name string) []byte { + data, err := ioutil.ReadFile(filepath.Join(scenepath, name)) + require.Nil(tb, err, "no error expected reading %v", name) + return data + } + controlWordsData := read("controlDict.bin") + controlWords, err := compression.UnpackControlWords(controlWordsData) + require.Nil(tb, err, "no error expected unpacking control words") + paletteLookup := read("paletteLookup.bin") + + width := 600 + height := 300 + frame := make([]byte, width*height) + decoderBuilder := compression.NewFrameDecoderBuilder(600, 300) + + logStatistics(tb, "In", controlWords, paletteLookup) + + decoderBuilder.WithControlWords(controlWords) + decoderBuilder.WithPaletteLookupList(paletteLookup) + decoderBuilder.ForStandardFrame(frame, width) + decoder := decoderBuilder.Build() + canDecodeFrame := func(index int) bool { + bitstream, err := ioutil.ReadFile(filepath.Join(scenepath, fmt.Sprintf("frame%02d_bitstream.bin", index))) + if err != nil { + return false + } + maskstream, err := ioutil.ReadFile(filepath.Join(scenepath, fmt.Sprintf("frame%02d_maskstream.bin", index))) + if err != nil { + return false + } + + tb.Logf("InStatistics F%2d: Bitstream: %v, Maskstream: %v", index, len(bitstream), len(maskstream)) + err = decoder.Decode(bitstream, maskstream) + if err != nil { + tb.Logf("Failed to decode frame F%2d: %v", index, err) + return false + } + return true + } + frameIndex := 0 + var frames [][]byte + for canDecodeFrame(frameIndex) { + frameCopy := make([]byte, len(frame)) + copy(frameCopy, frame) + frames = append(frames, frameCopy) + frameIndex++ + } + + tb.Logf("verifying compression of %v frames\n", len(frames)) + verifyCompression(tb, width, height, frames...) +} + +func BenchmarkRandomFrames(b *testing.B) { + // With a frame size of 600x300 we have 150*75=11250 tiles per frame. + // The maximum palette offset is 0x1FFFF, allowing for 8191 entries of full 16 byte (fully random tile) palettes. + // This means not even one frame of completely randomized tiles can be encoded. + // As a result, this test will first create a set of 4000 random tiles, and then create a sequence of frames + // using this set of tiles, instead of creating complete random frames. + // Still, this test is rather slow, so it is moved to be a benchmark for now. + + seed := time.Now().UnixNano() + b.Logf("Running with seed %v", seed) + hTiles := 150 + vTiles := 75 + width := 4 * hTiles + height := 4 * vTiles + r := rand.New(rand.NewSource(seed)) + tiles := make([][4][4]byte, 4000) + for tileIndex := 0; tileIndex < len(tiles); tileIndex++ { + tile := &tiles[tileIndex] + for y := 0; y < 4; y++ { + for x := 0; x < 4; x++ { + tile[y][x] = byte(1 + r.Intn(255)) // value of 0x00 may not be encoded. + } + } + } + + frames := make([][]byte, 5) + for frameIndex := 0; frameIndex < len(frames); frameIndex++ { + frame := make([]byte, width*height) + for y := 0; y < height; y += 4 { + for x := 0; x < width; x += 4 { + tile := &tiles[r.Intn(len(tiles))] + for line := 0; line < 4; line++ { + copy(frame[(y+line)*width+x:], tile[line][:]) + } + } + } + frames[frameIndex] = frame + } + + b.ResetTimer() + verifyCompression(b, width, height, frames...) +} + +func verifyCompression(tb testing.TB, width, height int, inFrames ...[]byte) { + tb.Helper() + encoder := compression.NewSceneEncoder(width, height) + for frameIndex, frame := range inFrames { + require.Equal(tb, width*height, len(frame), fmt.Sprintf("Length of frame %d is wrong for dimension", frameIndex)) + err := encoder.AddFrame(frame) + require.Nil(tb, err, fmt.Sprintf("no error expected adding frame %d: %v", frameIndex, err)) + } + controlWords, paletteLookup, encodedFrames, err := encoder.Encode(context.Background()) + require.Equal(tb, len(inFrames), len(encodedFrames), "expected equal amount of encoded frames for input frames") + require.Nil(tb, err, fmt.Sprintf("no error expected encoding: %v", err)) + + logStatistics(tb, "Out", controlWords, paletteLookup) + + decoderBuilder := compression.NewFrameDecoderBuilder(width, height) + decoderBuilder.WithControlWords(controlWords) + decoderBuilder.WithPaletteLookupList(paletteLookup) + frameBuffer := make([]byte, width*height) + decoderBuilder.ForStandardFrame(frameBuffer, width) + for frameIndex, encodedFrame := range encodedFrames { + decoder := decoderBuilder.Build() + tb.Logf("Statistics F%2d: Bitstream: %v, Maskstream: %v", + frameIndex, len(encodedFrame.Bitstream), len(encodedFrame.Maskstream)) + err = decoder.Decode(encodedFrame.Bitstream, encodedFrame.Maskstream) + assert.Nil(tb, err, fmt.Sprintf("no error expected re-decoding: %v", err)) + assert.Equal(tb, inFrames[frameIndex], frameBuffer, fmt.Sprintf("Frame content mismatch for frame %d", frameIndex)) + } +} + +func logStatistics(tb testing.TB, prefix string, controlWords []compression.ControlWord, paletteLookup []byte) { + tb.Helper() + controls := make(map[compression.ControlType]int) + skips := make(map[int]int) + longOffsets := 0 + for _, word := range controlWords { + if word.IsLongOffset() { + longOffsets++ + } else { + skips[word.Count()]++ + controls[word.Type()]++ + } + } + firstWords := "" + for i := 0; i < 16; i++ { + if i < len(controlWords) { + word := controlWords[i] + if word.IsLongOffset() { + firstWords += fmt.Sprintf(" LO: %v", word.LongOffset()) + } else { + firstWords += fmt.Sprintf(" [%v %v %v]", word.Count(), word.Type(), word.Parameter()) + } + } + } + tb.Logf("%vStatistics: PaletteLookup: %vB, ControlWords: %v, LO: %v, CTRL: %v, SKIP: %v -- firstWords: %v", + prefix, len(paletteLookup), len(controlWords), longOffsets, controls, skips, firstWords) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/Constants.go b/modding/hacked/ss1/content/movie/internal/compression/Constants.go new file mode 100644 index 0000000..6915251 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/Constants.go @@ -0,0 +1,10 @@ +package compression + +const ( + bytesPerControlWord = 3 + + // TileSideLength is the number of pixel per side of a square frame tile. + TileSideLength = 4 + // PixelPerTile is the number of pixel within one frame tile. + PixelPerTile = TileSideLength * TileSideLength +) diff --git a/modding/hacked/ss1/content/movie/internal/compression/ControlType.go b/modding/hacked/ss1/content/movie/internal/compression/ControlType.go new file mode 100644 index 0000000..692348e --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/ControlType.go @@ -0,0 +1,18 @@ +package compression + +// ControlType defines how to interpret a ControlWord. +type ControlType byte + +// Control type constants are listed below. +const ( + CtrlColorTile2ColorsStatic = ControlType(0) + CtrlColorTile2ColorsMasked = ControlType(1) + CtrlColorTile4ColorsMasked = ControlType(2) + CtrlColorTile8ColorsMasked = ControlType(3) + CtrlColorTile16ColorsMasked = ControlType(4) + + CtrlSkip = ControlType(5) + + CtrlRepeatPrevious = ControlType(6) + CtrlUnknown = ControlType(7) +) diff --git a/modding/hacked/ss1/content/movie/internal/compression/ControlWord.go b/modding/hacked/ss1/content/movie/internal/compression/ControlWord.go new file mode 100644 index 0000000..254d22d --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/ControlWord.go @@ -0,0 +1,47 @@ +package compression + +// ControlWordParamLimit is the highest number the param property of a control word can take. +const ControlWordParamLimit = 0x1FFFF + +// ControlWord describes the current compression action. +type ControlWord uint32 + +// ControlWordOf returns a word with the given parameters. +func ControlWordOf(count int, controlType ControlType, param uint32) ControlWord { + return (ControlWord(count) << 20) | (ControlWord(controlType&0x7) << 17) | ControlWord(param&ControlWordParamLimit) +} + +// LongOffsetOf returns a word that indicates a long offset with given value. +func LongOffsetOf(offset uint32) ControlWord { + return ControlWord(offset & 0xFFFFF) +} + +// Packed returns the control word packed in a PackedControlWord with the given number of times. +func (word ControlWord) Packed(times int) PackedControlWord { + return PackedControlWord((uint32(word) & 0x00FFFFFF) | (uint32(times) << 24)) +} + +// Count returns the count value of the control. +func (word ControlWord) Count() int { + return int((uint32(word) >> 20) & 0xF) +} + +// IsLongOffset returns true if Count() returns 0. +func (word ControlWord) IsLongOffset() bool { + return word.Count() == 0 +} + +// LongOffset returns the long offset value. Only relevant if IsLongOffset() returns true. +func (word ControlWord) LongOffset() uint32 { + return (uint32(word) >> 0) & 0xFFFFF +} + +// Type returns the type of the control. Only relevant if IsLongOffset() returns false. +func (word ControlWord) Type() ControlType { + return ControlType((uint32(word) >> 17) & 0x7) +} + +// Parameter returns the type specific parameter value. +func (word ControlWord) Parameter() uint32 { + return (uint32(word) >> 0) & ControlWordParamLimit +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer.go b/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer.go new file mode 100644 index 0000000..345dacd --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer.go @@ -0,0 +1,198 @@ +package compression + +import ( + "sort" + + "github.com/inkyblackness/hacked/ss1" +) + +const ( + errTooHighOperationOffset ss1.StringError = "too high operation offset" +) + +// TileColorOp describes one operation how a tile should be colored. +type TileColorOp struct { + Type ControlType + Offset uint32 +} + +// ControlWordSequencer receives a list of requested tile coloring operations +// and produces a sequence of low-level control words to reproduce the requested list. +type ControlWordSequencer struct { + // BitstreamIndexLimit specifies the highest value the sequencer may use to store offset values in the bitstream. + // If this value is 0, then the default of 0xFFF (12 bits) is used. + BitstreamIndexLimit uint32 + + // DirectIndexLimit specifies the amount of direct pointers into the bitstream. + // If this value is 0, then the default of BitstreamIndexLimit / 4 is used. + DirectIndexLimit uint32 + + ops map[TileColorOp]uint32 +} + +// Add extends the list of requested coloring operations with the given entry. +// The offset of the entry must be a value less than or equal ControlWordParamLimit, otherwise the function returns an error. +func (seq *ControlWordSequencer) Add(op TileColorOp) error { + if op.Offset > ControlWordParamLimit { + return errTooHighOperationOffset + } + if seq.ops == nil { + seq.ops = make(map[TileColorOp]uint32) + } + seq.ops[op]++ + return nil +} + +// Sequence packs the requested list of entries into a sequence of control words. +// An error is returned if the operations would exceed the possible storage space. +func (seq ControlWordSequencer) Sequence() (ControlWordSequence, error) { + var result ControlWordSequence + var sortedOps []TileColorOp + for op := range seq.ops { + sortedOps = append(sortedOps, op) + } + sort.Slice(sortedOps, func(a, b int) bool { + opA := sortedOps[a] + opB := sortedOps[b] + countA := seq.ops[opA] + countB := seq.ops[opB] + if countA != countB { + return countA > countB + } + if opA.Offset != opB.Offset { + return opA.Offset < opB.Offset + } + if opA.Type != opB.Type { + return opA.Type < opB.Type + } + return false + }) + + bitstreamIndexLimit := seq.BitstreamIndexLimit + if bitstreamIndexLimit == 0 { + bitstreamIndexLimit = 0xFFF + } + maximumDirect := seq.DirectIndexLimit + if maximumDirect > bitstreamIndexLimit { + maximumDirect = bitstreamIndexLimit + } else if maximumDirect == 0 { + maximumDirect = bitstreamIndexLimit / 4 + } + requiredExtensions := uint32(0) + + totalOps := uint32(len(sortedOps)) + if totalOps > maximumDirect { + requiredExtensions = (totalOps - maximumDirect + 15) / 16 + if requiredExtensions > (bitstreamIndexLimit - maximumDirect) { + return result, tooManyWordsError{ + Total: totalOps, + MaximumDirect: maximumDirect, + BitstreamIndexLimit: bitstreamIndexLimit, + } + } + } + extensionStart := maximumDirect + requiredExtensions + + result.opPaths = make(map[TileColorOp]nestedTileColorOp) + for opIndex, op := range sortedOps { + if uint32(opIndex) == maximumDirect { + // write required amount of long offset entries + for i := uint32(0); i < requiredExtensions; i++ { + result.words = append(result.words, LongOffsetOf(extensionStart+i*16)) + } + } + if uint32(opIndex) >= maximumDirect { + offset := uint32(opIndex) - maximumDirect + result.opPaths[op] = nestedTileColorOp{ + parent: &nestedTileColorOp{relOffsetBits: 12, relOffset: maximumDirect + offset/16}, + relOffsetBits: 4, + relOffset: offset % 16, + } + result.words = append(result.words, ControlWordOf(4, op.Type, op.Offset)) + } else { + result.opPaths[op] = nestedTileColorOp{parent: nil, relOffsetBits: 12, relOffset: uint32(opIndex)} + result.words = append(result.words, ControlWordOf(12, op.Type, op.Offset)) + } + } + + return result, nil +} + +type nestedTileColorOp struct { + parent *nestedTileColorOp + relOffsetBits uint + relOffset uint32 +} + +func (op nestedTileColorOp) writeTo(w *BitstreamWriter) { + if op.parent != nil { + op.parent.writeTo(w) + } + w.Write(op.relOffsetBits, op.relOffset) +} + +// ControlWordSequence is a finalized set of control words to reproduce a list of +// tile coloring operations. Based on this sequence, a bitstream can be created based +// on a selection of such coloring operations (i.e., per frame). +type ControlWordSequence struct { + // HTiles specifies the amount of horizontal tiles (= operations) a frame has. + // This number is relevant for skip operations. If 0, then no compression of skip-operations is done. + HTiles uint32 + + words []ControlWord + opPaths map[TileColorOp]nestedTileColorOp +} + +// ControlWords returns the list of low-level control words of the sequence. +func (seq ControlWordSequence) ControlWords() []ControlWord { + return seq.words +} + +// BitstreamFor returns the bitstream to reproduce the provided list of coloring operations +// from this sequence. +func (seq ControlWordSequence) BitstreamFor(ops []TileColorOp) ([]byte, error) { + var writer BitstreamWriter + writeOp := func(op TileColorOp) { + nested := seq.opPaths[op] + nested.writeTo(&writer) + } + pendingSkips := uint32(0) + writePendingSkips := func() { + for pendingSkips != 0 { + toSkip := pendingSkips + if toSkip >= 0x1F { + toSkip = 0x1E + } + writeOp(TileColorOp{Type: CtrlSkip}) + writer.Write(5, toSkip-1) + pendingSkips -= toSkip + } + } + writeLineSkip := func() { + if pendingSkips > 0 { + writeOp(TileColorOp{Type: CtrlSkip}) + writer.Write(5, 0x1F) + pendingSkips = 0 + } + } + hasWidthConfig := seq.HTiles > 0 + for opIndex, op := range ops { + atRowStart := hasWidthConfig && ((uint32(opIndex) % seq.HTiles) == 0) + if (pendingSkips > 0) && atRowStart { + writeLineSkip() + } + if op.Type == CtrlSkip { + pendingSkips++ + if !hasWidthConfig { + writePendingSkips() + } + } else { + writePendingSkips() + writeOp(op) + } + } + if pendingSkips > 0 { + writeLineSkip() + } + return writer.Buffer(), nil +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer_test.go b/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer_test.go new file mode 100644 index 0000000..85b13af --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer_test.go @@ -0,0 +1,249 @@ +package compression_test + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +type ControlWordSequencerSuite struct { + suite.Suite + + sequencer compression.ControlWordSequencer + + sequenceErr error + sequence compression.ControlWordSequence + hTiles uint32 +} + +func TestControlWordSequencer(t *testing.T) { + suite.Run(t, new(ControlWordSequencerSuite)) +} + +func (suite *ControlWordSequencerSuite) SetupTest() { + suite.sequencer = compression.ControlWordSequencer{} + suite.sequenceErr = nil + suite.sequence = compression.ControlWordSequence{} + suite.hTiles = 0 +} + +func (suite *ControlWordSequencerSuite) TestEmptySequence() { + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldHaveALenOf(0) +} + +func (suite *ControlWordSequencerSuite) TestAddReturnsErrorOnWrongOffset() { + err := suite.sequencer.Add(compression.TileColorOp{Offset: compression.ControlWordParamLimit + 1}) + assert.NotNil(suite.T(), err, "error expected adding an operation with too high offset") +} + +func (suite *ControlWordSequencerSuite) TestSingleOperation() { + suite.givenRegisteredOperations(compression.TileColorOp{}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldHaveALenOf(1) +} + +func (suite *ControlWordSequencerSuite) TestDuplicatedOperationShouldResultInOneControlWord() { + suite.givenRegisteredOperations(compression.TileColorOp{}, compression.TileColorOp{}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldHaveALenOf(1) +} + +func (suite *ControlWordSequencerSuite) TestControlWordsAreOrderedByOperationFrequencyFirst() { + suite.givenRegisteredOperations( + compression.TileColorOp{Offset: 1}, + compression.TileColorOp{}, compression.TileColorOp{}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldBe(compression.ControlWordOf(12, 0, 0), compression.ControlWordOf(12, 0, 1)) +} + +func (suite *ControlWordSequencerSuite) TestControlWordsAreOrderedByOffsetSecond() { + suite.givenRegisteredOperations( + compression.TileColorOp{Offset: 1}, compression.TileColorOp{Offset: 1}, + compression.TileColorOp{}, compression.TileColorOp{}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldBe(compression.ControlWordOf(12, 0, 0), compression.ControlWordOf(12, 0, 1)) +} + +func (suite *ControlWordSequencerSuite) TestControlWordsAreOrderedByControlTypeThird() { + suite.givenRegisteredOperations( + compression.TileColorOp{Type: compression.CtrlColorTile16ColorsMasked}, + compression.TileColorOp{Type: compression.CtrlColorTile16ColorsMasked}, + compression.TileColorOp{}, compression.TileColorOp{}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldBe( + compression.ControlWordOf(12, 0, 0), + compression.ControlWordOf(12, compression.CtrlColorTile16ColorsMasked, 0)) +} + +func (suite *ControlWordSequencerSuite) TestControlWordLongOffsetsWhenBitstreamSpaceExhausted_FirstTime() { + suite.givenSequencerLimitsOf(1, 0) + suite.givenRegisteredOperations( + compression.TileColorOp{Offset: 0}, + compression.TileColorOp{Offset: 1}, + compression.TileColorOp{Offset: 2}, + compression.TileColorOp{Offset: 3}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldBe( + compression.ControlWordOf(0, 0, 1), // first long offset jump to index 1 + compression.ControlWordOf(4, 0, 0), + compression.ControlWordOf(4, 0, 1), + compression.ControlWordOf(4, 0, 2), + compression.ControlWordOf(4, 0, 3)) +} + +func (suite *ControlWordSequencerSuite) TestControlWordLongOffsetsWhenBitstreamSpaceExhausted_SecondTime() { + suite.givenSequencerLimitsOf(3, 1) + suite.givenRegisteredOperations( + compression.TileColorOp{Offset: 0}, compression.TileColorOp{Offset: 1}, + compression.TileColorOp{Offset: 2}, compression.TileColorOp{Offset: 3}, + compression.TileColorOp{Offset: 4}, compression.TileColorOp{Offset: 5}, + compression.TileColorOp{Offset: 6}, compression.TileColorOp{Offset: 7}, + compression.TileColorOp{Offset: 8}, compression.TileColorOp{Offset: 9}, + compression.TileColorOp{Offset: 10}, compression.TileColorOp{Offset: 11}, + compression.TileColorOp{Offset: 12}, compression.TileColorOp{Offset: 13}, + compression.TileColorOp{Offset: 14}, compression.TileColorOp{Offset: 15}, + compression.TileColorOp{Offset: 16}, compression.TileColorOp{Offset: 17}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldBe( + compression.ControlWordOf(12, 0, 0), + compression.ControlWordOf(0, 0, 3), // first long offset jump to index 3 + compression.ControlWordOf(0, 0, 19), // first long offset jump to index 19 + compression.ControlWordOf(4, 0, 1), compression.ControlWordOf(4, 0, 2), + compression.ControlWordOf(4, 0, 3), compression.ControlWordOf(4, 0, 4), + compression.ControlWordOf(4, 0, 5), compression.ControlWordOf(4, 0, 6), + compression.ControlWordOf(4, 0, 7), compression.ControlWordOf(4, 0, 8), + compression.ControlWordOf(4, 0, 9), compression.ControlWordOf(4, 0, 10), + compression.ControlWordOf(4, 0, 11), compression.ControlWordOf(4, 0, 12), + compression.ControlWordOf(4, 0, 13), compression.ControlWordOf(4, 0, 14), + compression.ControlWordOf(4, 0, 15), compression.ControlWordOf(4, 0, 16), + compression.ControlWordOf(4, 0, 17)) +} + +type bitstreamExpectation struct { + bits int + value uint32 +} + +func bits12(value uint32) bitstreamExpectation { + return bitstreamExpectation{bits: 12, value: value} +} + +func bits5(value uint32) bitstreamExpectation { + return bitstreamExpectation{bits: 5, value: value} +} + +func bits4(value uint32) bitstreamExpectation { + return bitstreamExpectation{bits: 4, value: value} +} + +func (suite *ControlWordSequencerSuite) TestBitstreamForSimpleSequence() { + ops := []compression.TileColorOp{{Offset: 1}, {}, {}} + suite.givenRegisteredOperations(ops...) + suite.whenSequenceIsCreated() + suite.thenBitstreamShouldBeFor(ops, + []bitstreamExpectation{bits12(1), bits12(0), bits12(0)}) +} + +func (suite *ControlWordSequencerSuite) TestBitstreamForExtendedSequence() { + suite.givenSequencerLimitsOf(2, 1) + suite.givenRegisteredOperations( + compression.TileColorOp{Offset: 0}, compression.TileColorOp{Offset: 1}, + compression.TileColorOp{Offset: 2}, compression.TileColorOp{Offset: 3}, + compression.TileColorOp{Offset: 4}, compression.TileColorOp{Offset: 5}, + compression.TileColorOp{Offset: 6}, compression.TileColorOp{Offset: 7}, + compression.TileColorOp{Offset: 8}, compression.TileColorOp{Offset: 9}, + compression.TileColorOp{Offset: 10}, compression.TileColorOp{Offset: 11}, + compression.TileColorOp{Offset: 12}, compression.TileColorOp{Offset: 13}, + compression.TileColorOp{Offset: 14}, compression.TileColorOp{Offset: 15}, + compression.TileColorOp{Offset: 16}) + suite.whenSequenceIsCreated() + suite.thenBitstreamShouldBeFor([]compression.TileColorOp{{Offset: 16}}, + []bitstreamExpectation{bits12(1), bits4(15)}) +} + +func (suite *ControlWordSequencerSuite) TestSkipOperation_Single() { + suite.givenHorizontalTileCountOf(3) + ops := []compression.TileColorOp{{Offset: 0}, {Type: compression.CtrlSkip}, {Offset: 1}} + suite.givenRegisteredOperations(ops...) + suite.whenSequenceIsCreated() + suite.thenBitstreamShouldBeFor(ops, + []bitstreamExpectation{bits12(0), bits12(1), bits5(0), bits12(2)}) +} + +func (suite *ControlWordSequencerSuite) TestSkipOperation_Double() { + suite.givenHorizontalTileCountOf(4) + ops := []compression.TileColorOp{ + {Offset: 0}, {Type: compression.CtrlSkip}, + {Type: compression.CtrlSkip}, {Offset: 1}} + suite.givenRegisteredOperations(ops...) + suite.whenSequenceIsCreated() + suite.thenBitstreamShouldBeFor(ops, + []bitstreamExpectation{bits12(1), bits12(0), bits5(1), bits12(2)}) +} + +func (suite *ControlWordSequencerSuite) TestSkipOperation_EndOfLine() { + suite.givenHorizontalTileCountOf(2) + ops := []compression.TileColorOp{ + {Offset: 0}, {Type: compression.CtrlSkip}, + {Offset: 1}, {Offset: 2}} + suite.givenRegisteredOperations(ops...) + suite.whenSequenceIsCreated() + suite.thenBitstreamShouldBeFor(ops, + []bitstreamExpectation{bits12(0), bits12(1), bits5(0x1F), bits12(2), bits12(3)}) +} + +func (suite *ControlWordSequencerSuite) givenSequencerLimitsOf(bitstream uint32, direct uint32) { + suite.sequencer.BitstreamIndexLimit = bitstream + suite.sequencer.DirectIndexLimit = direct +} + +func (suite *ControlWordSequencerSuite) givenHorizontalTileCountOf(value uint32) { + suite.hTiles = value +} + +func (suite *ControlWordSequencerSuite) givenRegisteredOperations(ops ...compression.TileColorOp) { + suite.T().Helper() + for index, op := range ops { + err := suite.sequencer.Add(op) + require.Nil(suite.T(), err, "no error expected adding operation %v", index) + } +} + +func (suite *ControlWordSequencerSuite) whenSequenceIsCreated() { + suite.sequence, suite.sequenceErr = suite.sequencer.Sequence() + suite.sequence.HTiles = suite.hTiles +} + +func (suite *ControlWordSequencerSuite) thenControlWordsShouldHaveALenOf(expected int) { + suite.T().Helper() + require.Nil(suite.T(), suite.sequenceErr, "no error expected in this verification") + words := suite.sequence.ControlWords() + assert.Equal(suite.T(), expected, len(words)) +} + +func (suite *ControlWordSequencerSuite) thenControlWordsShouldBe(expected ...compression.ControlWord) { + suite.T().Helper() + require.Nil(suite.T(), suite.sequenceErr, "no error expected in this verification") + words := suite.sequence.ControlWords() + assert.Equal(suite.T(), len(expected), len(words), "length mismatch") + assert.Equal(suite.T(), expected, words, "words mismatch") +} + +func (suite *ControlWordSequencerSuite) thenBitstreamShouldBeFor(ops []compression.TileColorOp, expected []bitstreamExpectation) { + suite.T().Helper() + require.Nil(suite.T(), suite.sequenceErr, "no error expected in this verification") + data, err := suite.sequence.BitstreamFor(ops) + require.Nil(suite.T(), err, "no error expected extracting bitstream") + bitstream := compression.NewBitstreamReader(data) + for index, exp := range expected { + value := bitstream.Read(exp.bits) + assert.Equal(suite.T(), exp.value, value, "Value mismatch at index %v - bitstream is 0x%v", index, hex.EncodeToString(data)) + bitstream.Advance(exp.bits) + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/ControlWord_test.go b/modding/hacked/ss1/content/movie/internal/compression/ControlWord_test.go new file mode 100644 index 0000000..734ad55 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/ControlWord_test.go @@ -0,0 +1,57 @@ +package compression_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +func TestControlWordPackedReturnsPackedControlWord(t *testing.T) { + word := compression.ControlWord(0x00FFAA55) + + assert.Equal(t, compression.PackedControlWord(0x80FFAA55), word.Packed(128)) +} + +func TestControlWordPackedClearsHighestByteBeforeSettingCount(t *testing.T) { + word := compression.ControlWord(0x55FFAA55) + + assert.Equal(t, compression.PackedControlWord(0xC0FFAA55), word.Packed(0xC0)) +} + +func TestControlWordCountReturnsBits20To23(t *testing.T) { + word := compression.ControlWord(0x00F00000) + + assert.Equal(t, 15, word.Count()) +} + +func TestControlWordIsLongOffsetReturnsTrueForCount0(t *testing.T) { + word := compression.ControlWord(0x00000000) + + assert.True(t, word.IsLongOffset()) +} + +func TestControlWordIsLongOffsetReturnsFalseForCountGreater0(t *testing.T) { + word := compression.ControlWord(0x00300000) + + assert.False(t, word.IsLongOffset()) +} + +func TestControlWordLongOffsetReturnsBits00To19(t *testing.T) { + word := compression.ControlWord(0xFFFA6665) + + assert.Equal(t, uint32(0xA6665), word.LongOffset()) +} + +func TestControlWordParameterReturnsBits00To16(t *testing.T) { + word := compression.ControlWord(0xFFFF1665) + + assert.Equal(t, uint32(0x11665), word.Parameter()) +} + +func TestControlWordTypeReturnsBits17To19(t *testing.T) { + word := compression.ControlWord(0xFFFAFFFF) + + assert.Equal(t, compression.ControlType(5), word.Type()) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/Errors.go b/modding/hacked/ss1/content/movie/internal/compression/Errors.go new file mode 100644 index 0000000..9d0fae5 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/Errors.go @@ -0,0 +1,45 @@ +package compression + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1" +) + +const ( + // ErrFormat indicates errors in the compression format. + ErrFormat ss1.StringError = "format error" + + errCannotRepeatWordOnFirstTileOfRow ss1.StringError = "cannot repeat word on first tile of row" + errUnknownControl ss1.StringError = "found unknown control" + errTooManyLongOffsets ss1.StringError = "too many long offsets" + errInvalidByteCount ss1.StringError = "invalid byte count" + errInvalidFrameSize ss1.StringError = "invalid frame size" +) + +type tooManyWordsError struct { + Total uint32 + MaximumDirect uint32 + BitstreamIndexLimit uint32 +} + +func (err tooManyWordsError) Error() string { + return fmt.Sprintf("too many words: total=%v, maxDirect=%v, indexLimit=%v", err.Total, err.MaximumDirect, err.BitstreamIndexLimit) +} + +type wordIndexOutOfRangeError struct { + Index uint32 + Available uint32 +} + +func (err wordIndexOutOfRangeError) Error() string { + return fmt.Sprintf("control word index out of range: %v/%v", err.Index, err.Available) +} + +type paletteLookupTooBigError struct { + Size int +} + +func (err paletteLookupTooBigError) Error() string { + return fmt.Sprintf("palette lookup is too big: %vB", err.Size) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/FrameDecoder.go b/modding/hacked/ss1/content/movie/internal/compression/FrameDecoder.go new file mode 100644 index 0000000..c9fa455 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/FrameDecoder.go @@ -0,0 +1,123 @@ +package compression + +// FrameDecoder is for decoding compressed frames with the help of data streams. +// A new instance of a decoder is created with a FrameDecoderBuilder. +type FrameDecoder struct { + horizontalTiles int + verticalTiles int + + colorer TileColorFunction + + paletteLookupList []byte + controlWords []ControlWord +} + +func newFrameDecoder(builder *FrameDecoderBuilder) *FrameDecoder { + listLen := len(builder.paletteLookupList) + decoder := &FrameDecoder{ + horizontalTiles: builder.width / TileSideLength, + verticalTiles: builder.height / TileSideLength, + colorer: builder.colorer, + paletteLookupList: make([]byte, listLen+16), + controlWords: builder.controlWords, + } + + copy(decoder.paletteLookupList[:listLen], builder.paletteLookupList) + + return decoder +} + +// Decode reads the provided streams to paint a frame. +func (decoder *FrameDecoder) Decode(bitstreamData []byte, maskstreamData []byte) error { + bitstream := NewBitstreamReader(bitstreamData) + maskstream := NewMaskstreamReader(maskstreamData) + + for vTile := 0; vTile < decoder.verticalTiles && !bitstream.Exhausted(); vTile++ { + lastControl := ControlWordOf(0, CtrlUnknown, 0) + for hTile := 0; hTile < decoder.horizontalTiles && !bitstream.Exhausted(); hTile++ { + control, err := decoder.readNextControlWord(bitstream) + if err != nil { + return err + } + + if control.Type() == CtrlRepeatPrevious { + if hTile == 0 { + return errCannotRepeatWordOnFirstTileOfRow + } + control = lastControl + } + if control.Type() == CtrlUnknown { + return errUnknownControl + } + + if control.Type() == CtrlSkip { + skipCount := bitstream.Read(5) + bitstream.Advance(5) + if skipCount == 0x1F { + hTile = decoder.horizontalTiles + } else { + hTile += int(skipCount) + } + } else { + decoder.colorTile(hTile, vTile, control, maskstream) + } + + lastControl = control + } + } + return nil +} + +func (decoder *FrameDecoder) readNextControlWord(bitstream *BitstreamReader) (ControlWord, error) { + availableWords := uint32(len(decoder.controlWords)) + controlIndex := bitstream.Read(12) + if controlIndex > availableWords { + return ControlWordOf(0, CtrlUnknown, 0), + wordIndexOutOfRangeError{ + Index: controlIndex, + Available: availableWords, + } + } + + control := decoder.controlWords[controlIndex] + if control.IsLongOffset() { + longOffsetCount := 0 + bitstream.Advance(8) + for control.IsLongOffset() { + longOffsetCount++ + if longOffsetCount > 100 { + return ControlWordOf(0, CtrlUnknown, 0), errTooManyLongOffsets + } + bitstream.Advance(4) + offset := bitstream.Read(4) + controlIndex = control.LongOffset() + offset + if controlIndex > availableWords { + return ControlWordOf(0, CtrlUnknown, 0), + wordIndexOutOfRangeError{ + Index: controlIndex, + Available: availableWords, + } + } + control = decoder.controlWords[controlIndex] + } + } + bitstream.Advance(control.Count()) + return control, nil +} + +func (decoder *FrameDecoder) colorTile(hTile, vTile int, control ControlWord, maskstream *MaskstreamReader) { + param := control.Parameter() + + switch control.Type() { + case CtrlColorTile2ColorsStatic: + decoder.colorer(hTile, vTile, []byte{byte(param & 0xFF), byte(param >> 8 & 0xFF)}, 0xAAAA, 1) + case CtrlColorTile2ColorsMasked: + decoder.colorer(hTile, vTile, []byte{byte(param & 0xFF), byte(param >> 8 & 0xFF)}, maskstream.Read(2), 1) + case CtrlColorTile4ColorsMasked: + decoder.colorer(hTile, vTile, decoder.paletteLookupList[param:param+4], maskstream.Read(4), 2) + case CtrlColorTile8ColorsMasked: + decoder.colorer(hTile, vTile, decoder.paletteLookupList[param:param+8], maskstream.Read(6), 3) + case CtrlColorTile16ColorsMasked: + decoder.colorer(hTile, vTile, decoder.paletteLookupList[param:param+16], maskstream.Read(8), 4) + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/FrameDecoderBuilder.go b/modding/hacked/ss1/content/movie/internal/compression/FrameDecoderBuilder.go new file mode 100644 index 0000000..aa501b6 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/FrameDecoderBuilder.go @@ -0,0 +1,41 @@ +package compression + +// FrameDecoderBuilder implements the builder pattern for creating new instances of FrameDecoder. +// This builder can be reused during the processing of a movie. Every Build() call creates a new instance. +type FrameDecoderBuilder struct { + width int + height int + + colorer TileColorFunction + + paletteLookupList []byte + controlWords []ControlWord +} + +// NewFrameDecoderBuilder returns a new instance of a builder with given initial values. +func NewFrameDecoderBuilder(width, height int) *FrameDecoderBuilder { + return &FrameDecoderBuilder{ + width: width, + height: height, + } +} + +// Build creates a new instance of a decoder with the most recent parameters. +func (builder *FrameDecoderBuilder) Build() *FrameDecoder { + return newFrameDecoder(builder) +} + +// ForStandardFrame registers the frame buffer and sets the standard coloring method. +func (builder *FrameDecoderBuilder) ForStandardFrame(frame []byte, stride int) { + builder.colorer = StandardTileColorer(frame, stride) +} + +// WithPaletteLookupList registers the new list. +func (builder *FrameDecoderBuilder) WithPaletteLookupList(list []byte) { + builder.paletteLookupList = list +} + +// WithControlWords registers the new word dictionary. +func (builder *FrameDecoderBuilder) WithControlWords(words []ControlWord) { + builder.controlWords = words +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/Maskstream.go b/modding/hacked/ss1/content/movie/internal/compression/Maskstream.go new file mode 100644 index 0000000..31988aa --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/Maskstream.go @@ -0,0 +1,59 @@ +package compression + +// MaskstreamWriter writes mask integers to a byte array. +type MaskstreamWriter struct { + Buffer []byte +} + +// Write adds the given amount of bytes from the mask to the buffer. +func (w *MaskstreamWriter) Write(byteCount uint, mask uint64) error { + if byteCount > 8 { + return errInvalidByteCount + } + if w.Buffer == nil { + w.Buffer = make([]byte, 0, 1024*32) + } + for b := uint(0); b < byteCount; b++ { + w.Buffer = append(w.Buffer, byte(mask>>(b*8))) + } + return nil +} + +// MaskstreamReader reads mask integers from a byte array. +type MaskstreamReader struct { + source []byte + curPos int +} + +// NewMaskstreamReader returns a new reader instance for given source. +func NewMaskstreamReader(source []byte) *MaskstreamReader { + return &MaskstreamReader{source: source} +} + +// Read returns a mask integer of given byte length from the current position. +// If the current position is at or beyond the available size, 0x00 is assumed for the missing bytes. +// +// Reading more than 8, or less than 0, bytes panics. +func (reader *MaskstreamReader) Read(bytes int) (value uint64) { + if bytes > 8 { + panic("Limit of byte count: 8") + } + if bytes < 0 { + panic("Minimum byte count: 0") + } + + for i := 0; i < bytes; i++ { + value |= reader.nextByte() << uint64(8*i) + } + + return +} + +func (reader *MaskstreamReader) nextByte() (value uint64) { + if reader.curPos < len(reader.source) { + value = uint64(reader.source[reader.curPos]) + reader.curPos++ + } + + return +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/Maskstream_test.go b/modding/hacked/ss1/content/movie/internal/compression/Maskstream_test.go new file mode 100644 index 0000000..707507f --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/Maskstream_test.go @@ -0,0 +1,62 @@ +package compression_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + + "github.com/stretchr/testify/assert" +) + +func TestMaskstreamReaderReadPanicsForMoreThan8Bytes(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{}) + + assert.Panicsf(t, func() { reader.Read(9) }, "Limit of byte count: 8") +} + +func TestMaskstreamReaderReadPanicsForLessThan0Bytes(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{}) + + assert.Panicsf(t, func() { reader.Read(-1) }, "Minimum byte count: 0") +} + +func TestMaskstreamReaderReadReturnsValueOfRequestedByteSize(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{0xAF}) + + result := reader.Read(1) + + assert.Equal(t, uint64(0xAF), result) +} + +func TestMaskstreamReaderReadIntegerFromSourceInLittleEndianOrder(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{0x11, 0x22}) + + result := reader.Read(2) + + assert.Equal(t, uint64(0x2211), result) +} + +func TestMaskstreamReaderReadCanProvideUpTo64BitValues(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}) + + result := reader.Read(8) + + assert.Equal(t, uint64(0x8877665544332211), result) +} + +func TestMaskstreamReaderReadFillsMissingBytesWithZero(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{0xAA, 0xBB}) + + result := reader.Read(8) + + assert.Equal(t, uint64(0x00BBAA), result) +} + +func TestMaskstreamReaderReadAdvancesCurrentPosition(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}) + + reader.Read(2) + result := reader.Read(3) + + assert.Equal(t, uint64(0x554433), result) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/PackControlWords.go b/modding/hacked/ss1/content/movie/internal/compression/PackControlWords.go new file mode 100644 index 0000000..ef31702 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/PackControlWords.go @@ -0,0 +1,29 @@ +package compression + +import ( + "bytes" + "encoding/binary" +) + +// PackControlWords packs and encodes the given words into a byte stream. +func PackControlWords(words []ControlWord) []byte { + buf := bytes.NewBuffer(nil) + controlBytes := uint32(len(words) * bytesPerControlWord) + + _ = binary.Write(buf, binary.LittleEndian, controlBytes) + if controlBytes > 0 { + lastPacked := words[0].Packed(1) + + for _, word := range words[1:] { + if (lastPacked.Value() == word) && (lastPacked.Times() < 0xFF) { + lastPacked = word.Packed(lastPacked.Times() + 1) + } else { + _ = binary.Write(buf, binary.LittleEndian, uint32(lastPacked)) + lastPacked = word.Packed(1) + } + } + _ = binary.Write(buf, binary.LittleEndian, uint32(lastPacked)) + } + + return buf.Bytes() +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/PackControlWords_test.go b/modding/hacked/ss1/content/movie/internal/compression/PackControlWords_test.go new file mode 100644 index 0000000..745a20c --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/PackControlWords_test.go @@ -0,0 +1,72 @@ +package compression_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +func TestControlWordsEmptyArrayResultsInZeroCount(t *testing.T) { + data := compression.PackControlWords(nil) + + assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, data) +} + +func TestControlWordsCountIsInLengthTimesThree(t *testing.T) { + data := compression.PackControlWords([]compression.ControlWord{ + compression.ControlWord(0), + compression.ControlWord(1), + }) + + assert.Equal(t, []byte{0x06, 0x00, 0x00, 0x00}, data[:4]) +} + +func TestControlWordsSingleItem(t *testing.T) { + data := compression.PackControlWords([]compression.ControlWord{compression.ControlWord(0x00BBCCDD)}) + + assert.Equal(t, []byte{0x03, 0x00, 0x00, 0x00, 0xDD, 0xCC, 0xBB, 0x01}, data) +} + +func TestControlWordsMultiItemArray(t *testing.T) { + data := compression.PackControlWords([]compression.ControlWord{ + compression.ControlWord(0x00BBCCDD), + compression.ControlWord(0x00112233), + }) + + assert.Equal(t, []byte{0x06, 0x00, 0x00, 0x00, 0xDD, 0xCC, 0xBB, 0x01, 0x33, 0x22, 0x11, 0x01}, data) +} + +func TestControlWordsIdenticalWordsArePacked(t *testing.T) { + data := compression.PackControlWords([]compression.ControlWord{ + compression.ControlWord(0x00BBCCDD), + compression.ControlWord(0x00BBCCDD), + }) + + require.Equal(t, 8, len(data)) + assert.Equal(t, []byte{0xDD, 0xCC, 0xBB, 0x02}, data[4:8]) +} + +func TestControlWordsCountIsResetForFurtherWords(t *testing.T) { + data := compression.PackControlWords([]compression.ControlWord{ + compression.ControlWord(0x00BBCCDD), + compression.ControlWord(0x00BBCCDD), + compression.ControlWord(0x00112233), + }) + + require.Equal(t, 12, len(data)) + assert.Equal(t, []byte{0xDD, 0xCC, 0xBB, 0x02, 0x33, 0x22, 0x11, 0x01}, data[4:12]) +} + +func TestControlWordsMaximumCountIs255(t *testing.T) { + words := make([]compression.ControlWord, 260) + for i := 0; i < len(words); i++ { + words[i] = compression.ControlWord(0x00112233) + } + data := compression.PackControlWords(words) + + require.Equal(t, 12, len(data)) + assert.Equal(t, []byte{0x0C, 0x03, 0x00, 0x00, 0x33, 0x22, 0x11, 0xFF, 0x33, 0x22, 0x11, 0x05}, data) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord.go b/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord.go new file mode 100644 index 0000000..30496a8 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord.go @@ -0,0 +1,14 @@ +package compression + +// PackedControlWord contains a ControlWord together with a count value of occurrences. +type PackedControlWord uint32 + +// Times returns the amount of occurrences of the contained control word. +func (packed PackedControlWord) Times() int { + return int((packed >> 24) & 0xFF) +} + +// Value returns the control word value. +func (packed PackedControlWord) Value() ControlWord { + return ControlWord(uint32(packed) & 0x00FFFFFF) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord_test.go b/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord_test.go new file mode 100644 index 0000000..c606f95 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord_test.go @@ -0,0 +1,21 @@ +package compression_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +func TestPackedControlWordTimesReturnsHighestByte(t *testing.T) { + packed := compression.PackedControlWord(0xFF706050) + + assert.Equal(t, int(0xFF), packed.Times()) +} + +func TestPackedControlWordValueReturnsTheControlWord(t *testing.T) { + packed := compression.PackedControlWord(0xFF706050) + + assert.Equal(t, compression.ControlWord(0x00706050), packed.Value()) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/PaletteLookupGenerator.go b/modding/hacked/ss1/content/movie/internal/compression/PaletteLookupGenerator.go new file mode 100644 index 0000000..5dd4129 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/PaletteLookupGenerator.go @@ -0,0 +1,176 @@ +package compression + +import ( + "context" + "math/bits" + "sort" +) + +type paletteLookupEntry struct { + start int + size int +} + +// PaletteLookup is a dictionary of tile delta data, found in a palette buffer. +type PaletteLookup struct { + buffer []byte + entries map[TilePaletteKey]paletteLookupEntry +} + +// Buffer returns the underlying slice. +func (lookup *PaletteLookup) Buffer() []byte { + return lookup.buffer +} + +// Lookup finds the given tile again and returns the properties where and how to reproduce it. +func (lookup *PaletteLookup) Lookup(tile tileDelta) (index int, pal []byte, mask uint64) { + key := TilePaletteKeyFrom(tile[:]) + entry, inLookup := lookup.entries[key] + if inLookup { + index = entry.start + pal = lookup.buffer[entry.start : entry.start+entry.size] + } else { + pal = key.Buffer() + } + var mapped [256]byte + for mappedIndex := len(pal) - 1; mappedIndex >= 0; mappedIndex-- { + mapped[pal[mappedIndex]] = byte(mappedIndex) + } + bitSize := uint(bits.Len(uint(len(pal) - 1))) + for tileIndex := PixelPerTile - 1; tileIndex >= 0; tileIndex-- { + mask <<= bitSize + mask |= uint64(mapped[tile[tileIndex]]) + } + return +} + +// PaletteLookupGenerator creates palette lookups based on a set of registered tiles. +type PaletteLookupGenerator struct { + keyUses map[TilePaletteKey]int +} + +// Generate creates a lookup based on all currently registered tile deltas. +func (gen *PaletteLookupGenerator) Generate(ctx context.Context) (PaletteLookup, error) { + var lookup PaletteLookup + lookup.entries = make(map[TilePaletteKey]paletteLookupEntry) + + remainder := make(map[TilePaletteKey]struct{}) + for key := range gen.keyUses { + remainder[key] = struct{}{} + } + + type sizedEntry struct { + entries map[TilePaletteKey]paletteLookupEntry + lastOffset int + } + sizedEntries := make(map[int]*sizedEntry) + knownSizes := []int{4, 8, 16} + for _, size := range knownSizes { + sizedEntries[size] = &sizedEntry{ + entries: make(map[TilePaletteKey]paletteLookupEntry), + } + } + + toDeleteCache := make([]TilePaletteKey, 0, len(remainder)) + addToBuffer := func(data []byte) { + lookup.buffer = append(lookup.buffer, data...) + + newSize := len(lookup.buffer) + + for _, fitSize := range knownSizes { + fitLimit := newSize - fitSize + entry := sizedEntries[fitSize] + + // remove all entries beyond a certain limit. as these bytes don't change, retrying won't help. + toDelete := toDeleteCache[0:0] + limit := newSize - 16 - len(data) + for key, entry := range entry.entries { + if entry.start < limit { + toDelete = append(toDelete, key) + } + } + for _, key := range toDelete { + delete(entry.entries, key) + } + + // find any new keys + for start := entry.lastOffset; start < fitLimit; start++ { + tempKey := TilePaletteKeyFrom(lookup.buffer[start : start+fitSize]) + if _, existing := entry.entries[tempKey]; !existing { + entry.entries[tempKey] = paletteLookupEntry{ + start: start, + size: fitSize, + } + } + } + if fitLimit > 0 { + entry.lastOffset = fitLimit + } + } + } + + addEarlyEntry := func(key TilePaletteKey, limitSize int) bool { + for _, fitSize := range knownSizes { + if key.size <= fitSize && fitSize <= limitSize { + entry := sizedEntries[fitSize] + for tempKey, paletteEntry := range entry.entries { + if tempKey.Contains(&key) && (!key.HasColor(0x00) || (lookup.buffer[paletteEntry.start] == 0x00)) { + lookup.entries[key] = paletteEntry + return true + } + } + } + } + return false + } + + sizeLimitForSize := map[int]int{3: 4, 4: 8, 5: 8, 6: 8, 7: 8, 8: 8, 9: 16, 10: 16, 11: 16, 12: 16, 13: 16, 14: 16, 15: 16, 16: 16} + for size := PixelPerTile; (size > 2) && (ctx.Err() == nil); size-- { + keysInSize := make([]TilePaletteKey, 0, len(remainder)) + for key := range remainder { + if key.size == size { + keysInSize = append(keysInSize, key) + } + } + sort.Slice(keysInSize, func(a, b int) bool { return keysInSize[a].LessThan(&keysInSize[b]) }) + + // fmt.Printf("Working on key size %v, have %v sized, %v total remaining\n", size, len(keysInSize), len(remainder)) + for _, sizedKey := range keysInSize { + if ctx.Err() != nil { + return PaletteLookup{}, ctx.Err() + } + + { + var earlyRemoved []TilePaletteKey + for key := range remainder { + if addEarlyEntry(key, sizeLimitForSize[size]) { + earlyRemoved = append(earlyRemoved, key) + } + } + for _, key := range earlyRemoved { + delete(remainder, key) + } + } + if _, stillRemaining := remainder[sizedKey]; stillRemaining { + bytes := sizedKey.Buffer() + lookup.entries[sizedKey] = paletteLookupEntry{start: len(lookup.buffer), size: len(bytes)} + addToBuffer(bytes) + + delete(remainder, sizedKey) + } + } + } + + return lookup, ctx.Err() +} + +// Add registers a further delta to the generator. +func (gen *PaletteLookupGenerator) Add(delta tileDelta) { + key := TilePaletteKeyFrom(delta[:]) + if key.size > 2 { + if gen.keyUses == nil { + gen.keyUses = make(map[TilePaletteKey]int) + } + gen.keyUses[key]++ + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/SceneEncoder.go b/modding/hacked/ss1/content/movie/internal/compression/SceneEncoder.go new file mode 100644 index 0000000..0d33295 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/SceneEncoder.go @@ -0,0 +1,180 @@ +package compression + +import ( + "context" +) + +// EncodedFrame contains the streams of one compressed frame. +type EncodedFrame struct { + Bitstream []byte + Maskstream []byte +} + +type tileDelta [PixelPerTile]byte + +type frameDelta struct { + tiles []tileDelta +} + +// SceneEncoder encodes an entire scene of bitmaps sharing the same palette. +type SceneEncoder struct { + hTiles int + vTiles int + lineStride int + tileStride int + + lastFrame []byte + deltas []frameDelta +} + +// NewSceneEncoder returns a new instance. +func NewSceneEncoder(width, height int) *SceneEncoder { + e := &SceneEncoder{ + hTiles: width / TileSideLength, + vTiles: height / TileSideLength, + lineStride: width, + } + e.tileStride = e.lineStride * TileSideLength + e.lastFrame = make([]byte, e.vTiles*TileSideLength*e.lineStride) + return e +} + +// AddFrame registers a further frame to the scene. +func (e *SceneEncoder) AddFrame(frame []byte) error { + if len(frame) != len(e.lastFrame) { + return errInvalidFrameSize + } + var delta frameDelta + isFirstFrame := len(e.deltas) == 0 + vStart := 0 + for vTile := 0; vTile < e.vTiles; vTile++ { + tileStart := vStart + for hTile := 0; hTile < e.hTiles; hTile++ { + delta.tiles = append(delta.tiles, e.deltaTile(isFirstFrame, tileStart, frame)) + tileStart += TileSideLength + } + vStart += e.tileStride + } + e.deltas = append(e.deltas, delta) + copy(e.lastFrame, frame) + return nil +} + +func (e *SceneEncoder) deltaTile(isFirstFrame bool, offset int, frame []byte) tileDelta { + var delta tileDelta + for y := 0; y < TileSideLength; y++ { + start := offset + (y * e.lineStride) + for x := 0; x < TileSideLength; x++ { + pixel := frame[start+x] + if isFirstFrame || (pixel != e.lastFrame[start+x]) { + delta[y*TileSideLength+x] = pixel + } + } + } + return delta +} + +// Encode processes all the previously registered frames and creates the necessary components for decoding. +func (e *SceneEncoder) Encode(ctx context.Context) ( + words []ControlWord, paletteLookupBuffer []byte, frames []EncodedFrame, err error) { + var wordSequencer ControlWordSequencer + tileColorOpsPerFrame := make([][]TileColorOp, len(e.deltas)) + paletteLookup, err := e.createPaletteLookup(ctx) + if err != nil { + return + } + + paletteLookupBuffer = paletteLookup.Buffer() + if len(paletteLookupBuffer) > 0x1FFFF { + err = paletteLookupTooBigError{Size: len(paletteLookupBuffer)} + return + } + + frames = make([]EncodedFrame, len(e.deltas)) + for frameIndex := 0; frameIndex < len(e.deltas); frameIndex++ { + var maskstreamWriter MaskstreamWriter + outFrame := &frames[frameIndex] + delta := e.deltas[frameIndex] + + lastOp := TileColorOp{Type: CtrlUnknown} + for tileIndex, tile := range delta.tiles { + var op TileColorOp + paletteIndex, pal, mask := paletteLookup.Lookup(tile) + palSize := len(pal) + + switch { + case palSize == 1 && (pal[0] == 0x00): + op.Type = CtrlSkip + case palSize == 1: + op.Type = CtrlColorTile2ColorsStatic + op.Offset = uint32(pal[0])<<8 | uint32(pal[0]) + case palSize == 2 && mask == 0xAAAA && (pal[0] != 0x00) && (pal[1] != 0x00): + op.Type = CtrlColorTile2ColorsStatic + op.Offset = uint32(pal[1])<<8 | uint32(pal[0]) + case palSize == 2 && mask == 0x5555 && (pal[0] != 0x00) && (pal[1] != 0x00): + op.Type = CtrlColorTile2ColorsStatic + op.Offset = uint32(pal[0])<<8 | uint32(pal[1]) + case palSize <= 2: + op.Type = CtrlColorTile2ColorsMasked + if palSize == 2 { + op.Offset = uint32(pal[1]) + op.Offset <<= 8 + } + if palSize > 0 { + op.Offset |= uint32(pal[0]) + } + + _ = maskstreamWriter.Write(2, mask) + case palSize <= 4: + op.Type = CtrlColorTile4ColorsMasked + op.Offset = uint32(paletteIndex) + _ = maskstreamWriter.Write(4, mask) + case palSize <= 8: + op.Type = CtrlColorTile8ColorsMasked + op.Offset = uint32(paletteIndex) + _ = maskstreamWriter.Write(6, mask) + default: + op.Type = CtrlColorTile16ColorsMasked + op.Offset = uint32(paletteIndex) + _ = maskstreamWriter.Write(8, mask) + } + + if op.Type != CtrlSkip && (tileIndex%e.hTiles) != 0 && lastOp == op { + op = TileColorOp{Type: CtrlRepeatPrevious} + } else { + lastOp = op + } + + err = wordSequencer.Add(op) + if err != nil { + return nil, nil, nil, err + } + tileColorOpsPerFrame[frameIndex] = append(tileColorOpsPerFrame[frameIndex], op) + } + outFrame.Maskstream = maskstreamWriter.Buffer + } + + wordSequence, err := wordSequencer.Sequence() + if err != nil { + return nil, nil, nil, err + } + wordSequence.HTiles = uint32(e.hTiles) + words = wordSequence.ControlWords() + for frameIndex, ops := range tileColorOpsPerFrame { + frames[frameIndex].Bitstream, err = wordSequence.BitstreamFor(ops) + if err != nil { + return nil, nil, nil, err + } + } + return +} + +func (e *SceneEncoder) createPaletteLookup(ctx context.Context) (PaletteLookup, error) { + var paletteLookupGenerator PaletteLookupGenerator + for _, delta := range e.deltas { + for _, tile := range delta.tiles { + paletteLookupGenerator.Add(tile) + } + } + return paletteLookupGenerator.Generate(ctx) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer.go b/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer.go new file mode 100644 index 0000000..1d7b5f9 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer.go @@ -0,0 +1,22 @@ +package compression + +// StandardTileColorer returns a TileColorFunction for given frame buffer. +// +// This colorer iterates over all pixel of a tile. It sets the pixel value if the +// corresponding palette value is > 0x00. +func StandardTileColorer(frame []byte, stride int) TileColorFunction { + return func(hTile int, vTile int, lookupArray []byte, mask uint64, indexBitSize uint64) { + start := vTile*TileSideLength*stride + hTile*TileSideLength + singleMask := ^(^uint64(0) << indexBitSize) + + for i := 0; i < PixelPerTile; i++ { + pixelValue := lookupArray[(mask>>(indexBitSize*uint64(i)))&singleMask] + + if pixelValue != 0x00 { + offset := start + (i % TileSideLength) + stride*(i/TileSideLength) + + frame[offset] = pixelValue + } + } + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer_test.go b/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer_test.go new file mode 100644 index 0000000..318ad71 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer_test.go @@ -0,0 +1,104 @@ +package compression_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +type StandardTileColorerSuite struct { + suite.Suite + + buffer []byte + stride int + + colorer compression.TileColorFunction +} + +func TestStandardTileColorerSuite(t *testing.T) { + suite.Run(t, new(StandardTileColorerSuite)) +} + +func (suite *StandardTileColorerSuite) SetupTest() { + suite.buffer = make([]byte, compression.PixelPerTile*9) + suite.stride = compression.TileSideLength * 3 + + for i := 0; i < len(suite.buffer); i++ { + suite.buffer[i] = 0xDD + } + + suite.colorer = compression.StandardTileColorer(suite.buffer, suite.stride) +} + +func (suite *StandardTileColorerSuite) TestFunctionColorsAllPixelOfATile() { + suite.whenTileIsColored(1, 1, []byte{0xFF, 0x00}, 0, 1) + + suite.thenTileShouldBe(1, 1, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}) +} + +func (suite *StandardTileColorerSuite) TestColoringATileLeavesPixelOfOthersAlone() { + suite.whenTileIsColored(1, 1, []byte{0xFF}, 0, 1) + + suite.thenTileShouldBeUntouched(0, 0) + suite.thenTileShouldBeUntouched(1, 0) + suite.thenTileShouldBeUntouched(2, 0) + suite.thenTileShouldBeUntouched(0, 1) + suite.thenTileShouldBeUntouched(2, 1) + suite.thenTileShouldBeUntouched(0, 2) + suite.thenTileShouldBeUntouched(1, 2) + suite.thenTileShouldBeUntouched(2, 2) +} + +func (suite *StandardTileColorerSuite) TestFunctionWorksWithMaximumIndexSize() { + suite.whenTileIsColored(1, 1, []byte{0x01, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}, + 0xFEDCBA9876543210, 4) + + suite.thenTileShouldBe(1, 1, []byte{0x01, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}) +} + +func (suite *StandardTileColorerSuite) TestFunctionSkipsZeroPaletteIndices() { + suite.givenTileIsFilled(1, 1, 0xFF) + + suite.whenTileIsColored(1, 1, []byte{0x33, 0x00}, 0xAAAA, 1) + + suite.thenTileShouldBe(1, 1, []byte{0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF}) +} + +func (suite *StandardTileColorerSuite) givenTileIsFilled(hTile int, vTile int, initValue byte) { + suite.colorer(hTile, vTile, []byte{initValue, 0xEE}, 0x0000, 1) +} + +func (suite *StandardTileColorerSuite) whenTileIsColored(hTile int, vTile int, lookupArray []byte, mask uint64, indexBitSize uint64) { + suite.colorer(hTile, vTile, lookupArray, mask, indexBitSize) +} + +func (suite *StandardTileColorerSuite) thenTileShouldBe(hTile, vTile int, expected []byte) { + tile := suite.getTile(hTile, vTile) + + assert.Equal(suite.T(), expected, tile) +} + +func (suite *StandardTileColorerSuite) thenTileShouldBeUntouched(hTile, vTile int) { + expected := make([]byte, compression.PixelPerTile) + for i := 0; i < len(expected); i++ { + expected[i] = 0xDD + } + + suite.thenTileShouldBe(hTile, vTile, expected) +} + +func (suite *StandardTileColorerSuite) getTile(hTile, vTile int) []byte { + tileBuffer := make([]byte, compression.PixelPerTile) + start := vTile*compression.TileSideLength*suite.stride + hTile*compression.TileSideLength + + for i := 0; i < compression.TileSideLength; i++ { + outOffset := compression.TileSideLength * i + inOffset := start + suite.stride*i + copy(tileBuffer[outOffset:outOffset+compression.TileSideLength], suite.buffer[inOffset:inOffset+compression.TileSideLength]) + } + + return tileBuffer +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/TileColorFunction.go b/modding/hacked/ss1/content/movie/internal/compression/TileColorFunction.go new file mode 100644 index 0000000..f705541 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/TileColorFunction.go @@ -0,0 +1,6 @@ +package compression + +// TileColorFunction is one which shall color the pixel of a video frame tile using the provided values. +// mask is a packed field of 16 indices, each with a length of indexBitSize. These indices point into a +// value of lookupArray. +type TileColorFunction func(hTile int, vTile int, lookupArray []byte, mask uint64, indexBitSize uint64) diff --git a/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey.go b/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey.go new file mode 100644 index 0000000..86b3c6b --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey.go @@ -0,0 +1,70 @@ +package compression + +import "math/bits" + +// TilePaletteKey marks use of colors of a 256 palette. +type TilePaletteKey struct { + usedColors [4]uint64 + size int +} + +// TilePaletteKeyFrom returns a key for the given slice of colors. +func TilePaletteKeyFrom(colors []byte) TilePaletteKey { + var key TilePaletteKey + for _, c := range colors { + key.UseColor(c) + } + return key +} + +// Buffer returns the color indices marked in use by this key. +func (key *TilePaletteKey) Buffer() []byte { + result := make([]byte, 0, key.size) + for i := 0; i < 256; i++ { + if key.HasColor(byte(i)) { + result = append(result, byte(i)) + } + } + return result +} + +// UseColor marks the given color index in use. +func (key *TilePaletteKey) UseColor(index byte) { + if !key.HasColor(index) { + key.usedColors[index/64] |= 1 << uint(index%64) + key.size++ + } +} + +// HasColor returns true if the given color index is in use by this key. +func (key *TilePaletteKey) HasColor(index byte) bool { + return (key.usedColors[index/64] & (1 << uint(index%64))) != 0 +} + +// Contains returns true if this key is equal to, or a superset of, the given key. +func (key *TilePaletteKey) Contains(other *TilePaletteKey) bool { + return ((^key.usedColors[0] & other.usedColors[0]) == 0) && + ((^key.usedColors[1] & other.usedColors[1]) == 0) && + ((^key.usedColors[2] & other.usedColors[2]) == 0) && + ((^key.usedColors[3] & other.usedColors[3]) == 0) +} + +// Without returns a new key instance that has all remaining colors marked in use. +func (key *TilePaletteKey) Without(other *TilePaletteKey) TilePaletteKey { + var result TilePaletteKey + for i := 0; i < 4; i++ { + result.usedColors[i] = key.usedColors[i] & ^other.usedColors[i] + result.size += bits.OnesCount64(result.usedColors[i]) + } + return result +} + +// LessThan is a sorting function to order keys. +func (key *TilePaletteKey) LessThan(other *TilePaletteKey) bool { + for i := 0; i < 4; i++ { + if key.usedColors[i] < other.usedColors[i] { + return true + } + } + return false +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey_test.go b/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey_test.go new file mode 100644 index 0000000..e269475 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey_test.go @@ -0,0 +1,94 @@ +package compression_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +func TestTilePaletteKeyNullValueHasNoColorsSet(t *testing.T) { + var key compression.TilePaletteKey + for color := 0; color < 256; color++ { + assert.False(t, key.HasColor(byte(color)), "color set although should not %v", color) + } +} + +func TestTilePaletteKeyCanMarkSingleColors(t *testing.T) { + for color := 0; color < 256; color++ { + var key compression.TilePaletteKey + key.UseColor(byte(color)) + assert.True(t, key.HasColor(byte(color)), "color not set %v", color) + for otherColor := 0; otherColor < 256; otherColor++ { + if otherColor != color { + assert.False(t, key.HasColor(byte(otherColor)), + "color %v set although should not for %v", otherColor, color) + } + } + } +} + +func TestTilePaletteKeyCanMarkAllColors(t *testing.T) { + var key compression.TilePaletteKey + for color := 0; color < 256; color++ { + key.UseColor(byte(color)) + } + for color := 0; color < 256; color++ { + assert.True(t, key.HasColor(byte(color)), "should have color set %v", color) + } +} + +func TestTilePaletteKeyContains(t *testing.T) { + baseKey := compression.TilePaletteKeyFrom([]byte{1, 2, 3, 4}) + tt := []struct { + other []byte + expected bool + }{ + {other: []byte{1, 2, 3, 4}, expected: true}, + {other: []byte{2, 3, 4}, expected: true}, + {other: []byte{1, 2, 4}, expected: true}, + {other: []byte{4}, expected: true}, + {other: []byte{}, expected: true}, + {other: []byte{1, 2, 3, 4, 5}, expected: false}, + {other: []byte{3, 4, 5}, expected: false}, + {other: []byte{5}, expected: false}, + } + + for index, tc := range tt { + td := tc + t.Run(fmt.Sprintf("%v", index), func(t *testing.T) { + other := compression.TilePaletteKeyFrom(td.other) + result := baseKey.Contains(&other) + assert.Equal(t, result, td.expected) + }) + } +} + +func TestTilePaletteKeyWithout(t *testing.T) { + baseKey := compression.TilePaletteKeyFrom([]byte{1, 2, 3, 4}) + tt := []struct { + other []byte + expected []byte + }{ + {other: []byte{1, 2, 3, 4}, expected: []byte{}}, + {other: []byte{2, 3, 4}, expected: []byte{1}}, + {other: []byte{1, 2, 4}, expected: []byte{3}}, + {other: []byte{4}, expected: []byte{1, 2, 3}}, + {other: []byte{}, expected: []byte{1, 2, 3, 4}}, + {other: []byte{1, 2, 3, 4, 5}, expected: []byte{}}, + {other: []byte{3, 4, 5}, expected: []byte{1, 2}}, + {other: []byte{5}, expected: []byte{1, 2, 3, 4}}, + } + + for index, tc := range tt { + td := tc + t.Run(fmt.Sprintf("%v", index), func(t *testing.T) { + other := compression.TilePaletteKeyFrom(td.other) + result := baseKey.Without(&other) + expected := compression.TilePaletteKeyFrom(td.expected) + assert.Equal(t, result, expected, "Mismatch for %v", td.other) + }) + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords.go b/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords.go new file mode 100644 index 0000000..6cf7814 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords.go @@ -0,0 +1,46 @@ +package compression + +import ( + "bytes" + "encoding/binary" +) + +// UnpackControlWords reads from an encoded string of bytes a series of packed control words. +// If all is OK, the control words are returned. If the function returns an error, something +// could not be read/decoded properly. +func UnpackControlWords(data []byte) (words []ControlWord, err error) { + if len(data) < 4 { + return nil, ErrFormat + } + + var controlBytes uint32 + reader := bytes.NewReader(data) + + err = binary.Read(reader, binary.LittleEndian, &controlBytes) + if err != nil { + return + } + if controlBytes%bytesPerControlWord != 0 { + return nil, ErrFormat + } + wordCount := int(controlBytes / bytesPerControlWord) + unpacked := 0 + + words = make([]ControlWord, wordCount) + for unpacked < wordCount { + var packed PackedControlWord + err = binary.Read(reader, binary.LittleEndian, &packed) + if err != nil { + return + } + times := packed.Times() + if times > len(words)-unpacked { + return nil, ErrFormat + } + for i := 0; i < times; i++ { + words[unpacked+i] = packed.Value() + } + unpacked += times + } + return +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords_test.go b/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords_test.go new file mode 100644 index 0000000..aa8539f --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords_test.go @@ -0,0 +1,55 @@ +package compression_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +func TestUnpackControlWordsFormatErrorForEmptyArray(t *testing.T) { + _, err := compression.UnpackControlWords(nil) + + assert.Equal(t, compression.ErrFormat, err) +} + +func TestUnpackControlWordsFormatErrorForTooSmallSizeField(t *testing.T) { + _, err := compression.UnpackControlWords(make([]byte, 3)) + + assert.Equal(t, compression.ErrFormat, err) +} + +func TestUnpackControlWordsFormatErrorForSizeValueNotMultipleOfThree(t *testing.T) { + _, err := compression.UnpackControlWords([]byte{0x02, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x01}) + + assert.Equal(t, compression.ErrFormat, err) +} + +func TestUnpackControlWordsEmptyResultForNoEntries(t *testing.T) { + words, err := compression.UnpackControlWords([]byte{0x00, 0x00, 0x00, 0x00}) + + require.Nil(t, err) + assert.Equal(t, 0, len(words)) +} + +func TestUnpackControlWordsSingleEntry(t *testing.T) { + words, err := compression.UnpackControlWords([]byte{0x03, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x01}) + + require.Nil(t, err) + assert.Equal(t, []compression.ControlWord{compression.ControlWord(0xAABBCC)}, words) +} + +func TestUnpackControlWordsMultipleEntry(t *testing.T) { + words, err := compression.UnpackControlWords([]byte{0x09, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x02, 0x33, 0x22, 0x11, 0x01}) + + require.Nil(t, err) + assert.Equal(t, []compression.ControlWord{compression.ControlWord(0xAABBCC), compression.ControlWord(0xAABBCC), compression.ControlWord(0x112233)}, words) +} + +func TestUnpackControlWordsErrorForTooManyUnpacked(t *testing.T) { + _, err := compression.UnpackControlWords([]byte{0x03, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x02}) + + require.NotNil(t, err) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/controlDict.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/controlDict.bin new file mode 100644 index 0000000..e5f23f4 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/controlDict.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame00_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame00_bitstream.bin new file mode 100644 index 0000000..056d385 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame00_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame00_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame00_maskstream.bin new file mode 100644 index 0000000..a87be16 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame00_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame01_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame01_bitstream.bin new file mode 100644 index 0000000..4633600 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame01_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame01_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame01_maskstream.bin new file mode 100644 index 0000000..4b1b217 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame01_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_bitstream.bin new file mode 100644 index 0000000..6337587 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_maskstream.bin new file mode 100644 index 0000000..b9e9cc3 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame03_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame03_bitstream.bin new file mode 100644 index 0000000..f981c9a Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame03_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame03_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame03_maskstream.bin new file mode 100644 index 0000000..3790588 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame03_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame04_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame04_bitstream.bin new file mode 100644 index 0000000..e8c174f Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame04_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame04_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame04_maskstream.bin new file mode 100644 index 0000000..445e442 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame04_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame05_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame05_bitstream.bin new file mode 100644 index 0000000..eeff9ac Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame05_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame05_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame05_maskstream.bin new file mode 100644 index 0000000..f8ade59 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame05_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_bitstream.bin new file mode 100644 index 0000000..1b9a225 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_maskstream.bin new file mode 100644 index 0000000..f591050 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame07_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame07_bitstream.bin new file mode 100644 index 0000000..92f1349 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame07_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame07_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame07_maskstream.bin new file mode 100644 index 0000000..c891b29 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame07_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame08_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame08_bitstream.bin new file mode 100644 index 0000000..8dffa28 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame08_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame08_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame08_maskstream.bin new file mode 100644 index 0000000..074d772 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame08_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_bitstream.bin new file mode 100644 index 0000000..5c56118 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_maskstream.bin new file mode 100644 index 0000000..2aa6dea Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame10_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame10_bitstream.bin new file mode 100644 index 0000000..7b1b634 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame10_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame10_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame10_maskstream.bin new file mode 100644 index 0000000..37d7558 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame10_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame11_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame11_bitstream.bin new file mode 100644 index 0000000..81d7954 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame11_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame11_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame11_maskstream.bin new file mode 100644 index 0000000..de61cd4 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame11_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_bitstream.bin new file mode 100644 index 0000000..f0710ec Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_maskstream.bin new file mode 100644 index 0000000..a0d27b0 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_bitstream.bin new file mode 100644 index 0000000..d0febe9 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_maskstream.bin new file mode 100644 index 0000000..c5308cb Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame14_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame14_bitstream.bin new file mode 100644 index 0000000..bc63f3b Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame14_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame14_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame14_maskstream.bin new file mode 100644 index 0000000..7621640 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame14_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_bitstream.bin new file mode 100644 index 0000000..8d024f3 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_maskstream.bin new file mode 100644 index 0000000..f5ba436 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_bitstream.bin new file mode 100644 index 0000000..d28b5cf Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_maskstream.bin new file mode 100644 index 0000000..64f7c91 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame17_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame17_bitstream.bin new file mode 100644 index 0000000..e0de7a9 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame17_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame17_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame17_maskstream.bin new file mode 100644 index 0000000..ac54a47 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame17_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame18_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame18_bitstream.bin new file mode 100644 index 0000000..99525ce Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame18_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame18_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame18_maskstream.bin new file mode 100644 index 0000000..5a23456 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame18_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame19_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame19_bitstream.bin new file mode 100644 index 0000000..35de0fd Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame19_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame19_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame19_maskstream.bin new file mode 100644 index 0000000..f192229 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame19_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame20_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame20_bitstream.bin new file mode 100644 index 0000000..b1367bb Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame20_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame20_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame20_maskstream.bin new file mode 100644 index 0000000..61cc5f5 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame20_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame21_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame21_bitstream.bin new file mode 100644 index 0000000..e4e91ea Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame21_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame21_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame21_maskstream.bin new file mode 100644 index 0000000..4b928d1 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame21_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_bitstream.bin new file mode 100644 index 0000000..9e19975 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_maskstream.bin new file mode 100644 index 0000000..33baa6b Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame23_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame23_bitstream.bin new file mode 100644 index 0000000..3771fd5 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame23_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame23_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame23_maskstream.bin new file mode 100644 index 0000000..3ab6c8f Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame23_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame24_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame24_bitstream.bin new file mode 100644 index 0000000..1bea13b Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame24_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame24_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame24_maskstream.bin new file mode 100644 index 0000000..caccbdc Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame24_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame25_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame25_bitstream.bin new file mode 100644 index 0000000..5fa7e71 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame25_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame25_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame25_maskstream.bin new file mode 100644 index 0000000..5654234 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame25_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame26_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame26_bitstream.bin new file mode 100644 index 0000000..7df9279 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame26_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame26_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame26_maskstream.bin new file mode 100644 index 0000000..a396b6c Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame26_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame27_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame27_bitstream.bin new file mode 100644 index 0000000..b3abe4d Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame27_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame27_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame27_maskstream.bin new file mode 100644 index 0000000..49e0cd2 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame27_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame28_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame28_bitstream.bin new file mode 100644 index 0000000..6e18f70 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame28_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame28_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame28_maskstream.bin new file mode 100644 index 0000000..53faa29 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame28_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame29_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame29_bitstream.bin new file mode 100644 index 0000000..21f4534 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame29_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame29_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame29_maskstream.bin new file mode 100644 index 0000000..1a42f4f Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame29_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_bitstream.bin new file mode 100644 index 0000000..c9fde08 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_maskstream.bin new file mode 100644 index 0000000..a6edddd Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/paletteLookup.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/paletteLookup.bin new file mode 100644 index 0000000..a8c6ecc Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/paletteLookup.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/controlDict.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/controlDict.bin new file mode 100644 index 0000000..96c95c0 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/controlDict.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_bitstream.bin new file mode 100644 index 0000000..cacb706 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_maskstream.bin new file mode 100644 index 0000000..8af61cd Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_bitstream.bin new file mode 100644 index 0000000..7f89acb Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_maskstream.bin new file mode 100644 index 0000000..620c932 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_bitstream.bin new file mode 100644 index 0000000..d5f25ea Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_maskstream.bin new file mode 100644 index 0000000..14badfb Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame03_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame03_bitstream.bin new file mode 100644 index 0000000..4f0b623 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame03_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame03_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame03_maskstream.bin new file mode 100644 index 0000000..df18a15 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame03_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_bitstream.bin new file mode 100644 index 0000000..1dd8fc0 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_maskstream.bin new file mode 100644 index 0000000..aa39c29 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame05_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame05_bitstream.bin new file mode 100644 index 0000000..965e820 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame05_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame05_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame05_maskstream.bin new file mode 100644 index 0000000..4ba3732 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame05_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_bitstream.bin new file mode 100644 index 0000000..75c04fe Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_maskstream.bin new file mode 100644 index 0000000..4a620e2 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame07_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame07_bitstream.bin new file mode 100644 index 0000000..50dd6cf Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame07_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame07_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame07_maskstream.bin new file mode 100644 index 0000000..bf00a15 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame07_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame08_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame08_bitstream.bin new file mode 100644 index 0000000..fb68785 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame08_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame08_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame08_maskstream.bin new file mode 100644 index 0000000..4497f19 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame08_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame09_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame09_bitstream.bin new file mode 100644 index 0000000..e8d10cf Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame09_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame09_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame09_maskstream.bin new file mode 100644 index 0000000..0391973 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame09_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_bitstream.bin new file mode 100644 index 0000000..5cf8890 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_maskstream.bin new file mode 100644 index 0000000..82302d6 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame11_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame11_bitstream.bin new file mode 100644 index 0000000..0a3ee02 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame11_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame11_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame11_maskstream.bin new file mode 100644 index 0000000..0d1ed9b Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame11_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_bitstream.bin new file mode 100644 index 0000000..cfac73e Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_maskstream.bin new file mode 100644 index 0000000..94211a3 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame13_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame13_bitstream.bin new file mode 100644 index 0000000..7ce0abc Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame13_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame13_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame13_maskstream.bin new file mode 100644 index 0000000..16767fe Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame13_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame14_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame14_bitstream.bin new file mode 100644 index 0000000..a1dee02 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame14_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame14_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame14_maskstream.bin new file mode 100644 index 0000000..e07b910 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame14_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_bitstream.bin new file mode 100644 index 0000000..0cc1db3 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_maskstream.bin new file mode 100644 index 0000000..676b43c Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame16_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame16_bitstream.bin new file mode 100644 index 0000000..0fe4ce7 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame16_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame16_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame16_maskstream.bin new file mode 100644 index 0000000..f682402 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame16_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame17_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame17_bitstream.bin new file mode 100644 index 0000000..3a193b1 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame17_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame17_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame17_maskstream.bin new file mode 100644 index 0000000..fb75633 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame17_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame18_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame18_bitstream.bin new file mode 100644 index 0000000..3e36619 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame18_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame18_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame18_maskstream.bin new file mode 100644 index 0000000..8698527 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame18_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame19_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame19_bitstream.bin new file mode 100644 index 0000000..c65c177 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame19_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame19_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame19_maskstream.bin new file mode 100644 index 0000000..d0278d6 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame19_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame20_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame20_bitstream.bin new file mode 100644 index 0000000..b4b6c34 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame20_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame20_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame20_maskstream.bin new file mode 100644 index 0000000..9688df8 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame20_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame21_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame21_bitstream.bin new file mode 100644 index 0000000..d34f9cb Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame21_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame21_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame21_maskstream.bin new file mode 100644 index 0000000..4619e3a Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame21_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame22_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame22_bitstream.bin new file mode 100644 index 0000000..dbb36aa Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame22_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame22_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame22_maskstream.bin new file mode 100644 index 0000000..0087860 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame22_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame23_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame23_bitstream.bin new file mode 100644 index 0000000..6d37f27 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame23_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame23_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame23_maskstream.bin new file mode 100644 index 0000000..90f3fd8 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame23_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame24_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame24_bitstream.bin new file mode 100644 index 0000000..09525f9 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame24_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame24_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame24_maskstream.bin new file mode 100644 index 0000000..11ba84c Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame24_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_bitstream.bin new file mode 100644 index 0000000..90f0ed8 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_maskstream.bin new file mode 100644 index 0000000..0b511e2 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame26_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame26_bitstream.bin new file mode 100644 index 0000000..611cf50 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame26_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame26_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame26_maskstream.bin new file mode 100644 index 0000000..6f826f7 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame26_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame27_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame27_bitstream.bin new file mode 100644 index 0000000..79be5fb Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame27_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame27_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame27_maskstream.bin new file mode 100644 index 0000000..5d40e03 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame27_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_bitstream.bin new file mode 100644 index 0000000..67084be Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_maskstream.bin new file mode 100644 index 0000000..a6f6a45 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/paletteLookup.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/paletteLookup.bin new file mode 100644 index 0000000..eb91749 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/paletteLookup.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/controlDict.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/controlDict.bin new file mode 100644 index 0000000..7b12e97 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/controlDict.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame00_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame00_bitstream.bin new file mode 100644 index 0000000..4ae0d01 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame00_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame00_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame00_maskstream.bin new file mode 100644 index 0000000..a30b8b0 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame00_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame01_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame01_bitstream.bin new file mode 100644 index 0000000..9f5c5bc Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame01_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame01_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame01_maskstream.bin new file mode 100644 index 0000000..e362bd5 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame01_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame02_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame02_bitstream.bin new file mode 100644 index 0000000..ee80d14 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame02_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame02_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame02_maskstream.bin new file mode 100644 index 0000000..586710b Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame02_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame03_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame03_bitstream.bin new file mode 100644 index 0000000..dedabb1 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame03_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame03_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame03_maskstream.bin new file mode 100644 index 0000000..ba005c3 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame03_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame04_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame04_bitstream.bin new file mode 100644 index 0000000..0be033d Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame04_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame04_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame04_maskstream.bin new file mode 100644 index 0000000..46522b7 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame04_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_bitstream.bin new file mode 100644 index 0000000..74598cc Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_maskstream.bin new file mode 100644 index 0000000..1b28b67 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame06_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame06_bitstream.bin new file mode 100644 index 0000000..9fd23b5 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame06_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame06_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame06_maskstream.bin new file mode 100644 index 0000000..99d31f5 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame06_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame07_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame07_bitstream.bin new file mode 100644 index 0000000..1f41d8b Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame07_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame07_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame07_maskstream.bin new file mode 100644 index 0000000..ee0b33c Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame07_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame08_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame08_bitstream.bin new file mode 100644 index 0000000..78966d5 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame08_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame08_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame08_maskstream.bin new file mode 100644 index 0000000..5e731da Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame08_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame09_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame09_bitstream.bin new file mode 100644 index 0000000..ee346a8 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame09_bitstream.bin @@ -0,0 +1,4 @@ +~ýû÷ïß¿~ýë׫àƒ÷«mD~õu¯Þ¼ç_½[ñb–¡¸zŠÄ6ç_½])Ê™ë»"~õwëá){×™ûÕöÖž>é”éëzøý](iCK«’‚ûÊ7ëYˆÚ{_~º”4¹‰…½)¢+’º�Ir^¡¯�Þ¯ï\DAŠj‚ü«H M=²Fz†¾ýêË¢ ù!ø¬‹äƒ¼OhÅ8”ïçø)X/œ‘Dâ×ÔØ$7âlªkïÞ³…hn_±Q~*¶—-)ä�¤²ƒÝY-apÂãrÉ+â_«1‹‡ìéyŠ„ŠSÔÒK^CGýÝʧ§Š�…ÊM¡ ”þë´µôÓmmPb¥ �½]ó$™Õ]2ÀÓÖýKš{¡b§â¨}{Ê(4µôÿ5ÓPv—`¿gKÔ?5Ô”A¸]´öô÷4÷~/¯´÷®Ã‘ä¡% +޾—Þ•ÑÝÄ=ⵟª¥=/eFöCñ ƒ>d7b¡ýH¿»§Å§Æ‚ÿŸ&Ÿ/Í޹k%GE`]úª^×ÑüUŽäÚ÷�ßçíh¾~‚¶°­þ´8¬|ÿ­þÉR4ú>¿AáÉtýM/Sâ²â³B"]>œ/hoõ ˆ‡ŸO¯Ñtû4ûtû¥I÷�|ÐEÅij»Qk~¦—¨kè®vò/×ë^×íÙ>/צH>ò´=äwÿX"]ÅôÞÑëp“O¾~õe}+]a§à¢äü®r´­ˆeJ˜Š]–›n.ÃùÄСölÚ“ ëõ4¨¥Í׬Ån¬x­ê,õõ”2¥£Âý…ÿÞCñ#¹(þ/³Rò´Ÿ©¥ê\¨q\þ%¢ © ÅtEÝÊ^î+Åuƒ.B´ß­éí}+º á4ýUÝÀAÞ|H.MÔ¥LÅyCÚÓ]K-‹GXï)Å|~õ�دpÜñwÙÿ/ˆUÜ_*Ú¥ûûÖ44æŠÆ¾:(êî›òGŠþƒOÙûÖôX �I²+‚)~êSvDÄi0?zÊF%N”Gæµ§ðªÒkJqaqa’Jžƒ¸Ÿ½e_A*„ª( r™T®FE˜´þp+bÆ¿zÒU4®F^`YÃè`[m_~õ§½óbd]“§÷*«(È‚¾Žã·~iU‘öãI~õ�w NßÅ•AÛ¸ÉÈÓÝ•�Ù~ëÄ•)úLYœìëïÞ²íô“ʱаÁ`ŽO'< 4ÿpJéÿaƒøÈ9› &,ü‚»öû÷¬»ô=ZP÷E4�0K'ÔíLI1,ÄØbPà˜Á3¨ƾýêï›âÁ7‚sP#—P'šU”J( R/ÕwpŤ¹æÉVµpÆ ¸•oP? jW`¢~õuý„xC"ˆ/‡JºªÈÓJ½ö +zP_ï%_šˆy]ñL[MAÖ²¾ýë oŠÖ±�Á=©X!¸|Bãß»2±ÜzX·)äMûÖÝ­ïºÑÝ2 __½m†9oÁûÖ�£*ûÕ¶1ûÖuäuõUôƒ÷ª!o^ ¸½Eû÷ïß¿~ýû÷à \ No newline at end of file diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame09_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame09_maskstream.bin new file mode 100644 index 0000000..bcef5f4 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame09_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_bitstream.bin new file mode 100644 index 0000000..77f673f Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_maskstream.bin new file mode 100644 index 0000000..12f1b76 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_bitstream.bin new file mode 100644 index 0000000..06331af Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_maskstream.bin new file mode 100644 index 0000000..f64af8e Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame12_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame12_bitstream.bin new file mode 100644 index 0000000..fea9e25 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame12_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame12_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame12_maskstream.bin new file mode 100644 index 0000000..f44f073 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame12_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_bitstream.bin new file mode 100644 index 0000000..4f70421 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_maskstream.bin new file mode 100644 index 0000000..95f7c62 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame14_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame14_bitstream.bin new file mode 100644 index 0000000..1c457ed Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame14_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame14_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame14_maskstream.bin new file mode 100644 index 0000000..35f05f7 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame14_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame15_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame15_bitstream.bin new file mode 100644 index 0000000..6b33553 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame15_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame15_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame15_maskstream.bin new file mode 100644 index 0000000..1b0ccba Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame15_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame16_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame16_bitstream.bin new file mode 100644 index 0000000..412bd41 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame16_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame16_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame16_maskstream.bin new file mode 100644 index 0000000..504ad82 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame16_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_bitstream.bin new file mode 100644 index 0000000..545abd6 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_maskstream.bin new file mode 100644 index 0000000..4f3da33 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame18_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame18_bitstream.bin new file mode 100644 index 0000000..05cb4ac Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame18_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame18_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame18_maskstream.bin new file mode 100644 index 0000000..6c1c456 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame18_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_bitstream.bin new file mode 100644 index 0000000..f6d2787 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_maskstream.bin new file mode 100644 index 0000000..3696bf8 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_bitstream.bin new file mode 100644 index 0000000..edd267a Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_maskstream.bin new file mode 100644 index 0000000..30c7e34 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame21_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame21_bitstream.bin new file mode 100644 index 0000000..d0abbfc --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame21_bitstream.bin @@ -0,0 +1,2 @@ +~ýû÷ïß¿~ýû÷ïß¿OÒ.�ô éú~„=!éOÔt¦‘ï+~£¥4�Wà”~£¥T½Yx¯{Ð ìé$QÝoÓôª—�¢ÛÚ+hVþGÒYA@2Z½¦ö§éúUHõoÝÓ7^×{eí²ký Ð.»~Ÿ¥T�V^Ýú$—/ûžfkÛþ½½ÚP�hý?J©z² ½Âfyš/qø¤·3OÙ2<”$¡_³¥êßÒ/™²òjÅ3T·¹|×�W-û:^¬¹t7�Ì7úÌ{œÁRXA@ÙÒóý][îá6íñú.®¿ìAó?gK÷êzQKõ=?QÑJ +G©¤ŠõxýU)izŠß½mõCÕ0šýëoª§Äß~õ”Œ?zÛê‡ï[ÈËJÄ›–’ ¿zç¹½zÞXt½¸ýënÈýë¨Ho{ì�(ï·ï^µÁ-â?}-û÷¯^Ö7ïß¿~ýê�ƒ÷©îzýê_ª¿~ýû÷ïß¿~ýû÷ïß¿~ýûð \ No newline at end of file diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame21_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame21_maskstream.bin new file mode 100644 index 0000000..014d3b8 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame21_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame22_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame22_bitstream.bin new file mode 100644 index 0000000..e367314 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame22_bitstream.bin @@ -0,0 +1,2 @@ +~ýû÷ïß¿~ýû÷ïß¿OKôô�ÔRýE/\ZA +Œ‘\!’3õ½mh—'·®µ™ Á6ÖåyúzTÒõopÌ‘%ï®ï% �±b3—*\¹�û#ôô½q«µ2'×x“fE™&do±q ‚09úz^·ºb s?J½?®#lFøŽ1þ÷A~ý=/\þ—ˆëÞ#ÉDñÌ•.wÔÿ¦^¤ýëÔwM½N\ÜG×jÂþíº>$Dc~šýëÔxnaÔ1×®òÄ�ˆ¾d{žüŸ½z�– ¼>W^œ8C»vðô6&ãµ?~Ê—ì©~¦‘(i~¢�CK÷­aMûׯQÂk÷ïYHÃ÷¯WAï[ÈËNÔýëžä~õë{ýû÷ïß¿~ýû÷ïß¿~ýü~ýû÷ïß¿~ýû÷ïß¿~ \ No newline at end of file diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame22_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame22_maskstream.bin new file mode 100644 index 0000000..1ccb6d1 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame22_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame23_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame23_bitstream.bin new file mode 100644 index 0000000..d1bf8c2 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame23_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame23_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame23_maskstream.bin new file mode 100644 index 0000000..5287c0c Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame23_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_bitstream.bin new file mode 100644 index 0000000..c487343 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_bitstream.bin @@ -0,0 +1,3 @@ +~ýû÷ïß¿~ýû÷ïß¿~¯¤=!éû÷¯SVÂñï^¤û~1|þõ´žš +=û.ž­ì�¤™;’íÀoAyËL³&�Ÿ?gÓÕqŠF¯¼’e«Ýæ[½rP7®É¹.ä~õê/­!w¯^¿{ì2D™reÛÈÿy½Šg~ϧ«# ¾ô™zeô_]¯xÇܼIýû>ž¬ï?Oû–f Á¾­™†M31 ûšögìúz³÷î]Bf4•LÉ3-ᵎçÏÙôõ}‹º:»d¶ƒî�yûö]?eÐýOB�ôýOBŸ½k +oÞ½zŽ_¿zÊF¿zÞFZv§ï\÷#÷ïß¿~ýû÷ïß¿~ýû÷ïß¿~ýû÷ïß¿~ýû÷ï߀ \ No newline at end of file diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_maskstream.bin new file mode 100644 index 0000000..b426404 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame25_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame25_bitstream.bin new file mode 100644 index 0000000..22a6817 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame25_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame25_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame25_maskstream.bin new file mode 100644 index 0000000..f18464a Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame25_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_bitstream.bin new file mode 100644 index 0000000..9cc6ac0 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_bitstream.bin @@ -0,0 +1,3 @@ +~ýû÷ïß¿~ýû÷ïß¿WÒ„!è~¯¤=!éCö}ö}VZA +Œ‘\!’3ö�=Y.Oo]k2‚m­Ê +óö}=YÜ3$I{ë»Éh#ìDŒåÊ—.d>ÈýŸCÕ}Ã2'×x“fE™&do±q ‚09û>�ê¹s?J½?®#lFøŽ1þ÷A~ýŸOVþ—ˆëÞ#ÉDñÌ•.wÔÿ¦^¤ýŸOV]6õ9sq]« û¶èø‘}�úk÷¯QþýÌ º†#úõÞX�1Ì�sß“ö}=YË^Þ·X4p‡v죨lKö}?~ϧêzt‡§êzôýëXS~õëÔpšýûÖR0ýëÕÕ‰ûÖò2ýëžä~ýû÷ïß¿~ýû÷ïß¿~ýû÷ïß¿~ýû÷ïß¿~ýûð \ No newline at end of file diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_maskstream.bin new file mode 100644 index 0000000..6a577fc Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_bitstream.bin new file mode 100644 index 0000000..0925597 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_bitstream.bin @@ -0,0 +1 @@ +~ýû÷ïß¿~ýû÷ïß¿kKö´¿kK÷¯RPÐ}Á.².Z·Ÿ½z’‚eÙ Þ¡ù²ñ÷ï^¤—jîÞÉ[ÿ«?NA.Ú w>‚ýëÔpo¦‚d»Õ¦LûBöKÕû¡.ïÇ~Ö—ª¬ w¾Ñ½cãþÏœ°W`Ì›ö—ÚoÞ½IyÿÞ'LŸñ¥ß™Beµ&RûUûרëÓ)Öd¼* »¯ʾßkýÞýëÔw/ðA2´Êè0‡Ì±LïÞ½GòôÐv—GûjM8D=í§ïß¿SKõ4ôƒ§êºz¦ß½zõ&¿~õ”Œ?~õ¼Œ´¬OÞ¹îGïß½zº íOß¿~ýû÷ïß¿~ýû÷ïß¿~ýû÷ïß¿~ýû÷ïÀ \ No newline at end of file diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_maskstream.bin new file mode 100644 index 0000000..cb8e365 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame28_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame28_bitstream.bin new file mode 100644 index 0000000..bdc96c9 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame28_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame28_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame28_maskstream.bin new file mode 100644 index 0000000..1f8df2a Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame28_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_bitstream.bin new file mode 100644 index 0000000..2ea0e60 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_bitstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_maskstream.bin new file mode 100644 index 0000000..db2f7eb Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_maskstream.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/paletteLookup.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/paletteLookup.bin new file mode 100644 index 0000000..317b960 Binary files /dev/null and b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/paletteLookup.bin differ diff --git a/modding/hacked/ss1/content/movie/internal/format/DataType.go b/modding/hacked/ss1/content/movie/internal/format/DataType.go new file mode 100644 index 0000000..f950327 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/DataType.go @@ -0,0 +1,25 @@ +package format + +// DataType classifies entries. +type DataType byte + +const ( + // DataTypeEndOfMedia marks the last entry. + DataTypeEndOfMedia = DataType(0) + // DataTypeLowResVideo for low resolution (low compression) video. + DataTypeLowResVideo = DataType(0x21) + // DataTypeHighResVideo for high resolution (high compression) video. + DataTypeHighResVideo = DataType(0x79) + // DataTypeAudio marks an audio entry. + DataTypeAudio = DataType(2) + // DataTypeSubtitle control for subtitle entries. + DataTypeSubtitle = DataType(3) + // DataTypePalette describes a color palette. + DataTypePalette = DataType(4) + // DataTypePaletteReset is a zero-byte entry immediately before a Palette entry. + DataTypePaletteReset = DataType(0x4C) + // DataTypePaletteLookupList for high compression video. + DataTypePaletteLookupList = DataType(5) + // DataTypeControlDictionary for high compression video. + DataTypeControlDictionary = DataType(0x0D) +) diff --git a/modding/hacked/ss1/content/movie/internal/format/Entry.go b/modding/hacked/ss1/content/movie/internal/format/Entry.go new file mode 100644 index 0000000..204b3ab --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/Entry.go @@ -0,0 +1,305 @@ +package format + +import ( + "bytes" + "io" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// EntryBucketPriority describes the general priority of the bucket. +// Lower numbers are put before higher numbers if the bucket has the same timestamp. +type EntryBucketPriority int + +// List of entry bucket priorities. +const ( + EntryBucketPriorityAudio = 0 + EntryBucketPriorityVideoControl = 1 + EntryBucketPrioritySubtitleControl = 2 + EntryBucketPrioritySubtitle = 3 + EntryBucketPriorityFrame = 4 +) + +// EntryBucket is a set of entries that need to be together within the stream. +// This exists mainly to cover special cases of entries within a stream that have no valid timestamp. +type EntryBucket struct { + // Priority helps in merging content from multiple buckets. + Priority EntryBucketPriority + // Timestamp specifies at which time the bucket should be inserted into the stream. + // This is typically derived from one of the timestamps of the contained entries. + Timestamp Timestamp + // Entries is the list of entries within this bucket. + Entries []Entry +} + +// Entry describes a timestamped block from a MOVI container. +type Entry struct { + // Timestamp marks the beginning time of the entry. + Timestamp Timestamp + // Data is a type specific information of a movie. + Data EntryData +} + +// EntryData describes one entry in a MOVI container. +type EntryData interface { + // Type describes the content type of the data. + Type() DataType + // Bytes returns the raw bytes of the entry. + Bytes() []byte +} + +// UnknownEntryData is an entry that is not know to this codebase. +type UnknownEntryData struct { + DataType DataType + Raw []byte +} + +// UnknownEntryFrom decodes an entry from given data. +func UnknownEntryFrom(dataType DataType, r io.Reader, dataSize int) (UnknownEntryData, error) { + data := UnknownEntryData{ + DataType: dataType, + Raw: make([]byte, dataSize), + } + coder := serial.NewDecoder(r) + coder.Code(data.Raw) + return data, coder.FirstError() +} + +// Type describes the entry. +func (data UnknownEntryData) Type() DataType { + return data.DataType +} + +// Bytes returns the raw bytes of the entry. +func (data UnknownEntryData) Bytes() []byte { + return data.Raw +} + +// LowResVideoEntryData is a compressed low-resolution video frame. +type LowResVideoEntryData struct { + BoundingBox [4]uint16 + Packed []byte +} + +// LowResVideoEntryFrom decodes an entry from given data. +func LowResVideoEntryFrom(r io.Reader, dataSize int) (LowResVideoEntryData, error) { + var data LowResVideoEntryData + coder := serial.NewDecoder(r) + coder.Code(&data.BoundingBox) + data.Packed = make([]byte, dataSize-int(coder.CurPos())) + coder.Code(data.Packed) + return data, coder.FirstError() +} + +// Type describes the entry. +func (data LowResVideoEntryData) Type() DataType { + return DataTypeLowResVideo +} + +// Bytes returns the raw bytes of the entry. +func (data LowResVideoEntryData) Bytes() []byte { + buf := bytes.NewBuffer(nil) + coder := serial.NewEncoder(buf) + coder.Code(data.BoundingBox) + coder.Code(data.Packed) + return buf.Bytes() +} + +// HighResVideoEntryData is a compressed high-resolution video frame. +type HighResVideoEntryData struct { + Bitstream []byte + Maskstream []byte +} + +// HighResVideoEntryFrom decodes an entry from given data. +func HighResVideoEntryFrom(r io.Reader, dataSize int) (HighResVideoEntryData, error) { + var data HighResVideoEntryData + coder := serial.NewDecoder(r) + var maskstreamOffset uint16 + coder.Code(&maskstreamOffset) + data.Bitstream = make([]byte, int(maskstreamOffset)-int(coder.CurPos())) + coder.Code(data.Bitstream) + data.Maskstream = make([]byte, dataSize-int(coder.CurPos())) + coder.Code(data.Maskstream) + return data, coder.FirstError() +} + +// Type describes the entry. +func (data HighResVideoEntryData) Type() DataType { + return DataTypeHighResVideo +} + +// Bytes returns the raw bytes of the entry. +func (data HighResVideoEntryData) Bytes() []byte { + buf := bytes.NewBuffer(nil) + coder := serial.NewEncoder(buf) + maskstreamOffset := uint16(len(data.Bitstream)) + 2 + coder.Code(&maskstreamOffset) + coder.Code(data.Bitstream) + coder.Code(data.Maskstream) + return buf.Bytes() +} + +// AudioEntryData is an entry with audio samples. +type AudioEntryData struct { + Samples []byte +} + +// AudioEntryFrom decodes an entry from given data. +func AudioEntryFrom(r io.Reader, dataSize int) (AudioEntryData, error) { + var data AudioEntryData + data.Samples = make([]byte, dataSize) + coder := serial.NewDecoder(r) + coder.Code(data.Samples) + return data, coder.FirstError() +} + +// Type describes the entry. +func (data AudioEntryData) Type() DataType { + return DataTypeAudio +} + +// Bytes returns the raw bytes of the entry. +func (data AudioEntryData) Bytes() []byte { + return data.Samples +} + +// SubtitleEntryData is an entry with Subtitle samples. +type SubtitleEntryData struct { + Control SubtitleControl + Text []byte +} + +// SubtitleEntryFrom decodes an entry from given data. +func SubtitleEntryFrom(r io.Reader, dataSize int) (SubtitleEntryData, error) { + var data SubtitleEntryData + coder := serial.NewDecoder(r) + var header SubtitleHeader + coder.Code(&header) + data.Control = header.Control + if header.TextOffset > coder.CurPos() { + coder.Code(make([]byte, header.TextOffset-coder.CurPos())) + } + data.Text = make([]byte, dataSize-int(coder.CurPos())) + coder.Code(data.Text) + return data, coder.FirstError() +} + +// Type describes the entry. +func (data SubtitleEntryData) Type() DataType { + return DataTypeSubtitle +} + +// Bytes returns the raw bytes of the entry. +func (data SubtitleEntryData) Bytes() []byte { + header := SubtitleHeader{ + Control: data.Control, + TextOffset: SubtitleDefaultTextOffset, + } + buf := bytes.NewBuffer(nil) + coder := serial.NewEncoder(buf) + coder.Code(&header) + coder.Code(make([]byte, int(header.TextOffset-coder.CurPos()))) + coder.Code(data.Text) + return buf.Bytes() +} + +// PaletteEntryData is an entry with a palette. +type PaletteEntryData struct { + Colors bitmap.Palette +} + +// PaletteEntryFrom decodes an entry from given data. +func PaletteEntryFrom(r io.Reader) (PaletteEntryData, error) { + var data PaletteEntryData + coder := serial.NewDecoder(r) + coder.Code(&data.Colors) + return data, coder.FirstError() +} + +// Type describes the entry. +func (data PaletteEntryData) Type() DataType { + return DataTypePalette +} + +// Bytes returns the raw bytes of the entry. +func (data PaletteEntryData) Bytes() []byte { + buf := bytes.NewBuffer(nil) + coder := serial.NewEncoder(buf) + coder.Code(&data.Colors) + return buf.Bytes() +} + +// PaletteResetEntryData marks a reset of the palette. +type PaletteResetEntryData struct { +} + +// PaletteResetEntryFrom decodes an entry from given data. +func PaletteResetEntryFrom() (PaletteResetEntryData, error) { + return PaletteResetEntryData{}, nil +} + +// Type describes the entry. +func (data PaletteResetEntryData) Type() DataType { + return DataTypePaletteReset +} + +// Bytes serializes the entry. +func (data PaletteResetEntryData) Bytes() []byte { + return nil +} + +// PaletteLookupEntryData contains a list of palette indices. +type PaletteLookupEntryData struct { + List []byte +} + +// PaletteLookupEntryFrom decodes an entry from given data. +func PaletteLookupEntryFrom(r io.Reader, dataSize int) (PaletteLookupEntryData, error) { + var data PaletteLookupEntryData + data.List = make([]byte, dataSize) + coder := serial.NewDecoder(r) + coder.Code(data.List) + return data, coder.FirstError() +} + +// Type describes the entry. +func (data PaletteLookupEntryData) Type() DataType { + return DataTypePaletteLookupList +} + +// Bytes serializes the entry. +func (data PaletteLookupEntryData) Bytes() []byte { + return data.List +} + +// ControlDictionaryEntryData contains control words for compression. +type ControlDictionaryEntryData struct { + Words []compression.ControlWord +} + +// ControlDictionaryEntryFrom decodes an entry from given data. +func ControlDictionaryEntryFrom(r io.Reader, dataSize int) (ControlDictionaryEntryData, error) { + var data ControlDictionaryEntryData + coder := serial.NewDecoder(r) + b := make([]byte, dataSize) + coder.Code(b) + if coder.FirstError() != nil { + return data, coder.FirstError() + } + var err error + data.Words, err = compression.UnpackControlWords(b) + return data, err +} + +// Type describes the entry. +func (data ControlDictionaryEntryData) Type() DataType { + return DataTypeControlDictionary +} + +// Bytes serializes the entry. +func (data ControlDictionaryEntryData) Bytes() []byte { + return compression.PackControlWords(data.Words) +} diff --git a/modding/hacked/ss1/content/movie/internal/format/Header.go b/modding/hacked/ss1/content/movie/internal/format/Header.go new file mode 100644 index 0000000..786210e --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/Header.go @@ -0,0 +1,73 @@ +package format + +import ( + "fmt" + "math" +) + +// Tag is the identifier key of a MOVI container. +const Tag string = "MOVI" + +// HeaderSize specifies the length of a serialized Header in bytes. +const HeaderSize = 256 + +// Fix represents a fixed-point number, with 16 bits of fraction and 15 bits of integer number. +type Fix struct { + Fraction uint16 + Number int16 +} + +// FixFromFloat returns a fixed-point number from given floating-point number. +func FixFromFloat(value float32) Fix { + number := int16(value) + return Fix{ + Fraction: uint16(math.Abs(float64(value)-float64(number)) * 0x10000), + Number: number, + } +} + +// ToFloat returns the equivalent floating-point number. +func (f Fix) ToFloat() float32 { + return float32(f.Number) + float32(f.Fraction)/0x10000 +} + +// Header is the first entry of a MOVI container. +type Header struct { + // Tag is the identifier key of a MOVI container + Tag [4]byte + // IndexEntryCount specifies how many index entries are in the index. + IndexEntryCount int32 + // IndexSize specifies how long the index is. + IndexSize int32 + // ContentSize specifies the length of the content. + ContentSize int32 + // Duration is the length of the media in seconds. + Duration Fix + + // VideoFrameRate gives a hint on number of frames per second. + VideoFrameRate Fix + // VideoWidth specifies the width of the video. + VideoWidth uint16 + // VideoHeight specifies the height of the video. + VideoHeight uint16 + // VideoBitsPerPixel specifies bit count for color information: 8, 15, 24. + VideoBitsPerPixel int16 + // VideoPalettePresent specifies whether a palette is set. + VideoPalettePresent int16 + + // AudioChannelCount specifies whether there is audio, and how many channels to use. 0 = no audio, 1 = mono, 2 = stereo. + AudioChannelCount int16 + // AudioBytesPerSample specifies how many bytes one audio sample has. 1 = 8-bit, 2 = 16-bit. + AudioBytesPerSample int16 + // AudioSampleRate specifies the sample rate of the audio. + AudioSampleRate Fix + + _ [216]byte +} + +func (header *Header) String() (result string) { + result += fmt.Sprintf("Index: %d entries (%d bytes)\n", header.IndexEntryCount, header.IndexSize) + result += fmt.Sprintf("Content: %d bytes, %f seconds\n", header.ContentSize, header.Duration.ToFloat()) + result += fmt.Sprintf("Video: %dx%d pixel @%f sample rate\n", header.VideoWidth, header.VideoHeight, header.AudioSampleRate.ToFloat()) + return +} diff --git a/modding/hacked/ss1/content/movie/internal/format/Header_test.go b/modding/hacked/ss1/content/movie/internal/format/Header_test.go new file mode 100644 index 0000000..708200e --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/Header_test.go @@ -0,0 +1,22 @@ +package format_test + +import ( + "bytes" + "encoding/binary" + "io" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" +) + +func TestHeaderSerializesToProperLength(t *testing.T) { + source := bytes.NewReader(make([]byte, 0x200)) + var header format.Header + + _ = binary.Read(source, binary.LittleEndian, &header) + curPos, _ := source.Seek(0, io.SeekCurrent) + + assert.Equal(t, int64(format.HeaderSize), curPos) +} diff --git a/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry.go b/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry.go new file mode 100644 index 0000000..a0e3b5a --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry.go @@ -0,0 +1,26 @@ +package format + +// IndexTableEntrySize specifies how many bytes a serialized IndexTableEntry uses. +const IndexTableEntrySize = 8 + +const indexHeaderSizeIncrement = 0x0400 + +// IndexTableEntry describes one data entry of a MOVI container. +type IndexTableEntry struct { + TimestampFraction uint16 + TimestampSecond byte + Type byte + DataOffset int32 +} + +// IndexTableSizeFor returns the number of bytes reserved for given amount of table entries. +func IndexTableSizeFor(entryCount int) int { + size := indexHeaderSizeIncrement + requiredSize := entryCount * IndexTableEntrySize + + if requiredSize > size { + size *= requiredSize/size + 2 + } + + return size +} diff --git a/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry_test.go b/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry_test.go new file mode 100644 index 0000000..d5d773c --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry_test.go @@ -0,0 +1,26 @@ +package format_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" +) + +func TestIndexTableSizeFor_ExistingSizes(t *testing.T) { + // These sample values are always the minimum and maximum amount of index entries + // found for a given index size. + + assert.Equal(t, 0x0400, format.IndexTableSizeFor(3)) + assert.Equal(t, 0x0400, format.IndexTableSizeFor(127)) + + assert.Equal(t, 0x0C00, format.IndexTableSizeFor(130)) + assert.Equal(t, 0x0C00, format.IndexTableSizeFor(218)) + + assert.Equal(t, 0x1C00, format.IndexTableSizeFor(738)) + assert.Equal(t, 0x1C00, format.IndexTableSizeFor(755)) + + assert.Equal(t, 0x3400, format.IndexTableSizeFor(1475)) + assert.Equal(t, 0x3400, format.IndexTableSizeFor(1523)) +} diff --git a/modding/hacked/ss1/content/movie/internal/format/SubtitleControl.go b/modding/hacked/ss1/content/movie/internal/format/SubtitleControl.go new file mode 100644 index 0000000..7d3896a --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/SubtitleControl.go @@ -0,0 +1,33 @@ +package format + +import "github.com/inkyblackness/hacked/ss1/resource" + +// SubtitleControl specifies how to interpret a subtitle entry. +type SubtitleControl uint32 + +// Subtitle constants are based on four-letter ASCII sequences. +const ( + SubtitleArea = SubtitleControl(0x41455241) + SubtitleTextStd = SubtitleControl(0x20445453) + SubtitleTextFrn = SubtitleControl(0x204E5246) + SubtitleTextGer = SubtitleControl(0x20524547) +) + +// String returns the string presentation of the control value. +func (ctrl SubtitleControl) String() string { + return string([]rune{rune((ctrl >> 0) & 0xFF), rune((ctrl >> 8) & 0xFF), rune((ctrl >> 16) & 0xFF), rune((ctrl >> 24) & 0xFF)}) +} + +// SubtitleControlForLanguage returns the corresponding subtitle control for given language. +func SubtitleControlForLanguage(lang resource.Language) SubtitleControl { + switch lang { + case resource.LangDefault: + return SubtitleTextStd + case resource.LangFrench: + return SubtitleTextFrn + case resource.LangGerman: + return SubtitleTextGer + default: + panic("unknown language") + } +} diff --git a/modding/hacked/ss1/content/movie/internal/format/SubtitleHeader.go b/modding/hacked/ss1/content/movie/internal/format/SubtitleHeader.go new file mode 100644 index 0000000..6fff670 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/SubtitleHeader.go @@ -0,0 +1,12 @@ +package format + +// SubtitleDefaultTextOffset is the typical offset, in bytes, to text content. +const SubtitleDefaultTextOffset = 16 + +// SubtitleHeader is the header structure of a subtitle data. +type SubtitleHeader struct { + // Control specifies how to interpret the string content + Control SubtitleControl + // TextOffset specifies the offset, in bytes from beginning of entry data, where the text starts. + TextOffset uint32 +} diff --git a/modding/hacked/ss1/content/movie/internal/format/Timestamp.go b/modding/hacked/ss1/content/movie/internal/format/Timestamp.go new file mode 100644 index 0000000..6f7ceff --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/Timestamp.go @@ -0,0 +1,86 @@ +package format + +import ( + "math" + "time" +) + +const fractionDivisor = 0x10000 + +// Timestamp represents a point in time using fixed resolution. +type Timestamp struct { + Second uint8 + Fraction uint16 +} + +// TimestampLimit returns the highest possible timestamp value. +func TimestampLimit() Timestamp { + return Timestamp{ + Second: math.MaxUint8, + Fraction: math.MaxUint16, + } +} + +// TimestampFromSeconds creates a timestamp instance from given floating point value. +func TimestampFromSeconds(value float32) Timestamp { + second := uint8(value) + return Timestamp{ + Second: second, + Fraction: uint16((value - float32(second)) * fractionDivisor), + } +} + +// TimestampFromDuration creates a timestamp instance from given duration value. +func TimestampFromDuration(d time.Duration) Timestamp { + limit := TimestampLimit() + if d >= limit.ToDuration() { + return limit + } + return Timestamp{ + Second: uint8(d / time.Second), + Fraction: uint16(((d - time.Second) * fractionDivisor) / time.Second), + } +} + +// ToDuration returns the equivalent duration for this timestamp. +func (ts Timestamp) ToDuration() time.Duration { + return (time.Duration(ts.Second) * time.Second) + (time.Duration(ts.Fraction)*time.Second)/fractionDivisor +} + +// IsAfter returns true if this timestamp is later than the given one. +func (ts Timestamp) IsAfter(other Timestamp) bool { + return (ts.Second > other.Second) || ((ts.Second == other.Second) && (ts.Fraction > other.Fraction)) +} + +// IsBefore returns true if this timestamp is before the given one. +func (ts Timestamp) IsBefore(other Timestamp) bool { + return (ts.Second < other.Second) || ((ts.Second == other.Second) && (ts.Fraction < other.Fraction)) +} + +// Plus returns a timestamp with the given one added to the current one. +// The result is saturated if the addition is larger than the timestamp can hold. +func (ts Timestamp) Plus(other Timestamp) Timestamp { + return TimestampFromDuration(ts.ToDuration() + other.ToDuration()) +} + +// Minus returns a timestamp with given one removed from the current one. +// The result is clipped to 0 if subtraction would result in a negative number. +func (ts Timestamp) Minus(other Timestamp) Timestamp { + otherLinear := other.toLinear() + tsLinear := ts.toLinear() + if otherLinear >= tsLinear { + return timestampFromLinear(0) + } + return timestampFromLinear(tsLinear - otherLinear) +} + +func (ts Timestamp) toLinear() uint32 { + return uint32(ts.Second)<<16 + uint32(ts.Fraction) +} + +func timestampFromLinear(value uint32) Timestamp { + return Timestamp{ + Second: uint8(value >> 16), + Fraction: uint16(value), + } +} diff --git a/modding/hacked/ss1/content/movie/internal/format/Timestamp_test.go b/modding/hacked/ss1/content/movie/internal/format/Timestamp_test.go new file mode 100644 index 0000000..cd9f3c3 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/Timestamp_test.go @@ -0,0 +1,66 @@ +package format_test + +import ( + "fmt" + "math" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" +) + +func TestTimestampFromSecond(t *testing.T) { + ts := format.TimestampFromSeconds(7.25) + + assert.Equal(t, byte(7), ts.Second) + assert.Equal(t, uint16(0x4000), ts.Fraction) +} + +func TestTimeToRawB(t *testing.T) { + ts := format.TimestampFromSeconds(255.75) + + assert.Equal(t, byte(255), ts.Second) + assert.Equal(t, uint16(0xC000), ts.Fraction) +} + +func TestDurationConversion(t *testing.T) { + for fraction := 0; fraction <= math.MaxUint16; fraction++ { + ts := format.Timestamp{Second: 0, Fraction: uint16(fraction)} + dur := ts.ToDuration() + ts2 := format.TimestampFromDuration(dur) + + assert.Equal(t, ts, ts2, fmt.Sprintf("Mismatch for fraction %v", fraction)) + } +} + +func TestTimestampPlus(t *testing.T) { + tt := []struct { + a format.Timestamp + b format.Timestamp + expected format.Timestamp + }{ + { + a: format.Timestamp{Second: 0, Fraction: 0}, + b: format.Timestamp{Second: 1, Fraction: 0x8000}, + expected: format.Timestamp{Second: 1, Fraction: 0x8000}, + }, + { + a: format.Timestamp{Second: 2, Fraction: 0x8000}, + b: format.Timestamp{Second: 1, Fraction: 0x8000}, + expected: format.Timestamp{Second: 4, Fraction: 0x0000}, + }, + { + a: format.Timestamp{Second: 0xFF, Fraction: 0x0000}, + b: format.Timestamp{Second: 2, Fraction: 0x8000}, + expected: format.Timestamp{Second: 0xFF, Fraction: 0xFFFF}, + }, + } + for index, tc := range tt { + tc := tc + t.Run(fmt.Sprintf("case %d", index), func(t *testing.T) { + result := tc.a.Plus(tc.b) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/modding/hacked/ss1/content/numbers/BinaryCodedDecimal.go b/modding/hacked/ss1/content/numbers/BinaryCodedDecimal.go new file mode 100644 index 0000000..512d8b3 --- /dev/null +++ b/modding/hacked/ss1/content/numbers/BinaryCodedDecimal.go @@ -0,0 +1,26 @@ +package numbers + +// ToBinaryCodedDecimal converts given integer value to a 4-digit BCD value. +func ToBinaryCodedDecimal(value uint16) (bcd uint16) { + factors := []uint16{1000, 100, 10, 1} + value %= 10000 + + for _, factor := range factors { + bcd = (bcd << 4) | (value / factor) + value %= factor + } + + return +} + +// FromBinaryCodedDecimal converts given 4-digit BCD value into an integer. +func FromBinaryCodedDecimal(bcd uint16) (value uint16) { + factors := []uint16{1, 10, 100, 1000} + + for _, factor := range factors { + value += (bcd & 0xF) * factor + bcd >>= 4 + } + + return +} diff --git a/modding/hacked/ss1/content/numbers/BinaryCodedDecimal_test.go b/modding/hacked/ss1/content/numbers/BinaryCodedDecimal_test.go new file mode 100644 index 0000000..c5de7d7 --- /dev/null +++ b/modding/hacked/ss1/content/numbers/BinaryCodedDecimal_test.go @@ -0,0 +1,37 @@ +package numbers_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/numbers" +) + +func TestBinaryCodedDecimal(t *testing.T) { + tt := []struct { + int uint16 + bcd uint16 + }{ + {int: 0, bcd: 0x0000}, + {int: 1, bcd: 0x0001}, + {int: 20, bcd: 0x0020}, + {int: 300, bcd: 0x0300}, + {int: 4000, bcd: 0x4000}, + {int: 50000, bcd: 0x0000}, + {int: 9999, bcd: 0x9999}, + {int: 451, bcd: 0x0451}, + } + + for _, tc := range tt { + toBCD := numbers.ToBinaryCodedDecimal(tc.int) + assert.Equal(t, tc.bcd, toBCD, "Wrong result converting to BCD for %d", tc.int) + + fromBCD := numbers.FromBinaryCodedDecimal(tc.bcd) + if tc.int <= 9999 { + assert.Equal(t, tc.int, fromBCD, "Wrong result converting from BCD for %d", tc.int) + } else { + assert.Equal(t, uint16(0), fromBCD, "Wrong result converting from BCD for %d", tc.int) + } + } +} diff --git a/modding/hacked/ss1/content/object/Bitmap3D.go b/modding/hacked/ss1/content/object/Bitmap3D.go new file mode 100644 index 0000000..957ba20 --- /dev/null +++ b/modding/hacked/ss1/content/object/Bitmap3D.go @@ -0,0 +1,73 @@ +package object + +// Bitmap3D describes how a bitmap object should be represented in 3D world. +type Bitmap3D uint16 + +const ( + bitmap3DBitmapNumberLowMask uint16 = 0x03FF + bitmap3DBitmapNumberHighMask uint16 = 0x8000 + bitmap3DBitmapNumberMask = bitmap3DBitmapNumberLowMask | bitmap3DBitmapNumberHighMask + bitmap3DAnimationMask uint16 = 0x0400 + bitmap3DRepeatMask uint16 = 0x0800 + bitmap3DFrameNumberMask uint16 = 0x7000 + + // Bitmap3DBitmapNumberLimit is the maximum amount of bitmaps. + Bitmap3DBitmapNumberLimit uint16 = 0x07FF + // Bitmap3DFrameNumberLimit is the maximum amount of frames. + Bitmap3DFrameNumberLimit uint16 = 0x0007 +) + +// WithBitmapNumber returns a Bitmap3D with the given value. +func (bmp Bitmap3D) WithBitmapNumber(value uint16) Bitmap3D { + bitmapValue := value & bitmap3DBitmapNumberLowMask + if value >= Bitmap3DBitmapNumberLimit { + bitmapValue = bitmap3DBitmapNumberMask + } else if value > bitmap3DBitmapNumberLowMask { + bitmapValue |= bitmap3DBitmapNumberHighMask + } + return Bitmap3D((uint16(bmp) & ^bitmap3DBitmapNumberMask) | bitmapValue) +} + +// BitmapNumber returns the number of bitmaps. +func (bmp Bitmap3D) BitmapNumber() uint16 { + return ((uint16(bmp) & bitmap3DBitmapNumberHighMask) >> 5) | (uint16(bmp) & bitmap3DBitmapNumberLowMask) +} + +// WithFrameNumber returns a Bitmap3D with the given value. +func (bmp Bitmap3D) WithFrameNumber(value uint16) Bitmap3D { + frameValue := (value % (Bitmap3DFrameNumberLimit + 1)) << 12 + return Bitmap3D((uint16(bmp) & ^bitmap3DFrameNumberMask) | frameValue) +} + +// FrameNumber returns the frame number. +func (bmp Bitmap3D) FrameNumber() uint16 { + return (uint16(bmp) & bitmap3DFrameNumberMask) >> 12 +} + +// WithAnimation returns a Bitmap3D with the given value. +func (bmp Bitmap3D) WithAnimation(value bool) Bitmap3D { + animationValue := uint16(0) + if value { + animationValue = bitmap3DAnimationMask + } + return Bitmap3D((uint16(bmp) & ^bitmap3DAnimationMask) | animationValue) +} + +// Animation returns whether this object is animated. +func (bmp Bitmap3D) Animation() bool { + return (uint16(bmp) & bitmap3DAnimationMask) != 0 +} + +// WithRepeat returns a Bitmap3D with the given value. +func (bmp Bitmap3D) WithRepeat(value bool) Bitmap3D { + repeatValue := uint16(0) + if value { + repeatValue = bitmap3DRepeatMask + } + return Bitmap3D((uint16(bmp) & ^bitmap3DRepeatMask) | repeatValue) +} + +// Repeat returns whether this object repeats its animation. +func (bmp Bitmap3D) Repeat() bool { + return (uint16(bmp) & bitmap3DRepeatMask) != 0 +} diff --git a/modding/hacked/ss1/content/object/Bitmap3D_test.go b/modding/hacked/ss1/content/object/Bitmap3D_test.go new file mode 100644 index 0000000..ef9e13d --- /dev/null +++ b/modding/hacked/ss1/content/object/Bitmap3D_test.go @@ -0,0 +1,40 @@ +package object_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/object" +) + +func TestBitmap3DValueRange(t *testing.T) { + for _, animation := range []bool{false, true} { + for _, repeat := range []bool{false, true} { + for frameNumber := uint16(0); frameNumber <= object.Bitmap3DFrameNumberLimit; frameNumber++ { + for _, bitmapNumber := range []uint16{ + 0, + object.Bitmap3DBitmapNumberLimit / 4, + object.Bitmap3DBitmapNumberLimit / 2, + object.Bitmap3DBitmapNumberLimit / 2, + object.Bitmap3DBitmapNumberLimit/2 + 4, + object.Bitmap3DBitmapNumberLimit - 1, + object.Bitmap3DBitmapNumberLimit, + } { + key := fmt.Sprintf("%v:%v:%v:%v", animation, repeat, frameNumber, bitmapNumber) + value := object.Bitmap3D(0). + WithAnimation(animation). + WithRepeat(repeat). + WithFrameNumber(frameNumber). + WithBitmapNumber(bitmapNumber) + + assert.Equal(t, animation, value.Animation(), fmt.Sprintf("Animation wrong for %v", key)) + assert.Equal(t, repeat, value.Repeat(), fmt.Sprintf("Repeat wrong for %v", key)) + assert.Equal(t, frameNumber, value.FrameNumber(), fmt.Sprintf("Frame number wrong for %v", key)) + assert.Equal(t, bitmapNumber, value.BitmapNumber(), fmt.Sprintf("Bitmap number wrong for %v", key)) + } + } + } + } +} diff --git a/modding/hacked/ss1/content/object/CommonFlags.go b/modding/hacked/ss1/content/object/CommonFlags.go new file mode 100644 index 0000000..bfda697 --- /dev/null +++ b/modding/hacked/ss1/content/object/CommonFlags.go @@ -0,0 +1,160 @@ +package object + +import "fmt" + +// CommonFlag describe flags applicable to all object classes. +type CommonFlag int + +// String returns the textual representation of the value. +func (flag CommonFlag) String() string { + if int(flag) >= len(commonFlagNames) { + return fmt.Sprintf("Unknown 0x%04X", int(flag)) + } + return commonFlagNames[flag] +} + +func (flag CommonFlag) mask() CommonFlagField { + return CommonFlagField(1<> commonFlagLightTypeShift) +} + +// WithLightType returns a new flag field value with given light type set. +func (field CommonFlagField) WithLightType(value LightType) CommonFlagField { + return CommonFlagField((uint16(field) & ^uint16(commonFlagLightTypeMask)) | ((uint16(value) << commonFlagLightTypeShift) & commonFlagLightTypeMask)) +} + +// UseMode returns the stored enumeration value. +func (field CommonFlagField) UseMode() UseMode { + return UseMode((field & commonFlagUseModeMask) >> commonFlagUseModeShift) +} + +// WithUseMode returns a new flag field value with given use mode set. +func (field CommonFlagField) WithUseMode(value UseMode) CommonFlagField { + return CommonFlagField((uint16(field) & ^uint16(commonFlagUseModeMask)) | ((uint16(value) << commonFlagUseModeShift) & commonFlagUseModeMask)) +} + +// LightType describes how an object is affected by lighting. +type LightType byte + +// String returns the textual representation of the value. +func (mode LightType) String() string { + if int(mode) >= len(lightTypeNames) { + return fmt.Sprintf("Unknown 0x%02X", int(mode)) + } + return lightTypeNames[mode] +} + +// LightType constants. +const ( + LightTypeSimple LightType = 0x00 + LightTypeComplex LightType = 0x01 + LightTypeIgnore LightType = 0x02 + LightTypeDeferred LightType = 0x03 +) + +var lightTypeNames = []string{"Simple", "Complex", "Ignore", "Deferred"} + +// LightTypes returns all known constants. +func LightTypes() []LightType { + return []LightType{LightTypeSimple, LightTypeComplex, LightTypeIgnore, LightTypeDeferred} +} + +// UseMode describes how an object can be interacted with. +type UseMode byte + +// String returns the textual representation of the value. +func (mode UseMode) String() string { + if int(mode) >= len(useModeNames) { + return fmt.Sprintf("Unknown 0x%02X", int(mode)) + } + return useModeNames[mode] +} + +// UseMode constants. +const ( + UseModePickup UseMode = 0x00 + UseModeUse UseMode = 0x01 + UseModeNull UseMode = 0x03 +) + +var useModeNames = []string{"Pickup", "Use", "Unknown 0x02", "Null"} + +// UseModes returns all known constants. +func UseModes() []UseMode { + return []UseMode{UseModePickup, UseModeUse, UseModeNull} +} diff --git a/modding/hacked/ss1/content/object/CommonFlags_test.go b/modding/hacked/ss1/content/object/CommonFlags_test.go new file mode 100644 index 0000000..e1c84b4 --- /dev/null +++ b/modding/hacked/ss1/content/object/CommonFlags_test.go @@ -0,0 +1,98 @@ +package object_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/object" + + "github.com/stretchr/testify/assert" +) + +func TestLightTypes(t *testing.T) { + assert.Equal(t, 4, len(object.LightTypes())) +} + +func TestLightTypeString(t *testing.T) { + tt := []struct { + lightType object.LightType + expected string + }{ + {lightType: object.LightTypeSimple, expected: "Simple"}, + {lightType: object.LightTypeComplex, expected: "Complex"}, + {lightType: object.LightTypeIgnore, expected: "Ignore"}, + {lightType: object.LightTypeDeferred, expected: "Deferred"}, + + {lightType: object.LightType(255), expected: "Unknown 0xFF"}, + } + + for _, tc := range tt { + result := tc.lightType.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.lightType))) + } +} + +func TestUseModes(t *testing.T) { + assert.Equal(t, 3, len(object.UseModes())) +} + +func TestUseModeString(t *testing.T) { + tt := []struct { + useMode object.UseMode + expected string + }{ + {useMode: object.UseModePickup, expected: "Pickup"}, + {useMode: object.UseModeUse, expected: "Use"}, + {useMode: object.UseModeNull, expected: "Null"}, + + {useMode: object.UseMode(255), expected: "Unknown 0xFF"}, + } + + for _, tc := range tt { + result := tc.useMode.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.useMode))) + } +} + +func TestCommonFlagField(t *testing.T) { + for _, useMode := range object.UseModes() { + for _, lightType := range object.LightTypes() { + for _, flag := range object.CommonFlags() { + key := fmt.Sprintf("%v:%v:%v", useMode, lightType, flag) + field := object.CommonFlagField(0).WithUseMode(useMode).WithLightType(lightType).With(flag) + + assert.Equal(t, useMode, field.UseMode(), fmt.Sprintf("UseMode wrong for %v", key)) + assert.Equal(t, lightType, field.LightType(), fmt.Sprintf("LightType wrong for %v", key)) + assert.True(t, field.Has(flag), fmt.Sprintf("flag not set for %v", key)) + assert.False(t, field.Without(flag).Has(flag), fmt.Sprintf("flag still set for %v", key)) + } + } + } +} + +func TestCommonFlagString(t *testing.T) { + tt := []struct { + flag object.CommonFlag + expected string + }{ + {flag: object.CommonFlagUseful, expected: "Useful"}, + {flag: object.CommonFlagSolid, expected: "Solid"}, + {flag: object.CommonFlagUsableInventoryObject, expected: "UsableInventoryObject"}, + {flag: object.CommonFlagBlockRendering, expected: "BlockRendering"}, + {flag: object.CommonFlagSolidIfClosed, expected: "SolidIfClosed"}, + {flag: object.CommonFlagFlatSolid, expected: "FlatSolid"}, + {flag: object.CommonFlagDoubledBitmapSize, expected: "DoubleBitmapSize"}, + {flag: object.CommonFlagDestroyOnContact, expected: "DestroyOnContact"}, + {flag: object.CommonFlagClassSpecific1, expected: "ClassSpecific1"}, + {flag: object.CommonFlagClassSpecific2, expected: "ClassSpecific2"}, + {flag: object.CommonFlagClassSpecific3, expected: "ClassSpecific3"}, + {flag: object.CommonFlagUseless, expected: "Useless"}, + + {flag: object.CommonFlag(255), expected: "Unknown 0x00FF"}, + } + + for _, tc := range tt { + result := tc.flag.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.flag))) + } +} diff --git a/modding/hacked/ss1/content/object/CommonProperties.go b/modding/hacked/ss1/content/object/CommonProperties.go new file mode 100644 index 0000000..7abf2e2 --- /dev/null +++ b/modding/hacked/ss1/content/object/CommonProperties.go @@ -0,0 +1,39 @@ +package object + +const ( + // CommonPropertiesSize specifies, in bytes, the length a common properties structure has. + CommonPropertiesSize = 27 + + // HardnessLimit is the maximum hardness value. + HardnessLimit = 253 + // PhysicsXRLimit is the maximum size value. + PhysicsXRLimit = 253 + + // DefenseNoCriticals is the defense value that prohibits criticals. + DefenseNoCriticals = 0xFF + // ToughnessNoDamage is the toughness value that prohibits regular damage. + ToughnessNoDamage = 3 +) + +// CommonProperties are generic ones available for all objects. +type CommonProperties struct { + Mass int32 + Hitpoints int16 + Armor byte + RenderType RenderType + PhysicsModel PhysicsModel + Hardness byte + _ byte // pep: not used + PhysicsXR byte + _ byte // physics_y: not used + PhysicsZ byte + Vulnerabilities DamageTypeMask + SpecialVulnerabilities SpecialDamageType + _ uint16 // remainder of "resistances": not used + Defense byte + Toughness byte + Flags CommonFlagField + MfdOrMeshID uint16 + Bitmap3D Bitmap3D + DestroyEffect DestroyEffect +} diff --git a/modding/hacked/ss1/content/object/Constants.go b/modding/hacked/ss1/content/object/Constants.go new file mode 100644 index 0000000..31a7bb0 --- /dev/null +++ b/modding/hacked/ss1/content/object/Constants.go @@ -0,0 +1,8 @@ +package object + +const ( + // ClassCount describes how many object classes there are. + ClassCount = 15 + + propertiesFileVersion uint32 = 0x0000002D +) diff --git a/modding/hacked/ss1/content/object/DamageType.go b/modding/hacked/ss1/content/object/DamageType.go new file mode 100644 index 0000000..6a80ca4 --- /dev/null +++ b/modding/hacked/ss1/content/object/DamageType.go @@ -0,0 +1,103 @@ +package object + +import "fmt" + +// DamageType classifies damage. +type DamageType byte + +// String returns the textual representation of the value. +func (damageType DamageType) String() string { + if int(damageType) >= len(damageTypeNames) { + return fmt.Sprintf("Unknown 0x%02X", int(damageType)) + } + return damageTypeNames[damageType] +} + +func (damageType DamageType) mask() DamageTypeMask { + return 1 << damageType +} + +// DamageType constants. +const ( + DamageTypeExplosion DamageType = 0 + DamageTypeEnergy DamageType = 1 + DamageTypeMagnetic DamageType = 2 + DamageTypeRadiation DamageType = 3 + DamageTypeGas DamageType = 4 + DamageTypeTranquilizer DamageType = 5 + DamageTypeNeedle DamageType = 6 + DamageTypeBio DamageType = 7 +) + +var damageTypeNames = []string{ + "Explosion", "Energy", "Magnetic", "Radiation", + "Gas", "Tranquilizer", "Needle", "Bio", +} + +// DamageTypes returns all known constants. +func DamageTypes() []DamageType { + return []DamageType{ + DamageTypeExplosion, DamageTypeEnergy, DamageTypeMagnetic, DamageTypeRadiation, + DamageTypeGas, DamageTypeTranquilizer, DamageTypeNeedle, DamageTypeBio, + } +} + +// DamageTypeMask combines a set of damage types. +type DamageTypeMask byte + +// Has returns whether the mask contains the specified value. +func (mask DamageTypeMask) Has(dmg DamageType) bool { + return (mask & dmg.mask()) != 0 +} + +// With returns a new mask that specifies the combination of this and the given damage type. +func (mask DamageTypeMask) With(dmg DamageType) DamageTypeMask { + return mask | dmg.mask() +} + +// Without returns a new mask that specifies the remainder of this, not including the given type. +func (mask DamageTypeMask) Without(dmg DamageType) DamageTypeMask { + return mask & ^dmg.mask() +} + +// SpecialDamageType is a combination of "primary" (double) and "super" (quadruple) damage potential. +// The identifier are "freeform" enumerations without public constants. +type SpecialDamageType byte + +const ( + specialDamageTypePrimaryShift = 0 + specialDamageTypePrimaryMask = 0x0F + specialDamageTypeSuperShift = 4 + specialDamageTypeSuperMask = 0xF0 + + // SpecialDamageTypeLimit identifies the maximum value of special damage type. + SpecialDamageTypeLimit = 0x0F +) + +// PrimaryValue returns the double damage type. +func (dmg SpecialDamageType) PrimaryValue() int { + return int((dmg & specialDamageTypePrimaryMask) >> specialDamageTypePrimaryShift) +} + +// WithPrimaryValue returns a new type instance with the given primary value set. +func (dmg SpecialDamageType) WithPrimaryValue(value int) SpecialDamageType { + result := dmg + if (value >= 0) && (value <= SpecialDamageTypeLimit) { + result = SpecialDamageType((byte(result) & ^byte(specialDamageTypePrimaryMask)) | byte(value<> specialDamageTypeSuperShift) +} + +// WithSuperValue returns a new type instance with the given super value set. +func (dmg SpecialDamageType) WithSuperValue(value int) SpecialDamageType { + result := dmg + if (value >= 0) && (value <= SpecialDamageTypeLimit) { + result = SpecialDamageType((byte(result) & ^byte(specialDamageTypeSuperMask)) | byte(value<= len(physicsModelNames) { + return fmt.Sprintf("Unknown 0x%02X", int(model)) + } + return physicsModelNames[model] +} + +// PhysicsModel constants. +const ( + PhysicsModelInsubstantial PhysicsModel = 0 + PhysicsModelRegular PhysicsModel = 1 + PhysicsModelStrange PhysicsModel = 2 +) + +var physicsModelNames = []string{ + "Insubstantial", + "Regular", + "Strange", +} + +// PhysicsModels returns all known constants. +func PhysicsModels() []PhysicsModel { + return []PhysicsModel{ + PhysicsModelInsubstantial, PhysicsModelRegular, PhysicsModelStrange, + } +} diff --git a/modding/hacked/ss1/content/object/PhysicsModel_test.go b/modding/hacked/ss1/content/object/PhysicsModel_test.go new file mode 100644 index 0000000..dbe8723 --- /dev/null +++ b/modding/hacked/ss1/content/object/PhysicsModel_test.go @@ -0,0 +1,32 @@ +package object_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/object" +) + +func TestPhysicsModels(t *testing.T) { + assert.Equal(t, 3, len(object.PhysicsModels())) +} + +func TestPhysicsModelString(t *testing.T) { + tt := []struct { + model object.PhysicsModel + expected string + }{ + {model: object.PhysicsModelInsubstantial, expected: "Insubstantial"}, + {model: object.PhysicsModelRegular, expected: "Regular"}, + {model: object.PhysicsModelStrange, expected: "Strange"}, + + {model: object.PhysicsModel(255), expected: "Unknown 0xFF"}, + } + + for _, tc := range tt { + result := tc.model.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.model))) + } +} diff --git a/modding/hacked/ss1/content/object/Pivot.go b/modding/hacked/ss1/content/object/Pivot.go new file mode 100644 index 0000000..c286ac3 --- /dev/null +++ b/modding/hacked/ss1/content/object/Pivot.go @@ -0,0 +1,17 @@ +package object + +// Pivot returns the assumed height offset for placing objects. +func Pivot(prop CommonProperties) float32 { + const DefaultHeight = 1.0 / float32(0xbd00) + const PhysicsScale = 96.0 + + switch { + case (prop.RenderType == RenderTypeTextPoly) || (prop.RenderType == RenderTypeSpecial): + return 0.0 + case prop.PhysicsZ != 0: + return float32(prop.PhysicsZ) / PhysicsScale + case prop.PhysicsXR != 0: + return float32(prop.PhysicsXR) / PhysicsScale + } + return DefaultHeight +} diff --git a/modding/hacked/ss1/content/object/Properties.go b/modding/hacked/ss1/content/object/Properties.go new file mode 100644 index 0000000..ec8a990 --- /dev/null +++ b/modding/hacked/ss1/content/object/Properties.go @@ -0,0 +1,160 @@ +package object + +import ( + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/serial" +) + +const ( + errInvalidClass ss1.StringError = "invalid class" + errInvalidSubclass ss1.StringError = "invalid subclass" + errInvalidType ss1.StringError = "invalid type" +) + +// PropertiesTable is a collection of class-specific properties. +type PropertiesTable []ClassProperties + +// ClassProperties is a collection of subclass-specific properties. +type ClassProperties []SubclassProperties + +// SubclassProperties is a collection of type-specific properties. +type SubclassProperties []Properties + +// Properties contains the object-specific properties. +type Properties struct { + Common CommonProperties + Generic []byte + Specific []byte +} + +// Clone returns a fresh instance with a copy of the data. +func (prop Properties) Clone() Properties { + cloneBytes := func(in []byte) []byte { + out := make([]byte, len(in)) + copy(out, in) + return out + } + return Properties{ + Common: prop.Common, + Generic: cloneBytes(prop.Generic), + Specific: cloneBytes(prop.Specific), + } +} + +// NewPropertiesTable returns a new instance based on given descriptors. +func NewPropertiesTable(desc Descriptors) PropertiesTable { + classCount := len(desc) + table := make([]ClassProperties, classCount) + for class := 0; class < classCount; class++ { + classDesc := desc[class] + subclassCount := len(classDesc.Subclasses) + subclassProperties := make([]SubclassProperties, subclassCount) + table[class] = subclassProperties + for subclass := 0; subclass < subclassCount; subclass++ { + subclassDesc := classDesc.Subclasses[subclass] + typeProperties := make([]Properties, subclassDesc.TypeCount) + subclassProperties[subclass] = typeProperties + for objType := 0; objType < subclassDesc.TypeCount; objType++ { + prop := &typeProperties[objType] + prop.Generic = make([]byte, classDesc.GenericDataSize) + prop.Specific = make([]byte, subclassDesc.SpecificDataSize) + } + } + } + return table +} + +// ForObject returns the object-specific properties by given triple. +func (table PropertiesTable) ForObject(triple Triple) (*Properties, error) { + if int(triple.Class) >= len(table) { + return nil, errInvalidClass + } + classEntry := table[triple.Class] + if int(triple.Subclass) >= len(classEntry) { + return nil, errInvalidSubclass + } + subclassEntry := classEntry[triple.Subclass] + if int(triple.Type) >= len(subclassEntry) { + return nil, errInvalidType + } + return &subclassEntry[triple.Type], nil +} + +// Code serializes the table with given coder. +func (table PropertiesTable) Code(coder serial.Coder) { + version := propertiesFileVersion + coder.Code(&version) + for _, subclasses := range table { + for _, types := range subclasses { + for _, prop := range types { + coder.Code(prop.Generic) + } + } + for _, types := range subclasses { + for _, prop := range types { + coder.Code(prop.Specific) + } + } + } + for _, subclasses := range table { + for _, types := range subclasses { + for objType := 0; objType < len(types); objType++ { + coder.Code(&types[objType].Common) + } + } + } +} + +// TriplesInClass returns all triples that are available in the given class. +func (table PropertiesTable) TriplesInClass(class Class) []Triple { + var triples []Triple + if int(class) < len(table) { + subclasses := table[class] + for subclass, types := range subclasses { + for objType := range types { + triples = append(triples, TripleFrom(int(class), subclass, objType)) + } + } + } + return triples +} + +// TripleIndex returns the linear index of the given index. +func (table PropertiesTable) TripleIndex(triple Triple) int { + if int(triple.Class) >= len(table) { + return -1 + } + counter := 0 + for class := Class(0); class < triple.Class; class++ { + subclasses := table[class] + for _, types := range subclasses { + counter += len(types) + } + } + subclasses := table[triple.Class] + if int(triple.Subclass) >= len(subclasses) { + return -1 + } + for subclass := Subclass(0); subclass < triple.Subclass; subclass++ { + counter += len(subclasses[subclass]) + } + types := subclasses[triple.Subclass] + if int(triple.Type) >= len(types) { + return -1 + } + return counter + int(triple.Type) +} + +// Iterate walks through all types sequentially and gives the properties to the given consumer. +// The iteration stops when the consumer returns false or the table has been exhausted. +func (table PropertiesTable) Iterate(consumer func(Triple, *Properties) bool) { + for class, subclasses := range table { + for subclass, types := range subclasses { + for objType := 0; objType < len(types); objType++ { + if !consumer(TripleFrom(class, subclass, objType), &types[objType]) { + return + } + } + } + } +} diff --git a/modding/hacked/ss1/content/object/Properties_test.go b/modding/hacked/ss1/content/object/Properties_test.go new file mode 100644 index 0000000..9bd811d --- /dev/null +++ b/modding/hacked/ss1/content/object/Properties_test.go @@ -0,0 +1,58 @@ +package object_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" +) + +func TestPropertiesForObjectReturnErrorForWrongIDs(t *testing.T) { + tt := []struct { + name string + triple object.Triple + }{ + {"wrong class", object.TripleFrom(20, 0, 2)}, + {"wrong subclass", object.TripleFrom(1, 40, 0)}, + {"wrong type", object.TripleFrom(1, 0, 20)}, + } + table := object.StandardPropertiesTable() + + for _, tc := range tt { + _, err := table.ForObject(tc.triple) + assert.Error(t, err, "error expected for "+tc.name) + } +} + +func TestPropertiesForObjectData(t *testing.T) { + tt := []struct { + triple object.Triple + genSize int + specSize int + }{ + {object.TripleFrom(0, 0, 0), 2, 1}, + {object.TripleFrom(14, 4, 1), 75, 1}, + {object.TripleFrom(10, 3, 4), 1, 1}, + } + table := object.StandardPropertiesTable() + + for _, tc := range tt { + prop, err := table.ForObject(tc.triple) + assert.Nil(t, err, "no error expected for "+tc.triple.String()) + assert.Equal(t, tc.genSize, len(prop.Generic), "wrong generic size for "+tc.triple.String()) + assert.Equal(t, tc.specSize, len(prop.Specific), "wrong specific size for "+tc.triple.String()) + } +} + +func TestPropertiesEncoding(t *testing.T) { + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + table := object.StandardPropertiesTable() + + table.Code(encoder) + result := buf.Bytes() + assert.Equal(t, 17951, len(result)) // as taken from original CD +} diff --git a/modding/hacked/ss1/content/object/RenderType.go b/modding/hacked/ss1/content/object/RenderType.go new file mode 100644 index 0000000..2d62692 --- /dev/null +++ b/modding/hacked/ss1/content/object/RenderType.go @@ -0,0 +1,48 @@ +package object + +import "fmt" + +// RenderType defines how an object is to be rendered. +type RenderType byte + +// String returns the textual representation of the value. +func (renderType RenderType) String() string { + if int(renderType) >= len(renderTypeNames) { + return fmt.Sprintf("Unknown 0x%02X", int(renderType)) + } + return renderTypeNames[renderType] +} + +// RenderType constants. +const ( + RenderTypeUnknown RenderType = 0 + RenderTypeTextPoly RenderType = 1 + RenderTypeBitmap RenderType = 2 + RenderTypeTPoly RenderType = 3 + RenderTypeCritter RenderType = 4 + RenderTypeAnimPoly RenderType = 5 + RenderTypeVox RenderType = 6 + RenderTypeNoObject RenderType = 7 + RenderTypeTexBitmap RenderType = 8 + RenderTypeFlatPoly RenderType = 9 + RenderTypeMultiView RenderType = 10 + RenderTypeSpecial RenderType = 11 + RenderTypeTLPoly RenderType = 12 +) + +var renderTypeNames = []string{ + "Unknown 0x00", + "TextPoly", "Bitmap", "TPoly", "Critter", + "AnimPoly", "Vox", "NoObject", "TexBitmap", + "FlatPoly", "MultiView", "Special", "TLPoly", +} + +// RenderTypes returns all known constants. +func RenderTypes() []RenderType { + return []RenderType{ + RenderTypeUnknown, + RenderTypeTextPoly, RenderTypeBitmap, RenderTypeTPoly, RenderTypeCritter, + RenderTypeAnimPoly, RenderTypeVox, RenderTypeNoObject, RenderTypeTexBitmap, + RenderTypeFlatPoly, RenderTypeMultiView, RenderTypeSpecial, RenderTypeTLPoly, + } +} diff --git a/modding/hacked/ss1/content/object/RenderType_test.go b/modding/hacked/ss1/content/object/RenderType_test.go new file mode 100644 index 0000000..70e6c6f --- /dev/null +++ b/modding/hacked/ss1/content/object/RenderType_test.go @@ -0,0 +1,42 @@ +package object_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/object" + + "github.com/stretchr/testify/assert" +) + +func TestRenderTypes(t *testing.T) { + assert.Equal(t, 13, len(object.RenderTypes())) +} + +func TestRenderTypeString(t *testing.T) { + tt := []struct { + renderType object.RenderType + expected string + }{ + {renderType: object.RenderTypeUnknown, expected: "Unknown 0x00"}, + {renderType: object.RenderTypeTextPoly, expected: "TextPoly"}, + {renderType: object.RenderTypeBitmap, expected: "Bitmap"}, + {renderType: object.RenderTypeTPoly, expected: "TPoly"}, + {renderType: object.RenderTypeCritter, expected: "Critter"}, + {renderType: object.RenderTypeAnimPoly, expected: "AnimPoly"}, + {renderType: object.RenderTypeVox, expected: "Vox"}, + {renderType: object.RenderTypeNoObject, expected: "NoObject"}, + {renderType: object.RenderTypeTexBitmap, expected: "TexBitmap"}, + {renderType: object.RenderTypeFlatPoly, expected: "FlatPoly"}, + {renderType: object.RenderTypeMultiView, expected: "MultiView"}, + {renderType: object.RenderTypeSpecial, expected: "Special"}, + {renderType: object.RenderTypeTLPoly, expected: "TLPoly"}, + + {renderType: object.RenderType(255), expected: "Unknown 0xFF"}, + } + + for _, tc := range tt { + result := tc.renderType.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.renderType))) + } +} diff --git a/modding/hacked/ss1/content/object/Standards.go b/modding/hacked/ss1/content/object/Standards.go new file mode 100644 index 0000000..7920176 --- /dev/null +++ b/modding/hacked/ss1/content/object/Standards.go @@ -0,0 +1,177 @@ +package object + +// StandardPropertiesTable returns a properties table based on the standard +// configuration of the existing objprop.dat file. +func StandardPropertiesTable() PropertiesTable { + return NewPropertiesTable(StandardDescriptors()) +} + +// StandardDescriptors returns an array of class descriptors that represent the standard +// configuration of the existing objprop.dat file. +func StandardDescriptors() Descriptors { + var result Descriptors + + { // Guns + subclasses := []SubclassDescriptor{ + {TypeCount: 5, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 16}, + {TypeCount: 2, SpecificDataSize: 13}, + {TypeCount: 3, SpecificDataSize: 13}, + {TypeCount: 2, SpecificDataSize: 18}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 2, Subclasses: subclasses}) + } + { // Ammo + subclasses := []SubclassDescriptor{ + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 14, Subclasses: subclasses}) + } + { // Physics + subclasses := []SubclassDescriptor{ + {TypeCount: 6, SpecificDataSize: 20}, + {TypeCount: 16, SpecificDataSize: 6}, + {TypeCount: 2, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 1, Subclasses: subclasses}) + } + { // Grenades + subclasses := []SubclassDescriptor{ + {TypeCount: 5, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 3}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 15, Subclasses: subclasses}) + } + { // Drugs + subclasses := []SubclassDescriptor{ + {TypeCount: 7, SpecificDataSize: 7}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 17, Subclasses: subclasses}) + } + { // Hardware + subclasses := []SubclassDescriptor{ + {TypeCount: 5, SpecificDataSize: 1}, + {TypeCount: 10, SpecificDataSize: 2}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 2, Subclasses: subclasses}) + } + { // Software + subclasses := []SubclassDescriptor{ + {TypeCount: 7, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 1}, + {TypeCount: 4, SpecificDataSize: 1}, + {TypeCount: 5, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 2, Subclasses: subclasses}) + } + { // BigStuff + subclasses := []SubclassDescriptor{ + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 10, SpecificDataSize: 1}, + {TypeCount: 11, SpecificDataSize: 1}, + {TypeCount: 4, SpecificDataSize: 1}, + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 8, SpecificDataSize: 1}, + {TypeCount: 16, SpecificDataSize: 1}, + {TypeCount: 10, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 4, Subclasses: subclasses}) + } + { // SmallStuff + subclasses := []SubclassDescriptor{ + {TypeCount: 8, SpecificDataSize: 1}, + {TypeCount: 10, SpecificDataSize: 1}, + {TypeCount: 15, SpecificDataSize: 1}, + {TypeCount: 6, SpecificDataSize: 1}, + {TypeCount: 12, SpecificDataSize: 1}, + {TypeCount: 12, SpecificDataSize: 6}, + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 8, SpecificDataSize: 2}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 2, Subclasses: subclasses}) + } + { // Fixtures + subclasses := []SubclassDescriptor{ + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 7, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 1}, + {TypeCount: 11, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 0}, // skipped vending machines + {TypeCount: 3, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 1, Subclasses: subclasses}) + } + { // Doors + subclasses := []SubclassDescriptor{ + {TypeCount: 10, SpecificDataSize: 1}, + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 7, SpecificDataSize: 1}, + {TypeCount: 5, SpecificDataSize: 1}, + {TypeCount: 10, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 1, Subclasses: subclasses}) + } + { // Animating + subclasses := []SubclassDescriptor{ + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 11, SpecificDataSize: 1}, + {TypeCount: 14, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 2, Subclasses: subclasses}) + } + { // Traps + subclasses := []SubclassDescriptor{ + {TypeCount: 13, SpecificDataSize: 1}, + {TypeCount: 1, SpecificDataSize: 1}, + {TypeCount: 5, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 1, Subclasses: subclasses}) + } + { // Containers + subclasses := []SubclassDescriptor{ + {TypeCount: 3, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 1}, + {TypeCount: 4, SpecificDataSize: 1}, + {TypeCount: 8, SpecificDataSize: 1}, + {TypeCount: 13, SpecificDataSize: 1}, + {TypeCount: 7, SpecificDataSize: 1}, + {TypeCount: 8, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 3, Subclasses: subclasses}) + } + { // Critters + subclasses := []SubclassDescriptor{ + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 12, SpecificDataSize: 2}, + {TypeCount: 7, SpecificDataSize: 2}, + {TypeCount: 7, SpecificDataSize: 6}, + {TypeCount: 2, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 75, Subclasses: subclasses}) + } + + return result +} diff --git a/modding/hacked/ss1/content/object/Standards_test.go b/modding/hacked/ss1/content/object/Standards_test.go new file mode 100644 index 0000000..72aa9a3 --- /dev/null +++ b/modding/hacked/ss1/content/object/Standards_test.go @@ -0,0 +1,29 @@ +package object_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/object" +) + +func TestStandardDescriptorsReturnsProperLength(t *testing.T) { + descriptor := object.StandardDescriptors() + totalLength := 4 // version prefix + + for _, classDesc := range descriptor { + totalLength += classDesc.TotalDataSize() + } + assert.Equal(t, 17951, totalLength) // as taken from original CD +} + +func TestStandardDescriptorsReturnsProperAmount(t *testing.T) { + descriptor := object.StandardDescriptors() + total := 0 + + for _, classDesc := range descriptor { + total += classDesc.TotalTypeCount() + } + assert.Equal(t, 476, total) +} diff --git a/modding/hacked/ss1/content/object/Triple.go b/modding/hacked/ss1/content/object/Triple.go new file mode 100644 index 0000000..fdc9590 --- /dev/null +++ b/modding/hacked/ss1/content/object/Triple.go @@ -0,0 +1,104 @@ +package object + +import "fmt" + +// Class describes a general category of objects. +type Class byte + +// String returns the textual representation. +func (c Class) String() string { + if int(c) >= len(classNames) { + return fmt.Sprintf("Unknown%02X", int(c)) + } + return classNames[c] +} + +// Object classes constants. +const ( + ClassGun Class = 0 + ClassAmmo Class = 1 + ClassPhysics Class = 2 + ClassGrenade Class = 3 + ClassDrug Class = 4 + ClassHardware Class = 5 + ClassSoftware Class = 6 + ClassBigStuff Class = 7 + ClassSmallStuff Class = 8 + ClassFixture Class = 9 + ClassDoor Class = 10 + ClassAnimating Class = 11 + ClassTrap Class = 12 + ClassContainer Class = 13 + ClassCritter Class = 14 +) + +var classNames = []string{ + "Gun", + "Ammo", + "Physics", + "Grenade", + "Drug", + "Hardware", + "Software", + "BigStuff", + "SmallStuff", + "Fixture", + "Door", + "Animating", + "Trap", + "Container", + "Critter", +} + +// Classes returns a list of all classes. +func Classes() []Class { + var classes [ClassCount]Class + for class := Class(0); class < ClassCount; class++ { + classes[class] = class + } + return classes[:] +} + +// Subclass divides an object class. +type Subclass byte + +// Type describes one specific object. +type Type byte + +// Triple identifies one specific object by its full coordinate. +type Triple struct { + Class Class + Subclass Subclass + Type Type +} + +// TripleFrom returns a Triple instance with given values as coordinates. +func TripleFrom(class, subclass, objType int) Triple { + return Triple{ + Class: Class(class), + Subclass: Subclass(subclass), + Type: Type(objType), + } +} + +// TripleFromInt returns a Triple instance based on a single integer value. +func TripleFromInt(value int) Triple { + return Triple{ + Class: Class((value >> 16) & 0xFF), + Subclass: Subclass((value >> 8) & 0xFF), + Type: Type((value >> 0) & 0xFF), + } +} + +// String returns the textual representation of the triple as "cl/s/ty" string. +func (triple Triple) String() string { + return fmt.Sprintf("%2d/%d/%2d", triple.Class, triple.Subclass, triple.Type) +} + +// Int returns the triple as a single integer value. +func (triple Triple) Int() int { + value := int(triple.Class) << 16 + value |= int(triple.Subclass) << 8 + value |= int(triple.Type) << 0 + return value +} diff --git a/modding/hacked/ss1/content/object/Triple_test.go b/modding/hacked/ss1/content/object/Triple_test.go new file mode 100644 index 0000000..7dbda32 --- /dev/null +++ b/modding/hacked/ss1/content/object/Triple_test.go @@ -0,0 +1,50 @@ +package object_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/object" + + "github.com/stretchr/testify/assert" +) + +func TestClasses(t *testing.T) { + classes := object.Classes() + assert.Equal(t, 15, len(classes)) +} + +func TestClassString(t *testing.T) { + assert.Equal(t, "Gun", object.Class(0).String()) + assert.Equal(t, "UnknownFF", object.Class(0xFF).String()) +} + +func TestTripleString(t *testing.T) { + tt := []struct { + triple object.Triple + expected string + }{ + {object.TripleFrom(1, 2, 3), " 1/2/ 3"}, + {object.TripleFrom(20, 0, 10), "20/0/10"}, + } + for _, tc := range tt { + result := tc.triple.String() + assert.Equal(t, tc.expected, result) + } +} + +func TestTripleIntegerRepresentation(t *testing.T) { + tt := []struct { + triple object.Triple + expected int + }{ + {object.TripleFrom(1, 2, 3), 0x00010203}, + {object.TripleFrom(20, 0, 10), 0x0014000A}, + } + for _, tc := range tt { + result := tc.triple.Int() + assert.Equal(t, tc.expected, result, fmt.Sprintf("integer value not matching for %v", tc.triple)) + reversed := object.TripleFromInt(tc.expected) + assert.Equal(t, tc.triple, reversed, fmt.Sprintf("reverse value not matching for %v", tc.triple)) + } +} diff --git a/modding/hacked/ss1/content/object/doc.go b/modding/hacked/ss1/content/object/doc.go new file mode 100644 index 0000000..e2db13d --- /dev/null +++ b/modding/hacked/ss1/content/object/doc.go @@ -0,0 +1,2 @@ +// Package object contains the game-global properties of objects. +package object diff --git a/modding/hacked/ss1/content/object/objprop/Ammo.go b/modding/hacked/ss1/content/object/objprop/Ammo.go new file mode 100644 index 0000000..a0559a0 --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Ammo.go @@ -0,0 +1,20 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var ammoClipGenerics = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("CartrigeSize", 8, 1). + With("BulletMass", 9, 1). + With("BulletSpeed", 10, 2).As(interpreters.RangedValue(-10000, +10000)). + With("Range", 12, 1). + With("RecoilForce", 13, 1) + +func initAmmo() { + objClass := object.Class(1) + + genericDescriptions[objClass] = ammoClipGenerics +} diff --git a/modding/hacked/ss1/content/object/objprop/Animating.go b/modding/hacked/ss1/content/object/objprop/Animating.go new file mode 100644 index 0000000..d29752d --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Animating.go @@ -0,0 +1,26 @@ +package objprop + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var animationGenerics = interpreters.New(). + With("FrameTime", 0, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) string { + return fmt.Sprintf("%3.0f millisec", (float64(value)*900)/255.0) + })). + With("Flags", 1, 1).As(interpreters.Bitfield(map[uint32]string{0x01: "Emit Light"})) + +var explosionAnimation = interpreters.New(). + With("FrameExplode", 0, 1) + +func initAnimating() { + objClass := object.Class(11) + + genericDescriptions[objClass] = animationGenerics + + setSpecific(objClass, 2, explosionAnimation) +} diff --git a/modding/hacked/ss1/content/object/objprop/Critters.go b/modding/hacked/ss1/content/object/objprop/Critters.go new file mode 100644 index 0000000..4d59c20 --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Critters.go @@ -0,0 +1,72 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var critterAttackInfo = interpreters.New(). + With("DamageType", 0x0000, 1).As(damageType). + With("SpecialDamageType", 0x0001, 1).As(specialDamageType). + With("DamageModifier", 0x0004, 2).As(interpreters.RangedValue(0, 500)). + With("OffenceValue", 0x0006, 1). + With("Penetration", 0x0007, 1). + With("AttackMass", 0x0008, 1). + With("AttackVelocity", 0x0009, 2). + With("Accuracy", 0x000B, 1). + With("AttackRange", 0x000C, 1). + With("ReloadTime", 0x000D, 2).As(interpreters.RangedValue(0, 1000)). + With("Projectile", 0x0011, 4).As(interpreters.SpecialValue("ObjectTriple")) + +var critterGenerics = interpreters.New(). + Refining("PrimaryAttack", 0x0001, 21, critterAttackInfo, interpreters.Always). + Refining("SecondaryAttack", 0x0016, 21, critterAttackInfo, interpreters.Always). + With("Perception", 0x002B, 1). + With("Defence", 0x002C, 1). + With("ProjectileSourceHeightOffset", 0x002D, 1).As(interpreters.RangedValue(-128, 127)). + With("Flags", 0x002E, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x01: "Flying"})). + With("AnimationSpeed", 0x003B, 1). + With("AttackSoundIndex", 0x003C, 1). + With("NearSoundIndex", 0x003D, 1). + With("HurtSoundIndex", 0x003E, 1). + With("DeathSoundIndex", 0x003F, 1). + With("NoticeSoundIndex", 0x0040, 1). + With("Corpse", 0x0041, 4).As(interpreters.SpecialValue("ObjectTriple")). + With("ViewCount", 0x0045, 1). + With("SecondaryAttackProbability", 0x0046, 1). + With("DisruptProbability", 0x0047, 1). + With("TreasureType", 0x0048, 1).As(interpreters.EnumValue(map[uint32]string{ + 0x00: "No treasure", + 0x01: "Humanoid", + 0x02: "Drone", + 0x03: "Assassin", + 0x04: "Warrior cyborg", + 0x05: "Flier bot", + 0x06: "Security 1 bot", + 0x07: "Exec bot", + 0x08: "Cyborg enforcer", + 0x09: "Security 2 bot", + 0x0A: "Elite cyborg", + 0x0B: "Standard corpse", + 0x0C: "Loot-oriented corpse", + 0x0D: "Electro-stuff treasure", + 0x0E: "Serv-bot"})). + With("HitEffect", 0x0049, 1).As(interpreters.EnumValue(map[uint32]string{0: "meat", 1: "plant", 2: "robot", 3: "cyborg", 4: "generic"})). + With("AttackKeyFrame", 0x004A, 1).As(interpreters.RangedValue(0, 10)) + +var cyberCritters = interpreters.New(). + With("Color0", 0, 1). + With("Color1", 1, 1). + With("Color2", 2, 1). + With("AltColor0", 3, 1). + With("AltColor1", 4, 1). + With("AltColor2", 5, 1) + +func initCritters() { + objClass := object.Class(14) + + genericDescriptions[objClass] = critterGenerics + + setSpecific(objClass, 3, cyberCritters) +} diff --git a/modding/hacked/ss1/content/object/objprop/CyberspaceProperties.go b/modding/hacked/ss1/content/object/objprop/CyberspaceProperties.go new file mode 100644 index 0000000..b17048f --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/CyberspaceProperties.go @@ -0,0 +1,13 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var cyberColorScheme = interpreters.New(). + With("Color0", 0, 1). + With("Color1", 1, 1). + With("Color2", 2, 1). + With("Color3", 3, 1). + With("Color4", 4, 1). + With("Color5", 5, 1) diff --git a/modding/hacked/ss1/content/object/objprop/GenericProperties.go b/modding/hacked/ss1/content/object/objprop/GenericProperties.go new file mode 100644 index 0000000..b42bbcf --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/GenericProperties.go @@ -0,0 +1,15 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +// GenericProperties returns an interpreter specific for the given object class. +func GenericProperties(objClass object.Class, data []byte) *interpreters.Instance { + desc := genericDescriptions[objClass] + if desc == nil { + desc = interpreters.New() + } + return desc.For(data) +} diff --git a/modding/hacked/ss1/content/object/objprop/Grenades.go b/modding/hacked/ss1/content/object/objprop/Grenades.go new file mode 100644 index 0000000..9ef97b6 --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Grenades.go @@ -0,0 +1,31 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var grenadeGenerics = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("Touchiness", 8, 1). + With("BlastRadius", 9, 1). + With("BlastCoreRange", 10, 1). + With("BlastDamage", 11, 1). + With("AttackMass", 12, 1). + With("Flags", 13, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x01: "Explode on contact", + 0x04: "Explode by timer", + 0x08: "Explode by vicinity"})) + +var timedExplosives = interpreters.New(). + With("MinimumTime", 0, 1). + With("MaximumTime", 1, 1). + With("RandomFactor", 2, 1) + +func initGrenades() { + objClass := object.Class(3) + + genericDescriptions[objClass] = grenadeGenerics + + setSpecific(objClass, 1, timedExplosives) +} diff --git a/modding/hacked/ss1/content/object/objprop/Guns.go b/modding/hacked/ss1/content/object/objprop/Guns.go new file mode 100644 index 0000000..2d2753c --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Guns.go @@ -0,0 +1,64 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var basicWeapon = interpreters.New(). + With("Damage", 0, 2).As(interpreters.RangedValue(0, 0x7FFF)). + With("OffenceValue", 2, 1). + With("DamageType", 3, 1).As(damageType). + With("SpecialDamageType", 4, 1).As(specialDamageType). + With("ArmorPenetration", 7, 1) + +var weaponGenerics = interpreters.New(). + With("FireRate", 0, 1). + With("AmmoType", 1, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x01: "AmmoType0", + 0x02: "AmmoType1", + 0x04: "AmmoType2", + 0x08: "AmmoType3", + 0xF0: "AmmoSubclass"})) + +var projectileWeapons = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("ProjectileTravelSpeed", 8, 1). + With("Projectile", 9, 4).As(interpreters.SpecialValue("ObjectTriple")). + With("AttackMass", 13, 1). + With("AttackSpeed", 14, 2).As(interpreters.RangedValue(-10000, +10000)) + +var meleeWeapons = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("EnergyUsage", 8, 1). + With("AttackMass", 9, 1). + With("Range", 10, 1). + With("AttackSpeed", 11, 2).As(interpreters.RangedValue(-10000, +10000)) + +var energyBeamWeapons = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("MaxCharge", 8, 1). + With("AttackMass", 9, 1). + With("Range", 10, 1). + With("AttackSpeed", 11, 2).As(interpreters.RangedValue(-10000, +10000)) + +var energyProjectileWeapons = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("MaxCharge", 8, 1). + With("AttackMass", 9, 1). + With("AttackSpeed", 10, 2).As(interpreters.RangedValue(-10000, +10000)). + With("ProjectileTravelSpeed", 12, 1). + With("Projectile", 13, 4).As(interpreters.SpecialValue("ObjectTriple")). + With("Flags", 17, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x02: "Stun"})) + +func initGuns() { + objClass := object.Class(0) + + genericDescriptions[objClass] = weaponGenerics + + setSpecific(objClass, 2, projectileWeapons) + setSpecific(objClass, 3, meleeWeapons) + setSpecific(objClass, 4, energyBeamWeapons) + setSpecific(objClass, 5, energyProjectileWeapons) +} diff --git a/modding/hacked/ss1/content/object/objprop/Physics.go b/modding/hacked/ss1/content/object/objprop/Physics.go new file mode 100644 index 0000000..270141c --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Physics.go @@ -0,0 +1,28 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var projectileGenerics = interpreters.New(). + With("Flags", 0, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x01: "EmitLight", + 0x02: "BounceOffWalls", + 0x04: "BounceOffObjects", + 0x08: "BounceOffProjectiles"})) + +var cyberProjectiles = interpreters.New(). + Refining("ColorScheme", 0, 6, cyberColorScheme, interpreters.Always) + +func initPhysics() { + objClass := object.Class(2) + + genericDescriptions[objClass] = projectileGenerics + + setSpecificByType(objClass, 1, 9, cyberProjectiles) + setSpecificByType(objClass, 1, 10, cyberProjectiles) + setSpecificByType(objClass, 1, 11, cyberProjectiles) + setSpecificByType(objClass, 1, 12, cyberProjectiles) + setSpecificByType(objClass, 1, 13, cyberProjectiles) +} diff --git a/modding/hacked/ss1/content/object/objprop/SmallStuff.go b/modding/hacked/ss1/content/object/objprop/SmallStuff.go new file mode 100644 index 0000000..742df01 --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/SmallStuff.go @@ -0,0 +1,15 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var cyberItems = interpreters.New(). + Refining("ColorScheme", 0, 6, cyberColorScheme, interpreters.Always) + +func initSmallStuff() { + objClass := object.Class(8) + + setSpecific(objClass, 5, cyberItems) +} diff --git a/modding/hacked/ss1/content/object/objprop/SpecificProperties.go b/modding/hacked/ss1/content/object/objprop/SpecificProperties.go new file mode 100644 index 0000000..baa9d8b --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/SpecificProperties.go @@ -0,0 +1,18 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +// SpecificProperties returns an interpreter specific for the given object class and subclass. +func SpecificProperties(triple object.Triple, data []byte) *interpreters.Instance { + desc := specificDescriptions[triple] + if desc == nil { + desc = specificDescriptions[object.TripleFrom(int(triple.Class), int(triple.Subclass), anyObjectType)] + } + if desc == nil { + desc = interpreters.New() + } + return desc.For(data) +} diff --git a/modding/hacked/ss1/content/object/objprop/objprop.go b/modding/hacked/ss1/content/object/objprop/objprop.go new file mode 100644 index 0000000..f174741 --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/objprop.go @@ -0,0 +1,48 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var genericDescriptions map[object.Class]*interpreters.Description +var specificDescriptions map[object.Triple]*interpreters.Description + +const anyObjectType = 0xFF + +var damageType = interpreters.Bitfield(map[uint32]string{ + 0x01: "Explosion", + 0x02: "Energy", + 0x04: "Magnetic", + 0x08: "Radiation", + 0x10: "Gas", + 0x20: "Tranquilizer", + 0x40: "Needle", + 0x80: "Bio", +}) + +var specialDamageType = interpreters.Bitfield(map[uint32]string{ + 0x0F: "Primary (double)", + 0xF0: "Super (quad)", +}) + +func init() { + genericDescriptions = make(map[object.Class]*interpreters.Description) + specificDescriptions = make(map[object.Triple]*interpreters.Description) + + initGuns() + initAmmo() + initPhysics() + initGrenades() + initSmallStuff() + initAnimating() + initCritters() +} + +func setSpecific(objClass object.Class, objSubclass int, desc *interpreters.Description) { + specificDescriptions[object.TripleFrom(int(objClass), objSubclass, anyObjectType)] = desc +} + +func setSpecificByType(objClass object.Class, objSubclass int, objType int, desc *interpreters.Description) { + specificDescriptions[object.TripleFrom(int(objClass), objSubclass, objType)] = desc +} diff --git a/modding/hacked/ss1/content/sound/SoundEffectCache.go b/modding/hacked/ss1/content/sound/SoundEffectCache.go new file mode 100644 index 0000000..46cfda8 --- /dev/null +++ b/modding/hacked/ss1/content/sound/SoundEffectCache.go @@ -0,0 +1,70 @@ +package sound + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/audio/voc" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// EffectCache retrieves audio samples stored as VOC files from a localizer and +// keeps them cached until they are invalidated. +type EffectCache struct { + localizer resource.Localizer + + sounds map[resource.Key]audio.L8 +} + +// NewEffectCache returns a new instance. +func NewEffectCache(localizer resource.Localizer) *EffectCache { + cache := &EffectCache{ + localizer: localizer, + + sounds: make(map[resource.Key]audio.L8), + } + return cache +} + +// InvalidateResources lets the cache remove any sounds from resources that are specified in the given slice. +func (cache *EffectCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.sounds { + if key.ID == id { + delete(cache.sounds, key) + } + } + } +} + +func (cache *EffectCache) cached(key resource.Key) (audio.L8, error) { + value, existing := cache.sounds[key] + if existing { + return value, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID.Plus(key.Index)) + if err != nil { + return audio.L8{}, err + } + if view.ContentType() != resource.Sound { + return audio.L8{}, resource.ErrWrongType(key, resource.Sound) + } + reader, err := view.Block(0) + if err != nil { + return audio.L8{}, err + } + sound, err := voc.Load(reader) + if err != nil { + return audio.L8{}, err + } + cache.sounds[key] = sound + return sound, nil +} + +// Audio retrieves and caches the underlying sound. +func (cache *EffectCache) Audio(key resource.Key) (audio.L8, error) { + cached, err := cache.cached(key) + if err != nil { + return audio.L8{}, err + } + return cached, nil +} diff --git a/modding/hacked/ss1/content/text/Blocked.go b/modding/hacked/ss1/content/text/Blocked.go new file mode 100644 index 0000000..c796c67 --- /dev/null +++ b/modding/hacked/ss1/content/text/Blocked.go @@ -0,0 +1,42 @@ +package text + +import "strings" + +const ( + // textLineLimit is the lowest number found by a cursory search. + // It appears that various texts have different limits. + textLineLimit = 79 +) + +// Blocked splits the given input string into a series of lines, usable for blocked serialization. +// It returns an array of strings that each are below a maximum line length. The list is terminated with +// one empty line (necessary for compound strings). +func Blocked(input string) []string { + var result []string + textLines := strings.Split(input, "\n") + resultLine := "" + addBlock := func() { + if len(resultLine) > 0 { + result = append(result, resultLine) + resultLine = "" + } + } + + for textLineIndex, textLine := range textLines { + words := strings.Split(textLine, " ") + for wordIndex, word := range words { + if wordIndex > 0 { + resultLine += " " + } + if (len(resultLine) + len(word)) > textLineLimit { + addBlock() + } + resultLine += word + } + if textLineIndex < (len(textLines) - 1) { + resultLine += "\n" + } + } + addBlock() + return append(result, "") +} diff --git a/modding/hacked/ss1/content/text/Blocked_test.go b/modding/hacked/ss1/content/text/Blocked_test.go new file mode 100644 index 0000000..8b0b298 --- /dev/null +++ b/modding/hacked/ss1/content/text/Blocked_test.go @@ -0,0 +1,36 @@ +package text_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/text" + + "github.com/stretchr/testify/assert" +) + +func TestSplitText(t *testing.T) { + tt := []struct { + input string + expected []string + }{ + {input: "", expected: []string{""}}, + {input: "a", expected: []string{"a", ""}}, + {input: "a", expected: []string{"a", ""}}, + {input: "b\n", expected: []string{"b\n", ""}}, + {input: " spacing! ", expected: []string{" spacing! ", ""}}, + {input: "terse1\n\n\nterse2", expected: []string{"terse1\n\n\nterse2", ""}}, + { + input: "aaaaaaaaa bbbbbbbbb ccccccccc ddddddddd eeeeeeeee fffffffff ggggggggg hhhhhhhhh iiiiiiiii jjjjjjjjj kkkkk", + expected: []string{ + "aaaaaaaaa bbbbbbbbb ccccccccc ddddddddd eeeeeeeee fffffffff ggggggggg hhhhhhhhh ", + "iiiiiiiii jjjjjjjjj kkkkk", + "", + }, + }, + } + + for _, tc := range tt { + result := text.Blocked(tc.input) + assert.Equal(t, tc.expected, result, "wrong result for <"+tc.input+">") + } +} diff --git a/modding/hacked/ss1/content/text/Cache.go b/modding/hacked/ss1/content/text/Cache.go new file mode 100644 index 0000000..6e88a05 --- /dev/null +++ b/modding/hacked/ss1/content/text/Cache.go @@ -0,0 +1,114 @@ +package text + +import ( + "io/ioutil" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +type keyResolver func(resource.Key) resource.Key +type textReader func(resource.Selector, resource.Key, Codepage) (string, error) + +// Cache retrieves texts from a localizer and keeps them decoded until they are invalidated. +type Cache struct { + cp Codepage + localizer resource.Localizer + reader textReader + + keyResolver keyResolver + texts map[resource.Key]string +} + +func newCache(cp Codepage, localizer resource.Localizer, keyResolver keyResolver, reader textReader) *Cache { + cache := &Cache{ + cp: cp, + localizer: localizer, + reader: reader, + + keyResolver: keyResolver, + texts: make(map[resource.Key]string), + } + return cache +} + +// NewLineCache returns a cache for single-block texts. +func NewLineCache(cp Codepage, localizer resource.Localizer) *Cache { + return newCache(cp, localizer, func(key resource.Key) resource.Key { return key }, readLine) +} + +// NewPageCache returns a cache for resource-based texts. +func NewPageCache(cp Codepage, localizer resource.Localizer) *Cache { + return newCache(cp, localizer, func(key resource.Key) resource.Key { + return resource.KeyOf(key.ID.Plus(key.Index), key.Lang, 0) + }, readPage) +} + +func readLine(selector resource.Selector, key resource.Key, cp Codepage) (string, error) { + view, err := selector.Select(key.ID) + if err != nil { + return "", err + } + if view.ContentType() != resource.Text { + return "", resource.ErrWrongType(key, resource.Text) + } + reader, err := view.Block(key.Index) + if err != nil { + return "", err + } + raw, err := ioutil.ReadAll(reader) + if err != nil { + return "", err + } + return cp.Decode(raw), nil +} + +func readPage(selector resource.Selector, key resource.Key, cp Codepage) (string, error) { + view, err := selector.Select(key.ID.Plus(key.Index)) + if err != nil { + return "", err + } + if view.ContentType() != resource.Text { + return "", resource.ErrWrongType(key, resource.Text) + } + blockCount := view.BlockCount() + value := "" + for block := 0; block < blockCount; block++ { + reader, err := view.Block(block) + if err != nil { + return "", err + } + raw, err := ioutil.ReadAll(reader) + if err != nil { + return "", err + } + value += cp.Decode(raw) + } + return value, nil +} + +// InvalidateResources lets the cache remove any texts from resources that are specified in the given slice. +func (cache *Cache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.texts { + if key.ID == id { + delete(cache.texts, key) + } + } + } +} + +// Text retrieves and caches the text of given key. +func (cache *Cache) Text(key resource.Key) (string, error) { + cacheKey := cache.keyResolver(key) + value, existing := cache.texts[cacheKey] + if existing { + return value, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + value, err := cache.reader(selector, key, cache.cp) + if err != nil { + return "", err + } + cache.texts[cacheKey] = value + return value, nil +} diff --git a/modding/hacked/ss1/content/text/Cache_test.go b/modding/hacked/ss1/content/text/Cache_test.go new file mode 100644 index 0000000..dc8d744 --- /dev/null +++ b/modding/hacked/ss1/content/text/Cache_test.go @@ -0,0 +1,198 @@ +package text_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type CacheSuite struct { + suite.Suite + + localizedResources resource.LocalizedResourcesList + + cp text.Codepage + instance *text.Cache +} + +func TestCacheSuite(t *testing.T) { + suite.Run(t, new(CacheSuite)) +} + +func (suite *CacheSuite) SetupTest() { + suite.cp = text.DefaultCodepage() + suite.instance = nil +} + +func (suite *CacheSuite) TestTextReturnsValueIfOKForLineCache() { + suite.givenALineCache() + suite.whenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "test"))) + suite.thenTextShouldReturn("test", resource.KeyOf(0x1000, resource.LangGerman, 0)) +} + +func (suite *CacheSuite) TestTextReturnsValueIfOKForPageCache() { + suite.givenAPageCache() + suite.whenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "this is", " separated", " over lines"))) + suite.thenTextShouldReturn("this is separated over lines", resource.KeyOf(0x1000, resource.LangGerman, 0)) +} + +func (suite *CacheSuite) TestTextReturnsErrorIfResourceNotExistingForLineCache() { + suite.givenALineCache() + suite.whenResourcesAre() + suite.thenTextShouldReturnError(resource.KeyOf(0x1000, resource.LangGerman, 0)) +} + +func (suite *CacheSuite) TestTextReturnsErrorIfResourceNotExistingForPageCache() { + suite.givenAPageCache() + suite.whenResourcesAre() + suite.thenTextShouldReturnError(resource.KeyOf(0x1000, resource.LangGerman, 0)) +} + +func (suite *CacheSuite) TestTextReturnsErrorIfBlockNotExistingForLineCache() { + suite.givenALineCache() + suite.whenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "zero", "one"))) + suite.thenTextShouldReturnError(resource.KeyOf(0x1000, resource.LangGerman, 2)) +} + +func (suite *CacheSuite) TestTextReturnsCachedValueIfPreviouslyRetrieved() { + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenALineCache() + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "test"))) + suite.givenTextWasRetrieved(key) + suite.whenResourcesAre() + suite.thenTextShouldReturn("test", key) +} + +func (suite *CacheSuite) TestTextTriesToReloadWhenCacheInvalidated() { + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenALineCache() + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "test"))) + suite.givenTextWasRetrieved(key) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x1000) + suite.thenTextShouldReturnError(key) +} + +func (suite *CacheSuite) TestInvalidationConsidersOnlyAffectedIDs() { + suite.givenALineCache() + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "test"), suite.storing(0x1001, "other"))) + suite.givenTextWasRetrieved(key) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x1001) + suite.thenTextShouldReturn("test", key) +} + +func (suite *CacheSuite) TestTextReturnsErrorIfResourceIsNotATextForLineCache() { + suite.givenALineCache() + suite.whenResourcesAre(suite.someLocalizedResources(resource.LangDefault, + suite.storingNonText(0x1000))) + suite.thenTextShouldReturnError(resource.KeyOf(0x1000, resource.LangDefault, 0)) +} + +func (suite *CacheSuite) TestTextReturnsErrorIfResourceIsNotATextForPageCache() { + suite.givenAPageCache() + suite.whenResourcesAre(suite.someLocalizedResources(resource.LangDefault, + suite.storingNonText(0x1000))) + suite.thenTextShouldReturnError(resource.KeyOf(0x1000, resource.LangDefault, 0)) +} + +func (suite *CacheSuite) givenALineCache() { + suite.instance = text.NewLineCache(suite.cp, suite) +} + +func (suite *CacheSuite) givenAPageCache() { + suite.instance = text.NewPageCache(suite.cp, suite) +} + +func (suite *CacheSuite) givenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *CacheSuite) givenTextWasRetrieved(key resource.Key) { + _, err := suite.instance.Text(key) + assert.Nil(suite.T(), err, "no error expected") +} + +func (suite *CacheSuite) whenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *CacheSuite) whenCacheResourcesAreInvalidated(ids ...resource.ID) { + suite.instance.InvalidateResources(ids) +} + +func (suite *CacheSuite) thenTextShouldReturn(expected string, key resource.Key) { + result, err := suite.instance.Text(key) + require.Nil(suite.T(), err, "No error expected") + assert.Equal(suite.T(), expected, result, "Texts don't match") +} + +func (suite *CacheSuite) thenTextShouldReturnError(key resource.Key) { + _, err := suite.instance.Text(key) + require.NotNil(suite.T(), err, "Error expected") +} + +func (suite *CacheSuite) someLocalizedResources(lang resource.Language, modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: lang, + Viewer: store, + } +} + +func (suite *CacheSuite) storing(id int, lines ...string) func(*resource.Store) { + data := make([][]byte, len(lines)) + for i, line := range lines { + data[i] = suite.cp.Encode(line) + } + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Text, + Compound: len(data) != 1, + }, + Blocks: resource.BlocksFrom(data), + }) + } +} + +func (suite *CacheSuite) storingNonText(id int) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Sound, + Compound: false, + }, + Blocks: resource.BlocksFrom([][]byte{{}}), + }) + } +} + +func (suite *CacheSuite) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: suite.localizedResources, + Lang: lang, + } +} diff --git a/modding/hacked/ss1/content/text/Codepage.go b/modding/hacked/ss1/content/text/Codepage.go new file mode 100644 index 0000000..5fc22b2 --- /dev/null +++ b/modding/hacked/ss1/content/text/Codepage.go @@ -0,0 +1,10 @@ +package text + +// Codepage wraps the methods for serializing strings. The encoded byte array is expected +// to be terminated by one 0x00 byte. +type Codepage interface { + // Encode converts the provided string to a byte array. It appends one 0x00 byte at the end. + Encode(value string) []byte + // Decode converts the provided byte slice to a string. It ignores trailing 0x00 bytes. + Decode(data []byte) string +} diff --git a/modding/hacked/ss1/content/text/DefaultCodepage.go b/modding/hacked/ss1/content/text/DefaultCodepage.go new file mode 100644 index 0000000..c316496 --- /dev/null +++ b/modding/hacked/ss1/content/text/DefaultCodepage.go @@ -0,0 +1,34 @@ +package text + +var cp437ToRune = [256]rune{ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, + 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, + 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, +} + +// DefaultCodepage returns a Codepage instance that represents the one used for the resources. +// It is based on the Code Page 437 ( https://en.wikipedia.org/wiki/Code_page_437 ). +func DefaultCodepage() Codepage { + cp437ToByte := make(map[rune]byte) + + for i, r := range &cp437ToRune { + cp437ToByte[r] = byte(i) + } + + cp := &tabledCodepage{tableToRune: cp437ToRune[:], tableToByte: cp437ToByte} + + return cp +} diff --git a/modding/hacked/ss1/content/text/DefaultCodepage_test.go b/modding/hacked/ss1/content/text/DefaultCodepage_test.go new file mode 100644 index 0000000..57c7f32 --- /dev/null +++ b/modding/hacked/ss1/content/text/DefaultCodepage_test.go @@ -0,0 +1,27 @@ +package text_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/text" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultCodepageEncode(t *testing.T) { + result := text.DefaultCodepage().Encode("ä") + + assert.Equal(t, []byte{132, 0x00}, result) +} + +func TestDefaultCodepageDecode(t *testing.T) { + result := text.DefaultCodepage().Decode([]byte{144, 225, 0x00}) + + assert.Equal(t, "Éß", result) +} + +func TestDefaultCodepageMapsUnknownCharacterToQuestionMark(t *testing.T) { + result := text.DefaultCodepage().Encode("„quoted�") + + assert.Equal(t, []byte{0x3F, 0x71, 0x75, 0x6F, 0x74, 0x65, 0x64, 0x3F, 0x00}, result) +} diff --git a/modding/hacked/ss1/content/text/ElectronicMessage.go b/modding/hacked/ss1/content/text/ElectronicMessage.go new file mode 100644 index 0000000..df00a1a --- /dev/null +++ b/modding/hacked/ss1/content/text/ElectronicMessage.go @@ -0,0 +1,201 @@ +package text + +import ( + "fmt" + "io/ioutil" + "regexp" + "strconv" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +const ( + metaExpressionInterrupt = "(t)" + metaExpressionNextMessage = "(?:i([0-9a-fA-F]{2}))" + metaExpressionColorIndex = "(?:c([0-9a-fA-F]{2}))" + metaExpressionLeftDisplay = "([0-9]+)" + metaExpressionRightDisplay = "(?:,[ ]*([0-9]+))" +) + +var metaExpression = regexp.MustCompile("^[ ]*(?:(?:" + + metaExpressionInterrupt + "|" + + metaExpressionNextMessage + "|" + + metaExpressionColorIndex + "|" + + metaExpressionLeftDisplay + "|" + + metaExpressionRightDisplay + ")[ ]*)*$") + +type metaStringError struct { + MetaString string +} + +func (err metaStringError) Error() string { + return fmt.Sprintf("failed to parse meta string: '%s'", err.MetaString) +} + +type blockError struct { + Action string + Index int + Nested error +} + +func (err blockError) Error() string { + return fmt.Sprintf("failed to %s block %d: %v", err.Action, err.Index, err.Nested) +} + +func (err blockError) Unwrap() error { + return err.Nested +} + +// ElectronicMessage describes one message. +type ElectronicMessage struct { + // NextMessage identifies the message that should follow this one. -1 for none. + NextMessage int + // IsInterrupt marks messages that are the ones following another. + IsInterrupt bool + // ColorIndex is into the palette for the sender text. -1 for default. + ColorIndex int + // LeftDisplay indicates the image to show in the left MFD. -1 for none. + LeftDisplay int + // RightDisplay indicates the image to show in the right MFD. -1 for none. + RightDisplay int + + // Title of the message. + Title string + // Sender of the message. + Sender string + // Subject of the message. + Subject string + // VerboseText the long form. + VerboseText string + // TerseText the short form. + TerseText string +} + +// EmptyElectronicMessage returns an instance of an empty electronic message. +func EmptyElectronicMessage() ElectronicMessage { + message := ElectronicMessage{ + NextMessage: -1, + ColorIndex: -1, + LeftDisplay: -1, + RightDisplay: -1, + } + + return message +} + +// DecodeElectronicMessage tries to decode a message from given block holder. +func DecodeElectronicMessage(cp Codepage, provider resource.BlockProvider) (message ElectronicMessage, err error) { + blockIndex := 0 + nextBlockString := func() (line string) { + if err != nil { + return + } + if blockIndex < provider.BlockCount() { + blockReader, blockErr := provider.Block(blockIndex) + if blockErr != nil { + err = blockError{ + Action: "access", + Index: blockIndex, + Nested: blockErr, + } + return + } + data, dataErr := ioutil.ReadAll(blockReader) + if dataErr != nil { + err = blockError{ + Action: "read data from", + Index: blockIndex, + Nested: dataErr, + } + } + line = cp.Decode(data) + blockIndex++ + } + return + } + nextText := func() (text string) { + for line := nextBlockString(); len(line) > 0; line = nextBlockString() { + text += line + } + return + } + + metaString := nextBlockString() + metaData := metaExpression.FindStringSubmatch(metaString) + parseInt := func(metaIndex int, base, bits int) (result int) { + var value uint64 + result = -1 + if (err == nil) && (len(metaData[metaIndex]) > 0) { + value, err = strconv.ParseUint(metaData[metaIndex], base, bits) + if err == nil { + result = int(value) + } + } + return + } + + message = EmptyElectronicMessage() + if (len(metaData) == 0) || (len(metaData[0]) != len(metaString)) { + err = metaStringError{MetaString: metaString} + } + if (err == nil) && (len(metaData[1]) > 0) { + message.IsInterrupt = true + } + message.NextMessage = parseInt(2, 16, 16) + message.ColorIndex = parseInt(3, 16, 8) + message.LeftDisplay = parseInt(4, 10, 16) + message.RightDisplay = parseInt(5, 10, 16) + message.Title = nextBlockString() + message.Sender = nextBlockString() + message.Subject = nextBlockString() + message.VerboseText = nextText() + message.TerseText = nextText() + + return +} + +// Encode serializes the message into a block holder. +func (message ElectronicMessage) Encode(cp Codepage) [][]byte { + var blocks [][]byte + + blocks = append(blocks, cp.Encode(message.metaString())) + blocks = append(blocks, cp.Encode(message.Title)) + blocks = append(blocks, cp.Encode(message.Sender)) + blocks = append(blocks, cp.Encode(message.Subject)) + for _, line := range Blocked(message.VerboseText) { + blocks = append(blocks, cp.Encode(line)) + } + for _, line := range Blocked(message.TerseText) { + blocks = append(blocks, cp.Encode(line)) + } + + return blocks +} + +func (message ElectronicMessage) metaString() string { + result := "" + add := func(sep, part string) { + if len(result) > 0 { + result += sep + } + result += part + } + + if message.IsInterrupt { + add("", "t") + } + if message.NextMessage >= 0 { + add(" ", fmt.Sprintf("i%02X", message.NextMessage)) + } + if message.ColorIndex >= 0 { + add(" ", fmt.Sprintf("c%02X", message.ColorIndex)) + } + if message.LeftDisplay >= 0 { + add(" ", fmt.Sprintf("%d", message.LeftDisplay)) + } + if message.RightDisplay >= 0 { + add("", fmt.Sprintf(",%d", message.RightDisplay)) + } + + return result +} diff --git a/modding/hacked/ss1/content/text/ElectronicMessageCache.go b/modding/hacked/ss1/content/text/ElectronicMessageCache.go new file mode 100644 index 0000000..03241b6 --- /dev/null +++ b/modding/hacked/ss1/content/text/ElectronicMessageCache.go @@ -0,0 +1,58 @@ +package text + +import ( + "github.com/inkyblackness/hacked/ss1/resource" +) + +// ElectronicMessageCache retrieves messages from a localizer and keeps them decoded until they are invalidated. +type ElectronicMessageCache struct { + cp Codepage + localizer resource.Localizer + + messages map[resource.Key]ElectronicMessage +} + +// NewElectronicMessageCache returns a new instance. +func NewElectronicMessageCache(cp Codepage, localizer resource.Localizer) *ElectronicMessageCache { + cache := &ElectronicMessageCache{ + cp: cp, + localizer: localizer, + + messages: make(map[resource.Key]ElectronicMessage), + } + return cache +} + +// InvalidateResources lets the cache remove any texts from resources that are specified in the given slice. +func (cache *ElectronicMessageCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.messages { + if key.ID == id { + delete(cache.messages, key) + } + } + } +} + +// Message retrieves and caches the message of given key. +func (cache *ElectronicMessageCache) Message(key resource.Key) (ElectronicMessage, error) { + cacheKey := resource.KeyOf(key.ID.Plus(key.Index), key.Lang, 0) + value, existing := cache.messages[cacheKey] + if existing { + return value, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(cacheKey.ID) + if err != nil { + return EmptyElectronicMessage(), err + } + if (view.ContentType() != resource.Text) || !view.Compound() { + return EmptyElectronicMessage(), resource.ErrWrongType(key, resource.Text) + } + value, err = DecodeElectronicMessage(cache.cp, view) + if err != nil { + return EmptyElectronicMessage(), err + } + cache.messages[cacheKey] = value + return value, nil +} diff --git a/modding/hacked/ss1/content/text/ElectronicMessageCache_test.go b/modding/hacked/ss1/content/text/ElectronicMessageCache_test.go new file mode 100644 index 0000000..2bfcfaa --- /dev/null +++ b/modding/hacked/ss1/content/text/ElectronicMessageCache_test.go @@ -0,0 +1,189 @@ +package text_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ElectronicMessageCacheSuite struct { + suite.Suite + + localizedResources resource.LocalizedResourcesList + + cp text.Codepage + instance *text.ElectronicMessageCache +} + +func TestElectronicMessageCacheSuite(t *testing.T) { + suite.Run(t, new(ElectronicMessageCacheSuite)) +} + +func (suite *ElectronicMessageCacheSuite) SetupTest() { + suite.cp = text.DefaultCodepage() + suite.instance = nil +} + +func (suite *ElectronicMessageCacheSuite) TestMessageReturnsValueIfOK() { + suite.givenACache() + suite.whenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, func(msg text.ElectronicMessage) text.ElectronicMessage { + msg.VerboseText = "123" + msg.TerseText = "terse" + return msg + }))) + suite.thenMessageShouldReturn(resource.KeyOf(0x1000, resource.LangGerman, 0), func(msg text.ElectronicMessage) { + assert.Equal(suite.T(), "123", msg.VerboseText) + assert.Equal(suite.T(), "terse", msg.TerseText) + }) +} + +func (suite *ElectronicMessageCacheSuite) TestTextReturnsErrorIfResourceNotExisting() { + suite.givenACache() + suite.whenResourcesAre() + suite.thenMessageShouldReturnError(resource.KeyOf(0x1000, resource.LangGerman, 0)) +} + +func (suite *ElectronicMessageCacheSuite) TestTextReturnsCachedValueIfPreviouslyRetrieved() { + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenACache() + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, func(msg text.ElectronicMessage) text.ElectronicMessage { + msg.VerboseText = "older text" + msg.TerseText = "terse text" + return msg + }))) + suite.givenMessageWasRetrieved(key) + suite.whenResourcesAre() + suite.thenMessageShouldReturn(key, func(msg text.ElectronicMessage) { + assert.Equal(suite.T(), "older text", msg.VerboseText) + assert.Equal(suite.T(), "terse text", msg.TerseText) + }) +} + +func (suite *ElectronicMessageCacheSuite) TestTextTriesToReloadWhenCacheInvalidated() { + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenACache() + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, func(msg text.ElectronicMessage) text.ElectronicMessage { + msg.VerboseText = "12" + msg.TerseText = "20" + return msg + }))) + suite.givenMessageWasRetrieved(key) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x1000) + suite.thenMessageShouldReturnError(key) +} + +func (suite *ElectronicMessageCacheSuite) TestInvalidationConsidersOnlyAffectedIDs() { + suite.givenACache() + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, func(msg text.ElectronicMessage) text.ElectronicMessage { + msg.VerboseText = "A" + return msg + }), + suite.storing(0x1001, func(msg text.ElectronicMessage) text.ElectronicMessage { + msg.VerboseText = "B" + return msg + }))) + suite.givenMessageWasRetrieved(key) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x1001) + suite.thenMessageShouldReturn(key, func(msg text.ElectronicMessage) { + assert.Equal(suite.T(), "A", msg.VerboseText) + }) +} + +func (suite *ElectronicMessageCacheSuite) TestTextReturnsErrorIfResourceIsNotATextForLineCache() { + suite.givenACache() + suite.whenResourcesAre(suite.someLocalizedResources(resource.LangDefault, + suite.storingNonText(0x1000))) + suite.thenMessageShouldReturnError(resource.KeyOf(0x1000, resource.LangDefault, 0)) +} + +func (suite *ElectronicMessageCacheSuite) givenACache() { + suite.instance = text.NewElectronicMessageCache(suite.cp, suite) +} + +func (suite *ElectronicMessageCacheSuite) givenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *ElectronicMessageCacheSuite) givenMessageWasRetrieved(key resource.Key) { + _, err := suite.instance.Message(key) + assert.Nil(suite.T(), err, "no error expected") +} + +func (suite *ElectronicMessageCacheSuite) whenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *ElectronicMessageCacheSuite) whenCacheResourcesAreInvalidated(ids ...resource.ID) { + suite.instance.InvalidateResources(ids) +} + +func (suite *ElectronicMessageCacheSuite) thenMessageShouldReturn(key resource.Key, validator func(msg text.ElectronicMessage)) { + result, err := suite.instance.Message(key) + require.Nil(suite.T(), err, "No error expected") + validator(result) +} + +func (suite *ElectronicMessageCacheSuite) thenMessageShouldReturnError(key resource.Key) { + _, err := suite.instance.Message(key) + require.NotNil(suite.T(), err, "Error expected") +} + +func (suite *ElectronicMessageCacheSuite) someLocalizedResources(lang resource.Language, modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: lang, + Viewer: store, + } +} + +func (suite *ElectronicMessageCacheSuite) storing(id int, modifier func(msg text.ElectronicMessage) text.ElectronicMessage) func(*resource.Store) { + data := modifier(text.EmptyElectronicMessage()).Encode(suite.cp) + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Text, + Compound: true, + }, + Blocks: resource.BlocksFrom(data), + }) + } +} + +func (suite *ElectronicMessageCacheSuite) storingNonText(id int) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Sound, + Compound: false, + }, + Blocks: resource.BlocksFrom([][]byte{{}}), + }) + } +} + +func (suite *ElectronicMessageCacheSuite) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: suite.localizedResources, + Lang: lang, + } +} diff --git a/modding/hacked/ss1/content/text/ElectronicMessage_test.go b/modding/hacked/ss1/content/text/ElectronicMessage_test.go new file mode 100644 index 0000000..6ec7ac2 --- /dev/null +++ b/modding/hacked/ss1/content/text/ElectronicMessage_test.go @@ -0,0 +1,262 @@ +package text_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ElectronicMessageSuite struct { + suite.Suite + cp text.Codepage +} + +func TestElectronicMessageSuite(t *testing.T) { + suite.Run(t, new(ElectronicMessageSuite)) +} + +func (suite *ElectronicMessageSuite) SetupTest() { + suite.cp = text.DefaultCodepage() +} + +func (suite *ElectronicMessageSuite) TestEncodeBasicMessage() { + message := text.EmptyElectronicMessage() + + message.Title = "1" + message.Sender = "2" + message.Subject = "3" + message.VerboseText = "4" + message.TerseText = "5" + + encoded := message.Encode(suite.cp) + + suite.verifyBlock(0, encoded, []byte{0x00}) + suite.verifyBlock(1, encoded, []byte{0x31, 0x00}) + suite.verifyBlock(2, encoded, []byte{0x32, 0x00}) + suite.verifyBlock(3, encoded, []byte{0x33, 0x00}) + suite.verifyBlock(4, encoded, []byte{0x34, 0x00}) + suite.verifyBlock(5, encoded, []byte{0x00}) + suite.verifyBlock(6, encoded, []byte{0x35, 0x00}) + suite.verifyBlock(7, encoded, []byte{0x00}) +} + +func (suite *ElectronicMessageSuite) TestEncodeMeta_A() { + message := text.EmptyElectronicMessage() + + message.NextMessage = 0x20 + message.ColorIndex = 0x13 + message.LeftDisplay = 30 + message.RightDisplay = 40 + + encoded := message.Encode(suite.cp) + + require.NotNil(suite.T(), encoded) + require.Equal(suite.T(), true, len(encoded) > 0) + suite.verifyBlock(0, encoded, suite.cp.Encode("i20 c13 30,40")) +} + +func (suite *ElectronicMessageSuite) TestEncodeMeta_B() { + message := text.EmptyElectronicMessage() + + message.IsInterrupt = true + message.LeftDisplay = 31 + + encoded := message.Encode(suite.cp) + + require.NotNil(suite.T(), encoded) + require.Equal(suite.T(), true, len(encoded) > 0) + suite.verifyBlock(0, encoded, suite.cp.Encode("t 31")) +} + +func (suite *ElectronicMessageSuite) TestEncodeMeta_C() { + message := text.EmptyElectronicMessage() + + message.IsInterrupt = true + + encoded := message.Encode(suite.cp) + + require.NotNil(suite.T(), encoded) + require.Equal(suite.T(), true, len(encoded) > 0) + suite.verifyBlock(0, encoded, suite.cp.Encode("t")) +} + +func (suite *ElectronicMessageSuite) TestEncodeCreatesNewBlocksPerNewLine() { + message := text.EmptyElectronicMessage() + + message.VerboseText = "line1\n\n\nline2" + message.TerseText = "terse1\n\n\nterse2" + + encoded := message.Encode(suite.cp) + + require.NotNil(suite.T(), encoded) + require.Equal(suite.T(), true, len(encoded) > 0) + suite.verifyBlock(4, encoded, suite.cp.Encode("line1\n\n\nline2")) + suite.verifyBlock(6, encoded, suite.cp.Encode("terse1\n\n\nterse2")) +} + +func (suite *ElectronicMessageSuite) TestEncodeBreaksUpLinesAfterLimitCharacters() { + message := text.EmptyElectronicMessage() + + message.VerboseText = "aaaaaaaaa bbbbbbbbb ccccccccc ddddddddd eeeeeeeee fffffffff ggggggggg hhhhhhhhh iiiiiiiii jjjjjjjjj kkkkk" + + encoded := message.Encode(suite.cp) + + require.NotNil(suite.T(), encoded) + require.Equal(suite.T(), true, len(encoded) > 0) + suite.verifyBlock(4, encoded, + suite.cp.Encode("aaaaaaaaa bbbbbbbbb ccccccccc ddddddddd eeeeeeeee fffffffff ggggggggg hhhhhhhhh ")) + suite.verifyBlock(5, encoded, + suite.cp.Encode("iiiiiiiii jjjjjjjjj kkkkk")) +} + +func (suite *ElectronicMessageSuite) TestDecodeMeta() { + message, err := text.DecodeElectronicMessage(suite.cp, suite.holderWithMeta("i20 c13 30,40")) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), message) + assert.Equal(suite.T(), 0x20, message.NextMessage) + assert.Equal(suite.T(), 0x13, message.ColorIndex) + assert.Equal(suite.T(), 30, message.LeftDisplay) + assert.Equal(suite.T(), 40, message.RightDisplay) +} + +func (suite *ElectronicMessageSuite) TestDecodeMeta_Failure() { + _, err := text.DecodeElectronicMessage(suite.cp, suite.holderWithMeta("i20 c 13 30,40")) + + assert.NotNil(suite.T(), err) +} + +func (suite *ElectronicMessageSuite) TestDecodeMetaColorIs8BitUnsigned() { + message, err := text.DecodeElectronicMessage(suite.cp, suite.holderWithMeta("cD1")) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), message) + assert.Equal(suite.T(), 0xD1, message.ColorIndex) +} + +func (suite *ElectronicMessageSuite) TestDecodeMessage() { + message, err := text.DecodeElectronicMessage(suite.cp, suite.holderWithMeta("10")) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), message) + assert.Equal(suite.T(), "title", message.Title) + assert.Equal(suite.T(), "sender", message.Sender) + assert.Equal(suite.T(), "subject", message.Subject) + assert.Equal(suite.T(), "verbose", message.VerboseText) + assert.Equal(suite.T(), "terse", message.TerseText) +} + +func (suite *ElectronicMessageSuite) TestDecodeMessageIsPossibleForVanillaStubMails() { + message, err := text.DecodeElectronicMessage(suite.cp, suite.vanillaStubMail()) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), message) + assert.Equal(suite.T(), "", message.Title) + assert.Equal(suite.T(), "", message.Sender) + assert.Equal(suite.T(), "", message.Subject) + assert.Equal(suite.T(), "stub emailstub email", message.VerboseText) + assert.Equal(suite.T(), "", message.TerseText) +} + +func (suite *ElectronicMessageSuite) TestDecodeMessageIsPossibleForMissingTerminatingLine() { + message, err := text.DecodeElectronicMessage(suite.cp, suite.holderWithMissingTerminatingLine()) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), message) + assert.Equal(suite.T(), "title", message.Title) + assert.Equal(suite.T(), "sender", message.Sender) + assert.Equal(suite.T(), "subject", message.Subject) + assert.Equal(suite.T(), "verbose text", message.VerboseText) + assert.Equal(suite.T(), "terse text", message.TerseText) +} + +func (suite *ElectronicMessageSuite) TestRecodeMessage() { + inMessage := text.EmptyElectronicMessage() + inMessage.IsInterrupt = true + inMessage.NextMessage = 0x10 + inMessage.ColorIndex = 0x20 + inMessage.LeftDisplay = 40 + inMessage.RightDisplay = 50 + inMessage.VerboseText = "abcd\nefgh\nsome" + inMessage.TerseText = "\n" + + data := inMessage.Encode(suite.cp) + outMessage, err := text.DecodeElectronicMessage(suite.cp, resource.BlocksFrom(data)) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), outMessage) + assert.Equal(suite.T(), inMessage, outMessage) +} + +func (suite *ElectronicMessageSuite) TestRecodeMessageWithMultipleNewLines() { + inMessage := text.EmptyElectronicMessage() + inMessage.IsInterrupt = true + inMessage.NextMessage = 0x10 + inMessage.ColorIndex = 0x20 + inMessage.LeftDisplay = 40 + inMessage.RightDisplay = 50 + inMessage.VerboseText = "first\n\n\nsecond" + inMessage.TerseText = "terse\n" + + data := inMessage.Encode(suite.cp) + outMessage, err := text.DecodeElectronicMessage(suite.cp, resource.BlocksFrom(data)) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), outMessage) + assert.Equal(suite.T(), inMessage, outMessage) +} + +func (suite *ElectronicMessageSuite) verifyBlock(index int, blocks [][]byte, expected []byte) { + require.True(suite.T(), len(blocks) > index, "too few blocks") + assert.Equal(suite.T(), expected, blocks[index]) +} + +func (suite *ElectronicMessageSuite) holderWithMeta(meta string) resource.BlockProvider { + data := [][]byte{ + suite.cp.Encode(meta), + suite.cp.Encode("title"), + suite.cp.Encode("sender"), + suite.cp.Encode("subject"), + suite.cp.Encode("verbose"), + suite.cp.Encode(""), + suite.cp.Encode("terse"), + suite.cp.Encode("")} + + return resource.BlocksFrom(data) +} + +func (suite *ElectronicMessageSuite) vanillaStubMail() resource.BlockProvider { + // The string resources contain a few mails which aren't used. + // They are missing the terminating line for the verbose text. + data := [][]byte{ + suite.cp.Encode(""), + suite.cp.Encode(""), + suite.cp.Encode(""), + suite.cp.Encode(""), + suite.cp.Encode("stub email"), + suite.cp.Encode("stub email"), + suite.cp.Encode("")} + + return resource.BlocksFrom(data) +} + +func (suite *ElectronicMessageSuite) holderWithMissingTerminatingLine() resource.BlockProvider { + // This case is encountered once in gerstrng.res + data := [][]byte{ + suite.cp.Encode(""), + suite.cp.Encode("title"), + suite.cp.Encode("sender"), + suite.cp.Encode("subject"), + suite.cp.Encode("verbose "), + suite.cp.Encode("text"), + suite.cp.Encode(""), + suite.cp.Encode("terse "), + suite.cp.Encode("text")} + + return resource.BlocksFrom(data) +} diff --git a/modding/hacked/ss1/content/text/TabledCodepage.go b/modding/hacked/ss1/content/text/TabledCodepage.go new file mode 100644 index 0000000..7a4dd62 --- /dev/null +++ b/modding/hacked/ss1/content/text/TabledCodepage.go @@ -0,0 +1,33 @@ +package text + +type tabledCodepage struct { + tableToRune []rune + tableToByte map[rune]byte +} + +func (cp *tabledCodepage) Encode(value string) []byte { + result := make([]byte, 0, len(value)+1) + + for _, c := range value { + mapped, known := cp.tableToByte[c] + if !known { + mapped = cp.tableToByte['?'] + } + result = append(result, mapped) + } + result = append(result, 0x00) + + return result +} + +func (cp *tabledCodepage) Decode(data []byte) string { + runes := make([]rune, 0, len(data)) + + for _, value := range data { + if value != 0x00 { + runes = append(runes, cp.tableToRune[value]) + } + } + + return string(runes) +} diff --git a/modding/hacked/ss1/content/texture/Constants.go b/modding/hacked/ss1/content/texture/Constants.go new file mode 100644 index 0000000..97e82ee --- /dev/null +++ b/modding/hacked/ss1/content/texture/Constants.go @@ -0,0 +1,5 @@ +package texture + +const ( + propertiesFileVersion uint32 = 0x00000009 +) diff --git a/modding/hacked/ss1/content/texture/Properties.go b/modding/hacked/ss1/content/texture/Properties.go new file mode 100644 index 0000000..c4b4c4c --- /dev/null +++ b/modding/hacked/ss1/content/texture/Properties.go @@ -0,0 +1,35 @@ +package texture + +import "github.com/inkyblackness/hacked/ss1/serial" + +const ( + // PropertiesSize specifies, in bytes, the length a properties structure has. + PropertiesSize = 11 +) + +// Properties describe additional information about a texture. +type Properties struct { + _ byte + _ byte + _ int16 + DistanceModifier int16 + Climbable byte + _ byte + + TransparencyControl TransparencyControl + + AnimationGroup byte + AnimationIndex byte +} + +// PropertiesList implements serial.Codable to serialize a set of texture properties. +type PropertiesList []Properties + +// Code serializes the list with the provided coder. +func (list PropertiesList) Code(coder serial.Coder) { + version := propertiesFileVersion + coder.Code(&version) + for i := 0; i < len(list); i++ { + coder.Code(&list[i]) + } +} diff --git a/modding/hacked/ss1/content/texture/Properties_test.go b/modding/hacked/ss1/content/texture/Properties_test.go new file mode 100644 index 0000000..db65a0f --- /dev/null +++ b/modding/hacked/ss1/content/texture/Properties_test.go @@ -0,0 +1,15 @@ +package texture_test + +import ( + "encoding/binary" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/texture" +) + +func TestPropertiesSize(t *testing.T) { + size := binary.Size(texture.Properties{}) + assert.Equal(t, texture.PropertiesSize, size) +} diff --git a/modding/hacked/ss1/content/texture/TransparencyControl.go b/modding/hacked/ss1/content/texture/TransparencyControl.go new file mode 100644 index 0000000..f5c1ed9 --- /dev/null +++ b/modding/hacked/ss1/content/texture/TransparencyControl.go @@ -0,0 +1,33 @@ +package texture + +import "fmt" + +// TransparencyControl describes how a texture should be displayed. +type TransparencyControl byte + +var transparencyControlNames = []string{ + "Regular", + "Space", + "SpaceBackground", +} + +func (ctrl TransparencyControl) String() string { + if int(ctrl) < len(transparencyControlNames) { + return transparencyControlNames[ctrl] + } + return fmt.Sprintf("Unknown%02X", int(ctrl)) +} + +const ( + // TransparencyControlRegular describes a typical texture. + TransparencyControlRegular TransparencyControl = 0x00 + // TransparencyControlSpace marks space texture, which ignores bitmap data. + TransparencyControlSpace TransparencyControl = 0x01 + // TransparencyControlSpaceBackground draws space for palette index 0x00. + TransparencyControlSpaceBackground TransparencyControl = 0x02 +) + +// TransparencyControls returns all known transparency control values. +func TransparencyControls() []TransparencyControl { + return []TransparencyControl{TransparencyControlRegular, TransparencyControlSpace, TransparencyControlSpaceBackground} +} diff --git a/modding/hacked/ss1/content/texture/TransparencyControl_test.go b/modding/hacked/ss1/content/texture/TransparencyControl_test.go new file mode 100644 index 0000000..45b0d88 --- /dev/null +++ b/modding/hacked/ss1/content/texture/TransparencyControl_test.go @@ -0,0 +1,31 @@ +package texture_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/texture" + + "github.com/stretchr/testify/assert" +) + +func TestTransparencyControlString(t *testing.T) { + tt := []struct { + ctrl texture.TransparencyControl + expected string + }{ + {ctrl: texture.TransparencyControlRegular, expected: "Regular"}, + {ctrl: texture.TransparencyControlSpace, expected: "Space"}, + {ctrl: texture.TransparencyControlSpaceBackground, expected: "SpaceBackground"}, + {ctrl: 0x80, expected: "Unknown80"}, + } + + for _, tc := range tt { + result := tc.ctrl.String() + assert.Equal(t, tc.expected, result) + } +} + +func TestTransparencyControls(t *testing.T) { + controls := texture.TransparencyControls() + assert.Equal(t, 3, len(controls)) +} diff --git a/modding/hacked/ss1/doc.go b/modding/hacked/ss1/doc.go new file mode 100644 index 0000000..5c4323c --- /dev/null +++ b/modding/hacked/ss1/doc.go @@ -0,0 +1,5 @@ +/* +Package ss1 and its sub-packages contain System Shock 1 (1994) specifics. +These specifics are based on the ss-specs document of InkyBlackness (https://github.com/inkyblackness/ss-specs). +*/ +package ss1 diff --git a/modding/hacked/ss1/edit/AugmentedText.go b/modding/hacked/ss1/edit/AugmentedText.go new file mode 100644 index 0000000..698e669 --- /dev/null +++ b/modding/hacked/ss1/edit/AugmentedText.go @@ -0,0 +1,138 @@ +package edit + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/edit/media" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// AugmentedTextBlockSetter modifies resource blocks. +type AugmentedTextBlockSetter interface { + SetResourceBlock(lang resource.Language, id resource.ID, index int, data []byte) + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + DelResource(lang resource.Language, id resource.ID) +} + +func augmentedTextAudioKeyFor(key resource.Key) resource.Key { + audioBase := KnownTexts().ByID(key.ID).audioBase + if audioBase == 0 { + return resource.KeyOf(0, resource.LangAny, 0) + } + + soundKey := key + soundKey.ID = audioBase.Plus(key.Index) + soundKey.Index = 0 + return soundKey +} + +// AugmentedTextService provides read/write functionality. +type AugmentedTextService struct { + textViewer media.TextViewerService + textSetter media.TextSetterService + audioViewer media.AudioViewerService + audioSetter media.AudioSetterService +} + +// NewAugmentedTextService returns a new instance based on given accessor. +func NewAugmentedTextService( + textViewer media.TextViewerService, textSetter media.TextSetterService, + audioViewer media.AudioViewerService, audioSetter media.AudioSetterService) AugmentedTextService { + return AugmentedTextService{ + textViewer: textViewer, + textSetter: textSetter, + audioViewer: audioViewer, + audioSetter: audioSetter, + } +} + +// WithAudio returns true if the identified resource is one with an audio component. +func (service AugmentedTextService) WithAudio(key resource.Key) bool { + return KnownTexts().ByID(key.ID).audioBase != 0 +} + +// Text returns the textual value of the identified text resource. +func (service AugmentedTextService) Text(key resource.Key) string { + return service.textViewer.Text(key) +} + +// SetText changes the textual value of a text resource. +func (service AugmentedTextService) SetText(setter AugmentedTextBlockSetter, key resource.Key, value string) { + service.textSetter.Set(setter, key, value) +} + +// RestoreTextFunc creates a snapshot of the current textual state and returns a function to restore it. +func (service AugmentedTextService) RestoreTextFunc(key resource.Key) func(setter AugmentedTextBlockSetter) { + oldText := service.textViewer.Text(key) + isModified := service.textViewer.Modified(key) + + return func(setter AugmentedTextBlockSetter) { + if isModified { + service.textSetter.Set(setter, key, oldText) + } else { + service.textSetter.Remove(setter, key) + } + } +} + +// Sound returns the audio value of the identified text resource. +// In case the text resource has no audio, an empty sound will be returned. +func (service AugmentedTextService) Sound(key resource.Key) audio.L8 { + if !service.WithAudio(key) { + return audio.L8{} + } + return service.audioViewer.Audio(augmentedTextAudioKeyFor(key)) +} + +// SetSound changes the sound of a text resource. +// Should the text resource have no audio component, this call does nothing. +func (service AugmentedTextService) SetSound(setter AugmentedTextBlockSetter, key resource.Key, sound audio.L8) { // nolint: interfacer + if service.WithAudio(key) { + service.audioSetter.Set(setter, augmentedTextAudioKeyFor(key), sound) + } +} + +// RestoreSoundFunc creates a snapshot of the current sound and returns a function to restore it. +// In case the text resource has no audio, a stub method will be returned. +func (service AugmentedTextService) RestoreSoundFunc(key resource.Key) func(setter AugmentedTextBlockSetter) { + if !service.WithAudio(key) { + return func(setter AugmentedTextBlockSetter) {} + } + + soundKey := augmentedTextAudioKeyFor(key) + isSoundModified := service.audioViewer.Modified(soundKey) + oldSound := service.audioViewer.Audio(soundKey) + + return func(setter AugmentedTextBlockSetter) { + if isSoundModified { + service.audioSetter.Set(setter, soundKey, oldSound) + } else { + service.audioSetter.Remove(setter, soundKey) + } + } +} + +// Clear sets the text to an empty string and sets an empty sound if audio is associated. +func (service AugmentedTextService) Clear(setter AugmentedTextBlockSetter, key resource.Key) { + service.textSetter.Clear(setter, key) + if service.WithAudio(key) { + service.audioSetter.Clear(setter, augmentedTextAudioKeyFor(key)) + } +} + +// Remove erases the text and audio from the resources. +func (service AugmentedTextService) Remove(setter AugmentedTextBlockSetter, key resource.Key) { + service.textSetter.Remove(setter, key) + if service.WithAudio(key) { + service.audioSetter.Remove(setter, augmentedTextAudioKeyFor(key)) + } +} + +// RestoreFunc creates a snapshot of all associated media components and returns a function to restore it. +func (service AugmentedTextService) RestoreFunc(key resource.Key) func(setter AugmentedTextBlockSetter) { + restoreText := service.RestoreTextFunc(key) + restoreSound := service.RestoreSoundFunc(key) + return func(setter AugmentedTextBlockSetter) { + restoreText(setter) + restoreSound(setter) + } +} diff --git a/modding/hacked/ss1/edit/EditableLevels.go b/modding/hacked/ss1/edit/EditableLevels.go new file mode 100644 index 0000000..e9bbbe2 --- /dev/null +++ b/modding/hacked/ss1/edit/EditableLevels.go @@ -0,0 +1,113 @@ +package edit + +import ( + "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/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// EditableLevels is a list of levels that can be modified. +type EditableLevels struct { + registry cmd.Registry + mod *world.Mod + list [archive.MaxLevels]*level.Level +} + +// NewEditableLevels returns a new instance for given mod. +func NewEditableLevels(registry cmd.Registry, mod *world.Mod) *EditableLevels { + var levels EditableLevels + levels.registry = registry + levels.mod = mod + for i := 0; i < len(levels.list); i++ { + levels.list[i] = level.NewLevel(ids.LevelResourcesStart, i, mod) + } + return &levels +} + +// InvalidateResources informs all levels of changed resource identifier. +func (levels *EditableLevels) InvalidateResources(modifiedIDs []resource.ID) { + for _, lvl := range levels.list { + lvl.InvalidateResources(modifiedIDs) + } +} + +// Level returns the pointer to the given level. The function panics if the index is invalid. +func (levels *EditableLevels) Level(index int) *level.Level { + if !levels.IsLevelAvailable(index) { + panic("Invalid level index") + } + return levels.list[index] +} + +// IsLevelReadOnly returns true if the identified level cannot be modified. +func (levels *EditableLevels) IsLevelReadOnly(index int) bool { + isInMod := len(levels.mod.ModifiedBlocks(resource.LangAny, ids.LevelResourcesStart.Plus(lvlids.PerLevel*index+lvlids.FirstUsed))) > 0 + return !levels.IsLevelAvailable(index) || !isInMod +} + +// IsLevelAvailable returns true if the given index is a valid level. +func (levels *EditableLevels) IsLevelAvailable(index int) bool { + return (index >= 0) && (index < len(levels.list)) +} + +// IsObjectInUse returns true if the given object ID is in use in given level. +func (levels *EditableLevels) IsObjectInUse(levelIndex int, id level.ObjectID) bool { + if !levels.IsLevelAvailable(levelIndex) { + return false + } + lvl := levels.list[levelIndex] + obj := lvl.Object(id) + return (obj != nil) && (obj.InUse != 0) +} + +// IsTileOnMap returns true if the given tile position points to a valid tile in the given level. +func (levels *EditableLevels) IsTileOnMap(levelIndex int, pos level.TilePosition) bool { + if !levels.IsLevelAvailable(levelIndex) { + return false + } + lvl := levels.list[levelIndex] + return lvl.Tile(pos) != nil +} + +// CommitLevelChanges applies any level data change compared to the underlying mod. +func (levels *EditableLevels) CommitLevelChanges(index int) error { + lvl := levels.Level(index) + newDataSet := lvl.EncodeState() + var patches []world.BlockPatch + for id, newData := range &newDataSet { + if len(newData) > 0 { + resourceID := ids.LevelResourcesStart.Plus(lvlids.PerLevel*lvl.ID() + id) + patch, changed, err := levels.mod.CreateBlockPatch(resource.LangAny, resourceID, 0, newData) + if err != nil { + return err + } else if changed { + patches = append(patches, patch) + } + } + } + + return levels.registry.Register(cmd.Named("CommitLevelChanges"), + cmd.Forward(forwardTask(patches)), cmd.Reverse(reverseTask(patches))) +} + +func forwardTask(patches []world.BlockPatch) cmd.Task { + return func(modder world.Modder) error { + for _, patch := range patches { + modder.PatchResourceBlock(resource.LangAny, patch.ID, patch.BlockIndex, patch.BlockLength, patch.ForwardData) + } + return nil + } +} + +func reverseTask(patches []world.BlockPatch) cmd.Task { + return func(modder world.Modder) error { + for _, patch := range patches { + modder.PatchResourceBlock(resource.LangAny, patch.ID, patch.BlockIndex, patch.BlockLength, patch.ReverseData) + } + return nil + } +} diff --git a/modding/hacked/ss1/edit/GameObjectsService.go b/modding/hacked/ss1/edit/GameObjectsService.go new file mode 100644 index 0000000..8b73747 --- /dev/null +++ b/modding/hacked/ss1/edit/GameObjectsService.go @@ -0,0 +1,64 @@ +package edit + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/world" +) + +// GameObjectBitmapInfo contains indices about bitmaps for a particular game object. +type GameObjectBitmapInfo struct { + // Start is the index for the first object bitmap in ids.ObjectBitmaps. + Start int + // Count is the amount of bitmaps the object has in total. + Count int + // IconRecommendation is the offset from start for using icons. + IconRecommendation int +} + +// GameObjectsService provides information for game objects. +type GameObjectsService struct { + mod *world.Mod +} + +// NewGameObjectsService returns a new instance. +func NewGameObjectsService(mod *world.Mod) *GameObjectsService { + return &GameObjectsService{ + mod: mod, + } +} + +// AllProperties returns the currently know properties. +func (service *GameObjectsService) AllProperties() object.PropertiesTable { + return service.mod.ObjectProperties() +} + +// PropertiesFor returns the properties for the given triple. +func (service *GameObjectsService) PropertiesFor(triple object.Triple) (object.Properties, error) { + prop, err := service.AllProperties().ForObject(triple) + if err != nil { + return object.Properties{}, err + } + return *prop, err +} + +// BitmapInfo returns information on all bitmaps. +func (service *GameObjectsService) BitmapInfo() map[object.Triple]GameObjectBitmapInfo { + properties := service.AllProperties() + result := make(map[object.Triple]GameObjectBitmapInfo) + start := 0 + properties.Iterate(func(triple object.Triple, prop *object.Properties) bool { + numExtra := int(prop.Common.Bitmap3D.FrameNumber()) + info := GameObjectBitmapInfo{ + Start: start, + Count: 3 + numExtra, + IconRecommendation: 1, + } + if triple.Class != object.ClassTrap { + info.IconRecommendation += 2 + } + start += info.Count + result[triple] = info + return true + }) + return result +} diff --git a/modding/hacked/ss1/edit/GameStateService.go b/modding/hacked/ss1/edit/GameStateService.go new file mode 100644 index 0000000..68514ff --- /dev/null +++ b/modding/hacked/ss1/edit/GameStateService.go @@ -0,0 +1,272 @@ +package edit + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/citadel" +) + +// VariableBaseContextIdentifier identifies which context for variable naming should be used. +type VariableBaseContextIdentifier int + +// Context identifier are listed below. +const ( + VariableContextCitadel = VariableBaseContextIdentifier(0) + VariableContextEngine = VariableBaseContextIdentifier(1) +) + +// VariableContextIdentifiers returns a list of all available identifiers. +func VariableContextIdentifiers() []VariableBaseContextIdentifier { + return []VariableBaseContextIdentifier{VariableContextCitadel, VariableContextEngine} +} + +// GameStateSettings describe the game state details. +type GameStateSettings struct { + BaseContext int + + BooleanVariables []VariableSettings + IntegerVariables []VariableSettings +} + +// VariableSettings describe game variables. +type VariableSettings struct { + Index int + + ResetValue *int16 `json:",omitempty"` + Name string + Description string + Limits *struct { + Minimum int16 + Maximum int16 + } `json:",omitempty"` + ValueNames []struct { + Value int16 + Name string + } `json:",omitempty"` +} + +func variableSettingsFrom(variables archive.GameVariables) []VariableSettings { + settings := make([]VariableSettings, 0, len(variables)) + for varIndex, varInfo := range variables { + varSettings := VariableSettings{ + Index: varIndex, + ResetValue: varInfo.InitValue, + Name: varInfo.Name, + Description: varInfo.Description, + Limits: nil, + ValueNames: nil, + } + if varInfo.Limits != nil { + varSettings.Limits = &struct { + Minimum int16 + Maximum int16 + }{ + Minimum: varInfo.Limits.Minimum, + Maximum: varInfo.Limits.Maximum, + } + } + for valIndex, valName := range varInfo.ValueNames { + varSettings.ValueNames = append(varSettings.ValueNames, + struct { + Value int16 + Name string + }{ + Value: valIndex, + Name: valName, + }) + } + settings = append(settings, varSettings) + } + return settings +} + +func variablesFrom(list []VariableSettings) archive.GameVariables { + variables := make(archive.GameVariables) + for _, settings := range list { + varInfo := archive.GameVariableInfo{ + InitValue: settings.ResetValue, + Name: settings.Name, + Description: settings.Description, + } + if settings.Limits != nil { + varInfo.Limits = &archive.GameVariableLimits{ + Minimum: settings.Limits.Maximum, + Maximum: settings.Limits.Minimum, + } + } + if len(settings.ValueNames) > 0 { + varInfo.ValueNames = make(map[int16]string) + for _, valName := range settings.ValueNames { + varInfo.ValueNames[valName.Value] = valName.Name + } + } + variables[settings.Index] = varInfo + } + return variables +} + +// GameStateService handles all details on the global game state. +type GameStateService struct { + registry cmd.Registry + + currentContext VariableBaseContextIdentifier + + booleanVariables archive.GameVariables + integerVariables archive.GameVariables +} + +// NewGameStateService returns a new instance. +func NewGameStateService(registry cmd.Registry) *GameStateService { + return &GameStateService{ + registry: registry, + + currentContext: VariableContextCitadel, + + booleanVariables: make(archive.GameVariables), + integerVariables: make(archive.GameVariables), + } +} + +// CurrentSettings returns the snapshot of the project. +func (service GameStateService) CurrentSettings() GameStateSettings { + return GameStateSettings{ + BaseContext: int(service.currentContext), + BooleanVariables: variableSettingsFrom(service.booleanVariables), + IntegerVariables: variableSettingsFrom(service.integerVariables), + } +} + +// RestoreSettings loads the given snapshot. +func (service *GameStateService) RestoreSettings(settings GameStateSettings) { + service.currentContext = VariableBaseContextIdentifier(settings.BaseContext) + service.booleanVariables = variablesFrom(settings.BooleanVariables) + service.integerVariables = variablesFrom(settings.IntegerVariables) +} + +// VariableBaseContext returns the current context to use as basis for variables not specified by the project. +func (service GameStateService) VariableBaseContext() VariableBaseContextIdentifier { + return service.currentContext +} + +// SetVariableBaseContext sets the current context to use as basis for variables not specified by the project. +func (service *GameStateService) SetVariableBaseContext(identifier VariableBaseContextIdentifier) { + service.currentContext = identifier +} + +func (service GameStateService) baseInfoProvider() archive.GameVariableInfoProvider { + switch service.currentContext { + case VariableContextCitadel: + return citadel.MissionVariables{} + case VariableContextEngine: + return archive.EngineVariables{} + default: + panic(fmt.Sprintf("invalid identifier: %d", service.currentContext)) + } +} + +// DefaultAllVariables removes all project specific overrides. +func (service *GameStateService) DefaultAllVariables() error { + return service.registry.Register(cmd.Named("RemoveAllOverrides"), + cmd.Nested(func() error { + for i := 0; i < archive.BooleanVarCount; i++ { + err := service.DefaultBooleanVariable(i) + if err != nil { + return err + } + } + for i := 0; i < archive.IntegerVarCount; i++ { + err := service.DefaultIntegerVariable(i) + if err != nil { + return err + } + } + return nil + })) +} + +// IntegerVariableOverride returns true if project-specific details are stored for given variable index. +func (service GameStateService) IntegerVariableOverride(index int) bool { + _, perProject := service.integerVariables[index] + return perProject +} + +// IntegerVariable returns the variable info as per project settings for the given index. +func (service GameStateService) IntegerVariable(index int) archive.GameVariableInfo { + baseInfo := service.baseInfoProvider().IntegerVariable(index) + projInfo, perProject := service.integerVariables[index] + if perProject { + projInfo.Hardcoded = baseInfo.Hardcoded + return projInfo + } + return baseInfo +} + +// SetIntegerVariable sets the variable info for the given index in the project settings. +func (service *GameStateService) SetIntegerVariable(index int, info archive.GameVariableInfo) error { + return service.registry.Register(cmd.Named("SetIntegerVariable"), + cmd.Forward(setVariableTask(service.integerVariables, index, info)), + cmd.Reverse(restoreVariableTask(service.integerVariables, index))) +} + +// DefaultIntegerVariable clears the variable info for the given index in the project settings. +func (service *GameStateService) DefaultIntegerVariable(index int) error { + return service.registry.Register(cmd.Named("DefaultIntegerVariable"), + cmd.Forward(deleteVariableTask(service.integerVariables, index)), + cmd.Reverse(restoreVariableTask(service.integerVariables, index))) +} + +// BooleanVariableOverride returns true if project-specific details are stored for given variable index. +func (service GameStateService) BooleanVariableOverride(index int) bool { + _, perProject := service.booleanVariables[index] + return perProject +} + +// BooleanVariable returns the variable info as per project settings for the given index. +func (service GameStateService) BooleanVariable(index int) archive.GameVariableInfo { + baseInfo := service.baseInfoProvider().BooleanVariable(index) + projInfo, perProject := service.booleanVariables[index] + if perProject { + projInfo.Hardcoded = baseInfo.Hardcoded + return projInfo + } + return baseInfo +} + +// SetBooleanVariable sets the variable info for the given index in the project settings. +func (service *GameStateService) SetBooleanVariable(index int, info archive.GameVariableInfo) error { + return service.registry.Register(cmd.Named("SetBooleanVariable"), + cmd.Forward(setVariableTask(service.booleanVariables, index, info)), + cmd.Reverse(restoreVariableTask(service.booleanVariables, index))) +} + +// DefaultBooleanVariable clears the variable info for the given index in the project settings. +func (service *GameStateService) DefaultBooleanVariable(index int) error { + return service.registry.Register(cmd.Named("DefaultBooleanVariable"), + cmd.Forward(deleteVariableTask(service.booleanVariables, index)), + cmd.Reverse(restoreVariableTask(service.booleanVariables, index))) +} + +func setVariableTask(variables archive.GameVariables, index int, info archive.GameVariableInfo) cmd.Task { + return func(modder world.Modder) error { + variables[index] = info + return nil + } +} + +func deleteVariableTask(variables archive.GameVariables, index int) cmd.Task { + return func(modder world.Modder) error { + delete(variables, index) + return nil + } +} + +func restoreVariableTask(variables archive.GameVariables, index int) cmd.Task { + info, present := variables[index] + if present { + return setVariableTask(variables, index, info) + } + return deleteVariableTask(variables, index) +} diff --git a/modding/hacked/ss1/edit/KnownResources.go b/modding/hacked/ss1/edit/KnownResources.go new file mode 100644 index 0000000..cb532ec --- /dev/null +++ b/modding/hacked/ss1/edit/KnownResources.go @@ -0,0 +1,55 @@ +package edit + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// TextInfo describes one kind of text. +type TextInfo struct { + ID resource.ID + Title string + + audioBase resource.ID +} + +// TextInfoList is a set of TextInfo. +type TextInfoList []TextInfo + +// ByID returns a text info for the given identifier. +func (list TextInfoList) ByID(id resource.ID) TextInfo { + for _, info := range list { + if info.ID == id { + return info + } + } + return TextInfo{ + ID: id, + Title: fmt.Sprintf("??? %v", id), + } +} + +// Title returns the title property of the identified info. +func (list TextInfoList) Title(id resource.ID) string { + return list.ByID(id).Title +} + +var knownTexts = TextInfoList{ + {ID: ids.TrapMessageTexts, Title: "Trap Messages", audioBase: ids.TrapMessagesAudioStart}, + {ID: ids.WordTexts, Title: "Words"}, + {ID: ids.LogCategoryTexts, Title: "Log Categories"}, + {ID: ids.VariousMessageTexts, Title: "Various Messages"}, + {ID: ids.ScreenMessageTexts, Title: "Screen Messages"}, + {ID: ids.InfoNodeMessageTexts, Title: "Info Node Message Texts (8/5/6)"}, + {ID: ids.AccessCardNameTexts, Title: "Access Card Names"}, + {ID: ids.DataletMessageTexts, Title: "Datalet Messages (8/5/8)"}, + {ID: ids.PaperTextsStart, Title: "Papers"}, + {ID: ids.PanelNameTexts, Title: "Panel Names"}, +} + +// KnownTexts returns a set of known texts. +func KnownTexts() TextInfoList { + return knownTexts +} diff --git a/modding/hacked/ss1/edit/LevelEditorService.go b/modding/hacked/ss1/edit/LevelEditorService.go new file mode 100644 index 0000000..ff90037 --- /dev/null +++ b/modding/hacked/ss1/edit/LevelEditorService.go @@ -0,0 +1,257 @@ +package edit + +import ( + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" +) + +// LevelEditorService provides level editing functionality based on the currently selected level and content. +type LevelEditorService struct { + registry cmd.Registry + + gameObjects *GameObjectsService + levels *EditableLevels + levelSelection *LevelSelectionService + + newObjectTriple object.Triple +} + +// NewLevelEditorService returns a new instance. +func NewLevelEditorService(registry cmd.Registry, + gameObjects *GameObjectsService, + levels *EditableLevels, + levelSelection *LevelSelectionService) *LevelEditorService { + return &LevelEditorService{ + registry: registry, + gameObjects: gameObjects, + levels: levels, + levelSelection: levelSelection, + } +} + +// IsReadOnly returns true if the currently selected level cannot be modified. +func (service *LevelEditorService) IsReadOnly() bool { + return service.levels.IsLevelReadOnly(service.levelSelection.CurrentLevelID()) +} + +// Level returns the currently selected level. +func (service *LevelEditorService) Level() *level.Level { + return service.levels.Level(service.levelSelection.CurrentLevelID()) +} + +// Tiles returns the list of currently selected tiles of the current level. +func (service *LevelEditorService) Tiles() []*level.TileMapEntry { + lvl := service.Level() + positions := service.levelSelection.CurrentSelectedTiles() + tiles := make([]*level.TileMapEntry, len(positions)) + for i, pos := range positions { + tiles[i] = lvl.Tile(pos) + } + return tiles +} + +// HasSelectedTiles returns true if Tiles() returns at least one entry. +func (service *LevelEditorService) HasSelectedTiles() bool { + return len(service.levelSelection.CurrentSelectedTiles()) > 0 +} + +// ClearTiles resets the selected tiles. +func (service *LevelEditorService) ClearTiles() error { + return service.ChangeTiles(func(tile *level.TileMapEntry) { + index := tile.FirstObjectIndex + tile.Reset() + tile.FirstObjectIndex = index + }) +} + +// ChangeTiles performs a modification on all currently selected tiles and commits these changes to the repository. +func (service *LevelEditorService) ChangeTiles(modifier func(*level.TileMapEntry)) error { + positions := service.levelSelection.CurrentSelectedTiles() + if len(positions) == 0 { + return nil + } + lvl := service.Level() + for _, pos := range positions { + modifier(lvl.Tile(pos)) + } + levelID := lvl.ID() + return service.registry.Register(cmd.Named("ChangeTiles"), + cmd.Forward(service.levelSelection.SetCurrentLevelIDTask(levelID)), + cmd.Forward(service.setSelectedTilesTask(positions)), + cmd.Nested(func() error { return service.levels.CommitLevelChanges(levelID) }), + cmd.Reverse(service.setSelectedTilesTask(positions)), + cmd.Reverse(service.levelSelection.SetCurrentLevelIDTask(levelID)), + ) +} + +// Objects returns the list of currently selected objects of the current level. +func (service *LevelEditorService) Objects() []*level.ObjectMainEntry { + lvl := service.Level() + ids := service.levelSelection.CurrentSelectedObjects() + objects := make([]*level.ObjectMainEntry, len(ids)) + for i, id := range ids { + objects[i] = lvl.Object(id) + } + return objects +} + +// HasSelectedObjects returns true if a call to Objects() returns at least one item. +func (service *LevelEditorService) HasSelectedObjects() bool { + return len(service.levelSelection.CurrentSelectedObjects()) > 0 +} + +// NewObjectTriple returns the identifier of implicitly created objects. +func (service *LevelEditorService) NewObjectTriple() object.Triple { + return service.newObjectTriple +} + +// SetNewObjectTriple sets the identifier for implicitly created objects. +func (service *LevelEditorService) SetNewObjectTriple(value object.Triple) { + service.newObjectTriple = value +} + +// CreateNewObject adds a new object to the level and selects it. +func (service *LevelEditorService) CreateNewObject(triple object.Triple, modifier level.ObjectMainEntryModifier) error { + lvl := service.Level() + id, err := lvl.NewObject(triple.Class) + if err != nil { + return err + } + obj := lvl.Object(id) + obj.Subclass = triple.Subclass + obj.Type = triple.Type + service.resetHitpointsToDefault(obj) + modifier(obj) + service.placeObject(lvl, obj, service.atFloorLevelIn(lvl)) + lvl.UpdateObjectLocation(id) + return service.patchLevelObjects(lvl, service.levelSelection.CurrentSelectedObjects(), []level.ObjectID{id}) +} + +// PlaceObjectsOnFloor puts all selected objects to sit on the floor. +func (service *LevelEditorService) PlaceObjectsOnFloor() error { + lvl := service.Level() + atHeight := service.atFloorLevelIn(lvl) + return service.ChangeObjects(func(obj *level.ObjectMainEntry) { service.placeObject(lvl, obj, atHeight) }) +} + +// PlaceObjectsOnEyeLevel puts all selected objects to be at eye level (approximately). +func (service *LevelEditorService) PlaceObjectsOnEyeLevel() error { + lvl := service.Level() + atHeight := service.atEyeLevelIn(lvl) + return service.ChangeObjects(func(obj *level.ObjectMainEntry) { service.placeObject(lvl, obj, atHeight) }) +} + +// PlaceObjectsOnCeiling puts all selected objects to hang from the ceiling. +func (service *LevelEditorService) PlaceObjectsOnCeiling() error { + lvl := service.Level() + atHeight := service.atCeilingLevelIn(lvl) + return service.ChangeObjects(func(obj *level.ObjectMainEntry) { service.placeObject(lvl, obj, atHeight) }) +} + +// ChangeObjects modifies properties of selected objects. The modifier is called for each selected object. +func (service *LevelEditorService) ChangeObjects(modifier level.ObjectMainEntryModifier) error { + objectIDs := service.levelSelection.CurrentSelectedObjects() + if len(objectIDs) == 0 { + return nil + } + lvl := service.Level() + for _, id := range objectIDs { + obj := lvl.Object(id) + oldPosition := obj.TilePosition() + modifier(obj) + if oldPosition != obj.TilePosition() { + lvl.UpdateObjectLocation(id) + } + } + return service.patchLevelObjects(lvl, objectIDs, objectIDs) +} + +// DeleteObjects deletes all currently selected objects, clearing the selection afterwards. +func (service *LevelEditorService) DeleteObjects() error { + objectIDs := service.levelSelection.CurrentSelectedObjects() + if len(objectIDs) == 0 { + return nil + } + lvl := service.Level() + for _, id := range objectIDs { + lvl.DelObject(id) + } + return service.patchLevelObjects(lvl, objectIDs, nil) +} + +func (service *LevelEditorService) patchLevelObjects( + lvl *level.Level, + reverseObjectIDs []level.ObjectID, + forwardObjectIDs []level.ObjectID) error { + levelID := lvl.ID() + + return service.registry.Register(cmd.Named("PatchLevelObjects"), + cmd.Forward(service.levelSelection.SetCurrentLevelIDTask(levelID)), + cmd.Reverse(service.setSelectedObjectsTask(reverseObjectIDs)), + cmd.Nested(func() error { return service.levels.CommitLevelChanges(levelID) }), + cmd.Forward(service.setSelectedObjectsTask(forwardObjectIDs)), + cmd.Reverse(service.levelSelection.SetCurrentLevelIDTask(levelID)), + ) +} + +func (service *LevelEditorService) setSelectedTilesTask(positions []level.TilePosition) cmd.Task { + return func(world.Modder) error { + service.levelSelection.SetCurrentSelectedTiles(positions) + return nil + } +} + +func (service *LevelEditorService) setSelectedObjectsTask(ids []level.ObjectID) cmd.Task { + return func(world.Modder) error { + service.levelSelection.SetCurrentSelectedObjects(ids) + return nil + } +} + +type heightCalculatorFunc func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit + +func (service *LevelEditorService) placeObject(lvl *level.Level, obj *level.ObjectMainEntry, atHeight heightCalculatorFunc) { + var objPivot float32 + prop, err := service.gameObjects.PropertiesFor(obj.Triple()) + if err == nil { + objPivot = object.Pivot(prop.Common) + } + tile := lvl.Tile(obj.TilePosition()) + if tile != nil { + obj.Z = atHeight(tile, obj.FinePosition(), objPivot) + } +} + +func (service *LevelEditorService) atFloorLevelIn(lvl *level.Level) heightCalculatorFunc { + _, _, height := lvl.Size() + return func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit { + floorHeight := tile.FloorTileHeightAt(pos, height) + return height.ValueToObjectHeight(floorHeight + objPivot) + } +} + +func (service *LevelEditorService) atEyeLevelIn(lvl *level.Level) heightCalculatorFunc { + _, _, height := lvl.Size() + return func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit { + floorHeight := tile.FloorTileHeightAt(pos, height) + return height.ValueToObjectHeight(floorHeight + 0.75 - objPivot) + } +} + +func (service *LevelEditorService) atCeilingLevelIn(lvl *level.Level) heightCalculatorFunc { + _, _, height := lvl.Size() + return func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit { + floorHeight := tile.CeilingTileHeightAt(pos, height) + return height.ValueToObjectHeight(floorHeight - objPivot) + } +} + +func (service *LevelEditorService) resetHitpointsToDefault(obj *level.ObjectMainEntry) { + prop, err := service.gameObjects.PropertiesFor(obj.Triple()) + if err != nil { + return + } + obj.Hitpoints = prop.Common.Hitpoints +} diff --git a/modding/hacked/ss1/edit/LevelSelectionService.go b/modding/hacked/ss1/edit/LevelSelectionService.go new file mode 100644 index 0000000..5593c8a --- /dev/null +++ b/modding/hacked/ss1/edit/LevelSelectionService.go @@ -0,0 +1,231 @@ +package edit + +import ( + "sort" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" +) + +type levelSelection struct { + tiles map[level.TilePosition]struct{} + objects map[level.ObjectID]struct{} +} + +func (selection levelSelection) tileList() []level.TilePosition { + list := make([]level.TilePosition, 0, len(selection.tiles)) + for pos := range selection.tiles { + list = append(list, pos) + } + sort.Slice(list, func(a, b int) bool { + posA := list[a] + posB := list[b] + return (posA.Y < posB.Y) || ((posA.Y == posB.Y) && (posA.X < posB.X)) + }) + return list +} + +func (selection levelSelection) objectList() []level.ObjectID { + list := make([]level.ObjectID, 0, len(selection.objects)) + for id := range selection.objects { + list = append(list, id) + } + sort.Slice(list, func(a, b int) bool { return list[a] < list[b] }) + return list +} + +// LevelInfoProvider returns details on level content. +type LevelInfoProvider interface { + IsLevelAvailable(index int) bool + IsObjectInUse(levelIndex int, id level.ObjectID) bool + IsTileOnMap(levelIndex int, pos level.TilePosition) bool +} + +// LevelSelectionService keeps track of selections related to levels. +type LevelSelectionService struct { + provider LevelInfoProvider + + currentLevel int + + levels map[int]*levelSelection +} + +// NewLevelSelectionService returns a new instance. +func NewLevelSelectionService(provider LevelInfoProvider) *LevelSelectionService { + return &LevelSelectionService{ + provider: provider, + currentLevel: 0, + levels: make(map[int]*levelSelection), + } +} + +// CurrentLevelID returns the identifier for the currently selected level. +func (service *LevelSelectionService) CurrentLevelID() int { + if !service.provider.IsLevelAvailable(service.currentLevel) { + return 0 + } + return service.currentLevel +} + +// SetCurrentLevelID sets the currently selected level. +func (service *LevelSelectionService) SetCurrentLevelID(index int) { + service.currentLevel = index +} + +// SetCurrentLevelIDTask returns a command task that will set the current level. +func (service *LevelSelectionService) SetCurrentLevelIDTask(index int) cmd.Task { + return func(modder world.Modder) error { + service.SetCurrentLevelID(index) + return nil + } +} + +func (service *LevelSelectionService) currentSelection() levelSelection { + selection, ok := service.levels[service.currentLevel] + if !ok { + return levelSelection{} + } + return *service.cleanSelection(service.currentLevel, selection) +} + +func (service *LevelSelectionService) modifiableSelection() *levelSelection { + selection, ok := service.levels[service.currentLevel] + if !ok { + selection = &levelSelection{ + tiles: make(map[level.TilePosition]struct{}), + objects: make(map[level.ObjectID]struct{}), + } + service.levels[service.currentLevel] = selection + } + return service.cleanSelection(service.currentLevel, selection) +} + +func (service *LevelSelectionService) cleanSelection(levelIndex int, selection *levelSelection) *levelSelection { + return service.cleanSelectionTiles(levelIndex, service.cleanSelectionObjects(levelIndex, selection)) +} + +func (service *LevelSelectionService) cleanSelectionTiles(levelIndex int, selection *levelSelection) *levelSelection { + var toDelete []level.TilePosition + for pos := range selection.tiles { + if !service.provider.IsTileOnMap(levelIndex, pos) { + toDelete = append(toDelete, pos) + } + } + for _, pos := range toDelete { + delete(selection.tiles, pos) + } + return selection +} + +func (service *LevelSelectionService) cleanSelectionObjects(levelIndex int, selection *levelSelection) *levelSelection { + var toDelete []level.ObjectID + for id := range selection.objects { + if !service.provider.IsObjectInUse(levelIndex, id) { + toDelete = append(toDelete, id) + } + } + for _, id := range toDelete { + delete(selection.objects, id) + } + return selection +} + +// IsTileSelected returns true if the given tile is currently selected. +func (service *LevelSelectionService) IsTileSelected(pos level.TilePosition) bool { + _, selected := service.currentSelection().tiles[pos] + return selected +} + +// CurrentSelectedTiles returns the list of currently selected tiles in the current level. +func (service *LevelSelectionService) CurrentSelectedTiles() []level.TilePosition { + return service.currentSelection().tileList() +} + +// SetCurrentSelectedTiles sets the list of currently selected tiles in the current level. +func (service *LevelSelectionService) SetCurrentSelectedTiles(list []level.TilePosition) { + selection := service.modifiableSelection() + for pos := range selection.tiles { + delete(selection.tiles, pos) + } + for _, pos := range list { + selection.tiles[pos] = struct{}{} + } +} + +// AddCurrentSelectedTiles adds the given tile IDs to the list of currently selected in the current level. +func (service *LevelSelectionService) AddCurrentSelectedTiles(list []level.TilePosition) { + selection := service.modifiableSelection() + for _, pos := range list { + selection.tiles[pos] = struct{}{} + } +} + +// RemoveCurrentSelectedTiles removes the given tile IDs from the list of currently selected in the current level. +func (service *LevelSelectionService) RemoveCurrentSelectedTiles(list []level.TilePosition) { + selection := service.modifiableSelection() + for _, pos := range list { + delete(selection.tiles, pos) + } +} + +// ToggleTileSelection toggles the selection of the given tile IDs in the current level. +func (service *LevelSelectionService) ToggleTileSelection(list []level.TilePosition) { + selection := service.modifiableSelection() + for _, pos := range list { + if _, selected := selection.tiles[pos]; selected { + delete(selection.tiles, pos) + } else { + selection.tiles[pos] = struct{}{} + } + } +} + +// NumberOfSelectedObjects returns the number of currently selected objects in the current level. +func (service *LevelSelectionService) NumberOfSelectedObjects() int { + return len(service.currentSelection().objects) +} + +// CurrentSelectedObjects returns the list of currently selected objects in the current level. +func (service *LevelSelectionService) CurrentSelectedObjects() []level.ObjectID { + return service.currentSelection().objectList() +} + +// SetCurrentSelectedObjects sets the list of currently selected objects in the current level. +func (service *LevelSelectionService) SetCurrentSelectedObjects(ids []level.ObjectID) { + selection := service.modifiableSelection() + for id := range selection.objects { + delete(selection.objects, id) + } + for _, id := range ids { + selection.objects[id] = struct{}{} + } +} + +// AddCurrentSelectedObjects adds the given object IDs to the list of currently selected in the current level. +func (service *LevelSelectionService) AddCurrentSelectedObjects(ids []level.ObjectID) { + selection := service.modifiableSelection() + for _, id := range ids { + selection.objects[id] = struct{}{} + } +} + +// RemoveCurrentSelectedObjects removes the given object IDs from the list of currently selected in the current level. +func (service *LevelSelectionService) RemoveCurrentSelectedObjects(ids []level.ObjectID) { + selection := service.modifiableSelection() + for _, id := range ids { + delete(selection.objects, id) + } +} + +// ToggleObjectSelection toggles the selection of the given object IDs in the current level. +func (service *LevelSelectionService) ToggleObjectSelection(ids []level.ObjectID) { + selection := service.modifiableSelection() + for _, id := range ids { + if _, selected := selection.objects[id]; selected { + delete(selection.objects, id) + } else { + selection.objects[id] = struct{}{} + } + } +} diff --git a/modding/hacked/ss1/edit/MovieService.go b/modding/hacked/ss1/edit/MovieService.go new file mode 100644 index 0000000..36ba818 --- /dev/null +++ b/modding/hacked/ss1/edit/MovieService.go @@ -0,0 +1,155 @@ +package edit + +import ( + "time" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/edit/media" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// MovieService provides read/write functionality. +type MovieService struct { + cp text.Codepage + + movieViewer media.MovieViewerService + movieSetter media.MovieSetterService +} + +// NewMovieService returns a new instance based on given accessor. +func NewMovieService(codepage text.Codepage, + movieViewer media.MovieViewerService, movieSetter media.MovieSetterService) MovieService { + return MovieService{ + cp: codepage, + + movieViewer: movieViewer, + movieSetter: movieSetter, + } +} + +// RestoreFunc creates a snapshot of the current movie and returns a function to restore it. +func (service MovieService) RestoreFunc(key resource.Key) func(setter media.MovieBlockSetter) { + oldContainer, _ := service.movieViewer.Container(key) + isModified := service.movieViewer.Modified(key) + + return func(setter media.MovieBlockSetter) { + if isModified { + service.movieSetter.Set(setter, key, oldContainer) + } else { + service.movieSetter.Remove(setter, key) + } + } +} + +// Remove erases the movie from the resources. +func (service MovieService) Remove(setter media.MovieBlockSetter, key resource.Key) { + service.movieSetter.Remove(setter, key) +} + +// SizeWarning returns true if the given movie is larger than the underlying archive would support. +func (service MovieService) SizeWarning(key resource.Key) bool { + return service.movieViewer.SizeWarning(key) +} + +// Video returns the video component of identified movie. +func (service MovieService) Video(key resource.Key) []movie.Scene { + return service.movieViewer.Video(key) +} + +// MoveSceneEarlier moves the given scene one step earlier. +func (service MovieService) MoveSceneEarlier(setter media.MovieBlockSetter, key resource.Key, scene int) { + service.swapScenes(setter, key, scene, scene-1) +} + +// MoveSceneLater moves the given scene one step later. +func (service MovieService) MoveSceneLater(setter media.MovieBlockSetter, key resource.Key, scene int) { + service.swapScenes(setter, key, scene, scene+1) +} + +func (service MovieService) swapScenes(setter media.MovieBlockSetter, key resource.Key, sceneA, sceneB int) { + baseContainer := service.getBaseContainer(key) + if (sceneA < 0) || (sceneA >= (len(baseContainer.Video.Scenes))) { + return + } + if (sceneB < 0) || (sceneB >= (len(baseContainer.Video.Scenes))) { + return + } + scenes := make([]movie.HighResScene, len(baseContainer.Video.Scenes)) + copy(scenes, baseContainer.Video.Scenes) + scenes[sceneA] = baseContainer.Video.Scenes[sceneB] + scenes[sceneB] = baseContainer.Video.Scenes[sceneA] + baseContainer.Video.Scenes = scenes + service.movieSetter.Set(setter, key, baseContainer) +} + +// AddScene adds the given scene at the end of the movie. +func (service MovieService) AddScene(setter media.MovieBlockSetter, key resource.Key, scene movie.HighResScene) { + baseContainer := service.getBaseContainer(key) + baseContainer.Video.Scenes = append(baseContainer.Video.Scenes, scene) + service.movieSetter.Set(setter, key, baseContainer) +} + +// RemoveScene cuts out the given scene from the movie. +func (service MovieService) RemoveScene(setter media.MovieBlockSetter, key resource.Key, scene int) { + baseContainer := service.getBaseContainer(key) + if (scene < 0) || (scene >= len(baseContainer.Video.Scenes)) { + return + } + scenes := make([]movie.HighResScene, len(baseContainer.Video.Scenes)-1) + copy(scenes[0:scene], baseContainer.Video.Scenes[0:scene]) + copy(scenes[scene:], baseContainer.Video.Scenes[scene+1:]) + baseContainer.Video.Scenes = scenes + service.movieSetter.Set(setter, key, baseContainer) +} + +// SetSceneFramesDisplayTime sets the display time for each frame. +func (service MovieService) SetSceneFramesDisplayTime(setter media.MovieBlockSetter, key resource.Key, + scene int, displayTime time.Duration) { + baseContainer := service.getBaseContainer(key) + if (scene < 0) || (scene >= len(baseContainer.Video.Scenes)) { + return + } + baseContainer.Video.Scenes[scene] = baseContainer.Video.Scenes[scene].WithFrameDisplayTime(displayTime) + service.movieSetter.Set(setter, key, baseContainer) +} + +// Audio returns the audio component of identified movie. +func (service MovieService) Audio(key resource.Key) audio.L8 { + return service.movieViewer.Audio(key) +} + +// SetAudio sets the audio component of identified movie. +func (service MovieService) SetAudio(setter media.MovieBlockSetter, key resource.Key, soundData audio.L8) { + baseContainer := service.getBaseContainer(key) + baseContainer.Audio.Sound = soundData + service.movieSetter.Set(setter, key, baseContainer) +} + +// Subtitles returns the subtitles associated with the given key. +func (service MovieService) Subtitles(key resource.Key, language resource.Language) movie.SubtitleList { + return service.movieViewer.Subtitles(key, language) +} + +// SetSubtitles sets the subtitles of identified movie in given language. +func (service MovieService) SetSubtitles(setter media.MovieBlockSetter, key resource.Key, + language resource.Language, subtitles movie.SubtitleList) { + baseContainer := service.getBaseContainer(key) + baseContainer.Subtitles.PerLanguage[language] = subtitles + service.movieSetter.Set(setter, key, baseContainer) +} + +func (service MovieService) getBaseContainer(key resource.Key) movie.Container { + container, err := service.movieViewer.Container(key) + if err != nil { + container = movie.Container{ + Audio: movie.Audio{Sound: audio.L8{SampleRate: 22050}}, + Video: movie.Video{ + Width: movie.HighResDefaultWidth, + Height: movie.HighResDefaultHeight, + }, + } + } + return container +} diff --git a/modding/hacked/ss1/edit/ProjectService.go b/modding/hacked/ss1/edit/ProjectService.go new file mode 100644 index 0000000..4c536fb --- /dev/null +++ b/modding/hacked/ss1/edit/ProjectService.go @@ -0,0 +1,394 @@ +package edit + +import ( + "bytes" + "os" + "path/filepath" + "time" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/serial" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +const ( + errNoResourcesFound ss1.StringError = "no resources found" + errNoStorageLocationSet ss1.StringError = "no storage location set" +) + +// ProjectSettings describe the properties of a project. +type ProjectSettings struct { + ModFiles []string + Manifest []ManifestEntrySettings +} + +// ManifestEntrySettings describe the properties of one manifest entry in a project. +type ManifestEntrySettings struct { + Origin []string +} + +const autosaveTimeoutSec = 5 + +// SaveStatus describes the current change state. +type SaveStatus struct { + mod *world.Mod + // FilesModified is the count of how many files are affected by the current pending change. + FilesModified int + // SavePending is set if the mod has a storage location and changes are to be saved. + SavePending bool + // SaveIn is the duration after which an auto-save should happen. + SaveIn time.Duration +} + +// ConfirmPendingSave marks that the recent auto-save status has been acknowledged and should no longer be notified. +// A new change will re-start the auto-save timer. +func (status SaveStatus) ConfirmPendingSave() { + if status.mod == nil { + return + } + status.mod.ResetLastChangeTime() +} + +// ProjectService handles the overall information about the active project. +type ProjectService struct { + commander cmd.Registry + + mod *world.Mod + modPath string + + stateFilename string +} + +// NewProjectService returns a new instance of a service for given mod. +func NewProjectService(commander cmd.Registry, mod *world.Mod) *ProjectService { + return &ProjectService{ + commander: commander, + mod: mod, + } +} + +// SaveStatus returns the current pending save information. +func (service ProjectService) SaveStatus() SaveStatus { + var status SaveStatus + status.mod = service.mod + status.FilesModified = len(service.mod.ModifiedFilenames()) + if status.FilesModified > 0 { + lastChangeTime := service.mod.LastChangeTime() + + if service.ModHasStorageLocation() && !lastChangeTime.IsZero() { + status.SavePending = true + saveAt := lastChangeTime.Add(time.Duration(autosaveTimeoutSec) * time.Second) + status.SaveIn = time.Until(saveAt) + if status.SaveIn <= 0 { + status.SaveIn = 0 + } + } + } + return status +} + +// CurrentStateFilename returns the name for the state of the project. +func (service ProjectService) CurrentStateFilename() string { + return service.stateFilename +} + +// SetCurrentStateFilename updates the current filename. +func (service *ProjectService) SetCurrentStateFilename(value string) { + service.stateFilename = value +} + +// CurrentSettings returns the snapshot of the project. +func (service ProjectService) CurrentSettings() ProjectSettings { + manifest := service.mod.World() + settings := ProjectSettings{Manifest: make([]ManifestEntrySettings, manifest.EntryCount())} + for i := 0; i < manifest.EntryCount(); i++ { + entry, _ := manifest.Entry(i) + settings.Manifest[i].Origin = entry.Origin + } + + settings.ModFiles = service.relativeToSettings(service.mod.AllAbsoluteFilenames(service.modPath)...) + + return settings +} + +func (service *ProjectService) relativeToSettings(filenames ...string) []string { + loc := world.FileLocationFrom(service.stateFilename) + relatives := make([]string, 0, len(filenames)) + for _, filename := range filenames { + relatives = append(relatives, world.FileLocationFrom(filename).NestedRelativeTo(loc.DirPath)) + } + return relatives +} + +func (service *ProjectService) absoluteFromSettings(filenames ...string) []string { + loc := world.FileLocationFrom(service.stateFilename) + absolutes := make([]string, 0, len(filenames)) + for _, filename := range filenames { + absolutes = append(absolutes, world.FileLocationFrom(filename).AbsolutePathFrom(loc.DirPath)) + } + return absolutes +} + +// RestoreProject sets internal data based on the given settings. +func (service *ProjectService) RestoreProject(settings ProjectSettings, stateFilename string) { + service.ResetProject() + + service.stateFilename = stateFilename + + manifest := service.mod.World() + for _, entrySettings := range settings.Manifest { + entry, err := world.NewManifestEntryFrom(entrySettings.Origin) + if err != nil { + continue + } + err = manifest.InsertEntry(manifest.EntryCount(), entry) + if err != nil { + continue + } + } + + _ = service.TryLoadModFrom(service.absoluteFromSettings(settings.ModFiles...)) +} + +// ResetProject clears the project and returns it to initial state. +func (service *ProjectService) ResetProject() { + service.setActiveMod("", nil, nil, nil) + service.mod.World().Reset() + service.stateFilename = "" +} + +// AddManifestEntry attempts to insert the given manifest entry at given index. +func (service *ProjectService) AddManifestEntry(at int, entry *world.ManifestEntry) error { + return service.commander.Register( + cmd.Named("AddManifestEntry"), + cmd.Forward(func(modder world.Modder) error { + return service.mod.World().InsertEntry(at, entry) + }), + cmd.Reverse(func(modder world.Modder) error { + return service.mod.World().RemoveEntry(at) + }), + ) +} + +// RemoveManifestEntry attempts to remove the manifest entry at given index. +func (service *ProjectService) RemoveManifestEntry(at int) error { + manifest := service.mod.World() + entry, err := manifest.Entry(at) + if err != nil { + return err + } + return service.commander.Register( + cmd.Named("RemoveManifestEntry"), + cmd.Forward(func(modder world.Modder) error { + return manifest.RemoveEntry(at) + }), + cmd.Reverse(func(modder world.Modder) error { + return manifest.InsertEntry(at, entry) + }), + ) +} + +// MoveManifestEntry attempts to remove the manifest entry at given from index and re-insert it at given to index. +func (service *ProjectService) MoveManifestEntry(to, from int) error { + return service.commander.Register( + cmd.Named("MoveManifestEntry"), + cmd.Forward(func(modder world.Modder) error { + return service.mod.World().MoveEntry(to, from) + }), + cmd.Reverse(func(modder world.Modder) error { + return service.mod.World().MoveEntry(from, to) + }), + ) +} + +// Mod returns the currently active mod in the project. +func (service ProjectService) Mod() *world.Mod { + return service.mod +} + +// NewMod resets the mod to a new state. +func (service *ProjectService) NewMod() { + service.setActiveMod("", nil, nil, nil) +} + +// TryLoadModFrom attempts to set the active mod from given filenames. +func (service *ProjectService) TryLoadModFrom(names []string) error { + loaded := world.LoadFiles(false, names) + + resourcesToTake := loaded.Resources + isSavegame := false + if (len(resourcesToTake) == 0) && (len(loaded.Savegames) == 1) { + resourcesToTake = loaded.Savegames + isSavegame = true + } + if len(resourcesToTake) == 0 { + return errNoResourcesFound + } + var locs []*world.LocalizedResources + modPath := "" + + for location := range resourcesToTake { + if (len(modPath) == 0) || (len(location.DirPath) < len(modPath)) { + modPath = location.DirPath + } + } + + for location, viewer := range resourcesToTake { + lang := ids.LocalizeFilename(location.Name) + template := location.Name + if isSavegame { + template = string(ids.Archive) + } + loc := &world.LocalizedResources{ + File: location, + Template: template, + Language: lang, + } + for _, id := range viewer.IDs() { + view, err := viewer.View(id) + if err != nil { + // optimistic ignore + continue + } + _ = loc.Store.Put(id, view) + } + locs = append(locs, loc) + } + + service.setActiveMod(modPath, locs, loaded.ObjectProperties, loaded.TextureProperties) + return nil +} + +func (service *ProjectService) setActiveMod(modPath string, resources []*world.LocalizedResources, + objectProperties object.PropertiesTable, textureProperties texture.PropertiesList) { + service.setModPath(modPath) + service.mod.Reset(resources, objectProperties, textureProperties) + // fix list resources for any "old" mod. + service.mod.FixListResources() +} + +// ModifyModWith runs a function with the intent to alter the current mod. +func (service *ProjectService) ModifyModWith(modifier func(world.Modder) error) (err error) { + service.mod.Modify(func(modder world.Modder) { + err = modifier(modder) + }) + return +} + +// ModHasStorageLocation returns whether the mod has a place to be stored. +func (service ProjectService) ModHasStorageLocation() bool { + return len(service.modPath) > 0 +} + +// ModPath returns the base path of the mod in the project. +func (service ProjectService) ModPath() string { + return service.modPath +} + +func (service *ProjectService) setModPath(value string) { + service.modPath = value +} + +// SaveMod will store the currently active mod in its current path. +func (service *ProjectService) SaveMod() error { + if !service.ModHasStorageLocation() { + return errNoStorageLocationSet + } + return service.SaveModUnder(service.modPath) +} + +// SaveModUnder will store the currently active mod in the given path. +func (service *ProjectService) SaveModUnder(modPath string) error { + service.mod.FixListResources() + err := service.saveModResourcesTo(modPath) + if err != nil { + return err + } + service.setModPath(modPath) + service.mod.MarkSave() + return nil +} + +func (service *ProjectService) saveModResourcesTo(modPath string) error { + localized := service.mod.ModifiedResources() + filenamesToSave := service.mod.ModifiedFilenames() + + shallBeSaved := func(filename string) bool { + for _, toSave := range filenamesToSave { + if toSave == filename { + return true + } + } + return false + } + + for _, loc := range localized { + if shallBeSaved(loc.File.Name) { + err := saveResourcesTo(loc.Store, loc.File.AbsolutePathFrom(modPath)) + if err != nil { + return err + } + } + } + + if shallBeSaved(world.TexturePropertiesFilename) { + err := saveTexturePropertiesTo(service.mod.TextureProperties(), filepath.Join(modPath, world.TexturePropertiesFilename)) + if err != nil { + return err + } + } + if shallBeSaved(world.ObjectPropertiesFilename) { + err := saveObjectPropertiesTo(service.mod.ObjectProperties(), filepath.Join(modPath, world.ObjectPropertiesFilename)) + if err != nil { + return err + } + } + + return nil +} + +func saveResourcesTo(viewer resource.Viewer, absFilename string) error { + file, err := os.Create(absFilename) + if err != nil { + return err + } + defer func() { + _ = file.Close() // nolint: gas + }() + err = lgres.Write(file, viewer) + return err +} + +func saveTexturePropertiesTo(list texture.PropertiesList, absFilename string) error { + return saveCodableTo(list, absFilename) +} + +func saveObjectPropertiesTo(list object.PropertiesTable, absFilename string) error { + return saveCodableTo(list, absFilename) +} + +func saveCodableTo(codable serial.Codable, absFilename string) error { + buffer := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buffer) + codable.Code(encoder) + err := encoder.FirstError() + if err != nil { + return err + } + + file, err := os.Create(absFilename) + if err != nil { + return err + } + defer func() { + _ = file.Close() // nolint: gas + }() + _, err = file.Write(buffer.Bytes()) + return err +} diff --git a/modding/hacked/ss1/edit/SoundEffectService.go b/modding/hacked/ss1/edit/SoundEffectService.go new file mode 100644 index 0000000..5fe0d4e --- /dev/null +++ b/modding/hacked/ss1/edit/SoundEffectService.go @@ -0,0 +1,61 @@ +package edit + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/edit/media" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// SoundEffectService provides read/write functionality. +type SoundEffectService struct { + viewer media.SoundEffectViewerService + setter media.SoundEffectSetterService +} + +// NewSoundEffectService returns a new instance based on given accessor. +func NewSoundEffectService( + viewer media.SoundEffectViewerService, setter media.SoundEffectSetterService) SoundEffectService { + return SoundEffectService{ + viewer: viewer, + setter: setter, + } +} + +// RestoreFunc creates a snapshot of the current sound and returns a function to restore it. +func (service SoundEffectService) RestoreFunc(key resource.Key) func(setter media.SoundEffectBlockSetter) { + oldAudio := service.viewer.Audio(key) + isModified := service.viewer.Modified(key) + + return func(setter media.SoundEffectBlockSetter) { + if isModified { + service.setter.Set(setter, key, oldAudio) + } else { + service.setter.Remove(setter, key) + } + } +} + +// Modified returns true if the identified sound resource is marked as modified. +func (service SoundEffectService) Modified(key resource.Key) bool { + return service.viewer.Modified(key) +} + +// Remove erases the sound from the resources. +func (service SoundEffectService) Remove(setter media.SoundEffectBlockSetter, key resource.Key) { + service.setter.Remove(setter, key) +} + +// Clear resets the identified audio resource to a silent one-sample audio. +func (service SoundEffectService) Clear(setter media.SoundEffectBlockSetter, key resource.Key) { + service.setter.Clear(setter, key) +} + +// Audio returns the audio component of identified sound effect. +func (service SoundEffectService) Audio(key resource.Key) audio.L8 { + return service.viewer.Audio(key) +} + +// SetAudio sets the audio component of identified sound effect. +func (service SoundEffectService) SetAudio(setter media.SoundEffectBlockSetter, key resource.Key, data audio.L8) { + service.setter.Set(setter, key, data) +} diff --git a/modding/hacked/ss1/edit/doc.go b/modding/hacked/ss1/edit/doc.go new file mode 100644 index 0000000..eb53660 --- /dev/null +++ b/modding/hacked/ss1/edit/doc.go @@ -0,0 +1,3 @@ +// Package edit contains logic for creating and updating game content. +// The services in this package modify resources directly. +package edit diff --git a/modding/hacked/ss1/edit/media/AudioSetterService.go b/modding/hacked/ss1/edit/media/AudioSetterService.go new file mode 100644 index 0000000..3a1d5de --- /dev/null +++ b/modding/hacked/ss1/edit/media/AudioSetterService.go @@ -0,0 +1,39 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// AudioBlockSetter modifies storage of raw resource data. +type AudioBlockSetter interface { + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + DelResource(lang resource.Language, id resource.ID) +} + +// AudioSetterService provides methods to change audio resources. +type AudioSetterService struct{} + +// NewAudioSetterService returns a new instance. +func NewAudioSetterService() AudioSetterService { + return AudioSetterService{} +} + +// Remove deletes any audio resource for given key. +func (service AudioSetterService) Remove(setter AudioBlockSetter, key resource.Key) { + setter.DelResource(key.Lang, key.ID) +} + +// Clear resets the identified audio resource to a silent one-sample audio. +func (service AudioSetterService) Clear(setter AudioBlockSetter, key resource.Key) { + silence := audio.L8{SampleRate: 22050, Samples: []byte{0x80}} + service.Set(setter, key, silence) +} + +// Set stores the given sound as the identified resource. +func (service AudioSetterService) Set(setter AudioBlockSetter, key resource.Key, sound audio.L8) { + movieData := movie.ContainSoundData(sound) + blockData := [][]byte{movieData} + setter.SetResourceBlocks(key.Lang, key.ID, blockData) +} diff --git a/modding/hacked/ss1/edit/media/AudioViewerService.go b/modding/hacked/ss1/edit/media/AudioViewerService.go new file mode 100644 index 0000000..7a1eabc --- /dev/null +++ b/modding/hacked/ss1/edit/media/AudioViewerService.go @@ -0,0 +1,38 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// AudioBlockGetter provides raw data of blocks. +type AudioBlockGetter interface { + ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte +} + +// AudioViewerService provides read-only access to audio resources. +type AudioViewerService struct { + movieCache *movie.Cache + getter AudioBlockGetter +} + +// NewAudioViewerService returns a new instance. +func NewAudioViewerService(movieCache *movie.Cache, getter AudioBlockGetter) AudioViewerService { + return AudioViewerService{ + movieCache: movieCache, + getter: getter, + } +} + +// Audio returns the sound data associated with the given key. +func (service AudioViewerService) Audio(key resource.Key) audio.L8 { + sound, _ := service.movieCache.Audio(key) + return sound +} + +// Modified returns true if the identified audio resource is marked as modified. +func (service AudioViewerService) Modified(key resource.Key) bool { + data := service.getter.ModifiedBlock(key.Lang, key.ID, 0) + return len(data) > 0 +} diff --git a/modding/hacked/ss1/edit/media/MovieSetterService.go b/modding/hacked/ss1/edit/media/MovieSetterService.go new file mode 100644 index 0000000..48b2812 --- /dev/null +++ b/modding/hacked/ss1/edit/media/MovieSetterService.go @@ -0,0 +1,37 @@ +package media + +import ( + "bytes" + + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// MovieBlockSetter modifies storage of raw resource data. +type MovieBlockSetter interface { + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + DelResource(lang resource.Language, id resource.ID) +} + +// MovieSetterService can be used to set movie data. +type MovieSetterService struct { + cp text.Codepage +} + +// NewMovieSetterService returns a new instance. +func NewMovieSetterService(cp text.Codepage) MovieSetterService { + return MovieSetterService{cp: cp} +} + +// Remove deletes any movie resource for given key. +func (service MovieSetterService) Remove(setter MovieBlockSetter, key resource.Key) { + setter.DelResource(key.Lang, key.ID) +} + +// Set exports the given container. +func (service MovieSetterService) Set(setter MovieBlockSetter, key resource.Key, container movie.Container) { + buf := bytes.NewBuffer(nil) + _ = movie.Write(buf, container, service.cp) + setter.SetResourceBlocks(key.Lang, key.ID, [][]byte{buf.Bytes()}) +} diff --git a/modding/hacked/ss1/edit/media/MovieViewerService.go b/modding/hacked/ss1/edit/media/MovieViewerService.go new file mode 100644 index 0000000..b0bf57c --- /dev/null +++ b/modding/hacked/ss1/edit/media/MovieViewerService.go @@ -0,0 +1,68 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// MovieBlockGetter provides raw data of blocks. +type MovieBlockGetter interface { + ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte +} + +// MovieViewerService provides read-only access to movie resources. +type MovieViewerService struct { + movieCache *movie.Cache + getter MovieBlockGetter +} + +// NewMovieViewerService returns a new instance. +func NewMovieViewerService(movieCache *movie.Cache, getter MovieBlockGetter) MovieViewerService { + return MovieViewerService{ + movieCache: movieCache, + getter: getter, + } +} + +// Modified returns true if the identified text resource is marked as modified. +func (service MovieViewerService) Modified(key resource.Key) bool { + return len(service.getter.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0 +} + +// SizeWarning returns true if the given movie is larger than the underlying archive would support. +func (service MovieViewerService) SizeWarning(key resource.Key) bool { + return service.movieCache.SizeWarning(key) +} + +// Container returns the base container of the movie. +func (service MovieViewerService) Container(key resource.Key) (movie.Container, error) { + return service.movieCache.Container(key) +} + +// Video returns the visual component associated with the given key. +func (service MovieViewerService) Video(key resource.Key) []movie.Scene { + currentValue, cacheErr := service.movieCache.Video(key) + if cacheErr != nil { + return nil + } + return currentValue +} + +// Audio returns the audio data associated with the given key. +func (service MovieViewerService) Audio(key resource.Key) audio.L8 { + currentValue, cacheErr := service.movieCache.Audio(key) + if cacheErr != nil { + return audio.L8{} + } + return currentValue +} + +// Subtitles returns the subtitles associated with the given key. +func (service MovieViewerService) Subtitles(key resource.Key, language resource.Language) movie.SubtitleList { + currentValue, cacheErr := service.movieCache.Subtitles(key, language) + if cacheErr != nil { + return movie.SubtitleList{} + } + return currentValue +} diff --git a/modding/hacked/ss1/edit/media/SoundEffectSetterService.go b/modding/hacked/ss1/edit/media/SoundEffectSetterService.go new file mode 100644 index 0000000..3297fa8 --- /dev/null +++ b/modding/hacked/ss1/edit/media/SoundEffectSetterService.go @@ -0,0 +1,42 @@ +package media + +import ( + "bytes" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/audio/voc" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// SoundEffectBlockSetter modifies storage of raw resource data. +type SoundEffectBlockSetter interface { + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + DelResource(lang resource.Language, id resource.ID) +} + +// SoundEffectSetterService provides methods to change sound resources. +type SoundEffectSetterService struct{} + +// NewSoundSetterService returns a new instance. +func NewSoundSetterService() SoundEffectSetterService { + return SoundEffectSetterService{} +} + +// Remove deletes any audio resource for given key. +func (service SoundEffectSetterService) Remove(setter SoundEffectBlockSetter, key resource.Key) { + setter.DelResource(key.Lang, key.ID.Plus(key.Index)) +} + +// Clear resets the identified audio resource to a silent one-sample audio. +func (service SoundEffectSetterService) Clear(setter SoundEffectBlockSetter, key resource.Key) { + silence := audio.L8{SampleRate: 22050, Samples: []byte{0x80}} + service.Set(setter, key, silence) +} + +// Set stores the given sound as the identified resource. +func (service SoundEffectSetterService) Set(setter SoundEffectBlockSetter, key resource.Key, data audio.L8) { + buf := bytes.NewBuffer(nil) + _ = voc.Save(buf, data.SampleRate, data.Samples) + blockData := [][]byte{buf.Bytes()} + setter.SetResourceBlocks(key.Lang, key.ID.Plus(key.Index), blockData) +} diff --git a/modding/hacked/ss1/edit/media/SoundEffectViewerService.go b/modding/hacked/ss1/edit/media/SoundEffectViewerService.go new file mode 100644 index 0000000..e71276b --- /dev/null +++ b/modding/hacked/ss1/edit/media/SoundEffectViewerService.go @@ -0,0 +1,38 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/sound" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// SoundEffectBlockGetter provides raw data of blocks. +type SoundEffectBlockGetter interface { + ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte +} + +// SoundEffectViewerService provides read-only access to audio resources. +type SoundEffectViewerService struct { + soundCache *sound.EffectCache + getter SoundEffectBlockGetter +} + +// NewSoundViewerService returns a new instance. +func NewSoundViewerService(soundCache *sound.EffectCache, getter SoundEffectBlockGetter) SoundEffectViewerService { + return SoundEffectViewerService{ + soundCache: soundCache, + getter: getter, + } +} + +// Audio returns the audio data associated with the given key. +func (service SoundEffectViewerService) Audio(key resource.Key) audio.L8 { + sound, _ := service.soundCache.Audio(key) + return sound +} + +// Modified returns true if the identified sound resource is marked as modified. +func (service SoundEffectViewerService) Modified(key resource.Key) bool { + data := service.getter.ModifiedBlock(key.Lang, key.ID.Plus(key.Index), 0) + return len(data) > 0 +} diff --git a/modding/hacked/ss1/edit/media/TextSetterService.go b/modding/hacked/ss1/edit/media/TextSetterService.go new file mode 100644 index 0000000..60f8f1e --- /dev/null +++ b/modding/hacked/ss1/edit/media/TextSetterService.go @@ -0,0 +1,59 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// TextBlockSetter modifies storage of raw resource data. +type TextBlockSetter interface { + SetResourceBlock(lang resource.Language, id resource.ID, index int, data []byte) + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + DelResource(lang resource.Language, id resource.ID) +} + +// TextSetterService provides methods to change text resources. +type TextSetterService struct { + cp text.Codepage +} + +// NewTextSetterService returns a new instance. +func NewTextSetterService(cp text.Codepage) TextSetterService { + return TextSetterService{ + cp: cp, + } +} + +// Remove deletes any text resource for given key. +func (service TextSetterService) Remove(setter TextBlockSetter, key resource.Key) { + info, _ := ids.Info(key.ID) + if info.List { + setter.SetResourceBlock(key.Lang, key.ID, key.Index, nil) + } else { + id := key.ID.Plus(key.Index) + setter.DelResource(key.Lang, id) + } +} + +// Clear resets the identified text resource to an empty string. +func (service TextSetterService) Clear(setter TextBlockSetter, key resource.Key) { + service.Set(setter, key, "") +} + +// Set stores the given text as the identified resource. +func (service TextSetterService) Set(setter TextBlockSetter, key resource.Key, value string) { + blockedValue := text.Blocked(value) + info, _ := ids.Info(key.ID) + if info.List { + newData := service.cp.Encode(blockedValue[0]) + setter.SetResourceBlock(key.Lang, key.ID, key.Index, newData) + } else { + newData := make([][]byte, len(blockedValue)) + for index, blockLine := range blockedValue { + newData[index] = service.cp.Encode(blockLine) + } + id := key.ID.Plus(key.Index) + setter.SetResourceBlocks(key.Lang, id, newData) + } +} diff --git a/modding/hacked/ss1/edit/media/TextViewerService.go b/modding/hacked/ss1/edit/media/TextViewerService.go new file mode 100644 index 0000000..44255d0 --- /dev/null +++ b/modding/hacked/ss1/edit/media/TextViewerService.go @@ -0,0 +1,54 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// TextBlockGetter provides raw data of blocks. +type TextBlockGetter interface { + ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte + ModifiedBlocks(lang resource.Language, id resource.ID) [][]byte +} + +// TextViewerService provides read-only access to text resources. +type TextViewerService struct { + lineCache *text.Cache + pageCache *text.Cache + getter TextBlockGetter +} + +// NewTextViewerService returns a new instance. +func NewTextViewerService(lineCache, pageCache *text.Cache, getter TextBlockGetter) TextViewerService { + return TextViewerService{ + lineCache: lineCache, + pageCache: pageCache, + getter: getter, + } +} + +// Text returns the text data associated with the given key. +func (service TextViewerService) Text(key resource.Key) string { + var cache *text.Cache + resourceInfo, existing := ids.Info(key.ID) + if !existing || resourceInfo.List { + cache = service.lineCache + } else { + cache = service.pageCache + } + currentValue, cacheErr := cache.Text(key) + if cacheErr != nil { + currentValue = "" + } + return currentValue +} + +// Modified returns true if the identified text resource is marked as modified. +func (service TextViewerService) Modified(key resource.Key) bool { + info, _ := ids.Info(key.ID) + if info.List { + return len(service.getter.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0 + } + return len(service.getter.ModifiedBlocks(key.Lang, key.ID.Plus(key.Index))) > 0 +} diff --git a/modding/hacked/ss1/edit/media/doc.go b/modding/hacked/ss1/edit/media/doc.go new file mode 100644 index 0000000..886b6d6 --- /dev/null +++ b/modding/hacked/ss1/edit/media/doc.go @@ -0,0 +1,2 @@ +// Package media contains low-level media accessors and modifier. +package media diff --git a/modding/hacked/ss1/edit/undoable/AugmentedText.go b/modding/hacked/ss1/edit/undoable/AugmentedText.go new file mode 100644 index 0000000..efcbb37 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/AugmentedText.go @@ -0,0 +1,91 @@ +package undoable + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "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" +) + +// AugmentedTextService provides read/write functionality with undo capability. +type AugmentedTextService struct { + wrapped edit.AugmentedTextService + commander cmd.Commander +} + +// NewAugmentedTextService returns a new instance of a service. +func NewAugmentedTextService(wrapped edit.AugmentedTextService, commander cmd.Commander) AugmentedTextService { + return AugmentedTextService{ + wrapped: wrapped, + commander: commander, + } +} + +// WithAudio returns true if the identified resource is one with an audio component. +func (service AugmentedTextService) WithAudio(key resource.Key) bool { + return service.wrapped.WithAudio(key) +} + +// Text returns the textual value of the identified text resource. +func (service AugmentedTextService) Text(key resource.Key) string { + return service.wrapped.Text(key) +} + +// RequestSetText queues the change to update the text. +func (service AugmentedTextService) RequestSetText(key resource.Key, value string, restoreFunc func()) { + service.requestCommand( + func(setter edit.AugmentedTextBlockSetter) { + service.wrapped.SetText(setter, key, value) + }, + service.wrapped.RestoreTextFunc(key), + restoreFunc) +} + +// Sound returns the audio value of the identified text resource. +// In case the text resource has no audio, an empty sound will be returned. +func (service AugmentedTextService) Sound(key resource.Key) audio.L8 { + return service.wrapped.Sound(key) +} + +// RequestSetSound queues the change to update the sound. +func (service AugmentedTextService) RequestSetSound(key resource.Key, sound audio.L8, restoreFunc func()) { + service.requestCommand( + func(setter edit.AugmentedTextBlockSetter) { + service.wrapped.SetSound(setter, key, sound) + }, + service.wrapped.RestoreSoundFunc(key), + restoreFunc) +} + +// RequestClear queues the change to set both the text and the sound empty. +func (service AugmentedTextService) RequestClear(key resource.Key, restoreFunc func()) { + service.requestCommand( + func(setter edit.AugmentedTextBlockSetter) { + service.wrapped.Clear(setter, key) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestRemove queues the change to remove both the text and the sound from the storage. +func (service AugmentedTextService) RequestRemove(key resource.Key, restoreFunc func()) { + service.requestCommand( + func(setter edit.AugmentedTextBlockSetter) { + service.wrapped.Remove(setter, key) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +func (service AugmentedTextService) requestCommand( + forward func(modder edit.AugmentedTextBlockSetter), + reverse func(modder edit.AugmentedTextBlockSetter), + restore func()) { + c := command{ + forward: func(modder world.Modder) { forward(modder) }, + reverse: func(modder world.Modder) { reverse(modder) }, + restore: restore, + } + service.commander.Queue(c) +} diff --git a/modding/hacked/ss1/edit/undoable/Command.go b/modding/hacked/ss1/edit/undoable/Command.go new file mode 100644 index 0000000..b0e81c4 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/Command.go @@ -0,0 +1,25 @@ +package undoable + +import ( + "github.com/inkyblackness/hacked/ss1/world" +) + +type command struct { + forward func(world.Modder) + reverse func(world.Modder) + restore func() +} + +func (c command) Do(modder world.Modder) error { + return c.perform(modder, c.forward) +} + +func (c command) Undo(modder world.Modder) error { + return c.perform(modder, c.reverse) +} + +func (c command) perform(modder world.Modder, callback func(setter world.Modder)) error { + callback(modder) + c.restore() + return nil +} diff --git a/modding/hacked/ss1/edit/undoable/MovieService.go b/modding/hacked/ss1/edit/undoable/MovieService.go new file mode 100644 index 0000000..0eee626 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/MovieService.go @@ -0,0 +1,131 @@ +package undoable + +import ( + "time" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/media" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +// MovieService provides read/write functionality with undo capability. +type MovieService struct { + wrapped edit.MovieService + commander cmd.Commander +} + +// NewMovieService returns a new instance of a service. +func NewMovieService(wrapped edit.MovieService, commander cmd.Commander) MovieService { + return MovieService{ + wrapped: wrapped, + commander: commander, + } +} + +// SizeWarning returns true if the given movie is larger than the underlying archive would support. +func (service MovieService) SizeWarning(key resource.Key) bool { + return service.wrapped.SizeWarning(key) +} + +// Video returns the video component of identified movie. +func (service MovieService) Video(key resource.Key) []movie.Scene { + return service.wrapped.Video(key) +} + +// RequestMoveSceneEarlier queues to move the identified scene earlier. +func (service MovieService) RequestMoveSceneEarlier(key resource.Key, scene int, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.MoveSceneEarlier(setter, key, scene) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestMoveSceneLater queues to move the identified scene later. +func (service MovieService) RequestMoveSceneLater(key resource.Key, scene int, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.MoveSceneLater(setter, key, scene) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestAddScene queues to add the given scene at the end of the movie. +func (service MovieService) RequestAddScene(key resource.Key, scene movie.HighResScene, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.AddScene(setter, key, scene) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestRemoveScene queues to remove the identified scene. +func (service MovieService) RequestRemoveScene(key resource.Key, scene int, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.RemoveScene(setter, key, scene) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestSetSceneFramesDisplayTime requests to set the display time for each frame. +func (service MovieService) RequestSetSceneFramesDisplayTime(key resource.Key, + scene int, displayTime time.Duration, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.SetSceneFramesDisplayTime(setter, key, scene, displayTime) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// Audio returns the audio component of identified movie. +func (service MovieService) Audio(key resource.Key) audio.L8 { + return service.wrapped.Audio(key) +} + +// RequestSetAudio queues the change to update the audio track. +func (service MovieService) RequestSetAudio(key resource.Key, soundData audio.L8, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.SetAudio(setter, key, soundData) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// Subtitles returns the subtitles associated with the given key. +func (service MovieService) Subtitles(key resource.Key, language resource.Language) movie.SubtitleList { + return service.wrapped.Subtitles(key, language) +} + +// RequestSetSubtitles queues the change to update subtitles. +func (service MovieService) RequestSetSubtitles(key resource.Key, + language resource.Language, subtitles movie.SubtitleList, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.SetSubtitles(setter, key, language, subtitles) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +func (service MovieService) requestCommand( + forward func(modder media.MovieBlockSetter), + reverse func(modder media.MovieBlockSetter), + restore func()) { + c := command{ + forward: func(modder world.Modder) { forward(modder) }, + reverse: func(modder world.Modder) { reverse(modder) }, + restore: restore, + } + service.commander.Queue(c) +} diff --git a/modding/hacked/ss1/edit/undoable/SoundEffectService.go b/modding/hacked/ss1/edit/undoable/SoundEffectService.go new file mode 100644 index 0000000..bc4ca2a --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/SoundEffectService.go @@ -0,0 +1,76 @@ +package undoable + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/media" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +// SoundEffectService provides read/write functionality with undo capability. +type SoundEffectService struct { + wrapped edit.SoundEffectService + commander cmd.Commander +} + +// NewSoundEffectService returns a new instance of a service. +func NewSoundEffectService(wrapped edit.SoundEffectService, commander cmd.Commander) SoundEffectService { + return SoundEffectService{ + wrapped: wrapped, + commander: commander, + } +} + +// Modified returns true if the identified sound resource is marked as modified. +func (service SoundEffectService) Modified(key resource.Key) bool { + return service.wrapped.Modified(key) +} + +// RequestRemove queues to erase the sound from the resources. +func (service SoundEffectService) RequestRemove(key resource.Key, restoreFunc func()) { + service.requestCommand( + func(setter media.SoundEffectBlockSetter) { + service.wrapped.Remove(setter, key) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestClear queues to reset the identified audio resource to a silent one-sample audio. +func (service SoundEffectService) RequestClear(key resource.Key, restoreFunc func()) { + service.requestCommand( + func(setter media.SoundEffectBlockSetter) { + service.wrapped.Clear(setter, key) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// Audio returns the audio component of identified sound effect. +func (service SoundEffectService) Audio(key resource.Key) audio.L8 { + return service.wrapped.Audio(key) +} + +// RequestSetAudio queues the change to update the audio. +func (service SoundEffectService) RequestSetAudio(key resource.Key, data audio.L8, restoreFunc func()) { + service.requestCommand( + func(setter media.SoundEffectBlockSetter) { + service.wrapped.SetAudio(setter, key, data) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +func (service SoundEffectService) requestCommand( + forward func(modder media.SoundEffectBlockSetter), + reverse func(modder media.SoundEffectBlockSetter), + restore func()) { + c := command{ + forward: func(modder world.Modder) { forward(modder) }, + reverse: func(modder world.Modder) { reverse(modder) }, + restore: restore, + } + service.commander.Queue(c) +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Command.go b/modding/hacked/ss1/edit/undoable/cmd/Command.go new file mode 100644 index 0000000..6b0d9ac --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Command.go @@ -0,0 +1,15 @@ +package cmd + +import "github.com/inkyblackness/hacked/ss1/world" + +// Command describes an action that can be performed, undone, and redone. +type Command interface { + // Do performs the action. + // An error is returned if the action could not be performed. + Do(modder world.Modder) error + + // Undo reverses its action to restore the environment to the previous state. + // An error is returned if the action could not be successfully undone. The + // environment may not be in the state as before in an error occurred. + Undo(modder world.Modder) error +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Commander.go b/modding/hacked/ss1/edit/undoable/cmd/Commander.go new file mode 100644 index 0000000..9a82bff --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Commander.go @@ -0,0 +1,6 @@ +package cmd + +// Commander tries to execute the given command. +type Commander interface { + Queue(command Command) +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/List.go b/modding/hacked/ss1/edit/undoable/cmd/List.go new file mode 100644 index 0000000..6379f16 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/List.go @@ -0,0 +1,30 @@ +package cmd + +import "github.com/inkyblackness/hacked/ss1/world" + +// List is a sequence of commands, which are executed as one step. +type List []Command + +// Do performs the entries in the list in ascending order. +// If an entry returns an error, the iteration is aborted and that error is returned. +func (list List) Do(modder world.Modder) error { + for _, entry := range list { + err := entry.Do(modder) + if err != nil { + return err + } + } + return nil +} + +// Undo performs the entries in the list in descending order. +// If an entry returns an error, the iteration is aborted and that error is returned. +func (list List) Undo(modder world.Modder) error { + for i := len(list) - 1; i >= 0; i-- { + err := list[i].Undo(modder) + if err != nil { + return err + } + } + return nil +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Stack.go b/modding/hacked/ss1/edit/undoable/cmd/Stack.go new file mode 100644 index 0000000..0eea84d --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Stack.go @@ -0,0 +1,100 @@ +package cmd + +import "github.com/inkyblackness/hacked/ss1/world" + +type stackEntry struct { + link *stackEntry + cmd Command +} + +// Stack describes a list of commands. The stack allows to sequentially +// undo and redo stacked commands. +// It essentially stores two lists: a list of commands to undo, and +// another of commands to redo. +// Modifying stack functions will panic if they are called while already in use. +type Stack struct { + lockedBy string + undoList *stackEntry + redoList *stackEntry +} + +// Perform executes the given command and puts it on the stack +// if the command was successful. +// This function also clears the list of commands to be redone. +func (stack *Stack) Perform(cmd Command, modder world.Modder) error { + stack.lock("Perform") + defer stack.unlock() + + err := cmd.Do(modder) + if err != nil { + return err + } + stack.undoList = &stackEntry{link: stack.undoList, cmd: cmd} + stack.redoList = nil + return nil +} + +// CanUndo returns true if there is at least one more command that can be undone. +func (stack *Stack) CanUndo() bool { + return stack.undoList != nil +} + +// Undo attempts to undo the previous command on the list. +// If there is no further command to undo, nothing happens. +// An error is returned if the command failed. In this case, the stack is +// unchanged and a further attempt to undo will try the same command again. +func (stack *Stack) Undo(modder world.Modder) error { + stack.lock("Undo") + defer stack.unlock() + + if stack.undoList == nil { + return nil + } + entry := stack.undoList + err := entry.cmd.Undo(modder) + if err != nil { + return err + } + stack.undoList = entry.link + entry.link = stack.redoList + stack.redoList = entry + return nil +} + +// CanRedo returns true if there is at least one more command that can be redone. +func (stack *Stack) CanRedo() bool { + return stack.redoList != nil +} + +// Redo attempts to perform the next command on the redo list. +// If there is no further command to redo, nothing happens. +// An error is returned if the command failed. In this case, the stack is +// unchanged and a further attempt to redo will try the same command again. +func (stack *Stack) Redo(modder world.Modder) error { + stack.lock("Redo") + defer stack.unlock() + + if stack.redoList == nil { + return nil + } + entry := stack.redoList + err := entry.cmd.Do(modder) + if err != nil { + return err + } + stack.redoList = entry.link + entry.link = stack.undoList + stack.undoList = entry + return nil +} + +func (stack *Stack) lock(by string) { + if stack.lockedBy != "" { + panic("Stack already in use by <" + stack.lockedBy + ">") + } + stack.lockedBy = by +} + +func (stack *Stack) unlock() { + stack.lockedBy = "" +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Stack_test.go b/modding/hacked/ss1/edit/undoable/cmd/Stack_test.go new file mode 100644 index 0000000..7972886 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Stack_test.go @@ -0,0 +1,348 @@ +package cmd_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" +) + +type TestCommand struct { + name string + + executed int + reverted int + + pendingError error + task func() +} + +func (cmd *TestCommand) Do(modder world.Modder) error { + cmd.executed++ + return cmd.run() +} + +func (cmd *TestCommand) Undo(modder world.Modder) error { + cmd.reverted++ + return cmd.run() +} + +func (cmd *TestCommand) run() (err error) { + if cmd.task != nil { + cmd.task() + } + err = cmd.pendingError + cmd.pendingError = nil + return +} + +type StackSuite struct { + suite.Suite + + stack cmd.Stack + commands map[string]*TestCommand +} + +func TestStackSuite(t *testing.T) { + suite.Run(t, new(StackSuite)) +} + +func (suite *StackSuite) SetupTest() { + suite.stack = cmd.Stack{} + suite.commands = make(map[string]*TestCommand) +} + +func (suite *StackSuite) TestNewStackCantDoAnything() { + suite.thenStackShouldNotSupportRedo() + suite.thenStackShouldNotSupportUndo() +} + +func (suite *StackSuite) TestPerformExecutesCommand() { + suite.whenPerforming(suite.aCommand("cmd1")) + suite.thenCommandShouldHaveBeenExecuted("cmd1") +} + +func (suite *StackSuite) TestPerformAllowsUndoIfSuccessful() { + suite.whenPerforming(suite.aCommand("cmd1")) + suite.thenStackShouldSupportUndo() +} + +func (suite *StackSuite) TestPerformIgnoresCommandIfItFails() { + suite.whenPerforming(suite.aCommandReturningError()) + suite.thenStackShouldNotSupportUndo() +} + +func (suite *StackSuite) TestPerformReturnsErrorOfCommand() { + err := ss1.StringError("fail first time") + suite.thenPerformShouldReturnError(suite.aCommandReturning(err), err) +} + +func (suite *StackSuite) TestUndoRevertsCommand() { + suite.givenCommandWasPerformed("cmd1") + suite.whenUndoing() + suite.thenCommandShouldHaveBeenReverted("cmd1") +} + +func (suite *StackSuite) TestUndoRevertsCommandOnlyOnce() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + suite.whenUndoing() + suite.thenCommandShouldHaveBeenRevertedTimes("cmd1", 1) +} + +func (suite *StackSuite) TestUndoRevertsCommandsInSequence() { + suite.givenCommandWasPerformed("cmd1") + suite.givenCommandWasPerformed("cmd2") + + suite.whenUndoing() + suite.thenCommandShouldHaveBeenReverted("cmd2") + + suite.whenUndoing() + suite.thenCommandShouldHaveBeenReverted("cmd1") +} + +func (suite *StackSuite) TestUndoLeavesStackUnchangedIfCommandFails() { + suite.givenCommandWasPerformed("cmd1") + suite.givenCommandWasPerformed("cmd2") + suite.givenCommandWillFail("cmd2") + suite.givenUndoWasCalledTimes(1) + suite.whenUndoing() + suite.thenCommandShouldHaveBeenRevertedTimes("cmd2", 2) +} + +func (suite *StackSuite) TestUndoEnablesRedo() { + suite.givenCommandWasPerformed("cmd1") + suite.whenUndoing() + suite.thenStackShouldSupportRedo() +} + +func (suite *StackSuite) TestRedoExecutesCommandAgain() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2) +} + +func (suite *StackSuite) TestRedoExecutesCommandOnlyOnce() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + suite.givenRedoWasCalledTimes(1) + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2) +} + +func (suite *StackSuite) TestRedoExecutesCommandsInSequence() { + suite.givenCommandWasPerformed("cmd1") + suite.givenCommandWasPerformed("cmd2") + suite.givenUndoWasCalledTimes(2) + + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2) + + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd2", 2) +} + +func (suite *StackSuite) TestRedoLeavesStackUnchangedIfCommandFails() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + suite.givenCommandWillFail("cmd1") + suite.givenRedoWasCalledTimes(1) + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 3) +} + +func (suite *StackSuite) TestRedoMakesCommandsUndoableAgain() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + suite.givenRedoWasCalledTimes(1) + suite.whenUndoing() + suite.thenCommandShouldHaveBeenRevertedTimes("cmd1", 2) +} + +func (suite *StackSuite) TestPerformDropsPendingRedoStack() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + + suite.whenPerforming(suite.aCommand("cmd2")) + suite.thenStackShouldNotSupportRedo() + + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 1) +} + +func (suite *StackSuite) TestPerformPanicsIfStackIsInUse() { + callPerform := func(name string) func() { + var times int + return func() { + require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.") + times++ + + assert.Panics(suite.T(), func() { + _ = suite.stack.Perform(suite.aCommand(name+"-nested"), nil) + }, "Perform during function <"+name+"> should panic") + } + } + + suite.assertPanics(callPerform) +} + +func (suite *StackSuite) TestUndoPanicsIfStackIsInUse() { + callUndo := func(name string) func() { + var times int + return func() { + require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.") + times++ + + assert.Panics(suite.T(), func() { + _ = suite.stack.Undo(nil) + }, "Undo during function <"+name+"> should panic") + } + } + + suite.assertPanics(callUndo) +} + +func (suite *StackSuite) TestRedoPanicsIfStackIsInUse() { + callRedo := func(name string) func() { + var times int + return func() { + require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.") + times++ + + assert.Panics(suite.T(), func() { + _ = suite.stack.Undo(nil) + }, "Redo during function <"+name+"> should panic") + } + } + + suite.assertPanics(callRedo) +} + +func (suite *StackSuite) assertPanics(taskFor func(string) func()) { + cmd1 := suite.aCommandExecuting("cmd1", taskFor("Perform")) + suite.whenPerforming(cmd1) + + suite.givenCommandWasPerformed("cmd2") + suite.givenCommandExecutes("cmd2", taskFor("Undo")) + suite.whenUndoing() + + suite.givenCommandWasPerformed("cmd3") + suite.givenUndoWasCalledTimes(1) + suite.givenCommandExecutes("cmd3", taskFor("Redo")) + suite.whenRedoing() +} + +func (suite *StackSuite) givenCommandWasPerformed(name string) { + suite.whenPerforming(suite.aCommand(name)) +} + +func (suite *StackSuite) givenUndoWasCalledTimes(times int) { + for i := 0; i < times; i++ { + _ = suite.stack.Undo(nil) + } +} + +func (suite *StackSuite) givenRedoWasCalledTimes(times int) { + for i := 0; i < times; i++ { + _ = suite.stack.Redo(nil) + } +} + +func (suite *StackSuite) givenCommandWillFail(name string) { + suite.pastCommand(name).pendingError = ss1.StringError("failing") +} + +func (suite *StackSuite) givenCommandExecutes(name string, task func()) { + cmd := suite.pastCommand(name) + cmd.task = task +} + +func (suite *StackSuite) whenUndoing() { + _ = suite.stack.Undo(nil) +} + +func (suite *StackSuite) whenRedoing() { + _ = suite.stack.Redo(nil) +} + +func (suite *StackSuite) whenPerforming(command cmd.Command) { + _ = suite.stack.Perform(command, nil) +} + +func (suite *StackSuite) thenStackShouldSupportRedo() { + assert.True(suite.T(), suite.stack.CanRedo(), "Stack should be able to redo") +} + +func (suite *StackSuite) thenStackShouldNotSupportRedo() { + assert.False(suite.T(), suite.stack.CanRedo(), "Stack should not be able to redo") +} + +func (suite *StackSuite) thenStackShouldSupportUndo() { + assert.True(suite.T(), suite.stack.CanUndo(), "Stack should be able to undo") +} + +func (suite *StackSuite) thenStackShouldNotSupportUndo() { + assert.False(suite.T(), suite.stack.CanUndo(), "Stack should not be able to undo") +} + +func (suite *StackSuite) thenCommandShouldHaveBeenExecuted(name string) { + cmd := suite.pastCommand(name) + assert.True(suite.T(), cmd.executed > 0, "Command <"+name+"> should have been executed at least once") +} + +func (suite *StackSuite) thenCommandShouldHaveBeenExecutedTimes(name string, expected int) { + cmd := suite.pastCommand(name) + assert.Equal(suite.T(), expected, cmd.executed) +} + +func (suite *StackSuite) thenCommandShouldHaveBeenReverted(name string) { + cmd := suite.pastCommand(name) + assert.True(suite.T(), cmd.reverted > 0, "Command <"+name+"> should have been reverted at least once") +} + +func (suite *StackSuite) thenCommandShouldHaveBeenRevertedTimes(name string, expected int) { + cmd := suite.pastCommand(name) + assert.Equal(suite.T(), expected, cmd.reverted) +} + +func (suite *StackSuite) thenPerformShouldReturnError(cmd cmd.Command, expected error) { + result := suite.stack.Perform(cmd, nil) + assert.Equal(suite.T(), expected, result) +} + +func (suite *StackSuite) aCommand(name string) cmd.Command { + cmd := &TestCommand{name: name} + suite.commands[name] = cmd + return cmd +} + +func (suite *StackSuite) aCommandExecuting(name string, task func()) cmd.Command { + cmd := &TestCommand{name: name, task: task} + suite.commands[name] = cmd + return cmd +} + +func (suite *StackSuite) aCommandReturningError() cmd.Command { + return suite.aCommandReturning(ss1.StringError("fail")) +} + +func (suite *StackSuite) aCommandReturning(err error) cmd.Command { + name := "unnamed" + cmd := &TestCommand{name: name, pendingError: err} + suite.commands[name] = cmd + return cmd +} + +func (suite *StackSuite) pastCommand(name string) *TestCommand { + cmd, found := suite.commands[name] + if !found { + require.True(suite.T(), found, "Command not found <"+name+"> - test is wrong") + } + return cmd +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Transaction.go b/modding/hacked/ss1/edit/undoable/cmd/Transaction.go new file mode 100644 index 0000000..157f1ee --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Transaction.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/world" +) + +// Task represents a modification on given modder. +type Task func(modder world.Modder) error + +// TransactionError is returned from a transaction execution if a task failed. +type TransactionError struct { + // Name is that of the transaction, if it was set. + Name string + // Index identifies the list index of tasks. + Index int + // Nested is the error returned from the task. In case of nested commands, this may be another TransactionError. + Nested error +} + +// Error returns the path of the transaction task, combined with the nested error text. +func (err TransactionError) Error() string { + return fmt.Sprintf("%s[%d]: %v", err.Name, err.Index, err.Nested) +} + +// Unwrap returns the nested error, to support diving into nested errors. +func (err TransactionError) Unwrap() error { + return err.Nested +} + +// Transaction is a named list of forward and reverse tasks. +// Transaction provides execution of the tasks as a Command. +type Transaction struct { + Name string + Forward []Task + Reverse []Task +} + +// Do executes the forward tasks in series. The first task to return an error aborts the iteration. +// A TransactionError is returned in case of aborted iteration. +func (txn Transaction) Do(modder world.Modder) error { + return txn.perform(txn.Forward, modder) +} + +// Undo executes the reverse tasks in series. The first task to return an error aborts the iteration. +// A TransactionError is returned in case of aborted iteration. +func (txn Transaction) Undo(modder world.Modder) error { + return txn.perform(txn.Reverse, modder) +} + +func (txn Transaction) perform(tasks []Task, modder world.Modder) error { + for i, task := range tasks { + err := task(modder) + if err != nil { + return TransactionError{ + Name: txn.Name, + Index: i, + Nested: err, + } + } + } + return nil +} + +// TransactionModifier is a function to change a transaction while it is being built. +type TransactionModifier func(*Transaction) error + +// Registry allows to build commands as transactions with modifier functions. +type Registry interface { + Commander + Register(modifier ...TransactionModifier) error +} + +// TransactionBuilder provides a way to register a command at a commander with +// possibly nested actions. +type TransactionBuilder struct { + Commander Commander + active *Transaction +} + +// Register creates a new transaction based on given modifier. +func (builder *TransactionBuilder) Register(modifier ...TransactionModifier) error { + previous := builder.active + current := &Transaction{} + + builder.active = current + for _, mod := range modifier { + err := mod(current) + if err != nil { + return err + } + } + builder.active = previous + + // invert reverse task for easier iteration + for left, right := 0, len(current.Reverse)-1; left < right; left, right = left+1, right-1 { + current.Reverse[left], current.Reverse[right] = current.Reverse[right], current.Reverse[left] + } + + builder.Queue(current) + return nil +} + +// Queue implements the Commander interface to add given command to a currently built transaction. +// If there is no outer transaction being built, then the command is immediately forwarded to the commander. +// Avoid building a loop between TransactionBuilder instances, as this will result in a stack overflow. +func (builder *TransactionBuilder) Queue(command Command) { + if builder.active == nil { + builder.Commander.Queue(command) + return + } + + builder.active.Forward = append(builder.active.Forward, command.Do) + builder.active.Reverse = append(builder.active.Reverse, command.Undo) +} + +// Named allows to name the current level of transaction. +func Named(name string) TransactionModifier { + return func(txn *Transaction) error { + txn.Name = name + return nil + } +} + +// Forward creates a modifier for a task that is called when doing a command. +// If a nested command is added to the transaction after the forward function, then +// the nested task is performed after the given task. +// If a nested command is added to the transaction before the forward function, then +// the nested task is performed before the given task. +func Forward(task Task) TransactionModifier { + return func(txn *Transaction) error { + txn.Forward = append(txn.Forward, task) + return nil + } +} + +// Reverse creates a modifier for a task that is called when undoing a command. +// If a nested command is added to the transaction after the reverse function, then +// the nested task is performed before the given task. +// If a nested command is added to the transaction before the reverse function, then +// the nested task is performed after the given task. +func Reverse(task Task) TransactionModifier { + return func(txn *Transaction) error { + txn.Reverse = append(txn.Reverse, task) + return nil + } +} + +// Nested adds a further level to the currently registered transaction. +// Depending on the order of the modifier in the register, the corresponding tasks will be called +// in sequence. See other modifier. +func Nested(nested func() error) TransactionModifier { + return func(txn *Transaction) error { + return nested() + } +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Transaction_test.go b/modding/hacked/ss1/edit/undoable/cmd/Transaction_test.go new file mode 100644 index 0000000..f55f134 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Transaction_test.go @@ -0,0 +1,135 @@ +package cmd_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" +) + +func TestTransactionBuilder(t *testing.T) { + suite.Run(t, new(TransactionBuilderSuite)) +} + +type TransactionBuilderSuite struct { + suite.Suite + + builder cmd.TransactionBuilder + command cmd.Command + result []string + err error +} + +func (suite *TransactionBuilderSuite) SetupTest() { + suite.builder = cmd.TransactionBuilder{Commander: suite} + suite.command = nil + suite.result = nil + suite.err = nil +} + +func (suite *TransactionBuilderSuite) TestTransactionCallsForwardTasksFromLeftToRight() { + suite.givenRegisteredTransaction( + cmd.Forward(suite.aTask("fa")), + cmd.Forward(suite.aTask("fb")), + cmd.Reverse(suite.aTask("ra")), + ) + suite.whenCommandIsDone() + suite.thenResultShouldBe([]string{"fa", "fb"}) +} + +func (suite *TransactionBuilderSuite) TestTransactionCallsReverseTasksFromRightToLeft() { + suite.givenRegisteredTransaction( + cmd.Forward(suite.aTask("fa")), + cmd.Reverse(suite.aTask("ra")), + cmd.Reverse(suite.aTask("rb")), + ) + suite.whenCommandIsUndone() + suite.thenResultShouldBe([]string{"rb", "ra"}) +} + +func (suite *TransactionBuilderSuite) TestTransactionCallsNestedInForwardDirection() { + suite.givenRegisteredTransaction( + cmd.Forward(suite.aTask("fa")), + cmd.Nested(func() error { + return suite.builder.Register(cmd.Forward(suite.aTask("na"))) + }), + cmd.Forward(suite.aTask("fb")), + ) + suite.whenCommandIsDone() + suite.thenResultShouldBe([]string{"fa", "na", "fb"}) +} + +func (suite *TransactionBuilderSuite) TestTransactionCallsNestedInReverseDirection() { + suite.givenRegisteredTransaction( + cmd.Reverse(suite.aTask("ra")), + cmd.Nested(func() error { + return suite.builder.Register(cmd.Reverse(suite.aTask("na")), cmd.Reverse(suite.aTask("nb"))) + }), + cmd.Reverse(suite.aTask("rb")), + ) + suite.whenCommandIsUndone() + suite.thenResultShouldBe([]string{"rb", "nb", "na", "ra"}) +} + +func (suite *TransactionBuilderSuite) TestTransactionReturnsErrorWithNamedPath() { + suite.givenRegisteredTransaction( + cmd.Named("main"), + cmd.Forward(suite.aTask("fa")), + cmd.Nested(func() error { + return suite.builder.Register(cmd.Named("nested"), cmd.Forward(suite.aTaskReturning(ss1.StringError("fail")))) + }), + cmd.Forward(suite.aTask("fb")), + ) + suite.whenCommandIsDone() + suite.thenResultShouldBeError("main[1]: nested[0]: fail") +} + +func (suite *TransactionBuilderSuite) givenRegisteredTransaction(modifier ...cmd.TransactionModifier) { + err := suite.builder.Register(modifier...) + require.Nil(suite.T(), err, "no error expected registering transaction") +} + +func (suite *TransactionBuilderSuite) whenCommandIsDone() { + suite.err = suite.command.Do(nil) +} + +func (suite *TransactionBuilderSuite) whenCommandIsUndone() { + suite.err = suite.command.Undo(nil) +} + +func (suite *TransactionBuilderSuite) thenResultShouldBe(expected []string) { + assert.Equal(suite.T(), expected, suite.result) +} + +func (suite *TransactionBuilderSuite) thenResultShouldBeError(expected string) { + require.NotNil(suite.T(), suite.err, "no error returned") + var txnErr cmd.TransactionError + isTxnErr := errors.As(suite.err, &txnErr) + require.True(suite.T(), isTxnErr, "error is not a transaction error: %v", suite.err) + assert.Equal(suite.T(), expected, txnErr.Error()) + assert.NotNil(suite.T(), txnErr.Nested, "no nested error set") +} + +func (suite *TransactionBuilderSuite) Queue(command cmd.Command) { + require.Nil(suite.T(), suite.command, "Not expecting a second command") + suite.command = command +} + +func (suite *TransactionBuilderSuite) aTask(text string) cmd.Task { + return func(modder world.Modder) error { + suite.result = append(suite.result, text) + return nil + } +} + +func (suite *TransactionBuilderSuite) aTaskReturning(err error) cmd.Task { + return func(modder world.Modder) error { + return err + } +} diff --git a/modding/hacked/ss1/edit/undoable/doc.go b/modding/hacked/ss1/edit/undoable/doc.go new file mode 100644 index 0000000..df03381 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/doc.go @@ -0,0 +1,2 @@ +// Package undoable contains edit extensions that provide an undo-mechanism. +package undoable diff --git a/modding/hacked/ss1/resource/BlockProvider.go b/modding/hacked/ss1/resource/BlockProvider.go new file mode 100644 index 0000000..71be4ea --- /dev/null +++ b/modding/hacked/ss1/resource/BlockProvider.go @@ -0,0 +1,15 @@ +package resource + +import "io" + +// BlockProvider are capable of returning block data of a resource. +type BlockProvider interface { + // BlockCount returns the number of available blocks in the resource. + // Simple resources will always have exactly one block. + BlockCount() int + + // Block returns the reader for the identified block. + // Each call returns a new reader instance. + // Data provided by this reader is always uncompressed. + Block(index int) (io.Reader, error) +} diff --git a/modding/hacked/ss1/resource/Blocks.go b/modding/hacked/ss1/resource/Blocks.go new file mode 100644 index 0000000..620c806 --- /dev/null +++ b/modding/hacked/ss1/resource/Blocks.go @@ -0,0 +1,62 @@ +package resource + +import ( + "bytes" + "io" +) + +// Block is one set of bytes stored in a resource. +// The interpretation of the bytes is dependent on the content type of the resource. +type Block []byte + +// Blocks is a list of blocks in memory. +type Blocks struct { + data [][]byte +} + +// BlocksFrom returns a blocks instance from given data. +func BlocksFrom(data [][]byte) Blocks { + return Blocks{data: data} +} + +// BlockCount returns the number of available blocks. +func (blocks Blocks) BlockCount() int { + return len(blocks.data) +} + +// BlockRaw returns the raw byte slice stored in the identified block. +func (blocks Blocks) BlockRaw(index int) ([]byte, error) { + available := len(blocks.data) + if (index < 0) || (index >= available) { + return nil, ErrBlockNotFound(index, available) + } + return blocks.data[index], nil +} + +// Block returns the reader for the identified block. +// Each call returns a new reader instance. +func (blocks Blocks) Block(index int) (io.Reader, error) { + raw, err := blocks.BlockRaw(index) + if err != nil { + return nil, err + } + return bytes.NewBuffer(raw), nil +} + +// Set the data of all the blocks. +func (blocks *Blocks) Set(data [][]byte) { + blocks.data = data +} + +// SetBlock sets the data of the identified block. +func (blocks *Blocks) SetBlock(index int, data []byte) { + if index < 0 { + return + } + if index >= len(blocks.data) { + newData := make([][]byte, index+1) + copy(newData, blocks.data) + blocks.data = newData + } + blocks.data[index] = data +} diff --git a/modding/hacked/ss1/resource/Blocks_test.go b/modding/hacked/ss1/resource/Blocks_test.go new file mode 100644 index 0000000..f4c6532 --- /dev/null +++ b/modding/hacked/ss1/resource/Blocks_test.go @@ -0,0 +1,59 @@ +package resource_test + +import ( + "io/ioutil" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" +) + +func TestBlockCountReturnsZeroForNilInstance(t *testing.T) { + var blocks resource.Blocks + assert.Equal(t, 0, blocks.BlockCount()) +} + +func TestBlockCountReturnsLengthOfArray(t *testing.T) { + blocks := resource.BlocksFrom(make([][]byte, 3)) + assert.Equal(t, 3, blocks.BlockCount()) +} + +func TestBlockReturnsArrayEntries(t *testing.T) { + blocks := resource.BlocksFrom([][]byte{{0x01}, {0x02, 0x03}}) + verifyBlock := func(index int) { + reader, err := blocks.Block(index) + assert.Nil(t, err) + assert.NotNil(t, reader) + } + verifyBlock(0) + verifyBlock(1) +} + +func TestBlockReturnsErrorForInvalidIndex(t *testing.T) { + blocks := resource.BlocksFrom([][]byte{{0x01}, {0x02, 0x03}}) + verifyError := func(index int) { + _, err := blocks.Block(index) + assert.NotNil(t, err, "Error expected for index ", index) + } + verifyError(-1) + verifyError(2) + verifyError(3) +} + +func TestBlockSetting(t *testing.T) { + var blocks resource.Blocks + + blocks.Set(make([][]byte, 3)) + assert.Equal(t, 3, blocks.BlockCount(), "block count should have been set") + + blocks.SetBlock(1, []byte{0x01, 0x02}) + block, err := blocks.Block(1) + assert.Nil(t, err, "block should be known") + storedData, err := ioutil.ReadAll(block) + assert.Nil(t, err, "data should be read") + assert.Equal(t, []byte{0x01, 0x02}, storedData, "stored data mismatch") + + blocks.SetBlock(5, []byte{0xAA}) + assert.Equal(t, 6, blocks.BlockCount(), "block count should have been updated") +} diff --git a/modding/hacked/ss1/resource/ChangeNotifier.go b/modding/hacked/ss1/resource/ChangeNotifier.go new file mode 100644 index 0000000..809660a --- /dev/null +++ b/modding/hacked/ss1/resource/ChangeNotifier.go @@ -0,0 +1,131 @@ +package resource + +import ( + "bytes" + "crypto/md5" // nolint: gas + "encoding/binary" + "io" + "sync" +) + +type resourceHash []byte +type resourceHashes map[ID]resourceHash +type resourceHashSnapshot map[Language]resourceHashes + +// ModificationCallback is a callback function to notify a change in resources. +type ModificationCallback func(modifiedIDs []ID, failedIDs []ID) + +// Localizer provides selectors to resources for specific languages. +type Localizer interface { + LocalizedResources(lang Language) Selector +} + +// ChangeNotifier is a utility that assists in detecting changes in modifying resources. +// A callback is called for any resource identifier that refers to resources that are different after a modification. +// +// Use this utility in combination to resource lists where resources can be overwritten by other entries. +// Changes in order, or content, affects how a resource is resolved. +type ChangeNotifier struct { + Localizer Localizer + Callback ModificationCallback +} + +// ModifyAndNotify must be called with a range of affected IDs that will change during the call of the modifier. +// A hash snapshot is taken before and after the modifier, considering the affected IDs. +// Any change is then reported to the callback, listing all IDs that have different hashes. +// +// Hashing the resources considers all languages, as well as the meta-information of the resources. +func (notifier ChangeNotifier) ModifyAndNotify(modifier func(), affectedIDs ...[]ID) { + var flattenedIDs []ID + for _, idList := range affectedIDs { + flattenedIDs = append(flattenedIDs, idList...) + } + oldHashes, _ := notifier.hashAll(flattenedIDs) + modifier() + newHashes, failedIDs := notifier.hashAll(flattenedIDs) + modifiedResourceIDs := notifier.modifiedIDs(oldHashes, newHashes) + notifier.Callback(modifiedResourceIDs, failedIDs) +} + +func (notifier ChangeNotifier) hashAll(ids []ID) (hashes resourceHashSnapshot, failedIDs []ID) { + var failedMap IDMarkerMap + hashes = make(resourceHashSnapshot) + var selectMutex 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) + mapMutex.Lock() + defer mapMutex.Unlock() + if hashErr == nil { + hashByResourceID[id] = hash + } else { + failedMap.Add(id) + } + }(id) + } + wg.Wait() + } + failedIDs = failedMap.ToList() + return +} + +func (notifier ChangeNotifier) hashResource(selector func(ID) (View, error), id ID) (resourceHash, error) { + view, viewErr := selector(id) + if viewErr != nil { + return nil, viewErr + } + hasher := md5.New() // nolint: gas + blocks := view.BlockCount() + for index := 0; index < blocks; index++ { + blockReader, blockErr := view.Block(index) + if blockErr != nil { + return nil, blockErr + } + written, hashErr := io.Copy(hasher, blockReader) + if hashErr != nil { + return nil, hashErr + } + binary.Write(hasher, binary.LittleEndian, &written) // nolint: errcheck + } + binary.Write(hasher, binary.LittleEndian, int64(blocks)) // nolint: errcheck + binary.Write(hasher, binary.LittleEndian, view.Compound()) // nolint: errcheck + binary.Write(hasher, binary.LittleEndian, view.ContentType()) // nolint: errcheck + binary.Write(hasher, binary.LittleEndian, view.Compressed()) // nolint: errcheck + + return hasher.Sum(nil), nil +} + +func (notifier ChangeNotifier) modifiedIDs(oldHashes resourceHashSnapshot, newHashes resourceHashSnapshot) []ID { + var modifiedIDs IDMarkerMap + for _, lang := range Languages() { + oldHashesByResourceID := oldHashes[lang] + newHashesByResourceID := newHashes[lang] + + for newID, newHash := range newHashesByResourceID { + oldHash, oldExisting := oldHashesByResourceID[newID] + if !oldExisting || !bytes.Equal(newHash, oldHash) { + modifiedIDs.Add(newID) + } + } + for oldID := range oldHashesByResourceID { + if _, newExisting := newHashesByResourceID[oldID]; !newExisting { + modifiedIDs.Add(oldID) + } + } + } + return modifiedIDs.ToList() +} diff --git a/modding/hacked/ss1/resource/ChangeNotifier_test.go b/modding/hacked/ss1/resource/ChangeNotifier_test.go new file mode 100644 index 0000000..44cd3fc --- /dev/null +++ b/modding/hacked/ss1/resource/ChangeNotifier_test.go @@ -0,0 +1,136 @@ +package resource_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type ChangeNotifierSuite struct { + suite.Suite + + lastModifiedIDs []resource.ID + lastFailedIDs []resource.ID + + localizedResources resource.LocalizedResourcesList + + instance resource.ChangeNotifier +} + +func TestResourceChangeNotifierSuite(t *testing.T) { + suite.Run(t, new(ChangeNotifierSuite)) +} + +func (suite *ChangeNotifierSuite) SetupTest() { + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil + + suite.instance.Localizer = suite + suite.instance.Callback = suite.onResourcesModified +} + +func (suite *ChangeNotifierSuite) TestNoIDsAreNotifiedIfDataNotChanged() { + suite.givenListIsMadeOf( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, []byte{0x01}))) + suite.whenResourcesAreModified(func() {}, 0x1000) + suite.thenModifiedIDsShouldBe() +} + +func (suite *ChangeNotifierSuite) TestIDsAreNotifiedOfNewData() { + suite.givenListIsMadeOf( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, []byte{0x01}))) + suite.whenResourcesAreModified(func() { + suite.extendingListWith( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x2000, []byte{0x01}))) + }, 0x2000) + suite.thenModifiedIDsShouldBe(0x2000) +} + +func (suite *ChangeNotifierSuite) TestIDsAreNotifiedOfRemovedData() { + suite.givenListIsMadeOf( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, []byte{0x01}))) + suite.whenResourcesAreModified(func() { + suite.listIsMadeOf() + }, 0x1000) + suite.thenModifiedIDsShouldBe(0x1000) +} + +func (suite *ChangeNotifierSuite) TestIDsAreNotifiedOfChangedData() { + suite.givenListIsMadeOf( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x1000, []byte{0x01}))) + suite.whenResourcesAreModified(func() { + suite.listIsMadeOf( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x1000, []byte{0x02}))) + }, 0x1000) + suite.thenModifiedIDsShouldBe(0x1000) +} + +func (suite *ChangeNotifierSuite) givenListIsMadeOf(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *ChangeNotifierSuite) extendingListWith(resources ...resource.LocalizedResources) { + suite.localizedResources = append(suite.localizedResources, resources...) +} + +func (suite *ChangeNotifierSuite) listIsMadeOf(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *ChangeNotifierSuite) whenResourcesAreModified(modifier func(), affectedIDs ...int) { + convertedIDs := make([]resource.ID, len(affectedIDs)) + for i, id := range affectedIDs { + convertedIDs[i] = resource.ID(id) + } + suite.instance.ModifyAndNotify(modifier, convertedIDs) +} + +func (suite *ChangeNotifierSuite) thenModifiedIDsShouldBe(expected ...int) { + convertedIDs := make([]resource.ID, len(expected)) + for i, id := range expected { + convertedIDs[i] = resource.ID(id) + } + + assert.Equal(suite.T(), convertedIDs, suite.lastModifiedIDs, "IDs don't match") +} + +func (suite *ChangeNotifierSuite) someLocalizedResources(lang resource.Language, modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: lang, + Viewer: store, + } +} + +func (suite *ChangeNotifierSuite) storing(id int, data ...[]byte) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Blocks: resource.BlocksFrom(data), + }) + } +} + +func (suite *ChangeNotifierSuite) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: suite.localizedResources, + Lang: lang, + } +} + +func (suite *ChangeNotifierSuite) onResourcesModified(modifiedIDs []resource.ID, failedIDs []resource.ID) { + suite.lastModifiedIDs = modifiedIDs + suite.lastFailedIDs = failedIDs +} diff --git a/modding/hacked/ss1/resource/ContentType.go b/modding/hacked/ss1/resource/ContentType.go new file mode 100644 index 0000000..df5e508 --- /dev/null +++ b/modding/hacked/ss1/resource/ContentType.go @@ -0,0 +1,48 @@ +package resource + +import "fmt" + +// ContentType identifies how resource data shall be interpreted. +type ContentType byte + +var contentTypeNames = map[ContentType]string{ + Palette: "Palette", + Text: "Text", + Bitmap: "Bitmap", + Font: "Font", + Animation: "Animation", + Sound: "Sound", + Geometry: "Geometry", + Movie: "Movie", + Archive: "Archive", +} + +// String returns the textual representation of the type. +func (t ContentType) String() string { + s, existing := contentTypeNames[t] + if existing { + return s + } + return fmt.Sprintf("Unknown%02X", int(t)) +} + +const ( + // Palette refers to color tables. + Palette = ContentType(0x00) + // Text refers to texts. + Text = ContentType(0x01) + // Bitmap refers to images. + Bitmap = ContentType(0x02) + // Font refers to font descriptions. + Font = ContentType(0x03) + // Animation refers to graphical animations. + Animation = ContentType(0x04) + // Sound refers to audio samples. + Sound = ContentType(0x07) + // Geometry refers to 3D models. + Geometry = ContentType(0x0F) + // Movie refers to audio logs and cutscenes. + Movie = ContentType(0x11) + // Archive refers to archive data. + Archive = ContentType(0x30) +) diff --git a/modding/hacked/ss1/resource/ContentType_test.go b/modding/hacked/ss1/resource/ContentType_test.go new file mode 100644 index 0000000..7f86107 --- /dev/null +++ b/modding/hacked/ss1/resource/ContentType_test.go @@ -0,0 +1,33 @@ +package resource_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +func TestContentTypeString(t *testing.T) { + tt := []struct { + contentType resource.ContentType + expected string + }{ + {resource.Palette, "Palette"}, + {resource.Text, "Text"}, + {resource.Bitmap, "Bitmap"}, + {resource.Font, "Font"}, + {resource.Animation, "Animation"}, + {resource.Sound, "Sound"}, + {resource.Geometry, "Geometry"}, + {resource.Movie, "Movie"}, + {resource.Archive, "Archive"}, + {resource.ContentType(254), "UnknownFE"}, + } + + for _, tc := range tt { + result := tc.contentType.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.contentType))) + } +} diff --git a/modding/hacked/ss1/resource/Errors.go b/modding/hacked/ss1/resource/Errors.go new file mode 100644 index 0000000..fa0a566 --- /dev/null +++ b/modding/hacked/ss1/resource/Errors.go @@ -0,0 +1,51 @@ +package resource + +import "fmt" + +// NotFoundError indicates the unknown ID. +type NotFoundError struct { + ID ID +} + +// Error implements the error interface. +func (err NotFoundError) Error() string { + return fmt.Sprintf("resource with ID %v does not exist", err.ID) +} + +// ErrNotFound returns an error specifying the given ID doesn't +// have an associated resource. +func ErrNotFound(id ID) error { + return NotFoundError{ID: id} +} + +// BlockNotFoundError indicates the unknown block index. +type BlockNotFoundError struct { + Index int + Available int +} + +// Error implements the error interface. +func (err BlockNotFoundError) Error() string { + return fmt.Sprintf("block index wrong: %v/%v", err.Index, err.Available) +} + +// ErrBlockNotFound returns an error specifying the given index does not identify a valid block. +func ErrBlockNotFound(index, available int) error { + return BlockNotFoundError{Index: index, Available: available} +} + +// WrongTypeError indicates a resource of unexpected type. +type WrongTypeError struct { + Key Key + Expected ContentType +} + +// Error implements the error interface. +func (err WrongTypeError) Error() string { + return fmt.Sprintf("resource %v does not have expected type %v", err.Key, err.Expected) +} + +// ErrWrongType returns an error specifying a resource was of unexpected type. +func ErrWrongType(key Key, expected ContentType) error { + return WrongTypeError{Key: key, Expected: expected} +} diff --git a/modding/hacked/ss1/resource/Identifier.go b/modding/hacked/ss1/resource/Identifier.go new file mode 100644 index 0000000..c80a4ba --- /dev/null +++ b/modding/hacked/ss1/resource/Identifier.go @@ -0,0 +1,47 @@ +package resource + +import ( + "fmt" + "sort" +) + +// ID represents an integer key of resources. +type ID uint16 + +// Value returns the numerical value of the identifier. +func (id ID) Value() uint16 { + return uint16(id) +} + +// Plus adds the given offset and returns the resulting ID. +func (id ID) Plus(offset int) ID { + return ID(int(id) + offset) +} + +// String returns the textual representation. +func (id ID) String() string { + return fmt.Sprintf("%04X", uint16(id)) +} + +// IDMarkerMap is used to collect IDs. +type IDMarkerMap struct { + ids map[ID]struct{} +} + +// Add adds the given ID to the map. +func (marker *IDMarkerMap) Add(id ID) { + if marker.ids == nil { + marker.ids = make(map[ID]struct{}) + } + marker.ids[id] = struct{}{} +} + +// ToList converts the map to a de-duplicated list. +func (marker IDMarkerMap) ToList() []ID { + result := make([]ID, 0, len(marker.ids)) + for id := range marker.ids { + result = append(result, id) + } + sort.Slice(result, func(a, b int) bool { return result[a] < result[b] }) + return result +} diff --git a/modding/hacked/ss1/resource/Identifier_test.go b/modding/hacked/ss1/resource/Identifier_test.go new file mode 100644 index 0000000..a80ff63 --- /dev/null +++ b/modding/hacked/ss1/resource/Identifier_test.go @@ -0,0 +1,39 @@ +package resource_test + +import ( + "fmt" + "math" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" +) + +func TestIDValueReturnsOwnValue(t *testing.T) { + assert.Equal(t, uint16(0), resource.ID(0).Value()) + assert.Equal(t, uint16(123), resource.ID(123).Value()) + assert.Equal(t, uint16(math.MaxUint16), resource.ID(math.MaxUint16).Value(), "maximum of uint16 should be supported") +} + +func TestIDImplementsStringer(t *testing.T) { + assert.Equal(t, "0FA0", fmt.Sprintf("%v", resource.ID(0x0FA0))) +} + +func TestIDPlusReturnsOffsetValue(t *testing.T) { + tt := []struct { + base resource.ID + offset int + expected resource.ID + }{ + {0, 0, 0}, + {10, 0, 10}, + {10, 2, 12}, + {10, -2, 8}, + } + + for _, tc := range tt { + result := tc.base.Plus(tc.offset) + assert.Equal(t, tc.expected, result, "ID mismatch") + } +} diff --git a/modding/hacked/ss1/resource/Key.go b/modding/hacked/ss1/resource/Key.go new file mode 100644 index 0000000..bb589bb --- /dev/null +++ b/modding/hacked/ss1/resource/Key.go @@ -0,0 +1,20 @@ +package resource + +// Key identifies a particular resource out of a combination of a resource ID, a language, and a block index. +// +// Keys are used for retrieving particular content that is stored in resources. +// Not all resources contain only one atomic entity. +type Key struct { + // ID identifies the resource. + ID ID + // Lang localizes the resource, should the actual content be localized. + Lang Language + // Index specifies the exact entry within a resource. + Index int +} + +// KeyOf is a factory function for creating a Key instance. +// It is done to avoid getting warnings when trying to do a similar construction via Key{id, lang, index}. +func KeyOf(id ID, lang Language, index int) Key { + return Key{ID: id, Lang: lang, Index: index} +} diff --git a/modding/hacked/ss1/resource/Key_test.go b/modding/hacked/ss1/resource/Key_test.go new file mode 100644 index 0000000..34ba507 --- /dev/null +++ b/modding/hacked/ss1/resource/Key_test.go @@ -0,0 +1,14 @@ +package resource_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" +) + +func TestKeyOfReturnsKey(t *testing.T) { + assert.Equal(t, resource.Key{ID: 0x1000, Lang: resource.LangGerman, Index: 123}, resource.KeyOf(0x1000, resource.LangGerman, 123)) + assert.Equal(t, resource.Key{ID: 0xA000, Lang: resource.LangAny, Index: 0}, resource.KeyOf(0xA000, resource.LangAny, 0)) +} diff --git a/modding/hacked/ss1/resource/Language.go b/modding/hacked/ss1/resource/Language.go new file mode 100644 index 0000000..2468342 --- /dev/null +++ b/modding/hacked/ss1/resource/Language.go @@ -0,0 +1,46 @@ +package resource + +import "fmt" + +// Language defines the human language of a resource. +type Language byte + +const ( + // LangAny identifies language agnostic resources. + LangAny Language = 0xFF + // LangDefault identifies the default language, typically English - unless modded. + LangDefault Language = 0 + // LangFrench identifies the French language. + LangFrench Language = 1 + // LangGerman identifies the German language. + LangGerman Language = 2 + + // LanguageCount specifies how many languages are supported. + LanguageCount = 3 +) + +func (lang Language) String() string { + switch lang { + case LangAny: + return "Any" + case LangDefault: + return "Default" + case LangFrench: + return "French" + case LangGerman: + return "German" + default: + return fmt.Sprintf("Unknown%02X", int(lang)) + } +} + +// Languages returns a slice of all human languages. Does not include "Any" selector. +func Languages() []Language { + return []Language{LangDefault, LangFrench, LangGerman} +} + +// Includes returns true if the language includes the provided one. +// This is not symmetrical. While "Any" includes "German", "German" does not include "Any". +func (lang Language) Includes(other Language) bool { + return (lang == LangAny) || (lang == other) +} diff --git a/modding/hacked/ss1/resource/Language_test.go b/modding/hacked/ss1/resource/Language_test.go new file mode 100644 index 0000000..c987195 --- /dev/null +++ b/modding/hacked/ss1/resource/Language_test.go @@ -0,0 +1,58 @@ +package resource_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +func TestLanguageIncludes(t *testing.T) { + tt := []struct { + primary resource.Language + secondary resource.Language + expected bool + }{ + {resource.LangAny, resource.LangAny, true}, + {resource.LangAny, resource.LangGerman, true}, + {resource.LangFrench, resource.LangAny, false}, + {resource.LangGerman, resource.LangFrench, false}, + {resource.LangFrench, resource.LangFrench, true}, + } + + for _, tc := range tt { + td := tc + t.Run(fmt.Sprintf("Expecting %v including %v should be %v", td.primary, td.secondary, td.expected), func(t *testing.T) { + result := td.primary.Includes(td.secondary) + assert.Equal(t, td.expected, result) + }) + } +} + +func TestLanguageString(t *testing.T) { + tt := []struct { + lang resource.Language + expected string + }{ + {resource.LangAny, "Any"}, + {resource.LangDefault, "Default"}, + {resource.LangFrench, "French"}, + {resource.LangGerman, "German"}, + {resource.Language(0x40), "Unknown40"}, + } + + for _, tc := range tt { + td := tc + t.Run(td.expected, func(t *testing.T) { + result := td.lang.String() + assert.Equal(t, td.expected, result) + }) + } +} + +func TestLanguages(t *testing.T) { + result := resource.Languages() + assert.Equal(t, 3, len(result)) +} diff --git a/modding/hacked/ss1/resource/List.go b/modding/hacked/ss1/resource/List.go new file mode 100644 index 0000000..05c9831 --- /dev/null +++ b/modding/hacked/ss1/resource/List.go @@ -0,0 +1,24 @@ +package resource + +// List is a helper type for slices of resources. +// Although the length of the list is immutable, the content and the fields are not. +// Meaning, modifying the cells of one list may affect the cells of others. +type List []View + +// With returns a new list with the provided resource appended at the end. +func (list List) With(res View) List { + return append(list, res) +} + +// Joined returns a new list which is the combination of this list and the provided one. +func (list List) Joined(other List) List { + otherLen := len(other) + if otherLen == 0 { + return list + } + listLen := len(list) + newList := make([]View, listLen+otherLen) + copy(newList[:listLen], list) + copy(newList[listLen:], other) + return newList +} diff --git a/modding/hacked/ss1/resource/ListMerger.go b/modding/hacked/ss1/resource/ListMerger.go new file mode 100644 index 0000000..2b1d424 --- /dev/null +++ b/modding/hacked/ss1/resource/ListMerger.go @@ -0,0 +1,62 @@ +package resource + +import ( + "bytes" + "io" +) + +type listMerger struct { + list List +} + +// Compound indicates whether the resource is a compound one, which can contain zero, one, or more blocks. +// Simple resources always contain exactly one block. +func (view listMerger) Compound() bool { + return view.list[0].Compound() +} + +// ContentType specifies how the data in the resource shall be interpreted. +func (view listMerger) ContentType() ContentType { + return view.list[0].ContentType() +} + +// Compressed indicates whether the data should be compressed when serialized. +func (view listMerger) Compressed() bool { + return view.list[0].Compressed() +} + +// BlockCount returns the maximum count of all resources. +func (view listMerger) BlockCount() (max int) { + for _, layer := range view.list { + count := layer.BlockCount() + if max < count { + max = count + } + } + return +} + +// Block returns a reader for the identified block in the resource. +// The view returns the block from the first resource that has a non-empty block. +// The resources are checked from last-to-first. +func (view listMerger) Block(index int) (reader io.Reader, err error) { + blockCount := view.BlockCount() + if (index < 0) || (index >= blockCount) { + return nil, ErrBlockNotFound(index, blockCount) + } + + for layer := len(view.list) - 1; (layer >= 0) && (reader == nil); layer-- { + tempReader, tempErr := view.list[layer].Block(index) + if tempErr == nil { + var buf [1]byte + read, _ := tempReader.Read(buf[:]) + if read == 1 { + reader, err = view.list[layer].Block(index) + } + } + } + if (reader == nil) && (err == nil) { + reader = bytes.NewBuffer(nil) + } + return +} diff --git a/modding/hacked/ss1/resource/List_test.go b/modding/hacked/ss1/resource/List_test.go new file mode 100644 index 0000000..00cfc8d --- /dev/null +++ b/modding/hacked/ss1/resource/List_test.go @@ -0,0 +1,52 @@ +package resource_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" +) + +func someResource(key byte) resource.View { + return resource.Resource{ + Blocks: resource.BlocksFrom([][]byte{{key}}), + } +} + +func TestListWith(t *testing.T) { + t.Run("nil list", func(t *testing.T) { + var base resource.List + result := base.With(someResource(0x01)) + assert.Equal(t, resource.List([]resource.View{someResource(0x01)}), result) + }) + + t.Run("append", func(t *testing.T) { + base := resource.List([]resource.View{someResource(0x01)}) + result := base.With(someResource(0x03)) + assert.Equal(t, resource.List([]resource.View{someResource(0x01), someResource(0x03)}), result) + }) + + t.Run("immutable source", func(t *testing.T) { + raw := make([]resource.View, 1, 2) + raw[0] = someResource(0x02) + base := resource.List(raw) + result := base.With(someResource(0x04)) + assert.Equal(t, 2, len(result)) + assert.Equal(t, resource.List([]resource.View{someResource(0x02)}), base) + }) +} + +func TestListJoined(t *testing.T) { + var empty resource.List + t.Run("two lists", func(t *testing.T) { + base := empty.With(someResource(0x01)).With(someResource(0x02)) + other := empty.With(someResource(0x03)).With(someResource(0x04)) + result := base.Joined(other) + assert.Equal(t, resource.List([]resource.View{someResource(0x01), someResource(0x02), someResource(0x03), someResource(0x04)}), result) + }) + t.Run("nil with nil is nil", func(t *testing.T) { + result := empty.Joined(empty) + assert.Nil(t, result) + }) +} diff --git a/modding/hacked/ss1/resource/LocalizedResources.go b/modding/hacked/ss1/resource/LocalizedResources.go new file mode 100644 index 0000000..c453886 --- /dev/null +++ b/modding/hacked/ss1/resource/LocalizedResources.go @@ -0,0 +1,11 @@ +package resource + +// LocalizedResources associates a language with a resource viewer under a specific identifier. +type LocalizedResources struct { + // ID is the identifier of the viewer. This could be a filename for instance. + ID string + // Language specifies for which language the viewer has resources. + Language Language + // Viewer is the actual container of the resources. + Viewer Viewer +} diff --git a/modding/hacked/ss1/resource/LocalizedResourcesList.go b/modding/hacked/ss1/resource/LocalizedResourcesList.go new file mode 100644 index 0000000..60c5158 --- /dev/null +++ b/modding/hacked/ss1/resource/LocalizedResourcesList.go @@ -0,0 +1,18 @@ +package resource + +// LocalizedResourcesList is a collection of localized resources. +// it exists to provide a typical implementation of a Selector. +type LocalizedResourcesList []LocalizedResources + +// Filter returns all resources that match the given parameters. +func (list LocalizedResourcesList) Filter(lang Language, id ID) List { + var result List + for _, localized := range list { + if localized.Language.Includes(lang) { + if res, err := localized.Viewer.View(id); err == nil { + result = result.With(res) + } + } + } + return result +} diff --git a/modding/hacked/ss1/resource/Resource.go b/modding/hacked/ss1/resource/Resource.go new file mode 100644 index 0000000..d61027e --- /dev/null +++ b/modding/hacked/ss1/resource/Resource.go @@ -0,0 +1,38 @@ +package resource + +// Properties describe the meta information about a resource. +type Properties struct { + // Compound tells whether the resource should be serialized with a directory. + // Compound resources can have zero, one, or more blocks. + // Simple resources always have exactly one block. + Compound bool + + // ContentType describes how the block data shall be interpreted. + ContentType ContentType + + // Compressed tells whether the data shall be serialized in compressed form. + Compressed bool +} + +// Resource provides meta information as well as access to its contained blocks. +type Resource struct { + Properties + Blocks +} + +// Compound tells whether the resource should be serialized with a directory. +// Compound resources can have zero, one, or more blocks. +// Simple resources always have exactly one block. +func (res Resource) Compound() bool { + return res.Properties.Compound +} + +// ContentType describes how the block data shall be interpreted. +func (res Resource) ContentType() ContentType { + return res.Properties.ContentType +} + +// Compressed tells whether the data shall be serialized in compressed form. +func (res Resource) Compressed() bool { + return res.Properties.Compressed +} diff --git a/modding/hacked/ss1/resource/Resource_test.go b/modding/hacked/ss1/resource/Resource_test.go new file mode 100644 index 0000000..9dfad00 --- /dev/null +++ b/modding/hacked/ss1/resource/Resource_test.go @@ -0,0 +1,54 @@ +package resource_test + +import ( + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +func verifyBlockContent(t *testing.T, provider resource.BlockProvider, index int, expected []byte) { + t.Helper() + reader, err := provider.Block(index) + assert.Nil(t, err, "No error expected for index %d", index) + assert.NotNil(t, reader, "Reader expected for index %d", index) + if reader != nil { + data, dataErr := ioutil.ReadAll(reader) + assert.Nil(t, dataErr, "No error expected reading data from index %d", index) + assert.Equal(t, expected, data, "Proper data expected from index %d", index) + } +} + +func verifyBlockError(t *testing.T, provider resource.BlockProvider, index int) { + t.Helper() + _, err := provider.Block(index) + assert.NotNil(t, err, "Error expected for index %d", index) +} + +func TestResourceRefersToBlockProviderByDefault(t *testing.T) { + res := &resource.Resource{Blocks: resource.BlocksFrom([][]byte{{0x01}, {0x02, 0x02}})} + + assert.Equal(t, 2, res.BlockCount()) + verifyBlockContent(t, res, 0, []byte{0x01}) + verifyBlockContent(t, res, 1, []byte{0x02, 0x02}) +} + +func TestResourceBlockReturnsErrorOnInvalidIndex(t *testing.T) { + res := &resource.Resource{Blocks: resource.BlocksFrom(nil)} + + verifyBlockError(t, res, -1) + verifyBlockError(t, res, 0) + verifyBlockError(t, res, 1) + verifyBlockError(t, res, 2) +} + +func TestResourceBlockReturnsErrorForDefaultObject(t *testing.T) { + var res resource.Resource + + assert.Equal(t, 0, res.BlockCount()) + verifyBlockError(t, res, -1) + verifyBlockError(t, res, 0) + verifyBlockError(t, res, 1) +} diff --git a/modding/hacked/ss1/resource/Selector.go b/modding/hacked/ss1/resource/Selector.go new file mode 100644 index 0000000..94332bb --- /dev/null +++ b/modding/hacked/ss1/resource/Selector.go @@ -0,0 +1,34 @@ +package resource + +// Filter filters for language and id to produce a list of matching resources. +type Filter interface { + Filter(lang Language, id ID) List +} + +// Selector provides a merged view of resources according to a language. +type Selector struct { + // Lang specifies the language to filter by. + Lang Language + + // From specifies from where the resources shall be taken. + From Filter + + // As defines how the found resources should be viewed in case more than one matches. + // By default, the last resource will be used. + As ViewStrategy +} + +// Select provides a collected view on one resource. +func (merger Selector) Select(id ID) (view View, err error) { + list := merger.From.Filter(merger.Lang, id) + if len(list) == 0 { + return nil, ErrNotFound(id) + } + if (merger.As == nil) || !merger.As.IsCompoundList(id) { + view = list[len(list)-1] + } else { + view = &listMerger{list: list} + } + + return view, nil +} diff --git a/modding/hacked/ss1/resource/Selector_test.go b/modding/hacked/ss1/resource/Selector_test.go new file mode 100644 index 0000000..fb731a2 --- /dev/null +++ b/modding/hacked/ss1/resource/Selector_test.go @@ -0,0 +1,225 @@ +package resource_test + +import ( + "fmt" + "io/ioutil" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ResourceSelectorSuite struct { + suite.Suite + + resources []resource.View + view resource.View + + isCompoundList bool + viewStrategy resource.ViewStrategy +} + +func TestResourceSelectorSuite(t *testing.T) { + suite.Run(t, new(ResourceSelectorSuite)) +} + +func (suite *ResourceSelectorSuite) SetupTest() { + suite.resources = nil + suite.view = nil + suite.isCompoundList = false + suite.viewStrategy = nil +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsData() { + suite.givenResource(resource.BlocksFrom([][]byte{{0x01}})) + suite.whenInstanceIsCreated() + suite.thenResourceBlockShouldBe(0, []byte{0x01}) +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsDataFromLastEntryByDefault() { + suite.givenSpecificResource(func(res *resource.Resource) { + res.Blocks = resource.BlocksFrom([][]byte{{0xAA}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Blocks = resource.BlocksFrom([][]byte{{0xBB}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceBlockShouldBe(0, []byte{0xBB}) +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsDataFromLastEntryForCompoundNonListResources() { + suite.givenViewStrategyIsSet() + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{0xAA}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{0xBB}, {0xCC}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceBlockCountShouldBe(2) + suite.thenResourceBlockShouldBe(0, []byte{0xBB}) + suite.thenResourceBlockShouldBe(1, []byte{0xCC}) +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsDataFromLastNonEmptyEntryIfACompoundList() { + suite.givenResourceIsACompoundList() + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{0xAA}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceBlockShouldBe(0, []byte{0xAA}) +} + +func (suite *ResourceSelectorSuite) TestBlockCountOfACompoundListReturnsHighestCount() { + suite.givenResourceIsACompoundList() + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {0x11}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{}, {0x22}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {}, {0x33}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceBlockCountShouldBe(4) +} + +func (suite *ResourceSelectorSuite) TestMetaOfCompoundListIsThatOfFirst() { + suite.givenResourceIsACompoundList() + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Properties.Compressed = true + res.Properties.ContentType = resource.Movie + res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {0x11}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = false + res.Properties.Compressed = false + res.Properties.ContentType = resource.Text + res.Blocks = resource.BlocksFrom([][]byte{{}, {0x22}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceShouldHaveMeta(true, resource.Movie, true) +} + +func (suite *ResourceSelectorSuite) TestMetaOfNonCompoundListIsThatOfLast() { + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Properties.Compressed = true + res.Properties.ContentType = resource.Movie + res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {0x11}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = false + res.Properties.Compressed = false + res.Properties.ContentType = resource.Text + res.Blocks = resource.BlocksFrom([][]byte{{}, {0x22}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceShouldHaveMeta(false, resource.Text, false) +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsErrorForIndexOutOfRange() { + suite.givenSpecificResource(func(res *resource.Resource) { + res.Blocks = resource.BlocksFrom(nil) + }) + suite.whenInstanceIsCreated() + suite.thenBlockShouldReturnErrorFor(0) + suite.thenBlockShouldReturnErrorFor(-1) +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsEmptyReaderIfAllLayersAreEmpty() { + suite.givenSpecificResource(func(res *resource.Resource) { + res.Blocks = resource.BlocksFrom([][]byte{{}}) + }) + suite.whenInstanceIsCreated() + suite.thenBlockShouldReturnFor(0, []byte{}) +} + +func (suite *ResourceSelectorSuite) givenResourceIsACompoundList() { + suite.isCompoundList = true + suite.viewStrategy = suite +} + +func (suite *ResourceSelectorSuite) givenViewStrategyIsSet() { + suite.viewStrategy = suite +} + +func (suite *ResourceSelectorSuite) givenResource(blocks resource.Blocks) { + suite.givenSpecificResource(func(res *resource.Resource) { + res.Blocks = blocks + }) +} + +func (suite *ResourceSelectorSuite) givenSpecificResource(modifier func(*resource.Resource)) { + res := resource.Resource{} + modifier(&res) + suite.resources = append(suite.resources, res) +} + +func (suite *ResourceSelectorSuite) whenInstanceIsCreated() { + selector := resource.Selector{ + Lang: resource.LangAny, + From: suite, + As: suite.viewStrategy, + } + + var err error + suite.view, err = selector.Select(resource.ID(1000)) + require.Nil(suite.T(), err, "No error expected creating view for %v", 1000) +} + +func (suite *ResourceSelectorSuite) thenResourceBlockCountShouldBe(expected int) { + assert.Equal(suite.T(), expected, suite.view.BlockCount(), "Proper block count expected") +} + +func (suite *ResourceSelectorSuite) thenResourceBlockShouldBe(block int, expected []byte) { + reader, err := suite.view.Block(block) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected retrieving block %v", block)) + data, readErr := ioutil.ReadAll(reader) + require.Nil(suite.T(), readErr, fmt.Sprintf("No error expected reading block %v", block)) + assert.Equal(suite.T(), expected, data, "Block data expected") +} + +func (suite *ResourceSelectorSuite) thenResourceShouldHaveMeta(compound bool, contentType resource.ContentType, compressed bool) { + assert.Equal(suite.T(), compound, suite.view.Compound(), "Compound state is wrong") + assert.Equal(suite.T(), contentType, suite.view.ContentType(), "Content type is wrong") + assert.Equal(suite.T(), compressed, suite.view.Compressed(), "Compression is wrong") +} + +func (suite *ResourceSelectorSuite) thenBlockShouldReturnErrorFor(index int) { + _, err := suite.view.Block(index) + assert.NotNil(suite.T(), err, "Error expected") +} + +func (suite *ResourceSelectorSuite) thenBlockShouldReturnFor(index int, expected []byte) { + reader, readerErr := suite.view.Block(index) + require.Nil(suite.T(), readerErr, "No error expected retrieving block") + require.NotNil(suite.T(), reader, "Reader expected") + data, dataErr := ioutil.ReadAll(reader) + require.Nil(suite.T(), dataErr, "No error expected reading block") + assert.Equal(suite.T(), expected, data, "Data mismatch") +} + +func (suite *ResourceSelectorSuite) IsCompoundList(id resource.ID) bool { + assert.Equal(suite.T(), resource.ID(1000), id, "Unknown resource queried") + return suite.isCompoundList +} + +func (suite *ResourceSelectorSuite) Filter(lang resource.Language, id resource.ID) resource.List { + return suite.resources +} diff --git a/modding/hacked/ss1/resource/Store.go b/modding/hacked/ss1/resource/Store.go new file mode 100644 index 0000000..4760b24 --- /dev/null +++ b/modding/hacked/ss1/resource/Store.go @@ -0,0 +1,85 @@ +package resource + +import "io/ioutil" + +// Store holds a set of resources. This set can be modified. +type Store struct { + ids []ID + resources map[ID]*Resource +} + +// IDs returns a list of available IDs this store currently contains. +func (store Store) IDs() []ID { + ids := make([]ID, 0, len(store.resources)) + for _, id := range store.ids { + if _, existing := store.resources[id]; existing { + ids = append(ids, id) + } + } + return ids +} + +// Resource returns reference to the contained resource for given identifier. +func (store Store) Resource(id ID) (*Resource, error) { + res, existing := store.resources[id] + if !existing { + return nil, ErrNotFound(id) + } + return res, nil +} + +// View returns a read-only view on the resource for given identifier. +func (store Store) View(id ID) (View, error) { + return store.Resource(id) +} + +// Del removes the resource with given identifier from the store. +func (store *Store) Del(id ID) bool { + _, existing := store.resources[id] + if existing { + delete(store.resources, id) + } + return existing +} + +// Put (re-)assigns an identifier with data. If no resource with given ID exists, +// then it is created. Existing resources are overwritten with the provided data. +func (store *Store) Put(id ID, view View) error { + data := make([][]byte, view.BlockCount()) + for index := 0; index < len(data); index++ { + reader, err := view.Block(index) + if err != nil { + return err + } + data[index], err = ioutil.ReadAll(reader) + if err != nil { + return err + } + } + + res := &Resource{ + Properties: Properties{ + Compound: view.Compound(), + ContentType: view.ContentType(), + Compressed: view.Compressed(), + }, + Blocks: BlocksFrom(data), + } + if store.resources == nil { + store.resources = make(map[ID]*Resource) + } + store.resources[id] = res + if store.findIDIndex(id) < 0 { + store.ids = append(store.ids, id) + } + return nil +} + +func (store Store) findIDIndex(id ID) int { + for index := 0; index < len(store.ids); index++ { + if store.ids[index] == id { + return index + } + } + return -1 +} diff --git a/modding/hacked/ss1/resource/Store_test.go b/modding/hacked/ss1/resource/Store_test.go new file mode 100644 index 0000000..fd9ad02 --- /dev/null +++ b/modding/hacked/ss1/resource/Store_test.go @@ -0,0 +1,146 @@ +package resource_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type StoreSuite struct { + suite.Suite + + store resource.Store + resourceCounter int +} + +func TestStoreSuite(t *testing.T) { + suite.Run(t, new(StoreSuite)) +} + +func (suite *StoreSuite) SetupTest() { + suite.store = resource.Store{} +} + +func (suite *StoreSuite) TestNewInstanceIsEmpty() { + suite.whenInstanceIsCreated() + suite.thenIDsShouldBeEmpty() +} + +func (suite *StoreSuite) TestResourceReturnsErrorForUnknownResource() { + suite.whenInstanceIsCreated() + suite.thenViewShouldReturnErrorFor(resource.ID(10)) +} + +func (suite *StoreSuite) TestDelWillHaveStoreIgnorePreviousEntry() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(1), suite.aResource()) + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.whenResourceIsDeleted(resource.ID(2)) + suite.thenIDsShouldBe([]resource.ID{resource.ID(1)}) + suite.thenViewShouldReturnErrorFor(resource.ID(2)) +} + +func (suite *StoreSuite) TestDelWillHaveStoreReportFewerIDs() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(1), suite.aResource()) + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.givenStoredResource(resource.ID(3), suite.aResource()) + suite.givenStoredResource(resource.ID(4), suite.aResource()) + suite.whenResourceIsDeleted(resource.ID(2)) + suite.thenIDsShouldBe([]resource.ID{resource.ID(1), resource.ID(3), resource.ID(4)}) +} + +func (suite *StoreSuite) TestPutOverridesPreviousResources() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.givenStoredResource(resource.ID(1), suite.aResource()) + newRes := suite.aResource() + suite.whenResourceIsPut(resource.ID(2), newRes) + suite.thenIDsShouldBe([]resource.ID{resource.ID(2), resource.ID(1)}) + suite.thenReturnedViewShouldBe(resource.ID(2), newRes) +} + +func (suite *StoreSuite) TestDelWillHaveStoreIgnoreResourceEvenIfPutMultipleTimes() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(1), suite.aResource()) + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.whenResourceIsDeleted(resource.ID(2)) + suite.thenIDsShouldBe([]resource.ID{resource.ID(1)}) + suite.thenViewShouldReturnErrorFor(resource.ID(2)) +} + +func (suite *StoreSuite) TestPutAddsNewResourcesAtEnd() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.givenStoredResource(resource.ID(1), suite.aResource()) + newRes := suite.aResource() + suite.whenResourceIsPut(resource.ID(3), newRes) + suite.thenIDsShouldBe([]resource.ID{resource.ID(2), resource.ID(1), resource.ID(3)}) + suite.thenReturnedViewShouldBe(resource.ID(3), newRes) +} + +func (suite *StoreSuite) TestPutRestoresIDAtOldPosition() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.givenStoredResource(resource.ID(1), suite.aResource()) + suite.givenResourceWasDeleted(resource.ID(2)) + newRes := suite.aResource() + suite.whenResourceIsPut(resource.ID(2), newRes) + suite.thenIDsShouldBe([]resource.ID{resource.ID(2), resource.ID(1)}) +} + +func (suite *StoreSuite) givenAnInstance() { + suite.whenInstanceIsCreated() +} + +func (suite *StoreSuite) givenStoredResource(id resource.ID, res *resource.Resource) { + err := suite.store.Put(id, res) + require.Nil(suite.T(), err, "No error expected storing resource") +} + +func (suite *StoreSuite) givenResourceWasDeleted(id resource.ID) { + suite.whenResourceIsDeleted(id) +} + +func (suite *StoreSuite) whenInstanceIsCreated() { + suite.store = resource.Store{} +} + +func (suite *StoreSuite) whenResourceIsDeleted(id resource.ID) { + suite.store.Del(id) +} + +func (suite *StoreSuite) whenResourceIsPut(id resource.ID, res *resource.Resource) { + err := suite.store.Put(id, res) + require.Nil(suite.T(), err, "No error expected putting resource") +} + +func (suite *StoreSuite) thenIDsShouldBeEmpty() { + assert.Equal(suite.T(), 0, len(suite.store.IDs())) +} + +func (suite *StoreSuite) thenIDsShouldBe(expected []resource.ID) { + assert.Equal(suite.T(), expected, suite.store.IDs()) +} + +func (suite *StoreSuite) thenReturnedViewShouldBe(id resource.ID, expected *resource.Resource) { + res, err := suite.store.View(id) + assert.Nil(suite.T(), err, "No error expected for ID %v", id) + assert.Equal(suite.T(), expected, res, "Different res returned for ID %v", id) +} + +func (suite *StoreSuite) thenViewShouldReturnErrorFor(id resource.ID) { + _, err := suite.store.View(id) + assert.Error(suite.T(), err, "Error expected for ID %v ", id) // nolint: vet +} + +func (suite *StoreSuite) aResource() *resource.Resource { + suite.resourceCounter++ + return &resource.Resource{Blocks: resource.BlocksFrom([][]byte{{byte(suite.resourceCounter)}})} +} diff --git a/modding/hacked/ss1/resource/View.go b/modding/hacked/ss1/resource/View.go new file mode 100644 index 0000000..913b4d0 --- /dev/null +++ b/modding/hacked/ss1/resource/View.go @@ -0,0 +1,29 @@ +package resource + +import ( + "io" +) + +// View is a read-only view on a selected resource. +type View interface { + + // Compound tells whether the resource should be serialized with a directory. + // Compound resources can have zero, one, or more blocks. + // Simple resources always have exactly one block. + Compound() bool + + // ContentType describes how the block data shall be interpreted. + ContentType() ContentType + + // Compressed tells whether the data shall be serialized in compressed form. + Compressed() bool + + // BlockCount returns the number of available blocks in the resource. + // Simple resources will always have exactly one block. + BlockCount() int + + // Block returns the reader for the identified block. + // Each call returns a new reader instance. + // Data provided by this reader is always uncompressed. + Block(index int) (io.Reader, error) +} diff --git a/modding/hacked/ss1/resource/ViewStrategy.go b/modding/hacked/ss1/resource/ViewStrategy.go new file mode 100644 index 0000000..e9f4e22 --- /dev/null +++ b/modding/hacked/ss1/resource/ViewStrategy.go @@ -0,0 +1,9 @@ +package resource + +// ViewStrategy defines how selected resources shall be viewed. +type ViewStrategy interface { + // IsCompoundList returns true for compound resources where each contained block is a separate entity. + // Separate entities are those that can be replaced without affecting others. + // Examples are the small game textures and the list of object names. + IsCompoundList(id ID) bool +} diff --git a/modding/hacked/ss1/resource/Viewer.go b/modding/hacked/ss1/resource/Viewer.go new file mode 100644 index 0000000..a985e18 --- /dev/null +++ b/modding/hacked/ss1/resource/Viewer.go @@ -0,0 +1,10 @@ +package resource + +// Viewer provides resources from some storage. +type Viewer interface { + // IDs returns a list of available IDs this viewer can provide. + IDs() []ID + + // View returns a read-only view to a resource for the given identifier. + View(id ID) (View, error) +} diff --git a/modding/hacked/ss1/resource/doc.go b/modding/hacked/ss1/resource/doc.go new file mode 100644 index 0000000..27eba71 --- /dev/null +++ b/modding/hacked/ss1/resource/doc.go @@ -0,0 +1,4 @@ +/* +Package resource provides interfaces and primitives for low-level handling of resources. +*/ +package resource diff --git a/modding/hacked/ss1/resource/lgres/BlockReader.go b/modding/hacked/ss1/resource/lgres/BlockReader.go new file mode 100644 index 0000000..c491efe --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/BlockReader.go @@ -0,0 +1,22 @@ +package lgres + +import "io" + +type blockFunc func(index int) (io.Reader, error) + +type blockReader struct { + blockCount int + blockFunc blockFunc +} + +// BlockCount returns the number of available blocks. +func (reader *blockReader) BlockCount() int { + return reader.blockCount +} + +// Block returns the reader for the identified block. +// Each call returns a new reader instance. +// Data provided by this reader is always uncompressed. +func (reader *blockReader) Block(index int) (io.Reader, error) { + return reader.blockFunc(index) +} diff --git a/modding/hacked/ss1/resource/lgres/BlockWriter.go b/modding/hacked/ss1/resource/lgres/BlockWriter.go new file mode 100644 index 0000000..0923858 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/BlockWriter.go @@ -0,0 +1,24 @@ +package lgres + +import "io" + +// BlockWriter is for writing data of a single block in a resource. +type BlockWriter struct { + target io.Writer + finisher func() + bytesWritten uint32 +} + +func (writer *BlockWriter) finish() (length uint32) { + writer.finisher() + writer.finisher = nil + writer.target = nil + return writer.bytesWritten +} + +// Write stores the given data in the block and follows the Writer interface. +func (writer *BlockWriter) Write(data []byte) (written int, err error) { + written, err = writer.target.Write(data) + writer.bytesWritten += uint32(written) + return +} diff --git a/modding/hacked/ss1/resource/lgres/FragmentedChunkWriter.go b/modding/hacked/ss1/resource/lgres/FragmentedChunkWriter.go new file mode 100644 index 0000000..3b619bd --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/FragmentedChunkWriter.go @@ -0,0 +1,64 @@ +package lgres + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// CompoundResourceWriter writes a resource with zero, one, or more blocks. +// Multiple blocks can be created and then written concurrently. Only when the +// resource is finished, the blocks are finalized. +type CompoundResourceWriter struct { + target *serial.PositioningEncoder + + compressed bool + dataPaddingSize int + blockStores []*serial.ByteStore + blockWriter []*BlockWriter +} + +// CreateBlock provides a new, dedicated writer for a new block. +func (writer *CompoundResourceWriter) CreateBlock() *BlockWriter { + store := serial.NewByteStore() + blockWriter := &BlockWriter{target: serial.NewEncoder(store), finisher: func() {}} + writer.blockStores = append(writer.blockStores, store) + writer.blockWriter = append(writer.blockWriter, blockWriter) + return blockWriter +} + +func (writer *CompoundResourceWriter) finish() (length uint32) { + var unpackedSize uint32 + blockCount := len(writer.blockStores) + writer.target.Code(uint16(blockCount)) + offset := 2 + (blockCount+1)*4 + writer.dataPaddingSize + for index, store := range writer.blockStores { + unpackedSize += writer.blockWriter[index].finish() + writer.target.Code(uint32(offset)) + offset += len(store.Data()) + } + writer.target.Code(uint32(offset)) + unpackedSize += writer.target.CurPos() + uint32(writer.dataPaddingSize) + + writer.writeBlocks() + + return unpackedSize +} + +func (writer *CompoundResourceWriter) writeBlocks() { + var targetWriter io.Writer = writer.target + targetFinisher := func() {} + if writer.compressed { + compressor := compression.NewCompressor(targetWriter) + targetWriter = compressor + targetFinisher = func() { compressor.Close() } // nolint: errcheck + } + for i := 0; i < writer.dataPaddingSize; i++ { + targetWriter.Write([]byte{0x00}) // nolint: errcheck + } + for _, store := range writer.blockStores { + targetWriter.Write(store.Data()) // nolint: errcheck + } + targetFinisher() +} diff --git a/modding/hacked/ss1/resource/lgres/Reader.go b/modding/hacked/ss1/resource/lgres/Reader.go new file mode 100644 index 0000000..414b13c --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Reader.go @@ -0,0 +1,256 @@ +package lgres + +import ( + "bytes" + "encoding/binary" + "io" + "io/ioutil" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// Reader provides methods to extract resource data from a serialized form. +// Resources may be accessed out of sequence due to the nature of the underlying io.ReaderAt. +type Reader struct { + source io.ReaderAt + firstResourceOffset uint32 + directoryOffset uint32 + directory []resourceDirectoryEntry + + cache map[uint16]resource.View +} + +const ( + // ErrSourceNil is returned if the source is not valid. + ErrSourceNil ss1.StringError = "source is nil" + + // ErrFormatMismatch is returned if the format is unknown. + ErrFormatMismatch ss1.StringError = "format mismatch" +) + +// ReaderFrom accesses the provided source and creates a new Reader instance +// from it. +// Should the provided decoder not follow the resource file format, an error +// is returned. +func ReaderFrom(source io.ReaderAt) (reader *Reader, err error) { + if source == nil { + return nil, ErrSourceNil + } + + var dirOffset uint32 + dirOffset, err = readAndVerifyHeader(io.NewSectionReader(source, 0, format.ResourceDirectoryFileOffsetPos+4)) + if err != nil { + return nil, err + } + firstResourceOffset, directory, err := readDirectoryAt(dirOffset, source) + if err != nil { + return nil, err + } + + reader = &Reader{ + source: source, + firstResourceOffset: firstResourceOffset, + directoryOffset: dirOffset, + directory: directory, + cache: make(map[uint16]resource.View)} + + return +} + +// IDs returns the resource identifier available via this reader. +// The order in the slice is the same as in the underlying serialized form. +func (reader *Reader) IDs() []resource.ID { + ids := make([]resource.ID, len(reader.directory)) + for index, entry := range reader.directory { + ids[index] = resource.ID(entry.ID) + } + return ids +} + +// View returns a reader prepared to extract data for the specified resource. +// An error is returned if either the ID is not known, or the resource could not be prepared. +func (reader *Reader) View(id resource.ID) (retrievedResource resource.View, err error) { + if cachedResource, existing := reader.cache[id.Value()]; existing { + return cachedResource, nil + } + resourceStartOffset, entry := reader.findEntry(id.Value()) + if entry == nil { + return nil, resource.ErrNotFound(id) + } + resourceType := entry.resourceType() + compressed := (resourceType & format.ResourceTypeFlagCompressed) != 0 + compound := (resourceType & format.ResourceTypeFlagCompound) != 0 + contentType := resource.ContentType(entry.contentType()) + + if compound { + retrievedResource, err = reader.newCompoundResourceReader(entry, contentType, compressed, resourceStartOffset) + } else { + retrievedResource, err = reader.newSingleBlockResourceReader(entry, contentType, compressed, resourceStartOffset) + } + if err == nil { + reader.cache[id.Value()] = retrievedResource + } + return +} + +func readAndVerifyHeader(source io.ReadSeeker) (dirOffset uint32, err error) { + coder := serial.NewPositioningDecoder(source) + data := make([]byte, format.ResourceDirectoryFileOffsetPos) + coder.Code(data) + coder.Code(&dirOffset) + + expected := make([]byte, len(format.HeaderString)+1) + for index, r := range format.HeaderString { + expected[index] = byte(r) + } + expected[len(format.HeaderString)] = format.CommentTerminator + if !bytes.Equal(data[:len(expected)], expected) { + return 0, ErrFormatMismatch + } + + return dirOffset, coder.FirstError() +} + +func readDirectoryAt(dirOffset uint32, source io.ReaderAt) (firstResourceOffset uint32, directory []resourceDirectoryEntry, err error) { + var header resourceDirectoryHeader + headerSize := int64(binary.Size(&header)) + { + headerCoder := serial.NewDecoder(io.NewSectionReader(source, int64(dirOffset), headerSize)) + headerCoder.Code(&header) + if headerCoder.FirstError() != nil { + return 0, nil, headerCoder.FirstError() + } + } + + firstResourceOffset = header.FirstResourceOffset + directory = make([]resourceDirectoryEntry, header.ResourceCount) + if header.ResourceCount > 0 { + listCoder := serial.NewDecoder(io.NewSectionReader(source, int64(dirOffset)+headerSize, int64(binary.Size(directory)))) + listCoder.Code(directory) + err = listCoder.FirstError() + } + return +} + +func (reader *Reader) findEntry(id uint16) (startOffset uint32, entry *resourceDirectoryEntry) { + startOffset = reader.firstResourceOffset + for index := 0; (index < len(reader.directory)) && (entry == nil); index++ { + cur := &reader.directory[index] + if cur.ID == id { + entry = cur + } else { + startOffset += cur.packedLength() + startOffset += (format.BoundarySize - (startOffset % format.BoundarySize)) % format.BoundarySize + } + } + return +} + +type blockListEntry struct { + start uint32 + size uint32 +} + +func (reader *Reader) newCompoundResourceReader(entry *resourceDirectoryEntry, + contentType resource.ContentType, compressed bool, resourceStartOffset uint32) (resource.View, error) { + resourceDataReader := io.NewSectionReader(reader.source, int64(resourceStartOffset), int64(entry.packedLength())) + + firstBlockOffset, blockList, err := reader.readBlockList(resourceDataReader) + if err != nil { + return nil, err + } + blockCount := len(blockList) + + rawBlockDataReader := io.NewSectionReader(resourceDataReader, int64(firstBlockOffset), resourceDataReader.Size()-int64(firstBlockOffset)) + var uncompressedReader io.ReaderAt + if !compressed { + uncompressedReader = rawBlockDataReader + } + + blockFunc := func(index int) (io.Reader, error) { + if (index < 0) || (index >= blockCount) { + return nil, resource.ErrBlockNotFound(index, blockCount) + } + + if compressed && (uncompressedReader == nil) { + decompressor := compression.NewDecompressor(rawBlockDataReader) + decompressedData, err := ioutil.ReadAll(decompressor) + if err != nil { + return nil, err + } + uncompressedReader = bytes.NewReader(decompressedData) + } + + entry := blockList[index] + reader := io.NewSectionReader(uncompressedReader, int64(entry.start)-int64(firstBlockOffset), int64(entry.size)) + return reader, nil + } + + return &readerResource{ + Properties: resource.Properties{ + Compound: true, + ContentType: contentType, + Compressed: compressed, + }, + blockReader: blockReader{blockCount: len(blockList), blockFunc: blockFunc}}, nil +} + +func (reader *Reader) readBlockList(source io.Reader) (uint32, []blockListEntry, error) { + listDecoder := serial.NewDecoder(source) + var blockCount uint16 + listDecoder.Code(&blockCount) + var firstBlockOffset uint32 + listDecoder.Code(&firstBlockOffset) + lastBlockEndOffset := firstBlockOffset + blockList := make([]blockListEntry, blockCount) + for blockIndex := uint16(0); blockIndex < blockCount; blockIndex++ { + var endOffset uint32 + listDecoder.Code(&endOffset) + blockList[blockIndex].start = lastBlockEndOffset + blockList[blockIndex].size = endOffset - lastBlockEndOffset + lastBlockEndOffset = endOffset + } + + return firstBlockOffset, blockList, listDecoder.FirstError() +} + +func (reader *Reader) newSingleBlockResourceReader(entry *resourceDirectoryEntry, + contentType resource.ContentType, compressed bool, resourceStartOffset uint32) (resource.View, error) { + blockFunc := func(index int) (io.Reader, error) { + if index != 0 { + return nil, resource.ErrBlockNotFound(index, 1) + } + resourceSize := entry.packedLength() + // The following check is a hack to re-create the behaviour of the engine. + // While in truth the resource should only cover what its entry needs, Movie resources + // can get larger than the 24bit size-field would allow (16MiB). + // Interestingly, the engine does not limit this and simply reads beyond the limit. + // In order to support such "broken" files, the behaviour is re-created here. This is mainly to + // still allow creating such files and also give the editor a chance to work (and display such a case) + // without completely having to rework the whole architecture here. + // As audio of logs is also stored with this content type, limit to only single-resource files + // to avoid a sudden increase of other resources in the file. + isCutscene := (resource.ContentType(entry.contentType()) == resource.Movie) && (len(reader.directory) == 1) + if isCutscene && (resourceStartOffset < reader.directoryOffset) { + resourceSize = reader.directoryOffset - resourceStartOffset + } + var resourceSource io.Reader = io.NewSectionReader(reader.source, int64(resourceStartOffset), int64(resourceSize)) + if compressed { + resourceSize = entry.unpackedLength() + resourceSource = compression.NewDecompressor(resourceSource) + } + return io.LimitReader(resourceSource, int64(resourceSize)), nil + } + + return &readerResource{ + Properties: resource.Properties{ + Compound: false, + ContentType: contentType, + Compressed: compressed, + }, + blockReader: blockReader{blockCount: 1, blockFunc: blockFunc}}, nil +} diff --git a/modding/hacked/ss1/resource/lgres/ReaderResource.go b/modding/hacked/ss1/resource/lgres/ReaderResource.go new file mode 100644 index 0000000..012ac3c --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/ReaderResource.go @@ -0,0 +1,20 @@ +package lgres + +import "github.com/inkyblackness/hacked/ss1/resource" + +type readerResource struct { + resource.Properties + blockReader +} + +func (rr readerResource) Compound() bool { + return rr.Properties.Compound +} + +func (rr readerResource) ContentType() resource.ContentType { + return rr.Properties.ContentType +} + +func (rr readerResource) Compressed() bool { + return rr.Properties.Compressed +} diff --git a/modding/hacked/ss1/resource/lgres/Reader_test.go b/modding/hacked/ss1/resource/lgres/Reader_test.go new file mode 100644 index 0000000..5f19aca --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Reader_test.go @@ -0,0 +1,155 @@ +package lgres_test + +import ( + "bytes" + "fmt" + "io/ioutil" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReaderFromReturnsErrorForNilSource(t *testing.T) { + reader, err := lgres.ReaderFrom(nil) + + assert.Nil(t, reader, "reader should be nil") + assert.Equal(t, lgres.ErrSourceNil, err) +} + +func TestReaderFromReturnsInstanceOnEmptySource(t *testing.T) { + source := bytes.NewReader(emptyResourceFile()) + reader, err := lgres.ReaderFrom(source) + + assert.Nil(t, err, "Error should be nil") + assert.NotNil(t, reader) +} + +func TestReaderFromReturnsErrorOnInvalidHeaderString(t *testing.T) { + sourceData := emptyResourceFile() + sourceData[10] = "A"[0] + + _, err := lgres.ReaderFrom(bytes.NewReader(sourceData)) + + assert.Equal(t, lgres.ErrFormatMismatch, err) +} + +func TestReaderFromReturnsErrorOnMissingCommentTerminator(t *testing.T) { + sourceData := emptyResourceFile() + sourceData[len(format.HeaderString)] = byte(0) + + _, err := lgres.ReaderFrom(bytes.NewReader(sourceData)) + + assert.Equal(t, lgres.ErrFormatMismatch, err) +} + +func TestReaderFromReturnsErrorOnInvalidDirectoryStart(t *testing.T) { + sourceData := emptyResourceFile() + sourceData[format.ResourceDirectoryFileOffsetPos] = byte(0xFF) + sourceData[format.ResourceDirectoryFileOffsetPos+1] = byte(0xFF) + + _, err := lgres.ReaderFrom(bytes.NewReader(sourceData)) + + assert.NotNil(t, err, "error expected") +} + +func TestReaderFromCanDecodeExampleResourceFile(t *testing.T) { + _, err := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + assert.Nil(t, err, "no error expected") +} + +func TestReaderIDsReturnsTheStoredResourceIDsInOrderFromFile(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + + assert.Equal(t, []resource.ID{exampleResourceIDSingleBlockResource, exampleResourceIDSingleBlockResourceCompressed, + exampleResourceIDCompoundResource, exampleResourceIDCompoundResourceCompressed}, reader.IDs()) +} + +func TestReaderResourceReturnsErrorForUnknownID(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(emptyResourceFile())) + resourceReader, err := reader.View(resource.ID(0x1111)) + assert.Nil(t, resourceReader, "no reader expected") + assert.NotNil(t, err) +} + +func TestReaderResourceReturnsAResourceReaderForKnownID(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + resourceReader, err := reader.View(exampleResourceIDSingleBlockResource) + assert.Nil(t, err, "no error expected") + assert.NotNil(t, resourceReader) +} + +func TestReaderResourceReturnsResourceWithMetaInformation(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + info := func(resourceID resource.ID, name string, expected interface{}) string { + return fmt.Sprintf("Resource 0x%04X should have %v = %v", resourceID.Value(), name, expected) + } + verifyResource := func(resourceID resource.ID, compound bool, contentType resource.ContentType, compressed bool) { + resourceReader, _ := reader.View(resourceID) + assert.Equal(t, compound, resourceReader.Compound(), info(resourceID, "compound", compound)) + assert.Equal(t, contentType, resourceReader.ContentType(), info(resourceID, "contentType", contentType)) + assert.Equal(t, compressed, resourceReader.Compressed(), info(resourceID, "compressed", compressed)) + } + verifyResource(exampleResourceIDSingleBlockResource, false, resource.ContentType(0x01), false) + verifyResource(exampleResourceIDSingleBlockResourceCompressed, false, resource.ContentType(0x02), true) + verifyResource(exampleResourceIDCompoundResource, true, resource.ContentType(0x03), false) + verifyResource(exampleResourceIDCompoundResourceCompressed, true, resource.ContentType(0x04), true) +} + +func TestReaderResourceReturnsSameInstance(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + + c1, _ := reader.View(exampleResourceIDSingleBlockResource) + c2, _ := reader.View(exampleResourceIDSingleBlockResource) + + assert.Equal(t, c1, c2, "Resources should be the same") +} + +func TestReaderResourceWithUncompressedSingleBlockContent(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + resourceReader, _ := reader.View(exampleResourceIDSingleBlockResource) + + assert.Equal(t, 1, resourceReader.BlockCount()) + verifyBlockContent(t, resourceReader, 0, []byte{0x01, 0x01, 0x01}) +} + +func TestReaderResourceWithCompressedSingleBlockContent(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + resourceReader, _ := reader.View(exampleResourceIDSingleBlockResourceCompressed) + + assert.Equal(t, 1, resourceReader.BlockCount()) + verifyBlockContent(t, resourceReader, 0, []byte{0x02, 0x02}) +} + +func TestReaderResourceWithUncompressedCompoundContent(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + resourceReader, _ := reader.View(exampleResourceIDCompoundResource) + + assert.Equal(t, 2, resourceReader.BlockCount()) + verifyBlockContent(t, resourceReader, 0, []byte{0x30, 0x30, 0x30, 0x30}) + verifyBlockContent(t, resourceReader, 1, []byte{0x31, 0x31, 0x31}) +} + +func TestReaderResourceWithCompressedCompoundContent(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + resourceReader, _ := reader.View(exampleResourceIDCompoundResourceCompressed) + + assert.Equal(t, 3, resourceReader.BlockCount()) + verifyBlockContent(t, resourceReader, 0, []byte{0x40, 0x40}) + verifyBlockContent(t, resourceReader, 1, []byte{0x41, 0x41, 0x41, 0x41}) + verifyBlockContent(t, resourceReader, 2, []byte{0x42}) +} + +func verifyBlockContent(t *testing.T, blockProvider resource.BlockProvider, blockIndex int, expected []byte) { + t.Helper() + blockReader, readerErr := blockProvider.Block(blockIndex) + assert.Nil(t, readerErr, "error retrieving reader") + require.NotNil(t, blockReader, "reader is nil") + data, dataErr := ioutil.ReadAll(blockReader) + assert.Nil(t, dataErr, "no error expected reading data") + assert.Equal(t, expected, data) +} diff --git a/modding/hacked/ss1/resource/lgres/ResourceDirectoryEntry.go b/modding/hacked/ss1/resource/lgres/ResourceDirectoryEntry.go new file mode 100644 index 0000000..99e7f6a --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/ResourceDirectoryEntry.go @@ -0,0 +1,52 @@ +package lgres + +import ( + "math" +) + +type resourceDirectoryEntry struct { + ID uint16 + UnpackedLengthAndResourceType uint32 + PackedLengthAndContentType uint32 +} + +func maskBits(field uint32, bitOffset uint, bitCount int) uint32 { + return (field >> bitOffset) & uint32(^(uint64(math.MaxUint64) << uint64(bitCount))) +} + +func setBits(field uint32, bitOffset uint, bitCount int, value uint32) uint32 { + mask := uint32(^(uint64(math.MaxUint64) << uint64(bitCount))) + return (field & (^mask << bitOffset)) | ((value & mask) << bitOffset) +} + +func (entry *resourceDirectoryEntry) setUnpackedLength(value uint32) { + entry.UnpackedLengthAndResourceType = setBits(entry.UnpackedLengthAndResourceType, 0, 24, value) +} + +func (entry *resourceDirectoryEntry) unpackedLength() uint32 { + return maskBits(entry.UnpackedLengthAndResourceType, 0, 24) +} + +func (entry *resourceDirectoryEntry) setResourceType(value byte) { + entry.UnpackedLengthAndResourceType = setBits(entry.UnpackedLengthAndResourceType, 24, 8, uint32(value)) +} + +func (entry *resourceDirectoryEntry) resourceType() byte { + return byte(maskBits(entry.UnpackedLengthAndResourceType, 24, 8)) +} + +func (entry *resourceDirectoryEntry) setPackedLength(value uint32) { + entry.PackedLengthAndContentType = setBits(entry.PackedLengthAndContentType, 0, 24, value) +} + +func (entry *resourceDirectoryEntry) packedLength() uint32 { + return maskBits(entry.PackedLengthAndContentType, 0, 24) +} + +func (entry *resourceDirectoryEntry) setContentType(value byte) { + entry.PackedLengthAndContentType = setBits(entry.PackedLengthAndContentType, 24, 8, uint32(value)) +} + +func (entry *resourceDirectoryEntry) contentType() byte { + return byte(maskBits(entry.PackedLengthAndContentType, 24, 8)) +} diff --git a/modding/hacked/ss1/resource/lgres/ResourceDirectoryHeader.go b/modding/hacked/ss1/resource/lgres/ResourceDirectoryHeader.go new file mode 100644 index 0000000..dcd62a9 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/ResourceDirectoryHeader.go @@ -0,0 +1,6 @@ +package lgres + +type resourceDirectoryHeader struct { + ResourceCount uint16 + FirstResourceOffset uint32 +} diff --git a/modding/hacked/ss1/resource/lgres/ResourceWriter.go b/modding/hacked/ss1/resource/lgres/ResourceWriter.go new file mode 100644 index 0000000..f172813 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/ResourceWriter.go @@ -0,0 +1,8 @@ +package lgres + +type resourceWriter interface { + // finish signals the writer that the resource is to be finished. + // Any pending data needs to be sent to the output and the uncompressed + // size shall be returned. + finish() uint32 +} diff --git a/modding/hacked/ss1/resource/lgres/Write.go b/modding/hacked/ss1/resource/lgres/Write.go new file mode 100644 index 0000000..9c3100f --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Write.go @@ -0,0 +1,72 @@ +package lgres + +import ( + "fmt" + "io" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +type resourceInconsistentError struct { + ID resource.ID + BlockCount int +} + +func (err resourceInconsistentError) Error() string { + return fmt.Sprintf("simple resource %v has invalid number of blocks: %d", err.ID, err.BlockCount) +} + +// Write serializes the resources from given source into the target. +// It is a convenience function for using Writer. +func Write(target io.WriteSeeker, source resource.Viewer) error { + writer, writerErr := NewWriter(target) + if writerErr != nil { + return writerErr + } + + for _, id := range source.IDs() { + entry, resourceErr := source.View(id) + if resourceErr != nil { + return resourceErr + } + + switch { + case entry.Compound(): + resourceWriter, resourceWriterErr := writer.CreateCompoundResource(id, entry.ContentType(), entry.Compressed()) + if resourceWriterErr != nil { + return resourceWriterErr + } + copyErr := copyBlocks(entry, func() io.Writer { return resourceWriter.CreateBlock() }) + if copyErr != nil { + return copyErr + } + case entry.BlockCount() == 1: + blockWriter, resourceWriterErr := writer.CreateResource(id, entry.ContentType(), entry.Compressed()) + if resourceWriterErr != nil { + return resourceWriterErr + } + copyErr := copyBlocks(entry, func() io.Writer { return blockWriter }) + if copyErr != nil { + return copyErr + } + default: + return resourceInconsistentError{ID: id, BlockCount: entry.BlockCount()} + } + } + + return writer.Finish() +} + +func copyBlocks(source resource.BlockProvider, nextWriter func() io.Writer) error { + for blockIndex := 0; blockIndex < source.BlockCount(); blockIndex++ { + blockReader, blockErr := source.Block(blockIndex) + if blockErr != nil { + return blockErr + } + _, copyErr := io.Copy(nextWriter(), blockReader) + if copyErr != nil { + return copyErr + } + } + return nil +} diff --git a/modding/hacked/ss1/resource/lgres/Write_test.go b/modding/hacked/ss1/resource/lgres/Write_test.go new file mode 100644 index 0000000..0918f82 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Write_test.go @@ -0,0 +1,44 @@ +package lgres_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" +) + +func TestWrite(t *testing.T) { + target := serial.NewByteStore() + var store resource.Store + aResource := func(compressed bool, contentType resource.ContentType, compound bool, blocks resource.Blocks) resource.View { + return resource.Resource{ + Properties: resource.Properties{ + Compressed: compressed, + ContentType: contentType, + Compound: compound, + }, + Blocks: blocks, + } + } + + _ = store.Put(resource.ID(1), aResource(false, resource.Bitmap, false, resource.BlocksFrom([][]byte{{0x11}}))) + _ = store.Put(resource.ID(3), aResource(false, resource.Font, true, resource.BlocksFrom([][]byte{{0x21}, {0x22, 0x23}}))) + _ = store.Put(resource.ID(2), aResource(true, resource.Geometry, false, resource.BlocksFrom([][]byte{{0x31}}))) + _ = store.Put(resource.ID(4), aResource(true, resource.Archive, true, resource.BlocksFrom([][]byte{{0x41}, {0x42, 0x43}}))) + + errWrite := lgres.Write(target, store) + if errWrite != nil { + assert.Nil(t, errWrite, "no error expected writing") + } + + reader, errReader := lgres.ReaderFrom(bytes.NewReader(target.Data())) + if errReader != nil { + assert.Nil(t, errReader, "no error expected reading") + } + + assert.Equal(t, []resource.ID{resource.ID(1), resource.ID(3), resource.ID(2), resource.ID(4)}, reader.IDs()) +} diff --git a/modding/hacked/ss1/resource/lgres/Writer.go b/modding/hacked/ss1/resource/lgres/Writer.go new file mode 100644 index 0000000..988f6fc --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Writer.go @@ -0,0 +1,177 @@ +package lgres + +import ( + "io" + "math" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// Writer provides methods to write a new resource file from scratch. +// Resources have to be created sequentially. The writer does not support +// concurrent creation and modification of resources. +type Writer struct { + encoder *serial.PositioningEncoder + + firstResourceOffset uint32 + currentResourceStartOffset uint32 + currentResource resourceWriter + + directory []*resourceDirectoryEntry +} + +// ErrTargetNil is returned if the target cannot be used. +const ErrTargetNil ss1.StringError = "target is nil" + +// NewWriter returns a new Writer instance prepared to add resources. +// To finalize the created file, call Finish(). +// +// This function will write initial information to the target and will return +// an error if the writer did. In such a case, the returned writer instance +// will produce invalid results and the state of the target is undefined. +func NewWriter(target io.WriteSeeker) (*Writer, error) { + if target == nil { + return nil, ErrTargetNil + } + + encoder := serial.NewPositioningEncoder(target) + writer := &Writer{encoder: encoder} + writer.writeHeader() + writer.firstResourceOffset = writer.encoder.CurPos() + + return writer, writer.encoder.FirstError() +} + +// ErrWriterFinished is returned if the writer was already finished. +const ErrWriterFinished ss1.StringError = "writer is finished" + +// CreateResource adds a new single-block resource to the current resource file. +// This resource is closed by creating another resource, or by finishing the writer. +func (writer *Writer) CreateResource(id resource.ID, contentType resource.ContentType, + compressed bool) (*BlockWriter, error) { + if writer.encoder == nil { + return nil, ErrWriterFinished + } + + writer.finishLastResource() + if writer.encoder.FirstError() != nil { + return nil, writer.encoder.FirstError() + } + + var targetWriter io.Writer = serial.NewEncoder(writer.encoder) + targetFinisher := func() {} + resourceType := byte(0x00) + if compressed { + compressor := compression.NewCompressor(targetWriter) + resourceType |= format.ResourceTypeFlagCompressed + targetWriter = compressor + targetFinisher = func() { compressor.Close() } // nolint: errcheck + } + blockWriter := &BlockWriter{target: targetWriter, finisher: targetFinisher} + writer.addNewResource(id, contentType, resourceType, blockWriter) + + return blockWriter, nil +} + +// CreateCompoundResource adds a new compound resource to the current resource file. +// This resource is closed by creating another resource, or by finishing the writer. +func (writer *Writer) CreateCompoundResource(id resource.ID, contentType resource.ContentType, + compressed bool) (*CompoundResourceWriter, error) { + if writer.encoder == nil { + return nil, ErrWriterFinished + } + + writer.finishLastResource() + if writer.encoder.FirstError() != nil { + return nil, writer.encoder.FirstError() + } + + resourceType := format.ResourceTypeFlagCompound + if compressed { + resourceType |= format.ResourceTypeFlagCompressed + } + resourceWriter := &CompoundResourceWriter{ + target: serial.NewPositioningEncoder(writer.encoder), + compressed: compressed, + dataPaddingSize: writer.dataPaddingSizeForCompoundResource(id)} + writer.addNewResource(id, contentType, resourceType, resourceWriter) + + return resourceWriter, nil +} + +// Finish finalizes the resource file. After calling this function, the +// writer becomes unusable. +func (writer *Writer) Finish() (err error) { + if writer.encoder == nil { + return ErrWriterFinished + } + + writer.finishLastResource() + + directoryOffset := writer.encoder.CurPos() + writer.encoder.SetCurPos(format.ResourceDirectoryFileOffsetPos) + writer.encoder.Code(directoryOffset) + writer.encoder.SetCurPos(directoryOffset) + writer.encoder.Code(uint16(len(writer.directory))) + writer.encoder.Code(writer.firstResourceOffset) + for _, entry := range writer.directory { + writer.encoder.Code(entry) + } + + err = writer.encoder.FirstError() + writer.encoder = nil + + return +} + +func (writer *Writer) writeHeader() { + header := make([]byte, format.ResourceDirectoryFileOffsetPos) + for index, r := range format.HeaderString { + header[index] = byte(r) + } + header[len(format.HeaderString)] = format.CommentTerminator + writer.encoder.Code(header) + writer.encoder.Code(uint32(math.MaxUint32)) +} + +func (writer *Writer) addNewResource(id resource.ID, contentType resource.ContentType, resourceType byte, newResource resourceWriter) { + entry := &resourceDirectoryEntry{ID: id.Value()} + entry.setContentType(byte(contentType)) + entry.setResourceType(resourceType) + writer.directory = append(writer.directory, entry) + writer.currentResource = newResource + writer.currentResourceStartOffset = writer.encoder.CurPos() +} + +func (writer *Writer) finishLastResource() { + if writer.currentResource != nil { + currentEntry := writer.directory[len(writer.directory)-1] + currentEntry.setUnpackedLength(writer.currentResource.finish()) + currentEntry.setPackedLength(writer.encoder.CurPos() - writer.currentResourceStartOffset) + + writer.currentResourceStartOffset = 0 + writer.currentResource = nil + } + writer.alignToBoundary() +} + +func (writer *Writer) alignToBoundary() { + extraBytes := writer.encoder.CurPos() % format.BoundarySize + if extraBytes > 0 { + padding := make([]byte, format.BoundarySize-extraBytes) + writer.encoder.Code(padding) + } +} + +func (writer *Writer) dataPaddingSizeForCompoundResource(id resource.ID) (padding int) { + // Some directories have a 2byte padding before the actual data + idValue := id.Value() + if (idValue >= 0x08FC) && (idValue <= 0x094B) { // all resources in obj3d.res + padding = 2 + } + return +} diff --git a/modding/hacked/ss1/resource/lgres/Writer_test.go b/modding/hacked/ss1/resource/lgres/Writer_test.go new file mode 100644 index 0000000..183072b --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Writer_test.go @@ -0,0 +1,196 @@ +package lgres_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" +) + +func TestNewWriterReturnsErrorForNilTarget(t *testing.T) { + writer, err := lgres.NewWriter(nil) + + assert.Nil(t, writer, "writer should be nil") + assert.Equal(t, lgres.ErrTargetNil, err) +} + +func TestWriterFinishWithoutAddingResourcesCreatesValidFileWithoutResources(t *testing.T) { + emptyFileData := emptyResourceFile() + store := serial.NewByteStore() + writer, err := lgres.NewWriter(store) + assert.Nil(t, err, "no error expected creating writer") + + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing writer") + assert.Equal(t, emptyFileData, store.Data()) +} + +func TestWriterFinishReturnsErrorWhenAlreadyFinished(t *testing.T) { + writer, _ := lgres.NewWriter(serial.NewByteStore()) + + err := writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + err = writer.Finish() + assert.Equal(t, lgres.ErrWriterFinished, err) +} + +func TestWriterUncompressedSingleBlockResourceCanBeWritten(t *testing.T) { + data := []byte{0xAB, 0x01, 0xCD, 0x02, 0xEF} + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + resourceWriter, err := writer.CreateResource(resource.ID(0x1234), resource.ContentType(0x0A), false) + assert.Nil(t, err, "no error expected creating resource") + _, err = resourceWriter.Write(data) + assert.Nil(t, err, "no error expected writing") + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + result := store.Data() + + var expected []byte + expected = append(expected, data...) + expected = append(expected, + 0x00, 0x00, 0x00, // alignment for directory + 0x01, 0x00, // resource count + 0x80, 0x00, 0x00, 0x00, // offset to first resource + 0x34, 0x12, // resource ID + 0x05, 0x00, 0x00, // resource length (uncompressed) + 0x00, // resource type (uncompressed, single-block) + 0x05, 0x00, 0x00, // resource length in file + 0x0A) // content type + assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:]) +} + +func TestWriterUncompressedCompoundResourceCanBeWritten(t *testing.T) { + blockData1 := []byte{0xAB, 0x01, 0xCD} + blockData2 := []byte{0x11, 0x22, 0x33, 0x44} + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x5678), resource.ContentType(0x0B), false) + assert.Nil(t, err, "no error expected creating resource") + _, _ = resourceWriter.CreateBlock().Write(blockData1) + _, _ = resourceWriter.CreateBlock().Write(blockData2) + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + result := store.Data() + + var expected []byte + expected = append(expected, + 0x02, 0x00, // number of blocks + 0x0E, 0x00, 0x00, 0x00, // offset to first block + 0x11, 0x00, 0x00, 0x00, // offset to second block + 0x15, 0x00, 0x00, 0x00) // size of resource + expected = append(expected, blockData1...) + expected = append(expected, blockData2...) + expected = append(expected, + 0x00, 0x00, 0x00, // alignment for directory + 0x01, 0x00, // resource count + 0x80, 0x00, 0x00, 0x00, // offset to first resource + 0x78, 0x56, // resource ID + 0x15, 0x00, 0x00, // resource length (uncompressed) + 0x02, // resource type + 0x15, 0x00, 0x00, // resource length in file + 0x0B) // content type + assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:]) +} + +func TestWriterUncompressedCompoundResourceCanBeWrittenWithPaddingForSpecialID(t *testing.T) { + blockData1 := []byte{0xAB, 0x01, 0xCD} + blockData2 := []byte{0x11, 0x22, 0x33, 0x44} + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x08FD), resource.ContentType(0x0B), false) + assert.Nil(t, err, "no error expected creating resource") + _, _ = resourceWriter.CreateBlock().Write(blockData1) + _, _ = resourceWriter.CreateBlock().Write(blockData2) + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + result := store.Data() + + var expected []byte + expected = append(expected, + 0x02, 0x00, // number of blocks + 0x10, 0x00, 0x00, 0x00, // offset to first block + 0x13, 0x00, 0x00, 0x00, // offset to second block + 0x17, 0x00, 0x00, 0x00, // size of resource + 0x00, 0x00) // padding + expected = append(expected, blockData1...) + expected = append(expected, blockData2...) + expected = append(expected, + 0x00, // alignment for directory + 0x01, 0x00, // resource count + 0x80, 0x00, 0x00, 0x00, // offset to first resource + 0xFD, 0x08, // resource ID + 0x17, 0x00, 0x00, // resource length (uncompressed) + 0x02, // resource type + 0x17, 0x00, 0x00, // resource length in file + 0x0B) // content type + assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:]) +} + +func TestWriterCompressedSingleBlockResourceCanBeWritten(t *testing.T) { + data := []byte{0x01, 0x02, 0x01, 0x02} + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + resourceWriter, err := writer.CreateResource(resource.ID(0x1122), resource.ContentType(0x0C), true) + assert.Nil(t, err, "no error expected creating resource") + _, _ = resourceWriter.Write(data) + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + result := store.Data() + + var expected []byte + // 0000 0000|0000 0100|0000 0000|0010 0000|0100 0000|0011 1111|1111 1111 + expected = append(expected, + 0x00, 0x04, 0x00, 0x20, 0x40, 0x3F, 0xFF, 0x00, // 14bit words 0x0001 0x0002 0x0100 0x3FFF + trailing 0x00 + // (no bytes) alignment for directory + 0x01, 0x00, // resource count + 0x80, 0x00, 0x00, 0x00, // offset to first resource + 0x22, 0x11, // resource ID + 0x04, 0x00, 0x00, // resource length (uncompressed) + 0x01, // resource type + 0x08, 0x00, 0x00, // resource length in file + 0x0C) // content type + assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:]) +} + +func TestWriterCompressedCompoundResourceCanBeWritten(t *testing.T) { + blockData1 := []byte{0x01, 0x02, 0x01, 0x02} + blockData2 := []byte{0x01, 0x02, 0x01, 0x02} + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x5544), resource.ContentType(0x09), true) + assert.Nil(t, err, "no error expected creating resource") + _, _ = resourceWriter.CreateBlock().Write(blockData1) + _, _ = resourceWriter.CreateBlock().Write(blockData2) + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + result := store.Data() + + var expected []byte + expected = append(expected, + 0x02, 0x00, // number of blocks + 0x0E, 0x00, 0x00, 0x00, // offset to first block + 0x12, 0x00, 0x00, 0x00, // offset to second block + 0x16, 0x00, 0x00, 0x00, // size of resource + 0x00, 0x04, 0x00, 0x20, 0x40, // compressed data, part 1 + 0x01, 0x02, 0x00, 0x0B, 0xFF, 0xF0, 0x00, // compressed data, part 2 + 0x00, 0x00, // alignment for directory + 0x01, 0x00, // resource count + 0x80, 0x00, 0x00, 0x00, // offset to first resource + 0x44, 0x55, // resource ID + 0x16, 0x00, 0x00, // resource length (uncompressed) + 0x03, // resource type + 0x1A, 0x00, 0x00, // resource length in file + 0x09) // content type + assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:]) +} diff --git a/modding/hacked/ss1/resource/lgres/doc.go b/modding/hacked/ss1/resource/lgres/doc.go new file mode 100644 index 0000000..ce11c82 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/doc.go @@ -0,0 +1,4 @@ +/* +Package lgres implements serialization of resources using the DOS format. +*/ +package lgres diff --git a/modding/hacked/ss1/resource/lgres/fixtures_test.go b/modding/hacked/ss1/resource/lgres/fixtures_test.go new file mode 100644 index 0000000..f8a4648 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/fixtures_test.go @@ -0,0 +1,54 @@ +package lgres_test + +import ( + "bytes" + "encoding/binary" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format" + "github.com/inkyblackness/hacked/ss1/serial" +) + +func emptyResourceFile() []byte { + buf := bytes.NewBufferString(format.HeaderString) + headerTrailer := make([]byte, format.ResourceDirectoryFileOffsetPos-buf.Len()) + headerTrailer[0] = format.CommentTerminator + + _ = binary.Write(buf, binary.LittleEndian, headerTrailer) + dictionaryOffset := uint32(buf.Len() + 4) + _ = binary.Write(buf, binary.LittleEndian, &dictionaryOffset) + + numberOfResources := uint16(0) + firstResourceOffset := uint32(buf.Len()) + + _ = binary.Write(buf, binary.LittleEndian, &numberOfResources) + _ = binary.Write(buf, binary.LittleEndian, &firstResourceOffset) + + return buf.Bytes() +} + +var exampleResourceIDSingleBlockResource = resource.ID(0x4000) +var exampleResourceIDSingleBlockResourceCompressed = resource.ID(0x1000) +var exampleResourceIDCompoundResource = resource.ID(0x2000) +var exampleResourceIDCompoundResourceCompressed = resource.ID(0x5000) + +func exampleResourceFile() []byte { + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + + resource1, _ := writer.CreateResource(exampleResourceIDSingleBlockResource, resource.ContentType(0x01), false) + _, _ = resource1.Write([]byte{0x01, 0x01, 0x01}) + resource2, _ := writer.CreateResource(exampleResourceIDSingleBlockResourceCompressed, resource.ContentType(0x02), true) + _, _ = resource2.Write([]byte{0x02, 0x02}) + resource3, _ := writer.CreateCompoundResource(exampleResourceIDCompoundResource, resource.ContentType(0x03), false) + _, _ = resource3.CreateBlock().Write([]byte{0x30, 0x30, 0x30, 0x30}) + _, _ = resource3.CreateBlock().Write([]byte{0x31, 0x31, 0x31}) + resource4, _ := writer.CreateCompoundResource(exampleResourceIDCompoundResourceCompressed, resource.ContentType(0x04), true) + _, _ = resource4.CreateBlock().Write([]byte{0x40, 0x40}) + _, _ = resource4.CreateBlock().Write([]byte{0x41, 0x41, 0x41, 0x41}) + _, _ = resource4.CreateBlock().Write([]byte{0x42}) + _ = writer.Finish() + + return store.Data() +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/Compressor.go b/modding/hacked/ss1/resource/lgres/internal/compression/Compressor.go new file mode 100644 index 0000000..b04fd1a --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/Compressor.go @@ -0,0 +1,84 @@ +package compression + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1/serial" +) + +type compressor struct { + coder serial.Coder + writer *WordWriter + + dictBuffer dictEntryBuffer + overtime int + dictionary *dictEntry + dictionarySize int + curEntry *dictEntry +} + +// NewCompressor creates a new compressor instance over a writer. +func NewCompressor(target io.Writer) io.WriteCloser { + coder := serial.NewEncoder(target) + obj := &compressor{ + coder: coder, + writer: NewWordWriter(coder), + dictionary: rootDictEntry(), + dictionarySize: 0, + overtime: 0} + + obj.resetDictionary() + + return obj +} + +func (obj *compressor) resetDictionary() { + obj.dictionarySize = 0 + for i := 0; i < 0x100; i++ { + obj.dictionary.Add(byte(i), Word(i), obj.dictBuffer.entry(Word(i))) + } + obj.curEntry = obj.dictionary +} + +func (obj *compressor) Close() error { + obj.writer.Write(obj.curEntry.key) + obj.writer.Close() + + return obj.coder.FirstError() +} + +func (obj *compressor) Write(p []byte) (n int, err error) { + for _, input := range p { + obj.addByte(input) + } + + return len(p), obj.coder.FirstError() +} + +func (obj *compressor) addByte(value byte) { + nextEntry := obj.curEntry.next[int(value)] + if nextEntry != nil { + obj.curEntry = nextEntry + } else { + obj.writer.Write(obj.curEntry.key) + + key := Word(int(literalLimit) + obj.dictionarySize) + if key < Reset { + obj.curEntry.Add(value, key, obj.dictBuffer.entry(key)) + obj.dictionarySize++ + } else { + obj.onKeySaturation() + } + + obj.curEntry = obj.dictionary.next[value] + } +} + +func (obj *compressor) onKeySaturation() { + obj.overtime++ + if obj.overtime > 1000 { + obj.writer.Write(Reset) + obj.resetDictionary() + obj.overtime = 0 + } +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/Compressor_test.go b/modding/hacked/ss1/resource/lgres/internal/compression/Compressor_test.go new file mode 100644 index 0000000..799a8e0 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/Compressor_test.go @@ -0,0 +1,68 @@ +package compression_test + +import ( + "bytes" + "io" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type CompressorSuite struct { + suite.Suite + store *serial.ByteStore + compressor io.WriteCloser +} + +func TestCompressorSuite(t *testing.T) { + suite.Run(t, new(CompressorSuite)) +} + +func (suite *CompressorSuite) SetupTest() { + suite.store = serial.NewByteStore() + suite.compressor = compression.NewCompressor(suite.store) +} + +func (suite *CompressorSuite) TestWriteCompressesFirstReocurrence() { + suite.givenWrittenData([]byte{0x00, 0x01}) + suite.givenWrittenData([]byte{0x00, 0x01}) + + err := suite.compressor.Close() + assert.Nil(suite.T(), err, "no error expected") + + suite.thenWordsShouldBe(compression.Word(0x0000), compression.Word(0x0001), compression.Word(0x0100)) +} + +func (suite *CompressorSuite) TestWriteCompressesTest1() { + suite.givenWrittenData([]byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01}) + + err := suite.compressor.Close() + assert.Nil(suite.T(), err, "no error expected") + + suite.thenWordsShouldBe( + compression.Word(0x0000), compression.Word(0x0001), compression.Word(0x0000), + compression.Word(0x0002), compression.Word(0x0101), compression.Word(0x0001)) +} + +func (suite *CompressorSuite) givenWrittenData(data []byte) { + written, err := suite.compressor.Write(data) + require.Nil(suite.T(), err, "no error expected writing") + require.Equal(suite.T(), len(data), written, "expected all bytes to be written") +} + +func (suite *CompressorSuite) thenWordsShouldBe(expected ...compression.Word) { + source := bytes.NewReader(suite.store.Data()) + reader := compression.NewWordReader(serial.NewDecoder(source)) + var words []compression.Word + + for read := reader.Read(); read != compression.EndOfStream; read = reader.Read() { + words = append(words, read) + } + + assert.Equal(suite.T(), expected, words) +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor.go b/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor.go new file mode 100644 index 0000000..a575975 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor.go @@ -0,0 +1,129 @@ +package compression + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1/serial" +) + +type decompressor struct { + coder serial.Coder + reader *WordReader + + isEndOfStream bool + dictBuffer dictEntryBuffer + dictionary *dictEntry + dictionarySize int + lastEntry *dictEntry + lookup []*dictEntry + + scratch []byte + leftover []byte +} + +// NewDecompressor creates a new decompressor instance over a reader. +func NewDecompressor(source io.Reader) io.Reader { + coder := serial.NewDecoder(source) + obj := &decompressor{ + coder: coder, + reader: NewWordReader(coder), + dictionary: rootDictEntry(), + scratch: make([]byte, 1024)} + obj.resetDictionary() + + return obj +} + +func (obj *decompressor) resetDictionary() { + obj.dictionarySize = 0 + obj.lookup = make([]*dictEntry, 1024) + obj.dictionary = rootDictEntry() + for i := 0; i < 0x100; i++ { + entry := obj.dictionary.Add(byte(i), Word(i), obj.dictBuffer.entry(Word(i))) + obj.lookup[Word(i)] = entry + } + obj.lastEntry = obj.dictionary +} + +func (obj *decompressor) Read(p []byte) (n int, err error) { + requested := len(p) + + for n < requested && !obj.isEndOfStream && obj.coder.FirstError() == nil { + n += obj.takeFromLeftover(p[n:]) + if n < requested { + obj.readNextWord() + n += obj.takeFromLeftover(p[n:]) + } + } + err = obj.coder.FirstError() + if err == nil && obj.isEndOfStream { + err = io.EOF + } + + return +} + +func (obj *decompressor) takeFromLeftover(target []byte) (provided int) { + requested := len(target) + available := len(obj.leftover) + + if available > 0 && requested > 0 { + provided = available + if provided > requested { + provided = requested + } + copy(target[0:provided], obj.leftover) + obj.leftover = obj.leftover[provided:] + } + + return +} + +func (obj *decompressor) readNextWord() { + nextWord := obj.reader.Read() + + if obj.lastEntry.depth > len(obj.scratch) { + obj.scratch = make([]byte, len(obj.scratch)+1024) + } + obj.leftover = obj.scratch[:obj.lastEntry.depth] + obj.lastEntry.Data(obj.leftover) + switch nextWord { + case EndOfStream: + obj.isEndOfStream = true + case Reset: + obj.resetDictionary() + default: + var nextEntry *dictEntry + if int(nextWord) < len(obj.lookup) { + nextEntry = obj.lookup[int(nextWord)] + } + + switch { + case nextEntry != nil: + if obj.lastEntry.depth > 0 { + obj.addToDictionary(nextEntry.FirstByte()) + } + obj.lastEntry = nextEntry + case nextWord >= literalLimit: + nextValue := obj.lastEntry.FirstByte() + obj.addToDictionary(nextValue) + obj.lastEntry = obj.lastEntry.next[nextValue] + default: + nextValue := byte(nextWord) + obj.addToDictionary(nextValue) + obj.lastEntry = obj.dictionary.next[nextValue] + } + } +} + +func (obj *decompressor) addToDictionary(value byte) { + key := Word(int(literalLimit) + obj.dictionarySize) + nextEntry := obj.lastEntry.Add(value, key, obj.dictBuffer.entry(key)) + if int(key) >= len(obj.lookup) { + newLookup := make([]*dictEntry, len(obj.lookup)+1024) + copy(newLookup, obj.lookup) + obj.lookup = newLookup + } + obj.lookup[key] = nextEntry + obj.dictionarySize++ +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor_test.go b/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor_test.go new file mode 100644 index 0000000..e99e554 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor_test.go @@ -0,0 +1,125 @@ +package compression_test + +import ( + "bytes" + "errors" + "fmt" + "io" + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" +) + +type DecompressorSuite struct { + suite.Suite + store *serial.ByteStore + compressor io.WriteCloser +} + +func TestDecompressorSuite(t *testing.T) { + suite.Run(t, new(DecompressorSuite)) +} + +func (suite *DecompressorSuite) SetupTest() { +} + +func (suite *DecompressorSuite) TestDecompressTest1() { + input := []byte{0x00, 0x01, 0x00, 0x01} + + suite.verify(input) +} + +func (suite *DecompressorSuite) TestDecompressTest2() { + input := []byte{0x00, 0x01, 0x00, 0x01, 0x00, 0x01} + + suite.verify(input) +} + +func (suite *DecompressorSuite) TestDecompressTest3() { + suite.verify([]byte{}) +} + +func (suite *DecompressorSuite) TestDecompressTest4() { + input := []byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01} + + suite.verify(input) +} + +func (suite *DecompressorSuite) TestDecompressTest5() { + input := []byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01, 0x02, 0x01, 0x02, 0x01, 0x00, 0x01, 0x02} + + suite.verify(input) +} + +func (suite *DecompressorSuite) TestDecompressTestRandom() { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + for testCase := 0; testCase < 100; testCase++ { + input := make([]byte, r.Intn(1024)) + for i := 0; i < len(input); i++ { + input[i] = byte(r.Intn(256)) + } + suite.verify(input) + } +} + +func (suite *DecompressorSuite) TestDecompressHandlesDictionaryResets() { + suite.writeWords(0x0001, 0x0002, 0x0100, compression.Reset, 0x0003, 0x0004, 0x0100, compression.EndOfStream) + + suite.verifyOutput([]byte{0x01, 0x02, 0x01, 0x02, 0x03, 0x04, 0x03, 0x04}) +} + +func (suite *DecompressorSuite) TestDecompressHandlesSelfReferencingWords() { + suite.writeWords(0x0001, 0x0002, 0x0101, compression.EndOfStream) + + suite.verifyOutput([]byte{0x01, 0x02, 0x02, 0x02}) +} + +func (suite *DecompressorSuite) writeWords(values ...compression.Word) { + suite.store = serial.NewByteStore() + coder := serial.NewEncoder(suite.store) + writer := compression.NewWordWriter(coder) + + for _, value := range values { + writer.Write(value) + } + writer.Close() +} + +func (suite *DecompressorSuite) verify(input []byte) { + suite.store = serial.NewByteStore() + suite.compressor = compression.NewCompressor(serial.NewEncoder(suite.store)) + + n, err := suite.compressor.Write(input) + assert.Nil(suite.T(), err, "no error expected writing") + assert.Equal(suite.T(), len(input), n, "invalid number written") + err = suite.compressor.Close() + assert.Nil(suite.T(), err, "no error expected closing") + + suite.verifyOutput(input) +} + +func (suite *DecompressorSuite) verifyOutput(expected []byte) { + output := suite.buffer(len(expected)) + source := bytes.NewReader(suite.store.Data()) + decompressor := compression.NewDecompressor(source) + read, err := decompressor.Read(output) + + assert.True(suite.T(), (err == nil) || errors.Is(err, io.EOF), fmt.Sprintf("unexpected error: %v", err)) + assert.Equal(suite.T(), len(output), read, "unexpected number of bytes read") + assert.Equal(suite.T(), expected, output) +} + +func (suite *DecompressorSuite) buffer(byteCount int) []byte { + result := make([]byte, byteCount) + for i := range result { + result[i] = 0xFF + } + return result +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/DictEntry.go b/modding/hacked/ss1/resource/lgres/internal/compression/DictEntry.go new file mode 100644 index 0000000..78a9570 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/DictEntry.go @@ -0,0 +1,50 @@ +package compression + +type dictEntry struct { + prev *dictEntry + depth int + + next [256]*dictEntry + + key Word + value byte + first byte + used bool +} + +func rootDictEntry() *dictEntry { + return &dictEntry{prev: nil, depth: 0, value: 0x00, key: Reset} +} + +var emptyList [256]*dictEntry + +func (entry *dictEntry) Add(value byte, key Word, newEntry *dictEntry) *dictEntry { + newEntry.prev = entry + newEntry.depth = entry.depth + 1 + newEntry.value = value + newEntry.key = key + newEntry.first = entry.first + if newEntry.used { + copy(newEntry.next[:], emptyList[:]) + } + newEntry.used = true + + entry.next[value] = newEntry + if entry.depth == 0 { + newEntry.first = value + } + + return newEntry +} + +func (entry *dictEntry) Data(bytes []byte) { + cur := entry + for i := entry.depth - 1; i >= 0; i-- { + bytes[i] = cur.value + cur = cur.prev + } +} + +func (entry *dictEntry) FirstByte() byte { + return entry.first +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/DictEntryBuffer.go b/modding/hacked/ss1/resource/lgres/internal/compression/DictEntryBuffer.go new file mode 100644 index 0000000..d9b80e9 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/DictEntryBuffer.go @@ -0,0 +1,21 @@ +package compression + +// dictEntryBucketSizeBits governs how big one bucket in the buffer is. +const dictEntryBucketSizeBits = 8 + +type dictEntryBucket [1 << dictEntryBucketSizeBits]dictEntry + +type dictEntryBuffer struct { + buckets []*dictEntryBucket +} + +func (buffer *dictEntryBuffer) entry(key Word) *dictEntry { + bucketIndex := int(key) >> dictEntryBucketSizeBits + index := int(key & ((1 << dictEntryBucketSizeBits) - 1)) + + for bucketIndex >= len(buffer.buckets) { + buffer.buckets = append(buffer.buckets, new(dictEntryBucket)) + } + + return &buffer.buckets[bucketIndex][index] +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/Word.go b/modding/hacked/ss1/resource/lgres/internal/compression/Word.go new file mode 100644 index 0000000..01bd105 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/Word.go @@ -0,0 +1,14 @@ +package compression + +// Word is a compression stream entry. +type Word uint16 + +const ( + bitsPerWord = uint(14) + + // EndOfStream indicates the last word in the compressed stream. + EndOfStream = Word(0x3FFF) + // Reset indicates a reset of the dictionary. + Reset = Word(0x3FFE) + literalLimit = Word(0x0100) +) diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/WordReader.go b/modding/hacked/ss1/resource/lgres/internal/compression/WordReader.go new file mode 100644 index 0000000..adf9b22 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/WordReader.go @@ -0,0 +1,41 @@ +package compression + +import "github.com/inkyblackness/hacked/ss1/serial" + +// WordReader provides word instances from a serialized stream. +type WordReader struct { + coder serial.Coder + + buffer byte + bufferBitsAvailable uint +} + +// NewWordReader returns a new instance. +func NewWordReader(coder serial.Coder) *WordReader { + reader := &WordReader{coder: coder, buffer: 0x00, bufferBitsAvailable: 0} + + return reader +} + +// Read returns the next word from the stream. +func (reader *WordReader) Read() (value Word) { + remaining := bitsPerWord + + for remaining > reader.bufferBitsAvailable { + value = Word((uint(value) << reader.bufferBitsAvailable) | uint(reader.buffer)) + remaining -= reader.bufferBitsAvailable + + reader.bufferByte() + } + + value = Word((uint(value) << remaining) | uint(reader.buffer)>>(8-remaining)) + reader.buffer &= 1<<(8-remaining) - 1 + reader.bufferBitsAvailable -= remaining + + return +} + +func (reader *WordReader) bufferByte() { + reader.coder.Code(&reader.buffer) + reader.bufferBitsAvailable = 8 +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/WordReader_test.go b/modding/hacked/ss1/resource/lgres/internal/compression/WordReader_test.go new file mode 100644 index 0000000..68228c0 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/WordReader_test.go @@ -0,0 +1,56 @@ +package compression_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type WordReaderSuite struct { + suite.Suite + reader *compression.WordReader +} + +func TestWordReaderSuite(t *testing.T) { + suite.Run(t, new(WordReaderSuite)) +} + +func (suite *WordReaderSuite) TestReadCanReadFirstWord() { + suite.givenReaderFrom() + + assert.Equal(suite.T(), compression.EndOfStream, suite.reader.Read()) +} + +func (suite *WordReaderSuite) TestReadCanReadSeveralWords() { + input := []compression.Word{ + compression.Word(0x3FFF), compression.Word(0x0000), compression.Word(0x3FFF), + compression.Word(0x0000), compression.Word(0x2001), compression.Word(0x1234), + } + suite.givenReaderFrom(input...) + + output := make([]compression.Word, len(input)+1) + for i := 0; i < len(output); i++ { + output[i] = suite.reader.Read() + } + + assert.Equal(suite.T(), append(input, compression.EndOfStream), output) +} + +func (suite *WordReaderSuite) givenReaderFrom(words ...compression.Word) { + store := serial.NewByteStore() + encoder := serial.NewEncoder(store) + writer := compression.NewWordWriter(encoder) + + for _, value := range words { + writer.Write(value) + } + writer.Close() + + source := bytes.NewReader(store.Data()) + suite.reader = compression.NewWordReader(serial.NewDecoder(source)) +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter.go b/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter.go new file mode 100644 index 0000000..da0936f --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter.go @@ -0,0 +1,59 @@ +package compression + +import "github.com/inkyblackness/hacked/ss1/serial" + +// WordWriter serializes a stream of word entries. +type WordWriter struct { + coder serial.Coder + + bufferedBits uint + scratch uint32 + + outBuffer [1024]byte + outUsed int +} + +// NewWordWriter returns a new instance. +func NewWordWriter(coder serial.Coder) *WordWriter { + writer := &WordWriter{coder: coder, bufferedBits: 0, scratch: 0} + + return writer +} + +// Close finishes the stream of words. +func (writer *WordWriter) Close() { + writer.Write(EndOfStream) + if writer.bufferedBits > 0 { + writer.writeByte(byte(writer.scratch >> 16)) + } + writer.writeByte(byte(0x00)) + writer.flushBuffer() +} + +// Write adds the given word to the stream. +func (writer *WordWriter) Write(value Word) { + writer.scratch |= uint32(value) << ((16 - bitsPerWord) + (8 - writer.bufferedBits)) + writer.bufferedBits += bitsPerWord + writer.writeByte(byte(writer.scratch >> 16)) + writer.scratch <<= 8 + writer.bufferedBits -= 8 + + if writer.bufferedBits >= 8 { + writer.writeByte(byte(writer.scratch >> 16)) + writer.scratch <<= 8 + writer.bufferedBits -= 8 + } +} + +func (writer *WordWriter) writeByte(value byte) { + writer.outBuffer[writer.outUsed] = value + writer.outUsed++ + if writer.outUsed >= len(writer.outBuffer) { + writer.flushBuffer() + } +} + +func (writer *WordWriter) flushBuffer() { + writer.coder.Code(writer.outBuffer[:writer.outUsed]) + writer.outUsed = 0 +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter_test.go b/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter_test.go new file mode 100644 index 0000000..8a62c6a --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter_test.go @@ -0,0 +1,49 @@ +package compression_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type WordWriterSuite struct { + suite.Suite + writer *compression.WordWriter + store *serial.ByteStore +} + +func TestWordWriterSuite(t *testing.T) { + suite.Run(t, new(WordWriterSuite)) +} + +func (suite *WordWriterSuite) SetupTest() { + suite.store = serial.NewByteStore() + coder := serial.NewEncoder(suite.store) + suite.writer = compression.NewWordWriter(coder) +} + +func (suite *WordWriterSuite) TestCloseWritesEndOfStreamMarkerAndTrailingZeroByte() { + suite.writer.Close() + + assert.Equal(suite.T(), []byte{0xFF, 0xFC, 0x00}, suite.store.Data()) +} + +func (suite *WordWriterSuite) TestCloseWritesRemainderOnlyIfNotEmpty() { + suite.writer.Write(compression.Word(0x0000)) + suite.writer.Write(compression.Word(0x0000)) + suite.writer.Write(compression.Word(0x0000)) + suite.writer.Close() + + assert.Equal(suite.T(), []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0x00}, suite.store.Data()) +} + +func (suite *WordWriterSuite) TestWriteAndCloseLinesUpBits() { + suite.writer.Write(compression.Word(0x1FFE)) // 0111111 1111110 + suite.writer.Close() // 1111111 1111111 + + assert.Equal(suite.T(), []byte{0x7F, 0xFB, 0xFF, 0xF0, 0x00}, suite.store.Data()) +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/benchmark_test.go b/modding/hacked/ss1/resource/lgres/internal/compression/benchmark_test.go new file mode 100644 index 0000000..85a07d0 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/benchmark_test.go @@ -0,0 +1,119 @@ +package compression_test + +import ( + "flag" + "io" + "math/rand" + "os" + "runtime/pprof" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// To run a profile, run it with the following command: +// go test -bench . -args -cpuprofile=prof +var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") + +func rawData(size int) []byte { + data := make([]byte, size) + rand.Read(data) // nolint:gas + return data +} + +func initProfiling(b *testing.B, nameSuffix string) func() { + b.Helper() + flag.Parse() + if *cpuprofile != "" { + f, err := os.Create(*cpuprofile + "-" + b.Name() + nameSuffix) + if err != nil { + b.Fatal(err) + } + _ = pprof.StartCPUProfile(f) + return func() { pprof.StopCPUProfile() } + } + return func() {} +} + +func BenchmarkRawDataStorage(b *testing.B) { + profileStop := initProfiling(b, "") + defer profileStop() + data := rawData(1024 * 1024) + b.ResetTimer() + for run := 0; run < b.N; run++ { + encoder := serial.NewEncoder(serial.NewByteStore()) + _, _ = encoder.Write(data) + } +} + +func benchmarkCompression(b *testing.B, size int, nameSuffix string) { + b.Helper() + profileStop := initProfiling(b, nameSuffix) + defer profileStop() + data := rawData(size) + b.ResetTimer() + for run := 0; run < b.N; run++ { + compressor := compression.NewCompressor(serial.NewByteStore()) + _, _ = compressor.Write(data) + _ = compressor.Close() + } +} + +func BenchmarkCompression1KB(b *testing.B) { + benchmarkCompression(b, 1024, "1KB") +} + +func BenchmarkCompression16KB(b *testing.B) { + benchmarkCompression(b, 1024*16, "16KB") +} + +func BenchmarkCompression128KB(b *testing.B) { + benchmarkCompression(b, 1024*128, "128KB") +} + +func BenchmarkCompression512KB(b *testing.B) { + benchmarkCompression(b, 1024*512, "512KB") +} + +func BenchmarkCompression1024KB(b *testing.B) { + benchmarkCompression(b, 1024*1024, "1024KB") +} + +func benchmarkCompressionDecompression(b *testing.B, size int, nameSuffix string) { + b.Helper() + profileStop := initProfiling(b, nameSuffix) + defer profileStop() + data := rawData(size) + output := make([]byte, len(data)) + b.ResetTimer() + for run := 0; run < b.N; run++ { + store := serial.NewByteStore() + compressor := compression.NewCompressor(store) + _, _ = compressor.Write(data) + _ = compressor.Close() + _, _ = store.Seek(0, io.SeekStart) + decompressor := compression.NewDecompressor(store) + _, _ = decompressor.Read(output) + } +} + +func BenchmarkCompressionDecompression1KB(b *testing.B) { + benchmarkCompressionDecompression(b, 1024, "1KB") +} + +func BenchmarkCompressionDecompression16KB(b *testing.B) { + benchmarkCompressionDecompression(b, 1024*16, "16KB") +} + +func BenchmarkCompressionDecompression128KB(b *testing.B) { + benchmarkCompressionDecompression(b, 1024*128, "128KB") +} + +func BenchmarkCompressionDecompression512KB(b *testing.B) { + benchmarkCompressionDecompression(b, 1024*512, "512KB") +} + +func BenchmarkCompressionDecompression1024KB(b *testing.B) { + benchmarkCompressionDecompression(b, 1024*1024, "1024KB") +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/doc.go b/modding/hacked/ss1/resource/lgres/internal/compression/doc.go new file mode 100644 index 0000000..9ec57e5 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/doc.go @@ -0,0 +1,4 @@ +/* +Package compression implements the LG resource file compression algorithm. +*/ +package compression diff --git a/modding/hacked/ss1/resource/lgres/internal/format/Constants.go b/modding/hacked/ss1/resource/lgres/internal/format/Constants.go new file mode 100644 index 0000000..ac7e543 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/format/Constants.go @@ -0,0 +1,17 @@ +package format + +const ( + // HeaderString is the start of an LG Res file. + HeaderString = "LG Res File v2\r\n" + // CommentTerminator terminates the header string. + CommentTerminator = byte(0x1A) + // ResourceDirectoryFileOffsetPos is the byte offset for the pointer to the directory. + ResourceDirectoryFileOffsetPos = 0x7C + // BoundarySize is the byte interval at which entries must be placed. + BoundarySize = 4 + + // ResourceTypeFlagCompressed indicates a compressed resource. + ResourceTypeFlagCompressed = byte(0x01) + // ResourceTypeFlagCompound indicates a compound resource. + ResourceTypeFlagCompound = byte(0x02) +) diff --git a/modding/hacked/ss1/serial/ByteStore.go b/modding/hacked/ss1/serial/ByteStore.go new file mode 100644 index 0000000..854af52 --- /dev/null +++ b/modding/hacked/ss1/serial/ByteStore.go @@ -0,0 +1,89 @@ +package serial + +import ( + "io" +) + +const bufferCapacityIncrement = 1024 * 1024 + +// ByteStore is implementing a ReadWriteSeeker storing data in memory. +type ByteStore struct { + data []byte + offset int +} + +// NewByteStore returns a new byte store instance. +func NewByteStore() *ByteStore { + return NewByteStoreFromData(make([]byte, 0, bufferCapacityIncrement)) +} + +// NewByteStoreFromData returns a new ByteStore instance wrapping the provided data. +func NewByteStoreFromData(data []byte) *ByteStore { + store := &ByteStore{ + data: data, + offset: 0, + } + + return store +} + +// Data returns the current data buffer. +func (store *ByteStore) Data() []byte { + return store.data +} + +// Seek implements the Seeker interface. +func (store *ByteStore) Seek(offset int64, whence int) (int64, error) { + switch whence { + case 0: + store.offset = int(offset) + case 1: + store.offset += int(offset) + case 2: + store.offset = len(store.data) + int(offset) + } + + return int64(store.offset), nil +} + +// Read implements the Reader interface. +func (store *ByteStore) Read(p []byte) (n int, err error) { + size := len(p) + n = len(store.data) - store.offset + if n > size { + n = size + } + if n < size && size > 0 { + err = io.EOF + } + copy(p, store.data[store.offset:store.offset+n]) + store.offset += n + + return +} + +// Write implements the Writer interface. +func (store *ByteStore) Write(p []byte) (n int, err error) { + size := len(p) + store.ensureAvailable(size) + copy(store.data[store.offset:], p) + store.offset += size + + return size, nil +} + +func (store *ByteStore) ensureAvailable(size int) { + expected := store.offset + size + available := len(store.data) + + if expected > available { + if expected > cap(store.data) { + old := store.data + + store.data = make([]byte, expected, expected+bufferCapacityIncrement) + copy(store.data, old) + } else { + store.data = append(store.data, make([]byte, expected-available)...) + } + } +} diff --git a/modding/hacked/ss1/serial/Codable.go b/modding/hacked/ss1/serial/Codable.go new file mode 100644 index 0000000..3e96579 --- /dev/null +++ b/modding/hacked/ss1/serial/Codable.go @@ -0,0 +1,7 @@ +package serial + +// Codable is something than can be serialized with a Coder. +type Codable interface { + // Code requests to serialize the Codable via the provided Coder. + Code(coder Coder) +} diff --git a/modding/hacked/ss1/serial/Coder.go b/modding/hacked/ss1/serial/Coder.go new file mode 100644 index 0000000..acb2130 --- /dev/null +++ b/modding/hacked/ss1/serial/Coder.go @@ -0,0 +1,12 @@ +package serial + +// Coder represents an Encoder/Decoder for binary data. +type Coder interface { + // FirstError returns the first error this coder encountered. + FirstError() error + + // Code serializes the given value. + // Any error state will be provided via FirstError(). Code will do nothing + // if the coder is already in error state. + Code(value interface{}) +} diff --git a/modding/hacked/ss1/serial/Decoder.go b/modding/hacked/ss1/serial/Decoder.go new file mode 100644 index 0000000..f44d4fd --- /dev/null +++ b/modding/hacked/ss1/serial/Decoder.go @@ -0,0 +1,58 @@ +package serial + +import ( + "encoding/binary" + "errors" + "io" +) + +// Decoder is for decoding from a reader. +type Decoder struct { + source io.Reader + firstError error + offset uint32 +} + +// NewDecoder creates a new Decoder from given source. +func NewDecoder(source io.Reader) *Decoder { + return &Decoder{source: source} +} + +// FirstError returns the error this Decoder encountered the first time. +func (coder *Decoder) FirstError() error { + return coder.firstError +} + +// CurPos returns the current decoding position, in bytes. +func (coder *Decoder) CurPos() uint32 { + return coder.offset +} + +// Code serializes the given value in little endian format using binary.Read(). +func (coder *Decoder) Code(value interface{}) { + if coder.firstError != nil { + return + } + if codable, isCodable := value.(Codable); isCodable { + codable.Code(coder) + } else { + coder.firstError = binary.Read(coder, binary.LittleEndian, value) + } +} + +// Read reads the next bytes from the underlying source. +func (coder *Decoder) Read(data []byte) (read int, err error) { + if coder.firstError != nil { + return 0, coder.firstError + } + read, err = coder.source.Read(data) + coder.offset += uint32(read) + + isErrEOF := errors.Is(err, io.EOF) + expectedAmountReturned := read == len(data) + errorCanBeIgnored := isErrEOF && expectedAmountReturned + if (coder.firstError == nil) && !errorCanBeIgnored { + coder.firstError = err + } + return +} diff --git a/modding/hacked/ss1/serial/Decoder_test.go b/modding/hacked/ss1/serial/Decoder_test.go new file mode 100644 index 0000000..f9b8adf --- /dev/null +++ b/modding/hacked/ss1/serial/Decoder_test.go @@ -0,0 +1,138 @@ +package serial_test + +import ( + "bytes" + "io" + "testing" + + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type DecoderSuite struct { + suite.Suite + errorBuf *errorBuffer + coder *serial.Decoder +} + +func TestDecoderSuite(t *testing.T) { + suite.Run(t, new(DecoderSuite)) +} + +func (suite *DecoderSuite) SetupTest() { + suite.errorBuf = nil + suite.coder = nil +} + +func (suite *DecoderSuite) TestImplementsCoderInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(serial.Coder) + assert.True(suite.T(), ok) +} + +func (suite *DecoderSuite) TestCodeUint32() { + suite.whenDecodingFrom([]byte{0x78, 0x56, 0x34, 0x12}) + + var value uint32 + suite.coder.Code(&value) + + assert.Equal(suite.T(), uint32(0x12345678), value) +} + +func (suite *DecoderSuite) TestCodeUint16() { + suite.whenDecodingFrom([]byte{0x45, 0x23}) + + var value uint16 + suite.coder.Code(&value) + + assert.Equal(suite.T(), uint16(0x2345), value) +} + +func (suite *DecoderSuite) TestCodeByte() { + suite.whenDecodingFrom([]byte{0xAB}) + + var value byte + suite.coder.Code(&value) + + assert.Equal(suite.T(), byte(0xAB), value) +} + +func (suite *DecoderSuite) TestCodeByteSlice() { + suite.whenDecodingFrom([]byte{0x78, 0x12, 0x34}) + + value := make([]byte, 3) + suite.coder.Code(value) + + assert.Equal(suite.T(), []byte{0x78, 0x12, 0x34}, value) +} + +func (suite *DecoderSuite) TestFirstErrorReturnsFirstError() { + suite.whenDecodingWithErrors() + + data := uint32(0) + suite.coder.Code(&data) + suite.errorBuf.errorOnNextCall = true + suite.coder.Code(uint16(0)) + + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 2") +} + +func (suite *DecoderSuite) TestFirstErrorIgnoresFurtherErrors() { + suite.whenDecodingWithErrors() + + suite.errorBuf.errorOnNextCall = true + suite.coder.Code(uint32(0)) + suite.errorBuf.errorOnNextCall = true + suite.coder.Code(uint32(0)) + + assert.Equal(suite.T(), suite.errorBuf.callCounter, 1) + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 1") +} + +func (suite *DecoderSuite) TestImplementsReaderInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(io.Reader) + assert.True(suite.T(), ok) +} + +func (suite *DecoderSuite) TestReadFromReader() { + suite.whenDecodingFrom([]byte{0x78, 0x12, 0x34}) + + value := make([]byte, 3) + read, err := suite.coder.Read(value) + + assert.Nil(suite.T(), err, "no error expected") + assert.Equal(suite.T(), 3, read, "unexpected length read") + assert.Equal(suite.T(), []byte{0x78, 0x12, 0x34}, value) +} + +func (suite *DecoderSuite) TestReadHandlesErrors() { + suite.whenDecodingWithErrors() + + suite.errorBuf.errorOnNextCall = true + _, _ = suite.coder.Read(make([]byte, 3)) + _, _ = suite.coder.Read(make([]byte, 5)) + assert.Equal(suite.T(), 1, suite.errorBuf.callCounter) + assert.NotNil(suite.T(), suite.coder.FirstError()) +} + +func (suite *DecoderSuite) TestCodeWithCodable() { + codable := new(MockedCodable) + + suite.whenDecodingFrom([]byte{0x78, 0x12, 0x34}) + suite.coder.Code(codable) + + assert.Equal(suite.T(), suite.coder, codable.calledCoder, "Should be called with coder") +} + +func (suite *DecoderSuite) whenDecodingFrom(data []byte) { + source := bytes.NewReader(data) + suite.coder = serial.NewDecoder(source) +} + +func (suite *DecoderSuite) whenDecodingWithErrors() { + suite.errorBuf = new(errorBuffer) + suite.coder = serial.NewDecoder(suite.errorBuf) +} diff --git a/modding/hacked/ss1/serial/Encoder.go b/modding/hacked/ss1/serial/Encoder.go new file mode 100644 index 0000000..622aa67 --- /dev/null +++ b/modding/hacked/ss1/serial/Encoder.go @@ -0,0 +1,56 @@ +package serial + +import ( + "encoding/binary" + "io" +) + +// Encoder implements the Coder interface to write to a writer. +// It also implements the Writer interface. +type Encoder struct { + target io.Writer + firstError error + offset uint32 +} + +// NewEncoder creates and returns a fresh Encoder. +func NewEncoder(target io.Writer) *Encoder { + return &Encoder{target: target} +} + +// FirstError returns the error this Encoder encountered the first time. +func (coder *Encoder) FirstError() error { + return coder.firstError +} + +// CurPos returns the current encoding position, in bytes. +func (coder *Encoder) CurPos() uint32 { + return coder.offset +} + +// Code serializes the given value in little endian format using binary.Write(). +func (coder *Encoder) Code(value interface{}) { + if coder.firstError != nil { + return + } + if codable, isCodable := value.(Codable); isCodable { + codable.Code(coder) + } else { + coder.firstError = binary.Write(coder, binary.LittleEndian, value) + } +} + +// Write serializes the given data to the contained writer. +// If the encoder is in the error state, the result of FirstError() is +// returned and nothing else is done. +func (coder *Encoder) Write(data []byte) (written int, err error) { + if coder.firstError != nil { + return 0, coder.firstError + } + written, err = coder.target.Write(data) + coder.offset += uint32(written) + if coder.firstError == nil { + coder.firstError = err + } + return +} diff --git a/modding/hacked/ss1/serial/Encoder_test.go b/modding/hacked/ss1/serial/Encoder_test.go new file mode 100644 index 0000000..b6530b0 --- /dev/null +++ b/modding/hacked/ss1/serial/Encoder_test.go @@ -0,0 +1,157 @@ +package serial_test + +import ( + "io" + "testing" + + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type EncoderSuite struct { + suite.Suite + store *serial.ByteStore + coder *serial.Encoder +} + +func TestEncoderSuite(t *testing.T) { + suite.Run(t, new(EncoderSuite)) +} + +func (suite *EncoderSuite) SetupTest() { + suite.store = serial.NewByteStore() + suite.coder = serial.NewEncoder(suite.store) +} + +func (suite *EncoderSuite) TestImplementsCoderInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(serial.Coder) + assert.True(suite.T(), ok) +} + +func (suite *EncoderSuite) TestDataOnEmptyEncoderReturnsEmptyArray() { + suite.verifyData(make([]byte, 0)) +} + +func (suite *EncoderSuite) TestCodeUint32Pointer() { + var value uint32 = 0x12345678 + suite.verifyCodedData(&value, []byte{0x78, 0x56, 0x34, 0x12}) +} + +func (suite *EncoderSuite) TestCodeUint32() { + var value uint32 = 0x12345678 + suite.verifyCodedData(value, []byte{0x78, 0x56, 0x34, 0x12}) +} + +func (suite *EncoderSuite) TestCodeUint16Pointer() { + var value uint16 = 0x3456 + suite.verifyCodedData(&value, []byte{0x56, 0x34}) +} + +func (suite *EncoderSuite) TestCodeUint16() { + var value uint16 = 0x3456 + suite.verifyCodedData(value, []byte{0x56, 0x34}) +} + +func (suite *EncoderSuite) TestCodeBytePointer() { + var value byte = 0x42 + suite.verifyCodedData(&value, []byte{0x42}) +} + +func (suite *EncoderSuite) TestCodeByte() { + var value byte = 0x42 + suite.verifyCodedData(value, []byte{0x42}) +} + +func (suite *EncoderSuite) TestCodeByteSlice() { + value := []byte{0x01, 0x02, 0x03} + suite.verifyCodedData(value, []byte{0x01, 0x02, 0x03}) +} + +func (suite *EncoderSuite) TestFirstErrorByCode() { + var target errorBuffer + suite.coder = serial.NewEncoder(&target) + suite.coder.Code(uint16(0)) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 2") +} + +func (suite *EncoderSuite) TestCodeDoesNothingOnPreviousError() { + var target errorBuffer + suite.coder = serial.NewEncoder(&target) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + + assert.Equal(suite.T(), target.callCounter, 1) + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 1") +} + +func (suite *EncoderSuite) TestImplementsWriterInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(io.Writer) + assert.True(suite.T(), ok) +} + +func (suite *EncoderSuite) TestWriteToWriter() { + written, err := suite.coder.Write([]byte{0x0A, 0x0B, 0x0C}) + assert.Nil(suite.T(), err, "no error expected") + assert.Equal(suite.T(), 3, written, "unexpected length written") + suite.verifyData([]byte{0x0A, 0x0B, 0x0C}) +} + +func (suite *EncoderSuite) TestWriteDoesNothingOnPreviousError() { + var target errorBuffer + suite.coder = serial.NewEncoder(&target) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + _, _ = suite.coder.Write([]byte{0x0A, 0x0B, 0x0C}) + + assert.Equal(suite.T(), target.callCounter, 1) +} + +func (suite *EncoderSuite) TestWriteReturnsFirstErrorOnPreviousError() { + var target errorBuffer + suite.coder = serial.NewEncoder(&target) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + n, err := suite.coder.Write([]byte{0x0A, 0x0B, 0x0C}) + + assert.Equal(suite.T(), 0, n, "Should return zero written bytes") + assert.EqualError(suite.T(), err, "errorBuffer on call number 1") +} + +func (suite *EncoderSuite) TestWriteOnErrorCase() { + var target errorBuffer + suite.coder = serial.NewEncoder(&target) + target.errorOnNextCall = true + target.errorByteCount = 2 + n, err := suite.coder.Write([]byte{0x0A, 0x0B, 0x0C}) + + assert.Equal(suite.T(), 2, n, "Should return count of successfully written bytes") + assert.EqualError(suite.T(), err, "errorBuffer on call number 1") + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 1", "should set first error") +} + +func (suite *EncoderSuite) TestCodeWithCodable() { + codable := new(MockedCodable) + suite.coder.Code(codable) + + assert.Equal(suite.T(), suite.coder, codable.calledCoder, "Should be called with coder") +} + +func (suite *EncoderSuite) verifyData(expected []byte) { + assert.Nil(suite.T(), suite.coder.FirstError()) + result := suite.store.Data() + assert.Equal(suite.T(), expected, result) +} + +func (suite *EncoderSuite) verifyCodedData(value interface{}, expected []byte) { + suite.coder.Code(value) + suite.verifyData(expected) +} diff --git a/modding/hacked/ss1/serial/MockedCodable_test.go b/modding/hacked/ss1/serial/MockedCodable_test.go new file mode 100644 index 0000000..f43c4ac --- /dev/null +++ b/modding/hacked/ss1/serial/MockedCodable_test.go @@ -0,0 +1,11 @@ +package serial_test + +import "github.com/inkyblackness/hacked/ss1/serial" + +type MockedCodable struct { + calledCoder serial.Coder +} + +func (codable *MockedCodable) Code(coder serial.Coder) { + codable.calledCoder = coder +} diff --git a/modding/hacked/ss1/serial/Positioner.go b/modding/hacked/ss1/serial/Positioner.go new file mode 100644 index 0000000..1b161b8 --- /dev/null +++ b/modding/hacked/ss1/serial/Positioner.go @@ -0,0 +1,9 @@ +package serial + +// Positioner specifies a type that knows about a position which can be modified. +type Positioner interface { + // CurPos gets the current position in the data. + CurPos() uint32 + // SetCurPos sets the current position in the data. + SetCurPos(offset uint32) +} diff --git a/modding/hacked/ss1/serial/PositioningCoder.go b/modding/hacked/ss1/serial/PositioningCoder.go new file mode 100644 index 0000000..2a976bd --- /dev/null +++ b/modding/hacked/ss1/serial/PositioningCoder.go @@ -0,0 +1,9 @@ +package serial + +// PositioningCoder is a coder that also knows about positioning. +// Any error state regarding repositioning will be provided via FirstError(). +// SetCurPos will do nothing if the coder is already in error state. +type PositioningCoder interface { + Positioner + Coder +} diff --git a/modding/hacked/ss1/serial/PositioningDecoder.go b/modding/hacked/ss1/serial/PositioningDecoder.go new file mode 100644 index 0000000..a847f9b --- /dev/null +++ b/modding/hacked/ss1/serial/PositioningDecoder.go @@ -0,0 +1,27 @@ +package serial + +import "io" + +// PositioningDecoder is a Decoder with positioning capabilities. +type PositioningDecoder struct { + Decoder + + seeker io.Seeker +} + +// NewPositioningDecoder creates a new PositioningDecoder from given reader. +func NewPositioningDecoder(source io.ReadSeeker) *PositioningDecoder { + return &PositioningDecoder{Decoder: Decoder{source: source, offset: 0}, seeker: source} +} + +// SetCurPos changes the encoding position to the specified absolute offset. +func (coder *PositioningDecoder) SetCurPos(offset uint32) { + if coder.firstError != nil { + return + } + _, coder.firstError = coder.seeker.Seek(int64(offset), io.SeekStart) + if coder.firstError != nil { + return + } + coder.offset = offset +} diff --git a/modding/hacked/ss1/serial/PositioningDecoder_test.go b/modding/hacked/ss1/serial/PositioningDecoder_test.go new file mode 100644 index 0000000..42779d3 --- /dev/null +++ b/modding/hacked/ss1/serial/PositioningDecoder_test.go @@ -0,0 +1,64 @@ +package serial_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" +) + +func TestPositioningDecoderSetCurPosRepositionsReadOffset(t *testing.T) { + var source = bytes.NewReader([]byte{0x78, 0x12, 0x34}) + coder := serial.NewPositioningDecoder(source) + arrayValue := make([]byte, 3) + var intValue uint16 + + coder.Code(arrayValue) + coder.SetCurPos(1) + coder.Code(&intValue) + + assert.Equal(t, uint16(0x3412), intValue) +} + +func TestPositioningDecoderCurPosReturnsCurrentOffset(t *testing.T) { + var source = bytes.NewReader([]byte{0x78, 0x12, 0x34}) + coder := serial.NewPositioningDecoder(source) + + coder.Code(uint16(0)) + + assert.Equal(t, uint32(2), coder.CurPos()) +} + +func TestPositioningDecoderFirstErrorBySetCurPos(t *testing.T) { + var target errorBuffer + coder := serial.NewPositioningDecoder(&target) + + data := uint32(0) + coder.Code(&data) + target.errorOnNextCall = true + coder.SetCurPos(0) + + assert.EqualError(t, coder.FirstError(), "errorBuffer on call number 2") +} + +func TestPositioningDecoderSetCurPosDoesNothingOnPreviousError(t *testing.T) { + var target errorBuffer + coder := serial.NewPositioningDecoder(&target) + target.errorOnNextCall = true + coder.Code(uint32(0)) + target.errorOnNextCall = true + coder.SetCurPos(0) + + assert.Equal(t, target.callCounter, 1) + assert.EqualError(t, coder.FirstError(), "errorBuffer on call number 1") +} + +func TestCurPosIsNotChangedOnSetError(t *testing.T) { + var target errorBuffer + coder := serial.NewPositioningDecoder(&target) + target.errorOnNextCall = true + coder.SetCurPos(4) + assert.Equal(t, uint32(0), coder.CurPos()) +} diff --git a/modding/hacked/ss1/serial/PositioningEncoder.go b/modding/hacked/ss1/serial/PositioningEncoder.go new file mode 100644 index 0000000..41f1d50 --- /dev/null +++ b/modding/hacked/ss1/serial/PositioningEncoder.go @@ -0,0 +1,57 @@ +package serial + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1" +) + +// PositioningEncoder is an Encoder with positioning capabilities. +type PositioningEncoder struct { + Encoder + + seeker io.Seeker +} + +// NewPositioningEncoder returns an Encoder that also implements the Positioner interface. +// The new Encoder starts with its zero position at the current position in the writer. +func NewPositioningEncoder(target io.WriteSeeker) *PositioningEncoder { + return &PositioningEncoder{Encoder: Encoder{target: target}, seeker: target} +} + +// SetCurPos changes the encoding position to the specified absolute offset. +func (coder *PositioningEncoder) SetCurPos(offset uint32) { + if coder.firstError != nil { + return + } + _, coder.firstError = coder.seeker.Seek(int64(offset)-int64(coder.offset), io.SeekCurrent) + if coder.firstError != nil { + return + } + coder.offset = offset +} + +const ( + errInvalidWhence ss1.StringError = "seek: invalid whence" + errSeekBeforeStart ss1.StringError = "seek: seeking before start" +) + +// Seek repositions the current encoding offset. +// This implementation does not support a whence value of io.SeekEnd. +func (coder *PositioningEncoder) Seek(offset int64, whence int) (int64, error) { + var newPosition int64 + switch whence { + default: + return int64(coder.offset), errInvalidWhence + case io.SeekStart: + newPosition = offset + case io.SeekCurrent: + newPosition = int64(coder.offset) + offset + } + if newPosition < 0 { + return int64(coder.offset), errSeekBeforeStart + } + + coder.SetCurPos(uint32(newPosition)) + return int64(coder.offset), coder.firstError +} diff --git a/modding/hacked/ss1/serial/PositioningEncoder_test.go b/modding/hacked/ss1/serial/PositioningEncoder_test.go new file mode 100644 index 0000000..228e592 --- /dev/null +++ b/modding/hacked/ss1/serial/PositioningEncoder_test.go @@ -0,0 +1,161 @@ +package serial_test + +import ( + "io" + "testing" + + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type PositioningEncoderSuite struct { + suite.Suite + coder *serial.PositioningEncoder + store *serial.ByteStore +} + +func TestPositioningEncoderSuite(t *testing.T) { + suite.Run(t, new(PositioningEncoderSuite)) +} + +func (suite *PositioningEncoderSuite) SetupTest() { + suite.store = serial.NewByteStore() + suite.coder = serial.NewPositioningEncoder(suite.store) +} + +func (suite *PositioningEncoderSuite) TestImplementsPositioningCoderInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(serial.PositioningCoder) + + assert.True(suite.T(), ok) +} + +func (suite *PositioningEncoderSuite) TestCurPosReturnsCurrentOffset() { + suite.coder.Code(uint32(0)) + assert.Equal(suite.T(), uint32(4), suite.coder.CurPos()) +} + +func (suite *PositioningEncoderSuite) TestSetCurPosRepositionsWritePointer() { + suite.coder.Code(uint32(0)) + suite.coder.SetCurPos(0) + suite.coder.Code(uint32(0x13243546)) + result := suite.store.Data() + + assert.Equal(suite.T(), []byte{0x46, 0x35, 0x24, 0x13}, result) +} + +func (suite *PositioningEncoderSuite) TestFirstErrorBySetCurPos() { + var target errorBuffer + suite.coder = serial.NewPositioningEncoder(&target) + + suite.coder.Code(uint32(0)) + target.errorOnNextCall = true + suite.coder.SetCurPos(0) + + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 2") +} + +func (suite *PositioningEncoderSuite) TestSetCurPosDoesNothingOnPreviousError() { + var target errorBuffer + suite.coder = serial.NewPositioningEncoder(&target) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + target.errorOnNextCall = true + suite.coder.SetCurPos(0) + + assert.Equal(suite.T(), target.callCounter, 1) + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 1") +} + +func (suite *PositioningEncoderSuite) TestCurPosIsNotChangedOnSetError() { + var target errorBuffer + suite.coder = serial.NewPositioningEncoder(&target) + target.errorOnNextCall = true + suite.coder.SetCurPos(4) + assert.Equal(suite.T(), uint32(0), suite.coder.CurPos()) +} + +func (suite *PositioningEncoderSuite) TestCurPosIsRelativeToConstructionTime() { + _, _ = suite.store.Write([]byte{0x01, 0x02, 0x03}) + + assert.Equal(suite.T(), uint32(0), suite.coder.CurPos(), "CurPos should be zero at the start") + suite.coder.Code(uint16(0xAAAA)) + suite.coder.SetCurPos(1) + suite.coder.Code(byte(0xCC)) + + result := suite.store.Data() + assert.Equal(suite.T(), []byte{0x01, 0x02, 0x03, 0xAA, 0xCC}, result, "SetCurPos did not work relative to original start") +} + +func (suite *PositioningEncoderSuite) TestImplementsSeekerInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(io.Seeker) + + assert.True(suite.T(), ok) +} + +func (suite *PositioningEncoderSuite) TestSeekReturnsErrorForWrongWhence() { + pos, err := suite.coder.Seek(1, 1234) + + assert.Equal(suite.T(), int64(0), pos, "zero position should be returned") + assert.EqualError(suite.T(), err, "seek: invalid whence") +} + +func (suite *PositioningEncoderSuite) TestSeekAbsolute() { + suite.coder.Code(uint32(0)) + pos, err := suite.coder.Seek(1, io.SeekStart) + assert.Nil(suite.T(), err, "no error expected") + assert.Equal(suite.T(), int64(1), pos, "invalid position returned") + + suite.coder.Code(uint16(0xAABB)) + result := suite.store.Data() + assert.Equal(suite.T(), []byte{0x00, 0xBB, 0xAA, 0x00}, result) + assert.Equal(suite.T(), uint32(3), suite.coder.CurPos(), "should update current position") +} + +func (suite *PositioningEncoderSuite) TestSeekRelative() { + suite.coder.Code(uint32(0)) + pos, err := suite.coder.Seek(-1, io.SeekCurrent) + assert.Equal(suite.T(), int64(3), pos, "should return new position") + assert.Nil(suite.T(), err, "should have no error") + + suite.coder.Code(uint16(0xAABB)) + + result := suite.store.Data() + assert.Equal(suite.T(), []byte{0x00, 0x00, 0x00, 0xBB, 0xAA}, result) + assert.Equal(suite.T(), uint32(5), suite.coder.CurPos(), "should update current position") +} + +func (suite *PositioningEncoderSuite) TestSeekDoesNothingOnPreviousError() { + var target errorBuffer + suite.coder = serial.NewPositioningEncoder(&target) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + _, _ = suite.coder.Seek(0, io.SeekStart) + + assert.Equal(suite.T(), target.callCounter, 1) + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 1") +} + +func (suite *PositioningEncoderSuite) TestSeekRegistersReturnedError() { + var target errorBuffer + suite.coder = serial.NewPositioningEncoder(&target) + suite.coder.Code(uint32(0)) + target.errorOnNextCall = true + _, _ = suite.coder.Seek(1, io.SeekStart) + + pos, err := suite.coder.Seek(1, io.SeekStart) + + assert.Equal(suite.T(), int64(4), pos, "Should return old position value for seek") + assert.EqualError(suite.T(), err, "errorBuffer on call number 2") + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 2", "should set first error") +} + +func (suite *PositioningEncoderSuite) TestSeekReturnsErrorForSeekBeforeStart() { + pos, err := suite.coder.Seek(-1, io.SeekStart) + + assert.Equal(suite.T(), int64(0), pos, "Should return old position value for seek") + assert.EqualError(suite.T(), err, "seek: seeking before start") +} diff --git a/modding/hacked/ss1/serial/SeekingReadCloser.go b/modding/hacked/ss1/serial/SeekingReadCloser.go new file mode 100644 index 0000000..113f8c1 --- /dev/null +++ b/modding/hacked/ss1/serial/SeekingReadCloser.go @@ -0,0 +1,10 @@ +package serial + +import "io" + +// SeekingReadCloser combines the interfaces of a Reader, a Seeker and a Closer. +type SeekingReadCloser interface { + io.Reader + io.Seeker + io.Closer +} diff --git a/modding/hacked/ss1/serial/SeekingWriteCloser.go b/modding/hacked/ss1/serial/SeekingWriteCloser.go new file mode 100644 index 0000000..f1485b4 --- /dev/null +++ b/modding/hacked/ss1/serial/SeekingWriteCloser.go @@ -0,0 +1,10 @@ +package serial + +import "io" + +// SeekingWriteCloser combines the interfaces of a Writer, a Seeker and a Closer. +type SeekingWriteCloser interface { + io.Writer + io.Seeker + io.Closer +} diff --git a/modding/hacked/ss1/serial/doc.go b/modding/hacked/ss1/serial/doc.go new file mode 100644 index 0000000..82480d9 --- /dev/null +++ b/modding/hacked/ss1/serial/doc.go @@ -0,0 +1,4 @@ +/* +Package serial provides serialization utilities for nested serialization. +*/ +package serial diff --git a/modding/hacked/ss1/serial/errorBuffer_test.go b/modding/hacked/ss1/serial/errorBuffer_test.go new file mode 100644 index 0000000..98d2bdf --- /dev/null +++ b/modding/hacked/ss1/serial/errorBuffer_test.go @@ -0,0 +1,56 @@ +package serial_test + +import ( + "fmt" + "io" + + "github.com/inkyblackness/hacked/ss1" +) + +type errorBuffer struct { + position int64 + callCounter int + errorOnNextCall bool + errorByteCount int +} + +func (buf *errorBuffer) check() (err error) { + buf.callCounter++ + if buf.errorOnNextCall { + buf.errorOnNextCall = false + err = ss1.StringError(fmt.Sprintf("errorBuffer on call number %v", buf.callCounter)) + } + return +} + +func (buf *errorBuffer) Seek(offset int64, whence int) (int64, error) { + err := buf.check() + if err != nil { + return 0, err + } + switch whence { + case io.SeekStart: + buf.position = offset + case io.SeekCurrent: + buf.position += offset + case io.SeekEnd: + panic("SeekEnd not supported") + } + return buf.position, nil +} + +func (buf *errorBuffer) Read(p []byte) (n int, err error) { + err = buf.check() + if err != nil { + return buf.errorByteCount, err + } + return len(p), nil +} + +func (buf *errorBuffer) Write(p []byte) (n int, err error) { + err = buf.check() + if err != nil { + return buf.errorByteCount, err + } + return len(p), nil +} diff --git a/modding/hacked/ss1/serial/rle/Compress.go b/modding/hacked/ss1/serial/rle/Compress.go new file mode 100644 index 0000000..c5d690f --- /dev/null +++ b/modding/hacked/ss1/serial/rle/Compress.go @@ -0,0 +1,177 @@ +package rle + +import "io" + +// Compress compresses the given byte array into the given writer. +// The optional reference array is used as a delta basis. If provided, bytes will be skipped +// where the data equals the reference. +func Compress(writer io.Writer, data []byte, reference []byte) error { + end := len(data) + refLen := len(reference) + ref := func(index int) byte { + if index < refLen { + return reference[index] + } + return 0x00 + } + + countIdenticalBytes := func(from int) int { + index := from + for (index < end) && (data[index] == ref(index)) { + index++ + } + return index - from + } + countConstantBytes := func(from int, value byte) int { + index := from + for (index < end) && (data[index] == value) { + index++ + } + return index - from + } + + { + trailingSkip := 0 + + for temp := end - 1; (temp >= 0) && (data[temp] == ref(temp)); temp-- { + trailingSkip++ + } + end -= trailingSkip % 0x7FFF + } + start := 0 + for start < end { + identicalByteCount := countIdenticalBytes(start) + constByteCount := countConstantBytes(start, data[start]) + + switch { + case identicalByteCount > 3: + err := skipBytes(writer, identicalByteCount) + if err != nil { + return err + } + start += identicalByteCount + case constByteCount > 3: + err := writeConstant(writer, constByteCount, data[start]) + if err != nil { + return err + } + start += constByteCount + default: + diffByteCount := 0 + abort := false + + for (start+diffByteCount) < end && !abort { + nextIdenticalByteCount := countIdenticalBytes(start + diffByteCount) + nextConstByteCount := countConstantBytes(start+diffByteCount, data[start+diffByteCount]) + + if nextIdenticalByteCount < 4 && nextConstByteCount < 4 { + if nextIdenticalByteCount > nextConstByteCount { + diffByteCount += nextIdenticalByteCount + } else { + diffByteCount += nextConstByteCount + } + } else { + abort = true + } + } + err := writeRaw(writer, data[start:start+diffByteCount]) + if err != nil { + return err + } + start += diffByteCount + } + } + return writeExtended(writer, 0x0000) +} + +func writeExtended(writer io.Writer, control uint16, extra ...byte) error { + _, err := writer.Write([]byte{0x80, byte(control & 0xFF), byte((control >> 8) & 0xFF)}) + if err != nil { + return err + } + _, err = writer.Write(extra) + return err +} + +func skipBytes(writer io.Writer, size int) error { + remain := size + + for remain > 0 { + if remain < 0x80 { + _, err := writer.Write([]byte{byte(0x80 + remain)}) + if err != nil { + return err + } + remain = 0 + } else { + lenControl := 0x7FFF + if remain < lenControl { + lenControl = remain + } + err := writeExtended(writer, uint16(lenControl)) + if err != nil { + return err + } + remain -= lenControl + } + } + return nil +} + +func writeConstant(writer io.Writer, size int, value byte) error { + start := 0 + + for start < size { + remain := size - start + if remain < 0x100 { + _, err := writer.Write([]byte{0x00, byte(remain), value}) + if err != nil { + return err + } + start = size + } else { + lenControl := 0x3FFF + if remain < lenControl { + lenControl = remain + } + err := writeExtended(writer, 0xC000+uint16(lenControl), value) + if err != nil { + return err + } + start += lenControl + } + } + return nil +} + +func writeRaw(writer io.Writer, data []byte) error { + end := len(data) + start := 0 + + for start < end { + remain := end - start + if remain < 0x80 { + _, err := writer.Write([]byte{byte(remain)}) + if err != nil { + return err + } + _, err = writer.Write(data[start:]) + if err != nil { + return err + } + start = end + } else { + lenControl := 0x3FFF + + if remain < lenControl { + lenControl = remain + } + err := writeExtended(writer, 0x8000+uint16(lenControl), data[start:start+lenControl]...) + if err != nil { + return err + } + start += lenControl + } + } + return nil +} diff --git a/modding/hacked/ss1/serial/rle/Compress_test.go b/modding/hacked/ss1/serial/rle/Compress_test.go new file mode 100644 index 0000000..45b43cd --- /dev/null +++ b/modding/hacked/ss1/serial/rle/Compress_test.go @@ -0,0 +1,79 @@ +package rle_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/serial/rle" +) + +func TestCompressEmptyArrayResultsInTerminator(t *testing.T) { + writer := bytes.NewBuffer(nil) + err := rle.Compress(writer, nil, nil) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x80, 0x00, 0x00}, writer.Bytes()) +} + +func TestCompressOfZeroBytesLong(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := make([]byte, 1000) + input[len(input)-1] = 1 + err := rle.Compress(writer, input, nil) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x80, 0xE7, 0x03, 0x01, 0x01, 0x80, 0x0, 0x0}, writer.Bytes()) +} + +func TestCompressOfEqualBytesLong(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := make([]byte, 1000) + for i := 0; i < len(input); i++ { + input[i] = 0xAA + } + input[len(input)-1] = 1 + err := rle.Compress(writer, input, nil) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x80, 0xE7, 0xC3, 0xAA, 0x01, 0x01, 0x80, 0x0, 0x0}, writer.Bytes()) +} + +func TestCompressOfEqualBytesShort(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := make([]byte, 40) + for i := 0; i < len(input); i++ { + input[i] = 0xAA + } + input[len(input)-1] = 1 + err := rle.Compress(writer, input, nil) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x00, 0x27, 0xAA, 0x01, 0x01, 0x80, 0x0, 0x0}, writer.Bytes()) +} + +func TestCompressOfRandomBytesShort(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := make([]byte, 10) + for i := 0; i < len(input); i++ { + input[i] = byte(i) + } + err := rle.Compress(writer, input, nil) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x0A, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x80, 0x00, 0x00}, writer.Bytes()) +} + +func TestCompressWithIdenticalReference(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := []byte{0xAA, 0xBB, 0xCC, 0xDD} + reference := input + err := rle.Compress(writer, input, reference) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x80, 0x00, 0x00}, writer.Bytes()) +} + +func TestCompressWithPartlyIdenticalReference(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + reference := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x0A} + err := rle.Compress(writer, input, reference) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x85, 0x01, 0xFF, 0x80, 0x00, 0x00}, writer.Bytes()) +} diff --git a/modding/hacked/ss1/serial/rle/Decompress.go b/modding/hacked/ss1/serial/rle/Decompress.go new file mode 100644 index 0000000..62937c0 --- /dev/null +++ b/modding/hacked/ss1/serial/rle/Decompress.go @@ -0,0 +1,67 @@ +package rle + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1" +) + +const ( + errUndefinedCase ss1.StringError = "undefined case 80 nn C0" +) + +// Decompress decompresses from the given reader and writes into the provided output buffer. +// The output buffer must be pre-allocated. +// If it contains non-zero data, this data may be preserved if the compressed data specifies to. +func Decompress(reader io.Reader, output []byte) (err error) { + outIndex := 0 + done := false + nextByte := func() byte { + zz := []byte{0x00} + _, err = reader.Read(zz) + return zz[0] + } + + for !done && (err == nil) { + first := nextByte() + + switch { + case first == 0x00: + nn := nextByte() + zz := nextByte() + + outIndex += writeBytesOfValue(output[outIndex:outIndex+int(nn)], func() byte { return zz }) + case first < 0x80: + outIndex += writeBytesOfValue(output[outIndex:outIndex+int(first)], nextByte) + case first == 0x80: + control := uint16(nextByte()) + control += uint16(nextByte()) << 8 + switch { + case control == 0x0000: + done = true + case control < 0x8000: + outIndex += int(control) + case control < 0xC000: + outIndex += writeBytesOfValue(output[outIndex:outIndex+int(control&0x3FFF)], nextByte) + case (control & 0xFF00) == 0xC000: + err = errUndefinedCase + default: + zz := nextByte() + + outIndex += writeBytesOfValue(output[outIndex:outIndex+int(control&0x3FFF)], func() byte { return zz }) + } + default: + outIndex += int(first & 0x7F) + } + } + + return +} + +func writeBytesOfValue(buffer []byte, producer func() byte) int { + count := len(buffer) + for i := 0; i < count; i++ { + buffer[i] = producer() + } + return count +} diff --git a/modding/hacked/ss1/serial/rle/Decompress_test.go b/modding/hacked/ss1/serial/rle/Decompress_test.go new file mode 100644 index 0000000..ece8609 --- /dev/null +++ b/modding/hacked/ss1/serial/rle/Decompress_test.go @@ -0,0 +1,146 @@ +package rle_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/serial/rle" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecompressEmptyArrayReturnsError(t *testing.T) { + err := rle.Decompress(bytes.NewReader(nil), make([]byte, 100)) + + assert.NotNil(t, err) +} + +func TestDecompress800000IsEndOfStream(t *testing.T) { + var result []byte + err := rle.Decompress(bytes.NewReader([]byte{0x80, 0x00, 0x00}), result) + + assert.Nil(t, err) +} + +func TestDecompress800000AppendsRemainingZeroes(t *testing.T) { + result := make([]byte, 10) + err := rle.Decompress(bytes.NewReader([]byte{0x80, 0x00, 0x00}), result) + + require.Nil(t, err) + assert.Equal(t, make([]byte, 10), result) +} + +func TestDecompress800000IsConsumedAtEnd(t *testing.T) { + var result []byte + reader := bytes.NewReader([]byte{0x80, 0x00, 0x00}) + err := rle.Decompress(reader, result) + + require.Nil(t, err) + pos, _ := reader.Seek(0, 1) + assert.Equal(t, int64(3), pos) +} + +func TestDecompress00WritesNNBytesOfColorZZ(t *testing.T) { + result := make([]byte, 5) + err := rle.Decompress(bytes.NewReader([]byte{0x00, 0x05, 0xCC, 0x80, 0x00, 0x00}), result) + + require.Nil(t, err) + assert.Equal(t, []byte{0xCC, 0xCC, 0xCC, 0xCC, 0xCC}, result) +} + +func TestDecompress00ReturnsErrorIfZZIsMissing(t *testing.T) { + err := rle.Decompress(bytes.NewReader([]byte{0x00, 0x05}), make([]byte, 5)) + + assert.NotNil(t, err) +} + +func TestDecompress00ReturnsErrorIfNNIsMissing(t *testing.T) { + err := rle.Decompress(bytes.NewReader([]byte{0x00}), make([]byte, 5)) + + assert.NotNil(t, err) +} + +func TestDecompressNNLess80WritesNNFollowingBytes(t *testing.T) { + result := make([]byte, 2) + err := rle.Decompress(bytes.NewReader([]byte{0x02, 0xAA, 0xBB, 0x80, 0x00, 0x00}), result) + + require.Nil(t, err) + assert.Equal(t, []byte{0xAA, 0xBB}, result) +} + +func TestDecompressNNLess80ReturnsErrorIfEndOfFile(t *testing.T) { + err := rle.Decompress(bytes.NewReader([]byte{0x02, 0xAA}), make([]byte, 2)) + + assert.NotNil(t, err) +} + +func TestDecompress80NNNNSkipsBytes(t *testing.T) { + expected := make([]byte, 0x123) + result := make([]byte, 0x123) + for i := 0; i < len(result); i++ { + result[i] = byte(i) + } + copy(expected, result) + err := rle.Decompress(bytes.NewReader([]byte{0x80, 0x23, 0x01, 0x80, 0x00, 0x00}), result) + + require.Nil(t, err) + assert.Equal(t, expected, result) +} + +func TestDecompress80CopiesNNBytes(t *testing.T) { + input := bytes.NewBuffer(nil) + input.Write([]byte{0x80, 0x04, 0x80}) + input.Write([]byte{0x01, 0x02, 0x03, 0x04}) + input.Write([]byte{0x80, 0x00, 0x00}) + result := make([]byte, 4) + err := rle.Decompress(bytes.NewReader(input.Bytes()), result) + + require.Nil(t, err) + assert.Equal(t, []byte{0x01, 0x02, 0x03, 0x04}, result) +} + +func TestDecompress80CopiesNNBytesExtended(t *testing.T) { + input := bytes.NewBuffer(nil) + expected := make([]byte, 0x3FFF) + input.Write([]byte{0x80, 0xFF, 0xBF}) + input.Write(expected) + input.Write([]byte{0x80, 0x00, 0x00}) + result := make([]byte, len(expected)) + err := rle.Decompress(bytes.NewReader(input.Bytes()), result) + + require.Nil(t, err) + assert.Equal(t, expected, result) +} + +func TestDecompress80ReturnsErrorForUndefinedCase(t *testing.T) { + input := bytes.NewBuffer(nil) + input.Write([]byte{0x80, 0x00, 0xC0}) + input.Write([]byte{0x80, 0x00, 0x00}) + err := rle.Decompress(bytes.NewReader(input.Bytes()), make([]byte, 1)) + + assert.NotNil(t, err) +} + +func TestDecompress80WritesNNBytesOfValue(t *testing.T) { + input := bytes.NewBuffer(nil) + expected := make([]byte, 0x3FFF) + for index := range expected { + expected[index] = 0xCD + } + input.Write([]byte{0x80, 0xFF, 0xFF, 0xCD}) + input.Write([]byte{0x80, 0x00, 0x00}) + result := make([]byte, len(expected)) + err := rle.Decompress(bytes.NewReader(input.Bytes()), result) + + require.Nil(t, err) + assert.Equal(t, expected, result) +} + +func TestDecompressNNMore80WritesZeroes(t *testing.T) { + result := make([]byte, 3) + err := rle.Decompress(bytes.NewReader([]byte{0x83, 0x80, 0x00, 0x00}), result) + + require.Nil(t, err) + assert.Equal(t, []byte{0, 0, 0}, result) +} diff --git a/modding/hacked/ss1/serial/rle/benchmark_test.go b/modding/hacked/ss1/serial/rle/benchmark_test.go new file mode 100644 index 0000000..60f226e --- /dev/null +++ b/modding/hacked/ss1/serial/rle/benchmark_test.go @@ -0,0 +1,83 @@ +package rle_test + +import ( + "bytes" + "math/rand" + "testing" + "time" + + "github.com/inkyblackness/hacked/ss1/serial/rle" +) + +func benchmarkRecompression(b *testing.B, size int, chanceLimit byte, nameSuffix string, seed int64) { + b.Helper() + input := make([]byte, size) + reference := make([]byte, size) + var chance [1]byte + + rand.Seed(seed) // nolint:gas + + b.ResetTimer() + for run := 0; run < b.N; run++ { + b.StopTimer() + rand.Read(input) // nolint:gas + copy(reference, input) + for i := 0; i < size; i++ { + rand.Read(chance[:]) // nolint:gas + if chance[0] < chanceLimit { + input[i] = chance[0] + } + } + buf := bytes.NewBuffer(nil) + b.StartTimer() + + err := rle.Compress(buf, input, reference) + if err != nil { + b.Errorf("Failed compression for %s in run %d of seed %v", nameSuffix, run, seed) + } + + err = rle.Decompress(bytes.NewReader(buf.Bytes()), reference) + if err != nil { + b.Errorf("Failed decompression for %s in run %d of seed %v", nameSuffix, run, seed) + } + if !bytes.Equal(input, reference) { + b.Errorf("Data mismatch in %s in run %d of seed %v", nameSuffix, run, seed) + } + } +} + +func BenchmarkRecompression64KB_10(b *testing.B) { + benchmarkRecompression(b, 1024*64, 10, "64KB,10", time.Now().UnixNano()) +} + +func BenchmarkRecompression64KB_128(b *testing.B) { + benchmarkRecompression(b, 1024*64, 128, "64KB,128", time.Now().UnixNano()) +} + +func BenchmarkRecompression1KB_0(b *testing.B) { + benchmarkRecompression(b, 1024, 0, "1KB,0", time.Now().UnixNano()) +} + +func BenchmarkRecompression1KB_10(b *testing.B) { + benchmarkRecompression(b, 1024, 10, "1KB,10", time.Now().UnixNano()) +} + +func BenchmarkRecompression1KB_128(b *testing.B) { + benchmarkRecompression(b, 1024, 128, "1KB,128", time.Now().UnixNano()) +} + +func BenchmarkRecompression1KB_255(b *testing.B) { + benchmarkRecompression(b, 1024, 255, "1KB,255", time.Now().UnixNano()) +} + +func BenchmarkRecompression128B_10(b *testing.B) { + benchmarkRecompression(b, 128, 10, "128B,10", time.Now().UnixNano()) +} + +func BenchmarkRecompression128B_128(b *testing.B) { + benchmarkRecompression(b, 128, 128, "128B,128", time.Now().UnixNano()) +} + +func BenchmarkRecompression128B_255(b *testing.B) { + benchmarkRecompression(b, 128, 255, "128B,255", time.Now().UnixNano()) +} diff --git a/modding/hacked/ss1/world/BlockPatch.go b/modding/hacked/ss1/world/BlockPatch.go new file mode 100644 index 0000000..317aea6 --- /dev/null +++ b/modding/hacked/ss1/world/BlockPatch.go @@ -0,0 +1,19 @@ +package world + +import "github.com/inkyblackness/hacked/ss1/resource" + +// BlockPatch describes a raw delta-change of a resource block. +// The change can be applied, and reverted. +type BlockPatch struct { + // ID identifier of the resource + ID resource.ID + // BlockIndex identifies the block for which the patch applies. + BlockIndex int + // BlockLength describes the expected length of the block. + BlockLength int + + // ForwardData contains the delta information to patch to a new version. + ForwardData []byte + // ReverseData contains the delta information to change a new version back to the original. + ReverseData []byte +} diff --git a/modding/hacked/ss1/world/Constants.go b/modding/hacked/ss1/world/Constants.go new file mode 100644 index 0000000..34617b3 --- /dev/null +++ b/modding/hacked/ss1/world/Constants.go @@ -0,0 +1,31 @@ +package world + +import "github.com/inkyblackness/hacked/ss1" + +const ( + // StartingLevel identifies the level a new game is started in by default. + StartingLevel = 1 + // StartingTileX identifies the default X position of the protagonist. + StartingTileX = 30 + // StartingTileY identifies the default Y position of the protagonist. + StartingTileY = 22 + + // MaxWorldTextures is the limit of how many textures the engine supports. + // Note that this value is equal to that of the resource limits in package ids. It is actually based on them. + MaxWorldTextures = 293 +) + +const ( + // TexturePropertiesFilename specifies the lowercase name of the file containing texture properties. + TexturePropertiesFilename = "textprop.dat" + + // ObjectPropertiesFilename specifies the lowercase name of the file containing object properties. + ObjectPropertiesFilename = "objprop.dat" +) + +const ( + errNoResourcesFound ss1.StringError = "resource unknown" + errBlockLengthMismatch ss1.StringError = "block length mismatch" + errIndexOutOfBounds ss1.StringError = "index is out of bounds" + errEntryIsNil ss1.StringError = "entry is nil" +) diff --git a/modding/hacked/ss1/world/Defaults.go b/modding/hacked/ss1/world/Defaults.go new file mode 100644 index 0000000..04d5d6d --- /dev/null +++ b/modding/hacked/ss1/world/Defaults.go @@ -0,0 +1,8 @@ +package world + +// IsConsideredCyberspaceByDefault returns true for those level identifier +// that are hardcoded to be cyberspace levels. +// For the vanilla engine, this is true for level 10 and any above 13. +func IsConsideredCyberspaceByDefault(levelID int) bool { + return (levelID == 10) || (levelID > 13) +} diff --git a/modding/hacked/ss1/world/Defaults_test.go b/modding/hacked/ss1/world/Defaults_test.go new file mode 100644 index 0000000..f9dc1c0 --- /dev/null +++ b/modding/hacked/ss1/world/Defaults_test.go @@ -0,0 +1,23 @@ +package world_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/world" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultCyberspaceLevels(t *testing.T) { + tt := []bool{ + false, false, false, false, + false, false, false, false, + false, false, true, false, + false, false, true, true, + } + + for levelID, expected := range tt { + result := world.IsConsideredCyberspaceByDefault(levelID) + assert.Equal(t, expected, result, "Cyberspace mismatch") + } +} diff --git a/modding/hacked/ss1/world/FileLoader.go b/modding/hacked/ss1/world/FileLoader.go new file mode 100644 index 0000000..458e8b3 --- /dev/null +++ b/modding/hacked/ss1/world/FileLoader.go @@ -0,0 +1,208 @@ +package world + +import ( + "archive/zip" + "bytes" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/serial" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +var fileAllowlist = ids.FilenameList{ + ids.Archive, + ids.CybStrng, + ids.CitALog, + ids.CitBark, + ids.CitMat, + ids.DigiFX, + ids.GamePal, + ids.GameScr, + ids.MfdArt, + ids.Obj3D, + ids.ObjArt, + ids.ObjArt2, + ids.ObjArt3, + ids.SideArt, + ids.Splash, + ids.SvgaDeth, + ids.SvgaEnd, + ids.SvgaIntr, + ids.Texture, + ids.VidMail, +} + +type fileLoader struct { + resultMutex sync.Mutex + + allowZips bool + + result FileLoadResult +} + +// FileLoadResult contains all the information of a LoadFiles attempt. +type FileLoadResult struct { + FailedFiles int + Savegames map[FileLocation]resource.Viewer + Resources map[FileLocation]resource.Viewer + + ObjectProperties object.PropertiesTable + TextureProperties texture.PropertiesList +} + +// LoadFiles attempts to load compatible files from the given set of filenames. +func LoadFiles(allowZips bool, names []string) FileLoadResult { + loader := fileLoader{ + allowZips: allowZips, + result: FileLoadResult{ + Resources: make(map[FileLocation]resource.Viewer), + Savegames: make(map[FileLocation]resource.Viewer), + }, + } + loader.loadAll(names) + return loader.result +} + +func (loader *fileLoader) loadAll(names []string) { + loader.loadList(names, len(names) == 1) +} + +func (loader *fileLoader) loadList(names []string, isOnlyRequestedFile bool) { + var wg sync.WaitGroup + + for _, name := range names { + wg.Add(1) + go func(name string) { + defer wg.Done() + loader.load(name, isOnlyRequestedFile) + }(name) + } + wg.Wait() +} + +func (loader *fileLoader) load(name string, isOnlyRequestedFile bool) { + fileInfo, err := os.Stat(name) + if err != nil { + loader.markFailedFile() + return + } + file, err := os.Open(name) + if err != nil { + loader.markFailedFile() + return + } + defer func() { + _ = file.Close() // nolint: gas + }() + + if fileInfo.IsDir() { + if isOnlyRequestedFile { + subNames, _ := file.Readdirnames(0) + joinedSubNames := make([]string, len(subNames)) + for index, subName := range subNames { + joinedSubNames[index] = filepath.Join(name, subName) + } + loader.loadList(joinedSubNames, false) + } + } else { + tryDirect := true + if loader.allowZips && isOnlyRequestedFile { + tryDirect = false + zipReader, err := zip.NewReader(file, fileInfo.Size()) + if err != nil { + _, _ = file.Seek(0, io.SeekStart) + tryDirect = true + } else { + loader.loadFileArchive(zipReader) + } + } + + if tryDirect { + loader.loadFile(name, isOnlyRequestedFile, file) + } + } +} + +func (loader *fileLoader) loadFileArchive(zipReader *zip.Reader) { + var wg sync.WaitGroup + + for _, file := range zipReader.File { + wg.Add(1) + go func(entry *zip.File) { + defer wg.Done() + entryFile, err := entry.Open() + if err != nil { + return + } + defer func() { + _ = entryFile.Close() + }() + + loader.loadFile(entry.Name, false, entryFile) + }(file) + } + wg.Wait() +} + +func (loader *fileLoader) loadFile(name string, isOnlyStagedFile bool, file io.Reader) { + fileData, err := ioutil.ReadAll(file) + if err != nil { + loader.markFailedFile() + } + + reader, err := lgres.ReaderFrom(bytes.NewReader(fileData)) + filename := filepath.Base(name) + if (err == nil) && (isOnlyStagedFile || fileAllowlist.Matches(filename)) { + location := FileLocation{DirPath: filepath.Dir(name), Name: filename} + loader.modify(func() { + if stateView, stateErr := reader.View(ids.GameState); (stateErr == nil) && archive.IsSavegame(stateView) { + loader.result.Savegames[location] = reader + } else { + loader.result.Resources[location] = reader + } + }) + } + if strings.ToLower(filename) == ObjectPropertiesFilename { + decoder := serial.NewDecoder(bytes.NewReader(fileData)) + properties := object.StandardPropertiesTable() + properties.Code(decoder) + err = decoder.FirstError() + if err == nil { + loader.modify(func() { loader.result.ObjectProperties = properties }) + } + } + if strings.ToLower(filename) == TexturePropertiesFilename && (len(fileData) > 4) { + decoder := serial.NewDecoder(bytes.NewReader(fileData)) + entryCount := (len(fileData) - 4) / texture.PropertiesSize + properties := make(texture.PropertiesList, entryCount) + properties.Code(decoder) + err = decoder.FirstError() + if err == nil { + loader.modify(func() { loader.result.TextureProperties = properties }) + } + } + + if err != nil { + loader.markFailedFile() + } +} + +func (loader *fileLoader) markFailedFile() { + loader.modify(func() { loader.result.FailedFiles++ }) +} + +func (loader *fileLoader) modify(modifier func()) { + loader.resultMutex.Lock() + defer loader.resultMutex.Unlock() + modifier() +} diff --git a/modding/hacked/ss1/world/FileLocation.go b/modding/hacked/ss1/world/FileLocation.go new file mode 100644 index 0000000..8090bd0 --- /dev/null +++ b/modding/hacked/ss1/world/FileLocation.go @@ -0,0 +1,48 @@ +package world + +import ( + "path/filepath" + "strings" +) + +// FileLocation describes the path and name of a file. +type FileLocation struct { + DirPath string + Name string +} + +// FileLocationFrom returns an instance for given filename. +func FileLocationFrom(filename string) FileLocation { + return FileLocation{ + DirPath: filepath.Dir(filename), + Name: filepath.Base(filename), + } +} + +// AbsolutePathFrom returns the complete path, based on given base, of the location. +func (loc FileLocation) AbsolutePathFrom(base string) string { + rel, err := filepath.Rel(base, loc.DirPath) + if err != nil { + if filepath.IsAbs(loc.DirPath) { + return filepath.Join(loc.DirPath, loc.Name) + } + return filepath.Join(base, loc.DirPath, loc.Name) + } + return filepath.Join(base, rel, loc.Name) +} + +// NestedRelativeTo returns a string that describes the relative nested location to given base - if possible. +func (loc FileLocation) NestedRelativeTo(base string) string { + absoluteDir := loc.DirPath + if !filepath.IsAbs(absoluteDir) { + absoluteDir = filepath.Clean(filepath.Join(base, loc.DirPath)) + } + if !strings.HasPrefix(absoluteDir+string(filepath.Separator), base+string(filepath.Separator)) { + return filepath.Join(absoluteDir, loc.Name) + } + rel, err := filepath.Rel(base, absoluteDir) + if err != nil { + return filepath.Join(base, loc.Name) + } + return filepath.Join(".", rel, loc.Name) +} diff --git a/modding/hacked/ss1/world/FileLocation_test.go b/modding/hacked/ss1/world/FileLocation_test.go new file mode 100644 index 0000000..e4a90aa --- /dev/null +++ b/modding/hacked/ss1/world/FileLocation_test.go @@ -0,0 +1,125 @@ +package world_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/world" +) + +func TestFileLocationAbsolutePathFrom(t *testing.T) { + temp := os.TempDir() + base := filepath.Join(temp, "base") + + tt := []struct { + name string + loc world.FileLocation + base string + expected string + }{ + { + name: "file loaded in same base", + loc: world.FileLocation{DirPath: base, Name: "test.res"}, + base: base, + expected: filepath.Join(base, "test.res"), + }, + { + name: "new file", + loc: world.FileLocation{DirPath: ".", Name: "test.res"}, + base: base, + expected: filepath.Join(base, "test.res"), + }, + { + name: "new file without dir path", + loc: world.FileLocation{DirPath: "", Name: "test.res"}, + base: base, + expected: filepath.Join(base, "test.res"), + }, + { + name: "file found in different sibling path", + loc: world.FileLocation{DirPath: filepath.Join(temp, "other"), Name: "test.res"}, + base: base, + expected: filepath.Join(temp, "other", "test.res"), + }, + { + name: "file found in sub path", + loc: world.FileLocation{DirPath: filepath.Join(base, "nested"), Name: "test.res"}, + base: base, + expected: filepath.Join(base, "nested", "test.res"), + }, + { + name: "nested relative file dir path", + loc: world.FileLocation{DirPath: "nested", Name: "test.res"}, + base: base, + expected: filepath.Join(base, "nested", "test.res"), + }, + } + for _, tc := range tt { + func(name string, loc world.FileLocation, base, expected string) { + t.Run(name, func(t *testing.T) { + abs := loc.AbsolutePathFrom(base) + assert.Equal(t, expected, abs) + }) + }(tc.name, tc.loc, tc.base, tc.expected) + } +} + +func TestFileLocationNestedRelativeTo(t *testing.T) { + temp := os.TempDir() + base := filepath.Join(temp, "base") + + tt := []struct { + name string + loc world.FileLocation + base string + expected string + }{ + { + name: "file loaded in same base", + loc: world.FileLocation{DirPath: base, Name: "test.res"}, + base: base, + expected: filepath.Join(".", "test.res"), + }, + { + name: "new file", + loc: world.FileLocation{DirPath: ".", Name: "test.res"}, + base: base, + expected: filepath.Join(".", "test.res"), + }, + { + name: "new file without dir path", + loc: world.FileLocation{DirPath: "", Name: "test.res"}, + base: base, + expected: filepath.Join(".", "test.res"), + }, + { + name: "file found in different path", + loc: world.FileLocation{DirPath: filepath.Join(temp, "other"), Name: "test.res"}, + base: base, + expected: filepath.Join(temp, "other", "test.res"), + }, + { + name: "file found in sub path", + loc: world.FileLocation{DirPath: filepath.Join(base, "nested"), Name: "test.res"}, + base: base, + expected: filepath.Join(".", "nested", "test.res"), + }, + { + name: "file found in similar named different path", + loc: world.FileLocation{DirPath: filepath.Join(temp, "baseOther"), Name: "test.res"}, + base: base, + expected: filepath.Join(temp, "baseOther", "test.res"), + }, + } + for _, tc := range tt { + func(name string, loc world.FileLocation, base, expected string) { + t.Run(name, func(t *testing.T) { + rel := loc.NestedRelativeTo(base) + assert.Equal(t, expected, rel) + }) + }(tc.name, tc.loc, tc.base, tc.expected) + } +} diff --git a/modding/hacked/ss1/world/Manifest.go b/modding/hacked/ss1/world/Manifest.go new file mode 100644 index 0000000..f888152 --- /dev/null +++ b/modding/hacked/ss1/world/Manifest.go @@ -0,0 +1,170 @@ +package world + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// Manifest contains all the data and information of concrete things in a world. +type Manifest struct { + resourceChangeNotifier resource.ChangeNotifier + entries []*ManifestEntry +} + +// NewManifest returns a new instance that notifies changes to the provided callback. +func NewManifest(modified resource.ModificationCallback) *Manifest { + var manifest Manifest + + manifest.resourceChangeNotifier.Callback = modified + manifest.resourceChangeNotifier.Localizer = &manifest + + return &manifest +} + +// EntryCount returns the number of entries currently in the manifest. +func (manifest Manifest) EntryCount() int { + return len(manifest.entries) +} + +// Entry returns the entry at given index. +func (manifest Manifest) Entry(at int) (*ManifestEntry, error) { + if (at < 0) || (at >= len(manifest.entries)) { + return nil, errIndexOutOfBounds + } + return manifest.entries[at], nil +} + +// Reset clears the manifest by removing all entries and notifying of all the removed resource identifier. +func (manifest *Manifest) Reset() { + oldEntries := manifest.entries + manifest.changeAndNotify(func() { + manifest.entries = nil + }, manifest.listIDs(oldEntries...)) +} + +// InsertEntry puts the provided entries in sequence at the specified index. +// Any entry at the identified index, and all those after that, are moved behind the given ones. +func (manifest *Manifest) InsertEntry(at int, entries ...*ManifestEntry) error { + oldLen := len(manifest.entries) + if (at < 0) || (at > oldLen) { + return errIndexOutOfBounds + } + for _, entry := range entries { + if entry == nil { + return errEntryIsNil + } + } + manifest.changeAndNotify(func() { + addLen := len(entries) + newEntries := make([]*ManifestEntry, oldLen+addLen) + copy(newEntries[:at], manifest.entries[:at]) + copy(newEntries[at:], entries) + copy(newEntries[at+addLen:oldLen+addLen], manifest.entries[at:oldLen]) + manifest.entries = newEntries + }, manifest.listIDs(entries...)) + return nil +} + +// RemoveEntry removes the entry at given index. +func (manifest *Manifest) RemoveEntry(at int) error { + oldLen := len(manifest.entries) + if (at < 0) || (at >= oldLen) { + return errIndexOutOfBounds + } + manifest.changeAndNotify(func() { + copy(manifest.entries[at:oldLen-1], manifest.entries[at+1:oldLen]) + manifest.entries = manifest.entries[:oldLen-1] + }, manifest.listIDs(manifest.entries[at])) + return nil +} + +// ReplaceEntry removes the current entry at the identified index and puts the provided one instead. +func (manifest *Manifest) ReplaceEntry(at int, entry *ManifestEntry) error { + if (at < 0) || (at >= len(manifest.entries)) { + return errIndexOutOfBounds + } + if entry == nil { + return errEntryIsNil + } + manifest.changeAndNotify(func() { + manifest.entries[at] = entry + }, manifest.listIDs(manifest.entries[at]), manifest.listIDs(entry)) + + return nil +} + +// MoveEntry removes an entry and reinserts it at another index. +// Both indices are resolved before the move. +func (manifest *Manifest) MoveEntry(to, from int) error { + curLen := len(manifest.entries) + if (to < 0) || (to >= curLen) || (from < 0) || (from >= curLen) { + return errIndexOutOfBounds + } + manifest.changeAndNotify(func() { + entry := manifest.entries[from] + if from >= to { + copy(manifest.entries[to+1:from+1], manifest.entries[to:from]) + } else { + copy(manifest.entries[from:to], manifest.entries[from+1:to+1]) + } + manifest.entries[to] = entry + }, manifest.listIDs(manifest.entries[from])) + return nil +} + +// Filter finds all resources in the world that match the given parameters. +func (manifest Manifest) Filter(lang resource.Language, id resource.ID) resource.List { + var list resource.List + for _, entry := range manifest.entries { + list = list.Joined(entry.Resources.Filter(lang, id)) + } + return list +} + +// LocalizedResources produces a selector to retrieve resources for a specific language from the manifest. +// The returned selector has the strategy to merge the typical compound resource lists, such +// as the small textures, or string lookups. It is based on StandardResourceViewStrategy(). +func (manifest *Manifest) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + Lang: lang, + From: manifest, + As: ResourceViewStrategy(), + } +} + +// ObjectProperties returns the table of object properties. +func (manifest *Manifest) ObjectProperties() object.PropertiesTable { + var table object.PropertiesTable + for _, entry := range manifest.entries { + if len(entry.ObjectProperties) > 0 { + table = entry.ObjectProperties + } + } + return table +} + +// TextureProperties returns the table of texture properties. +func (manifest *Manifest) TextureProperties() texture.PropertiesList { + var list texture.PropertiesList + for _, entry := range manifest.entries { + if len(entry.TextureProperties) > 0 { + list = entry.TextureProperties + } + } + return list +} + +func (manifest *Manifest) listIDs(entries ...*ManifestEntry) (ids []resource.ID) { + for _, entry := range entries { + for _, res := range entry.Resources { + singleIDs := res.Viewer.IDs() + ids = append(ids, singleIDs...) + } + } + return +} + +func (manifest *Manifest) changeAndNotify(modifier func(), affectedIDs ...[]resource.ID) { + manifest.resourceChangeNotifier.ModifyAndNotify(modifier, affectedIDs...) +} diff --git a/modding/hacked/ss1/world/ManifestEntry.go b/modding/hacked/ss1/world/ManifestEntry.go new file mode 100644 index 0000000..62a6163 --- /dev/null +++ b/modding/hacked/ss1/world/ManifestEntry.go @@ -0,0 +1,53 @@ +package world + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// ManifestEntry describes a set of localized resources under a collective identifier. +type ManifestEntry struct { + Origin []string + ID string + Resources resource.LocalizedResourcesList + + ObjectProperties object.PropertiesTable + TextureProperties texture.PropertiesList +} + +// NewManifestEntryFrom attempts to create a manifest in memory from the given set of files. +// It uses LoadFiles() to load the files with given filenames into memory, allowing archives as well. +func NewManifestEntryFrom(names []string) (*ManifestEntry, error) { + loaded := LoadFiles(true, names) + + if len(loaded.Resources) == 0 { + return nil, errNoResourcesFound + } + + entry := &ManifestEntry{ + Origin: names, + ID: names[0], + } + + for location, viewer := range loaded.Resources { + localized := resource.LocalizedResources{ + ID: location.Name, + Language: ids.LocalizeFilename(location.Name), + Viewer: viewer, + } + entry.Resources = append(entry.Resources, localized) + } + entry.ObjectProperties = loaded.ObjectProperties + entry.TextureProperties = loaded.TextureProperties + return entry, nil +} + +// LocalizedResources produces a selector to retrieve resources for a specific language from this entry. +func (entry ManifestEntry) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: entry.Resources, + Lang: lang, + } +} diff --git a/modding/hacked/ss1/world/ManifestEntry_test.go b/modding/hacked/ss1/world/ManifestEntry_test.go new file mode 100644 index 0000000..56384ac --- /dev/null +++ b/modding/hacked/ss1/world/ManifestEntry_test.go @@ -0,0 +1,92 @@ +package world_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ManifestEntrySuite struct { + suite.Suite + entry *world.ManifestEntry + finder resource.Selector +} + +func TestManifestEntrySuite(t *testing.T) { + suite.Run(t, new(ManifestEntrySuite)) +} + +func (suite *ManifestEntrySuite) SetupTest() { + suite.entry = new(world.ManifestEntry) +} + +func (suite *ManifestEntrySuite) TestResourceReturnsErrorIfNothingFound() { + suite.whenViewingResource(resource.LangAny) + suite.thenResourceViewShouldNotBeAvailable(1234) +} + +func (suite *ManifestEntrySuite) TestResourceReturnsViewIfMatched() { + suite.givenLocalizedResources(resource.LangAny, 1000, [][]byte{{0x01}}) + suite.whenViewingResource(resource.LangAny) + suite.thenResourceViewShouldBeAvailable(1000) +} + +func (suite *ManifestEntrySuite) TestResourceConsidersResourcesWithLanguageAnyWhenLookingForSpecific() { + suite.givenLocalizedResources(resource.LangAny, 1000, [][]byte{{0x01}}) + suite.whenViewingResource(resource.LangDefault) + suite.thenResourceViewShouldBeAvailable(1000) +} + +func (suite *ManifestEntrySuite) TestResourceConsidersResourcesWithSpecificLanguage() { + suite.givenLocalizedResources(resource.LangFrench, 1000, [][]byte{{0x01}}) + suite.whenViewingResource(resource.LangFrench) + suite.thenResourceViewShouldBeAvailable(1000) +} + +func (suite *ManifestEntrySuite) TestResourceIgnoresResourcesWithSpecificLanguageWhenLookingForAny() { + suite.givenLocalizedResources(resource.LangFrench, 1000, [][]byte{{0x01}}) + suite.whenViewingResource(resource.LangAny) + suite.thenResourceViewShouldNotBeAvailable(1000) +} + +func (suite *ManifestEntrySuite) TestResourceIgnoresResourcesWithSpecificLanguageWhenLookingForDifferentLanguage() { + suite.givenLocalizedResources(resource.LangFrench, 1000, [][]byte{{0x01}}) + suite.whenViewingResource(resource.LangDefault) + suite.thenResourceViewShouldNotBeAvailable(1000) + + suite.whenViewingResource(resource.LangGerman) + suite.thenResourceViewShouldNotBeAvailable(1000) +} + +func (suite *ManifestEntrySuite) givenLocalizedResources(lang resource.Language, id int, data [][]byte) { + var store resource.Store + _ = store.Put(resource.ID(id), resource.Resource{ + Blocks: resource.BlocksFrom(data), + }) + suite.entry.Resources = append(suite.entry.Resources, resource.LocalizedResources{ + Language: lang, + Viewer: store, + }) +} + +func (suite *ManifestEntrySuite) whenViewingResource(lang resource.Language) { + suite.finder = suite.entry.LocalizedResources(lang) +} + +func (suite *ManifestEntrySuite) thenResourceViewShouldNotBeAvailable(id int) { + view, err := suite.finder.Select(resource.ID(id)) + assert.Error(suite.T(), err, fmt.Sprintf("Error expected finding resource %v", id)) + assert.Nil(suite.T(), view, fmt.Sprintf("No view expected finding resource %v", id)) +} + +func (suite *ManifestEntrySuite) thenResourceViewShouldBeAvailable(id int) { + view, err := suite.finder.Select(resource.ID(id)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected finding resource %v", id)) + assert.NotNil(suite.T(), view, fmt.Sprintf("View expected for resource %v", id)) +} diff --git a/modding/hacked/ss1/world/Manifest_test.go b/modding/hacked/ss1/world/Manifest_test.go new file mode 100644 index 0000000..bb92282 --- /dev/null +++ b/modding/hacked/ss1/world/Manifest_test.go @@ -0,0 +1,478 @@ +package world_test + +import ( + "fmt" + "sort" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ManifestSuite struct { + suite.Suite + manifest *world.Manifest + selector *resource.Selector + + lastModifiedIDs []resource.ID + lastFailedIDs []resource.ID +} + +func TestManifestSuite(t *testing.T) { + suite.Run(t, new(ManifestSuite)) +} + +func (suite *ManifestSuite) SetupTest() { + suite.manifest = world.NewManifest(suite.onManifestModified) + suite.selector = nil + + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil +} + +func (suite *ManifestSuite) TestEntriesCanBeAdded() { + suite.whenEntryIsInserted(0, "id1") + suite.thenEntryCountShouldBe(1) + suite.thenEntryAtShouldBe(0, "id1") +} + +func (suite *ManifestSuite) TestMultipleEntriesCanBeAdded() { + suite.whenEntriesAreInserted(0, "id1", "id2", "id3") + suite.thenEntryCountShouldBe(3) + suite.thenEntryAtShouldBe(0, "id1") + suite.thenEntryAtShouldBe(1, "id2") + suite.thenEntryAtShouldBe(2, "id3") +} + +func (suite *ManifestSuite) TestEntryReturnsErrorOnInvalidInput() { + suite.givenEntryWasInserted(0, "id1") + + assertErrorAt := func(at int, message string) { + _, err := suite.manifest.Entry(at) + assert.Error(suite.T(), err, message) + } + assertErrorAt(-1, "Error expected with negative index") + assertErrorAt(1, "Error expected with index beyond limit") +} + +func (suite *ManifestSuite) TestEntriesCanBeInserted() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.whenEntryIsInserted(1, "id3") + suite.thenEntryCountShouldBe(3) + suite.thenEntryAtShouldBe(0, "id1") + suite.thenEntryAtShouldBe(1, "id3") + suite.thenEntryAtShouldBe(2, "id2") +} + +func (suite *ManifestSuite) TestMultipleEntriesCanBeInserted() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.whenEntriesAreInserted(1, "id3", "id4") + suite.thenEntryCountShouldBe(4) + suite.thenEntryAtShouldBe(0, "id1") + suite.thenEntryAtShouldBe(1, "id3") + suite.thenEntryAtShouldBe(2, "id4") + suite.thenEntryAtShouldBe(3, "id2") +} + +func (suite *ManifestSuite) TestInsertReturnsErrorsOnInvalidData() { + assert.Error(suite.T(), suite.manifest.InsertEntry(-1, suite.aSimpleEntry("-1")), "Error expected with negative index") + assert.Error(suite.T(), suite.manifest.InsertEntry(1, suite.aSimpleEntry("1")), "Error expected with index beyond count") + assert.Error(suite.T(), suite.manifest.InsertEntry(0, nil), "Error expected for nil entry") + assert.Error(suite.T(), suite.manifest.InsertEntry(0, suite.aSimpleEntry("1"), nil, suite.aSimpleEntry("2")), "Error expected for nil entry") +} + +func (suite *ManifestSuite) TestEntriesCanBeRemoved() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + suite.whenEntryIsRemoved(1) + suite.thenEntryCountShouldBe(2) + suite.thenEntryAtShouldBe(0, "id1") + suite.thenEntryAtShouldBe(1, "id3") + + suite.whenEntryIsRemoved(1) + suite.thenEntryCountShouldBe(1) + suite.thenEntryAtShouldBe(0, "id1") +} + +func (suite *ManifestSuite) TestRemoveReturnsErrorOnInvalidData() { + suite.givenEntryWasInserted(0, "id1") + + assert.Error(suite.T(), suite.manifest.RemoveEntry(-1), "Error expected with negative index") + assert.Error(suite.T(), suite.manifest.RemoveEntry(1), "Error expected with index beyond count") +} + +func (suite *ManifestSuite) TestEntriesCanBeReplaced() { + suite.givenEntryWasInserted(0, "id1") + suite.whenEntryIsReplaced(0, "id1-new") + suite.thenEntryAtShouldBe(0, "id1-new") +} + +func (suite *ManifestSuite) TestReplaceReturnsErrorOnInvalidData() { + suite.givenEntryWasInserted(0, "id1") + + assert.Error(suite.T(), suite.manifest.ReplaceEntry(-1, suite.aSimpleEntry("-1")), "Error expected with negative index") + assert.Error(suite.T(), suite.manifest.ReplaceEntry(1, suite.aSimpleEntry("1")), "Error expected with index beyond count") + assert.Error(suite.T(), suite.manifest.ReplaceEntry(0, nil), "Error expected for nil entry") +} + +func (suite *ManifestSuite) TestEntriesCanBeMovedBackward() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + suite.givenEntryWasInserted(3, "id4") + suite.givenEntryWasInserted(4, "id5") + suite.whenEntryIsMoved(1, 3) + suite.thenEntryOrderShouldBe("id1", "id4", "id2", "id3", "id5") +} + +func (suite *ManifestSuite) TestEntriesCanBeMovedForward() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + suite.givenEntryWasInserted(3, "id4") + suite.givenEntryWasInserted(4, "id5") + suite.whenEntryIsMoved(3, 1) + suite.thenEntryOrderShouldBe("id1", "id3", "id4", "id2", "id5") +} + +func (suite *ManifestSuite) TestMoveEntryToSamePlaceDoesNothing() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + suite.whenEntryIsMoved(1, 1) + suite.thenEntryOrderShouldBe("id1", "id2", "id3") +} + +func (suite *ManifestSuite) TestMoveEntryToEnd() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + suite.whenEntryIsMoved(2, 0) + suite.thenEntryOrderShouldBe("id2", "id3", "id1") +} + +func (suite *ManifestSuite) TestMoveEntryReturnsErrorOnInvalidInput() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + + assert.Error(suite.T(), suite.manifest.MoveEntry(0, -1), "Error expected moving from negative index") + assert.Error(suite.T(), suite.manifest.MoveEntry(0, 3), "Error expected moving from index beyond limit") + assert.Error(suite.T(), suite.manifest.MoveEntry(-1, 1), "Error expected moving to negative index") + assert.Error(suite.T(), suite.manifest.MoveEntry(3, 1), "Error expected moving to index beyond limit") +} + +func (suite *ManifestSuite) TestLocalizedResourcesCanBeRetrieved() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x1234, [][]byte{{0xAA}}))) + suite.givenEntryWasInserted(1, "id2") + suite.whenResourcesAreQueriedFor(resource.LangAny) + suite.thenResourcesCanBeSelected(0x1234) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnInsertFromEmptyListsNewIDs() { + suite.whenEntryIsInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x1000, [][]byte{{0x11}}), + suite.storing(0x2000, [][]byte{{0x22}}))) + suite.thenModifiedResourcesShouldBe([]int{0x1000, 0x2000}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnInsertFromEmptyListsNewIDsMultipleEntries() { + suite.whenEntriesAreInsertedWith(0, 10, + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x1000, [][]byte{{0x11}}), + suite.storing(0x2000, [][]byte{{0x22}})), + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x3000, [][]byte{{0x11}}), + suite.storing(0x4000, [][]byte{{0x22}}))) + suite.thenModifiedResourcesShouldBe([]int{0x1000, 0x2000, 0x3000, 0x4000}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnInsertWithNewOnesListsNewIDs() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0900, [][]byte{{0x11}}), + suite.storing(0x0A00, [][]byte{{0x22}}))) + suite.thenModifiedResourcesShouldBe([]int{0x0900, 0x0A00}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnInsertIgnoresIdenticalData() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.thenModifiedResourcesShouldBe([]int{}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnInsertWithChangedData() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xBB}}))) + suite.thenModifiedResourcesShouldBe([]int{0x0800}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnRemoveWithLostIDs() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenEntryWasInsertedWith(1, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0900, [][]byte{{0xBB}}))) + suite.whenEntryIsRemoved(0) + suite.thenModifiedResourcesShouldBe([]int{0x0800}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnRemoveIgnoresIdenticalData_RemovedBefore() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}, {0xBB}}))) + suite.givenEntryWasInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}, {0xBB}}))) + suite.whenEntryIsRemoved(0) + suite.thenModifiedResourcesShouldBe([]int{}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnRemoveIgnoresIdenticalData_RemovedAfter() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}, {0xBB}}))) + suite.givenEntryWasInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}, {0xBB}}))) + suite.whenEntryIsRemoved(1) + suite.thenModifiedResourcesShouldBe([]int{}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnRemoveWithChangedData() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenEntryWasInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xBB}}))) + suite.whenEntryIsRemoved(1) + suite.thenModifiedResourcesShouldBe([]int{0x0800}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnReplaceWithChangedData() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsReplacedWith(0, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xBB}}))) + suite.thenModifiedResourcesShouldBe([]int{0x0800}) +} + +func (suite *ManifestSuite) TestModifiedCallbackWithEmptyListOnIdenticalData() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsReplacedWith(0, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.thenModifiedResourcesShouldBe([]int{}) +} + +func (suite *ManifestSuite) TestModifiedCallbackWithChangedIDsOnReplace() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsReplacedWith(0, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0900, [][]byte{{0xAA}}))) + suite.thenModifiedResourcesShouldBe([]int{0x0800, 0x0900}) +} + +func (suite *ManifestSuite) TestModifiedCallbackWithChangedIDsOnMove() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenEntryWasInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xBB}}))) + suite.givenEntryWasInsertedWith(2, "id3", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0900, [][]byte{{0x11}}))) + suite.whenEntryIsMoved(2, 0) + suite.thenModifiedResourcesShouldBe([]int{0x0800}) +} + +func (suite *ManifestSuite) TestReset() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenEntryWasInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0900, [][]byte{{0xBB}}))) + suite.whenManifestIsReset() + suite.thenModifiedResourcesShouldBe([]int{0x0800, 0x0900}) + suite.thenEntryCountShouldBe(0) +} + +func (suite *ManifestSuite) onManifestModified(modifiedIDs []resource.ID, failedIDs []resource.ID) { + suite.lastModifiedIDs = modifiedIDs + suite.lastFailedIDs = failedIDs +} + +func (suite *ManifestSuite) givenEntryWasInserted(at int, id string) { + suite.whenEntryIsInserted(at, id) +} + +func (suite *ManifestSuite) givenEntryWasInsertedWith(at int, id string, res ...resource.LocalizedResources) { + suite.whenEntryIsInsertedWith(at, id, res...) + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil +} + +func (suite *ManifestSuite) whenEntryIsInsertedWith(at int, id string, res ...resource.LocalizedResources) { + err := suite.manifest.InsertEntry(at, suite.anEntryWithResources(id, res...)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected inserting entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntriesAreInsertedWith(at int, idBase int, resList ...resource.LocalizedResources) { + entries := make([]*world.ManifestEntry, len(resList)) + for index, res := range resList { + entries[index] = suite.anEntryWithResources(fmt.Sprintf("id%d", idBase+index), res) + } + err := suite.manifest.InsertEntry(at, entries...) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected inserting entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntryIsInserted(at int, id string) { + err := suite.manifest.InsertEntry(at, suite.aSimpleEntry(id)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected inserting entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntriesAreInserted(at int, ids ...string) { + entries := make([]*world.ManifestEntry, len(ids)) + for index, id := range ids { + entries[index] = suite.aSimpleEntry(id) + } + err := suite.manifest.InsertEntry(at, entries...) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected inserting entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntryIsRemoved(at int) { + err := suite.manifest.RemoveEntry(at) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected removing entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntryIsReplaced(at int, id string) { + err := suite.manifest.ReplaceEntry(at, suite.aSimpleEntry(id)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected replacing entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntryIsReplacedWith(at int, id string, res ...resource.LocalizedResources) { + err := suite.manifest.ReplaceEntry(at, suite.anEntryWithResources(id, res...)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected replacing entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntryIsMoved(to int, from int) { + err := suite.manifest.MoveEntry(to, from) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected moving entry to %d from %d", to, from)) +} + +func (suite *ManifestSuite) whenResourcesAreQueriedFor(lang resource.Language) { + selector := suite.manifest.LocalizedResources(lang) + suite.selector = &selector +} + +func (suite *ManifestSuite) whenManifestIsReset() { + suite.manifest.Reset() +} + +func (suite *ManifestSuite) thenEntryCountShouldBe(expected int) { + assert.Equal(suite.T(), expected, suite.manifest.EntryCount()) +} + +func (suite *ManifestSuite) thenEntryAtShouldBe(at int, id string) { + entry, err := suite.manifest.Entry(at) + assert.Nil(suite.T(), err, fmt.Sprintf("No error expected retrieving entry at %d", at)) + require.NotNil(suite.T(), entry, fmt.Sprintf("Entry expected at %d", at)) + assert.Equal(suite.T(), id, entry.ID, fmt.Sprintf("Wrong entry found at %d", at)) +} + +func (suite *ManifestSuite) thenEntryOrderShouldBe(expected ...string) { + expectedLen := len(expected) + require.Equal(suite.T(), expectedLen, suite.manifest.EntryCount(), "Invalid number of entries") + currentIDs := make([]string, expectedLen) + for index := 0; index < expectedLen; index++ { + entry, err := suite.manifest.Entry(index) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected retrieving entry at %d", index)) + currentIDs[index] = entry.ID + } + assert.Equal(suite.T(), expected, currentIDs, "IDs don't not match") +} + +func (suite *ManifestSuite) thenResourcesCanBeSelected(id int) { + view, err := suite.selector.Select(resource.ID(id)) + assert.Nil(suite.T(), err, "No error expected") + assert.NotNil(suite.T(), view, "View expected") +} + +func (suite *ManifestSuite) aSimpleEntry(id string) *world.ManifestEntry { + return &world.ManifestEntry{ + ID: id, + } +} + +func (suite *ManifestSuite) someLocalizedResources(lang resource.Language, modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: lang, + Viewer: store, + } +} + +func (suite *ManifestSuite) anEntryWithResources(id string, res ...resource.LocalizedResources) *world.ManifestEntry { + return &world.ManifestEntry{ + ID: id, + Resources: res, + } +} + +func (suite *ManifestSuite) storing(id int, data [][]byte) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Blocks: resource.BlocksFrom(data), + }) + } +} + +func (suite *ManifestSuite) thenModifiedResourcesShouldBe(expected []int) { + identified := make([]resource.ID, len(expected)) + for index, id := range expected { + identified[index] = resource.ID(id) + } + suite.sortIDs(identified) + suite.sortIDs(suite.lastModifiedIDs) + + assert.Equal(suite.T(), identified, suite.lastModifiedIDs, "Modified IDs don't match") +} + +func (suite *ManifestSuite) sortIDs(ids []resource.ID) { + sort.Slice(ids, func(a, b int) bool { return ids[a] < ids[b] }) +} diff --git a/modding/hacked/ss1/world/Mod.go b/modding/hacked/ss1/world/Mod.go new file mode 100644 index 0000000..786fed9 --- /dev/null +++ b/modding/hacked/ss1/world/Mod.go @@ -0,0 +1,324 @@ +package world + +import ( + "bytes" + "path/filepath" + "sort" + "time" + + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial/rle" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// ModResetCallback is called when the mod was reset. +type ModResetCallback func() + +// Mod is the central object for a game-mod. +// +// It is based on a "static" world and adds its own changes. The world data itself is not static, it is merely the +// unchangeable background for the mod. Changes to the mod are kept in a separate layer, which can be loaded and saved. +type Mod struct { + worldManifest *Manifest + resourcesChanged resource.ModificationCallback + resetCallback ModResetCallback + + lastChangeTime time.Time + changedFiles map[string]struct{} + + data ModData +} + +// NewMod returns a new instance. +func NewMod(resourcesChanged resource.ModificationCallback, resetCallback ModResetCallback) *Mod { + mod := &Mod{ + resourcesChanged: resourcesChanged, + resetCallback: resetCallback, + changedFiles: make(map[string]struct{}), + } + mod.worldManifest = NewManifest(mod.worldChanged) + mod.data.FileChangeCallback = mod.markFileChanged + + return mod +} + +// World returns the static background to the mod. Changes in the returned manifest may cause change callbacks +// being forwarded. +func (mod Mod) World() *Manifest { + return mod.worldManifest +} + +// ModifiedResources returns the current modification state. +func (mod Mod) ModifiedResources() []*LocalizedResources { + return mod.data.LocalizedResources +} + +// ModifiedFilenames returns the list of all filenames suspected of change. +func (mod Mod) ModifiedFilenames() []string { + result := make([]string, 0, len(mod.changedFiles)) + for filename := range mod.changedFiles { + result = append(result, filename) + } + return result +} + +// AllAbsoluteFilenames returns the list of all filenames currently loaded in the mod. +func (mod Mod) AllAbsoluteFilenames(reference string) []string { + var result []string + for _, res := range mod.data.LocalizedResources { + result = append(result, res.File.AbsolutePathFrom(reference)) + } + if len(mod.data.ObjectProperties) > 0 { + result = append(result, filepath.Join(reference, ObjectPropertiesFilename)) + } + if len(mod.data.TextureProperties) > 0 { + result = append(result, filepath.Join(reference, TexturePropertiesFilename)) + } + sort.Strings(result) + return result +} + +// LastChangeTime returns the timestamp of the last change. Zero if not modified. +func (mod *Mod) LastChangeTime() time.Time { + return mod.lastChangeTime +} + +// ResetLastChangeTime clears the last change timestamp. It will be set again at the next modification. +func (mod *Mod) ResetLastChangeTime() { + mod.lastChangeTime = time.Time{} +} + +// MarkSave clears the list of modified filenames. +func (mod *Mod) MarkSave() { + mod.changedFiles = make(map[string]struct{}) + mod.lastChangeTime = time.Time{} +} + +// ModifiedResource retrieves the resource of given language and ID. +// There is no fallback lookup, it will return the exact resource stored under the provided identifier. +// Returns nil if the resource does not exist. +func (mod Mod) ModifiedResource(lang resource.Language, id resource.ID) resource.View { + return mod.modifiedResource(lang, id) +} + +func (mod Mod) modifiedResource(lang resource.Language, id resource.ID) *resource.Resource { + for _, entry := range mod.data.LocalizedResources { + if entry.Language == lang { + res, err := entry.Store.Resource(id) + if err == nil { + return res + } + } + } + return nil +} + +// CreateBlockPatch creates delta information for a block witch static data length. +// The returned patch structure contains details for both modifying the current state to be equal the new state, +// as well as the reversal delta. These deltas are calculated using the rle compression package. +// The returned boolean indicates whether the data differs. This can be used to detect whether the patch is necessary. +// An error is returned if the resource or the block do not exist, or if the length of newData does not match that of the block. +// +// If no error is returned, both the patch and the boolean provide valid information - even if the data is equal. +func (mod Mod) CreateBlockPatch(lang resource.Language, id resource.ID, index int, newData []byte) (BlockPatch, bool, error) { + patch := BlockPatch{ + ID: id, + BlockIndex: -1, + BlockLength: 0, + } + res := mod.modifiedResource(lang, id) + if res == nil { + return patch, false, resource.ErrNotFound(id) + } + oldData, err := res.BlockRaw(index) + if err != nil { + return patch, false, err + } + if len(oldData) != len(newData) { + return patch, false, errBlockLengthMismatch + } + + forwardData := bytes.NewBuffer(nil) + err = rle.Compress(forwardData, newData, oldData) + if err != nil { + return patch, false, err + } + patch.ForwardData = forwardData.Bytes() + + reverseData := bytes.NewBuffer(nil) + err = rle.Compress(reverseData, oldData, newData) + if err != nil { + return patch, false, err + } + patch.ReverseData = reverseData.Bytes() + + patch.BlockIndex = index + patch.BlockLength = len(oldData) + + return patch, !bytes.Equal(oldData, newData), nil +} + +// ModifiedBlock retrieves the specific block identified by given parameter. +// Returns empty slice if the block (or resource) is not modified. +func (mod Mod) ModifiedBlock(lang resource.Language, id resource.ID, index int) (data []byte) { + res := mod.modifiedResource(lang, id) + if res == nil { + return + } + raw, _ := res.BlockRaw(index) + return mod.blockCopy(raw) +} + +// ModifiedBlocks returns all blocks of the modified resource. +func (mod Mod) ModifiedBlocks(lang resource.Language, id resource.ID) [][]byte { + res := mod.modifiedResource(lang, id) + if res == nil { + return nil + } + data := make([][]byte, res.BlockCount()) + for index := 0; index < res.BlockCount(); index++ { + raw, _ := res.BlockRaw(index) + data[index] = mod.blockCopy(raw) + } + return data +} + +func (mod Mod) blockCopy(data []byte) []byte { + result := make([]byte, len(data)) + copy(result, data) + return result +} + +// Filter returns a list of resources that match the given parameters. +func (mod Mod) Filter(lang resource.Language, id resource.ID) resource.List { + list := mod.worldManifest.Filter(lang, id) + if res := mod.modifiedResource(resource.LangAny, id); res != nil { + list = list.With(res) + } + for _, worldLang := range resource.Languages() { + if worldLang.Includes(lang) { + if res := mod.modifiedResource(lang, id); res != nil { + list = list.With(res) + } + } + } + return list +} + +// LocalizedResources returns a resource selector for a specific language. +func (mod Mod) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + Lang: lang, + From: mod, + As: ResourceViewStrategy(), + } +} + +// Modify requests to change the mod. The provided function will be called to collect all changes. +// After the modifier completes, all the requests will be applied and any changes notified. +func (mod *Mod) Modify(modifier func(Modder)) { + var trans ModTransaction + modifier(&trans) + mod.modifyAndNotify(func() { + for _, action := range trans.actions { + action(&mod.data) + } + }, trans.modifiedIDs.ToList()) +} + +// ObjectProperties returns the table of object properties. +func (mod *Mod) ObjectProperties() object.PropertiesTable { + if mod.HasModifiableObjectProperties() { + return mod.data.ObjectProperties + } + return mod.worldManifest.ObjectProperties() +} + +// HasModifiableObjectProperties returns true if the mod has dedicated object properties. +func (mod *Mod) HasModifiableObjectProperties() bool { + return len(mod.data.ObjectProperties) > 0 +} + +// TextureProperties returns the list of texture properties. +func (mod *Mod) TextureProperties() texture.PropertiesList { + if mod.HasModifiableTextureProperties() { + return mod.data.TextureProperties + } + return mod.worldManifest.TextureProperties() +} + +// HasModifiableTextureProperties returns true if the mod has dedicated texture properties. +func (mod *Mod) HasModifiableTextureProperties() bool { + return len(mod.data.TextureProperties) > 0 +} + +func (mod *Mod) modifyAndNotify(modifier func(), modifiedIDs []resource.ID) { + notifier := resource.ChangeNotifier{ + Callback: mod.resourcesChanged, + Localizer: mod, + } + notifier.ModifyAndNotify(modifier, modifiedIDs) +} + +func (mod Mod) worldChanged(modifiedIDs []resource.ID, failedIDs []resource.ID) { + // It would be great to also check whether the mod hides any of these changes. + // Sadly, this is not possible: + // a) At the point of this callback, we can't do a check on the previous state anymore. + // b) Even when changing the world only within a modification enclosure of our own notifier, we can't determine + // the list of changed IDs before actually changing them. (Specifying ALL IDs is not a good idea due to performance.) + // As a result, simply forward this list. I don't even expect any big performance gain through such a filter. + // This would only be relevant to "full conversion" mods AND a change in a big list in the world. Hardly the case. + mod.resourcesChanged(modifiedIDs, failedIDs) +} + +// Reset changes the mod to a new set of resources. +func (mod *Mod) Reset(newResources []*LocalizedResources, objectProperties object.PropertiesTable, textureProperties texture.PropertiesList) { + var modifiedIDs resource.IDMarkerMap + collectIDs := func(res []*LocalizedResources) { + for _, loc := range res { + for _, id := range loc.Store.IDs() { + modifiedIDs.Add(id) + } + } + } + collectIDs(mod.data.LocalizedResources) + collectIDs(newResources) + + mod.data.LocalizedResources = newResources + mod.data.ObjectProperties = objectProperties + mod.data.TextureProperties = textureProperties + mod.changedFiles = make(map[string]struct{}) + mod.lastChangeTime = time.Time{} + mod.resetCallback() + mod.resourcesChanged(modifiedIDs.ToList(), nil) +} + +func (mod *Mod) markFileChanged(filename string) { + mod.changedFiles[filename] = struct{}{} + mod.lastChangeTime = time.Now() +} + +// FixListResources ensures all resources that contain resource lists to +// have maximum size. This is done to ensure compatibility with layered modding in the +// Source Port branch of engines. +// These engines will have "lower" mods bleed through only if the block is empty in a +// "higher" mod. This even counts for entries past the last modified one in the higher mod. +func (mod *Mod) FixListResources() { + mod.Modify(func(modder Modder) { + for _, localized := range mod.data.LocalizedResources { + for _, id := range localized.Store.IDs() { + res, _ := localized.Store.Resource(id) + info, known := ids.Info(id) + if known && info.List { + baseCount := res.BlockCount() + required := info.MaxCount - baseCount + for i := 0; i < required; i++ { + modder.SetResourceBlock(localized.Language, id, baseCount+i, nil) + } + } + } + } + }) +} diff --git a/modding/hacked/ss1/world/ModData.go b/modding/hacked/ss1/world/ModData.go new file mode 100644 index 0000000..7082ae5 --- /dev/null +++ b/modding/hacked/ss1/world/ModData.go @@ -0,0 +1,154 @@ +package world + +import ( + "bytes" + + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial/rle" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// LocalizedResources associates a language with a resource store under a specific filename. +type LocalizedResources struct { + // File describes from which file the data was possibly loaded from, and where it should be stored in. + File FileLocation + // Template specifies the "original" filename the data would be stored in. + // For example, savegames have "archive.dat" as their template. + // Note: This could be expanded to be a list. The loader could check which IDs are all stored in the loaded file, + // and match all corresponding templates. Though, this works only for "Any" language resources. + Template string + // Language identifies the language this package of resources is for. + Language resource.Language + // Store contains all the resources. + Store resource.Store +} + +// ModData contains the core information about a mod. +type ModData struct { + FileChangeCallback func(string) + + LocalizedResources []*LocalizedResources + ObjectProperties object.PropertiesTable + TextureProperties texture.PropertiesList +} + +// SetResourceBlock changes the block data of a resource. +// +// If the block data is not empty, then: +// If the resource does not exist, it will be created with default meta information. +// If the block does not exist, the resource is extended to allow its addition. +// +// If the block data is empty (or nil), then the block is cleared. +func (data *ModData) SetResourceBlock(lang resource.Language, id resource.ID, index int, blockData []byte) { + loc, res := data.ensureResource(lang, id) + res.SetBlock(index, blockData) + data.notifyFileChanged(loc.File.Name) +} + +// PatchResourceBlock modifies an existing block. +// This modification assumes the block already exists and can take the given patch data. +// The patch data is expected to be produced by rle.Compress(). +func (data *ModData) PatchResourceBlock(lang resource.Language, id resource.ID, index int, expectedLength int, patch []byte) { + loc, res := data.ensureResource(lang, id) + raw, err := res.BlockRaw(index) + if (err == nil) && (len(raw) == expectedLength) { + _ = rle.Decompress(bytes.NewReader(patch), raw) + data.notifyFileChanged(loc.File.Name) + } +} + +// SetResourceBlocks sets the entire list of block data of a resource. +// This method is primarily meant for compound non-list resources (e.g. text pages). +func (data *ModData) SetResourceBlocks(lang resource.Language, id resource.ID, blocks [][]byte) { + loc, res := data.ensureResource(lang, id) + res.Set(blocks) + data.notifyFileChanged(loc.File.Name) +} + +// DelResource removes a resource from the mod in the given language. +func (data *ModData) DelResource(lang resource.Language, id resource.ID) { + for _, loc := range data.LocalizedResources { + if (loc.Language == lang) && loc.Store.Del(id) { + data.notifyFileChanged(loc.File.Name) + } + } +} + +// SetTextureProperties updates the properties of a specific texture. +func (data *ModData) SetTextureProperties(index int, properties texture.Properties) { + if (index >= 0) && (index < len(data.TextureProperties)) { + data.TextureProperties[index] = properties + data.notifyFileChanged(TexturePropertiesFilename) + } +} + +// SetObjectProperties updates the properties of a specific object. +func (data *ModData) SetObjectProperties(triple object.Triple, properties object.Properties) { + entry, err := data.ObjectProperties.ForObject(triple) + if err != nil { + return + } + *entry = properties.Clone() + data.notifyFileChanged(ObjectPropertiesFilename) +} + +func (data *ModData) ensureResource(lang resource.Language, id resource.ID) (*LocalizedResources, *resource.Resource) { + for _, loc := range data.LocalizedResources { + if loc.Language == lang { + res, err := loc.Store.Resource(id) + if err == nil { + return loc, res + } + } + } + + return data.newResource(lang, id) +} + +func (data *ModData) newResource(lang resource.Language, id resource.ID) (*LocalizedResources, *resource.Resource) { + compound := true + contentType := resource.ContentType(0xFF) // Default to something completely unknown. + compressed := false + filename := "unknown.res" + + if info, known := ids.Info(id); known { + compound = info.Compound + contentType = info.ContentType + compressed = info.Compressed + filename = info.ResFile.For(lang) + } + + loc := data.ensureStore(lang, filename) + _ = loc.Store.Put(id, resource.Resource{ + Properties: resource.Properties{ + Compound: compound, + ContentType: contentType, + Compressed: compressed, + }, + }) + res, _ := loc.Store.Resource(id) + return loc, res +} + +func (data *ModData) ensureStore(lang resource.Language, filename string) *LocalizedResources { + for _, loc := range data.LocalizedResources { + if (loc.Language == lang) && (loc.Template == filename) { + return loc + } + } + loc := &LocalizedResources{ + File: FileLocation{DirPath: ".", Name: filename}, + Template: filename, + Language: lang, + } + data.LocalizedResources = append(data.LocalizedResources, loc) + return loc +} + +func (data ModData) notifyFileChanged(filename string) { + if data.FileChangeCallback != nil { + data.FileChangeCallback(filename) + } +} diff --git a/modding/hacked/ss1/world/ModTransaction.go b/modding/hacked/ss1/world/ModTransaction.go new file mode 100644 index 0000000..fc1ffc9 --- /dev/null +++ b/modding/hacked/ss1/world/ModTransaction.go @@ -0,0 +1,74 @@ +package world + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" +) + +type modAction func(modder Modder) + +// ModTransaction is used to queue a list of modifications. +// It allows modifications of related resources in one atomic action. +type ModTransaction struct { + actions []modAction + modifiedIDs resource.IDMarkerMap +} + +// SetResourceBlock changes the block data of a resource. +// +// If the block data is not empty, then: +// If the resource does not exist, it will be created with default meta information. +// If the block does not exist, the resource is extended to allow its addition. +// +// If the block data is empty (or nil), then the block is cleared. +// If the resource is a compound list, then the underlying data will become visible again. +func (trans *ModTransaction) SetResourceBlock(lang resource.Language, id resource.ID, index int, data []byte) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.SetResourceBlock(lang, id, index, data) + }) + trans.modifiedIDs.Add(id) +} + +// PatchResourceBlock modifies an existing block. +// This modification assumes the block already exists and can take the given patch data. +// The patch data is expected to be produced by rle.Compress(). +func (trans *ModTransaction) PatchResourceBlock(lang resource.Language, id resource.ID, index int, expectedLength int, patch []byte) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.PatchResourceBlock(lang, id, index, expectedLength, patch) + }) + trans.modifiedIDs.Add(id) +} + +// SetResourceBlocks sets the entire list of block data of a resource. +// This method is primarily meant for compound non-list resources (e.g. text pages). +func (trans *ModTransaction) SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.SetResourceBlocks(lang, id, data) + }) + trans.modifiedIDs.Add(id) +} + +// DelResource removes a resource from the mod in the given language. +// +// After the deletion, all the underlying data of the world will become visible again. +func (trans *ModTransaction) DelResource(lang resource.Language, id resource.ID) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.DelResource(lang, id) + }) + trans.modifiedIDs.Add(id) +} + +// SetTextureProperties updates the properties of a specific texture. +func (trans *ModTransaction) SetTextureProperties(textureIndex int, properties texture.Properties) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.SetTextureProperties(textureIndex, properties) + }) +} + +// SetObjectProperties updates the properties of a specific object. +func (trans *ModTransaction) SetObjectProperties(triple object.Triple, properties object.Properties) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.SetObjectProperties(triple, properties) + }) +} diff --git a/modding/hacked/ss1/world/Mod_test.go b/modding/hacked/ss1/world/Mod_test.go new file mode 100644 index 0000000..703b1e6 --- /dev/null +++ b/modding/hacked/ss1/world/Mod_test.go @@ -0,0 +1,235 @@ +package world_test + +import ( + "fmt" + "io/ioutil" + "sort" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ModSuite struct { + suite.Suite + mod *world.Mod + + selector *resource.Selector + + lastModifiedIDs []resource.ID + lastFailedIDs []resource.ID +} + +func TestModSuite(t *testing.T) { + suite.Run(t, new(ModSuite)) +} + +func (suite *ModSuite) SetupTest() { + suite.mod = world.NewMod(suite.onResourcesModified, func() {}) + + suite.selector = nil + + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil +} + +func (suite *ModSuite) onResourcesModified(modifiedIDs []resource.ID, failedIDs []resource.ID) { + suite.lastModifiedIDs = modifiedIDs + suite.lastFailedIDs = failedIDs +} + +func (suite *ModSuite) TestResourcesCanBeRetrievedFromTheWorld() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenResourcesAreQueriedFor(resource.LangAny) + suite.thenResourcesCanBeSelected(0x0800) +} + +func (suite *ModSuite) TestResourcesCanBeModified() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + }) + suite.thenResourceBlockShouldBe(resource.LangAny, 0x0800, 0, []byte{0xBB}) +} + +func (suite *ModSuite) TestResourcesCanBeExtended() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 2, []byte{0xBB}) + }) + suite.thenResourceBlockShouldBe(resource.LangAny, 0x0800, 2, []byte{0xBB}) +} + +func (suite *ModSuite) TestResourcesCanBeRemoved() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenModifiedBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + }) + suite.whenModifyingBy(func(modder world.Modder) { + modder.DelResource(resource.LangAny, 0x0800) + }) + suite.thenResourceBlockShouldBe(resource.LangAny, 0x0800, 0, []byte{0xAA}) +} + +func (suite *ModSuite) TestAdditionsAreNotified() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 2, []byte{0xBB}) + }) + suite.thenModifiedResourcesShouldBe(0x0800) +} + +func (suite *ModSuite) TestModificationsToIdenticalDataAreNotNotified() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x004C, [][]byte{{0xAA}}))) + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x004C, 0, []byte{0xAA}) + }) + suite.thenModifiedResourcesShouldBe() +} + +func (suite *ModSuite) TestDeletionIsNotified() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenModifiedBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + }) + suite.whenModifyingBy(func(modder world.Modder) { + modder.DelResource(resource.LangAny, 0x0800) + }) + suite.thenModifiedResourcesShouldBe(0x0800) +} + +func (suite *ModSuite) TestModifiedResourceCanBeRetrieved() { + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + modder.SetResourceBlock(resource.LangAny, 0x0800, 1, []byte{0xCC}) + }) + + res := suite.mod.ModifiedResource(resource.LangAny, 0x0800) + require.NotNil(suite.T(), res, "Resource expected") + assert.Equal(suite.T(), 2, res.BlockCount(), "Two blocks expected") +} + +func (suite *ModSuite) TestModifiedBlocksCanBeRetrievedSingle() { + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + modder.SetResourceBlock(resource.LangAny, 0x0800, 1, []byte{0xCC}) + }) + + assert.Equal(suite.T(), []byte{0xBB}, suite.mod.ModifiedBlock(resource.LangAny, 0x0800, 0)) + assert.Equal(suite.T(), []byte{0xCC}, suite.mod.ModifiedBlock(resource.LangAny, 0x0800, 1)) +} + +func (suite *ModSuite) TestModifiedBlocksCanBeRetrievedBulk() { + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + modder.SetResourceBlock(resource.LangAny, 0x0800, 1, []byte{0xCC}) + }) + + assert.Equal(suite.T(), [][]byte{{0xBB}, {0xCC}}, suite.mod.ModifiedBlocks(resource.LangAny, 0x0800)) +} + +func (suite *ModSuite) givenWorldHas(res ...resource.LocalizedResources) { + suite.whenWorldIsExtendedWith(res...) + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil +} + +func (suite *ModSuite) whenWorldIsExtendedWith(res ...resource.LocalizedResources) { + manifest := suite.mod.World() + at := manifest.EntryCount() + id := fmt.Sprintf("entry-%d", at) + err := suite.mod.World().InsertEntry(at, suite.anEntryWithResources(id, res...)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected inserting entry at %d", at)) +} + +func (suite *ModSuite) whenResourcesAreQueriedFor(lang resource.Language) { + selector := suite.mod.LocalizedResources(lang) + suite.selector = &selector +} + +func (suite *ModSuite) givenModifiedBy(modifier func(world.Modder)) { + suite.mod.Modify(modifier) + + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil +} + +func (suite *ModSuite) whenModifyingBy(modifier func(world.Modder)) { + suite.mod.Modify(modifier) +} + +func (suite *ModSuite) thenResourceBlockShouldBe(lang resource.Language, id int, blockIndex int, expected []byte) { + view, viewErr := suite.mod.LocalizedResources(lang).Select(resource.ID(id)) + require.Nil(suite.T(), viewErr, "No error expected selecting resource") + reader, blockErr := view.Block(blockIndex) + require.Nil(suite.T(), blockErr, "No error expected retrieving block") + data, dataErr := ioutil.ReadAll(reader) + require.Nil(suite.T(), dataErr, "No error expected reading data") + assert.Equal(suite.T(), expected, data, "Data mismatch in block") +} + +func (suite *ModSuite) thenResourcesCanBeSelected(id int) { + view, err := suite.selector.Select(resource.ID(id)) + assert.Nil(suite.T(), err, "No error expected") + assert.NotNil(suite.T(), view, "View expected") +} + +func (suite *ModSuite) thenModifiedResourcesShouldBe(expected ...int) { + identified := make([]resource.ID, len(expected)) + for index, id := range expected { + identified[index] = resource.ID(id) + } + suite.sortIDs(identified) + suite.sortIDs(suite.lastModifiedIDs) + + assert.Equal(suite.T(), identified, suite.lastModifiedIDs, "Modified IDs don't match") +} + +func (suite *ModSuite) sortIDs(ids []resource.ID) { + sort.Slice(ids, func(a, b int) bool { return ids[a] < ids[b] }) +} + +func (suite *ModSuite) someLocalizedResources(lang resource.Language, modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: lang, + Viewer: store, + } +} + +func (suite *ModSuite) anEntryWithResources(id string, res ...resource.LocalizedResources) *world.ManifestEntry { + return &world.ManifestEntry{ + ID: id, + Resources: res, + } +} + +func (suite *ModSuite) storing(id int, data [][]byte) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Blocks: resource.BlocksFrom(data), + }) + } +} diff --git a/modding/hacked/ss1/world/Modder.go b/modding/hacked/ss1/world/Modder.go new file mode 100644 index 0000000..d369af4 --- /dev/null +++ b/modding/hacked/ss1/world/Modder.go @@ -0,0 +1,41 @@ +package world + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// Modder describes actions meant to modify resources. +type Modder interface { + + // SetResourceBlock changes the block data of a resource. + // + // If the block data is not empty, then: + // If the resource does not exist, it will be created with default meta information. + // If the block does not exist, the resource is extended to allow its addition. + // + // If the block data is empty (or nil), then the block is cleared. + // If the resource is a compound list, then the underlying data will become visible again. + SetResourceBlock(lang resource.Language, id resource.ID, index int, data []byte) + + // PatchResourceBlock modifies an existing block. + // This modification assumes the block already exists and can take the given patch data. + // The patch data is expected to be produced by rle.Compress(). (see also: Mod.CreateBlockPatch) + PatchResourceBlock(lang resource.Language, id resource.ID, index int, expectedLength int, patch []byte) + + // SetResourceBlocks sets the entire list of block data of a resource. + // This method is primarily meant for compound non-list resources (e.g. text pages). + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + + // DelResource removes a resource from the mod in the given language. + // + // After the deletion, all the underlying data of the world will become visible again. + DelResource(lang resource.Language, id resource.ID) + + // SetTextureProperties updates the properties of a specific texture. + SetTextureProperties(textureIndex int, properties texture.Properties) + + // SetObjectProperties updates the properties of a specific object. + SetObjectProperties(triple object.Triple, properties object.Properties) +} diff --git a/modding/hacked/ss1/world/ResourceViewStrategy.go b/modding/hacked/ss1/world/ResourceViewStrategy.go new file mode 100644 index 0000000..796acc9 --- /dev/null +++ b/modding/hacked/ss1/world/ResourceViewStrategy.go @@ -0,0 +1,18 @@ +package world + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// ResourceViewStrategy returns a strategy that is typical for the game. +func ResourceViewStrategy() resource.ViewStrategy { + return defaultResources{} +} + +type defaultResources struct{} + +func (def defaultResources) IsCompoundList(id resource.ID) bool { + info, _ := ids.Info(id) + return info.List +} diff --git a/modding/hacked/ss1/world/citadel/GameState.go b/modding/hacked/ss1/world/citadel/GameState.go new file mode 100644 index 0000000..f7dd645 --- /dev/null +++ b/modding/hacked/ss1/world/citadel/GameState.go @@ -0,0 +1,39 @@ +package citadel + +import ( + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/world" +) + +// DefaultGameState returns a new instance of a game state that the engine creates for a standard game. +func DefaultGameState() *archive.GameState { + state := archive.DefaultGameState() + + // location: medical level + state.Set("Current Level", world.StartingLevel) + + // location: in the neurosurgery chamber, looking West + state.Set("Hacker Position X", (world.StartingTileX<<16)+0x8000) + state.Set("Hacker Position Y", (world.StartingTileY<<16)+0x8000) + state.Set("Hacker Position Z", 0x01BD00) + state.Set("Hacker Yaw", 0x03243E) + + // set first message + state.EMailState(26).SetReceived(true) // Rebecca Lansing's first message + // The engine also sets the active state of email, though this field index is not used. + + for index, info := range booleanVariables { + if info.InitValue == nil { + continue + } + state.SetBooleanVar(index, *info.InitValue != 0) + } + for index, info := range integerVariables { + if info.InitValue == nil { + continue + } + state.SetIntegerVar(index, *info.InitValue) + } + + return state +} diff --git a/modding/hacked/ss1/world/citadel/Variables.go b/modding/hacked/ss1/world/citadel/Variables.go new file mode 100644 index 0000000..ae7f62b --- /dev/null +++ b/modding/hacked/ss1/world/citadel/Variables.go @@ -0,0 +1,192 @@ +package citadel + +import "github.com/inkyblackness/hacked/ss1/content/archive" + +var integerVariables = archive.GameVariables{ + 3: archive.GameVariableInfoFor("Engine State").At(2).LimitedBy(0, 100), + + 12: archive.GameVariableInfoFor("Number of Groves").At(3).LimitedBy(0, 4), + + 33: archive.GameVariableInfoFor("Destroyed CPUs Level 1").At(0).LimitedBy(0, 100), + 34: archive.GameVariableInfoFor("Destroyed CPUs Level 2").At(0).LimitedBy(0, 100), + 35: archive.GameVariableInfoFor("Destroyed CPUs Level 3").At(0).LimitedBy(0, 100), + 36: archive.GameVariableInfoFor("Destroyed CPUs Level 4").At(0).LimitedBy(0, 100), + 37: archive.GameVariableInfoFor("Destroyed CPUs Level 5").At(0).LimitedBy(0, 100), + 38: archive.GameVariableInfoFor("Destroyed CPUs Level 6").At(0).LimitedBy(0, 100), + + 39: archive.GameVariableInfoFor("Killed Cyberguards, largce CSpace").At(0).LimitedBy(0, 100), +} + +var booleanVariables = archive.GameVariables{ + 1: archive.GameVariableInfoFor("(unknown)").At(1), + 2: archive.GameVariableInfoFor("(unknown)").At(1), + 3: archive.GameVariableInfoFor("(unknown)").At(1), + 16: archive.GameVariableInfoFor("(unknown)").At(1), + 18: archive.GameVariableInfoFor("(unknown)").At(1), + 21: archive.GameVariableInfoFor("(unknown)").At(1), + 22: archive.GameVariableInfoFor("(unknown)").At(1), + 23: archive.GameVariableInfoFor("(unknown)").At(1), + 24: archive.GameVariableInfoFor("(unknown)").At(1), + 25: archive.GameVariableInfoFor("(unknown)").At(1), + 26: archive.GameVariableInfoFor("(unknown)").At(1), + 32: archive.GameVariableInfoFor("(unknown)").At(1), + 33: archive.GameVariableInfoFor("(unknown)").At(1), + 36: archive.GameVariableInfoFor("(unknown)").At(1), + 37: archive.GameVariableInfoFor("(unknown)").At(1), + 75: archive.GameVariableInfoFor("(unknown)").At(1), + 76: archive.GameVariableInfoFor("(unknown)").At(1), + 77: archive.GameVariableInfoFor("(unknown)").At(1), + 78: archive.GameVariableInfoFor("(unknown)").At(1), + 79: archive.GameVariableInfoFor("(unknown)").At(1), + 80: archive.GameVariableInfoFor("(unknown)").At(1), + 81: archive.GameVariableInfoFor("(unknown)").At(1), + 82: archive.GameVariableInfoFor("(unknown)").At(1), + 83: archive.GameVariableInfoFor("(unknown)").At(1), + 84: archive.GameVariableInfoFor("(unknown)").At(1), + 85: archive.GameVariableInfoFor("(unknown)").At(1), + 86: archive.GameVariableInfoFor("(unknown)").At(1), + 87: archive.GameVariableInfoFor("(unknown)").At(1), + 88: archive.GameVariableInfoFor("(unknown)").At(1), + 89: archive.GameVariableInfoFor("(unknown)").At(1), + 90: archive.GameVariableInfoFor("(unknown)").At(1), + 91: archive.GameVariableInfoFor("(unknown)").At(1), + 92: archive.GameVariableInfoFor("(unknown)").At(1), + 93: archive.GameVariableInfoFor("(unknown)").At(1), + 94: archive.GameVariableInfoFor("(unknown)").At(1), + 95: archive.GameVariableInfoFor("(unknown)").At(1), + 112: archive.GameVariableInfoFor("(unknown)").At(1), + 113: archive.GameVariableInfoFor("(unknown)").At(1), + 114: archive.GameVariableInfoFor("(unknown)").At(1), + 115: archive.GameVariableInfoFor("(unknown)").At(1), + 116: archive.GameVariableInfoFor("(unknown)").At(1), + 117: archive.GameVariableInfoFor("(unknown)").At(1), + 118: archive.GameVariableInfoFor("(unknown)").At(1), + 119: archive.GameVariableInfoFor("(unknown)").At(1), + 120: archive.GameVariableInfoFor("(unknown)").At(1), + 121: archive.GameVariableInfoFor("(unknown)").At(1), + 122: archive.GameVariableInfoFor("(unknown)").At(1), + 123: archive.GameVariableInfoFor("(unknown)").At(1), + 124: archive.GameVariableInfoFor("(unknown)").At(1), + 125: archive.GameVariableInfoFor("(unknown)").At(1), + 126: archive.GameVariableInfoFor("(unknown)").At(1), + 127: archive.GameVariableInfoFor("(unknown)").At(1), + + 152: archive.GameVariableInfoFor("Bridge Separated").At(0), + + 160: archive.GameVariableInfoFor("(unknown)").At(1), + 161: archive.GameVariableInfoFor("(unknown)").At(1), + 162: archive.GameVariableInfoFor("(unknown)").At(1), + 163: archive.GameVariableInfoFor("(unknown)").At(1), + 164: archive.GameVariableInfoFor("(unknown)").At(1), + 165: archive.GameVariableInfoFor("(unknown)").At(1), + 166: archive.GameVariableInfoFor("(unknown)").At(1), + 167: archive.GameVariableInfoFor("(unknown)").At(1), + 168: archive.GameVariableInfoFor("(unknown)").At(1), + 169: archive.GameVariableInfoFor("(unknown)").At(1), + 192: archive.GameVariableInfoFor("(unknown)").At(1), + 193: archive.GameVariableInfoFor("(unknown)").At(1), + 194: archive.GameVariableInfoFor("(unknown)").At(1), + 195: archive.GameVariableInfoFor("(unknown)").At(1), + 196: archive.GameVariableInfoFor("(unknown)").At(1), + 197: archive.GameVariableInfoFor("(unknown)").At(1), + 198: archive.GameVariableInfoFor("(unknown)").At(1), + 199: archive.GameVariableInfoFor("(unknown)").At(1), + 200: archive.GameVariableInfoFor("(unknown)").At(1), + 201: archive.GameVariableInfoFor("(unknown)").At(1), + 202: archive.GameVariableInfoFor("(unknown)").At(1), + 203: archive.GameVariableInfoFor("(unknown)").At(1), + 204: archive.GameVariableInfoFor("(unknown)").At(1), + 205: archive.GameVariableInfoFor("(unknown)").At(1), + 206: archive.GameVariableInfoFor("(unknown)").At(1), + 207: archive.GameVariableInfoFor("(unknown)").At(1), + 225: archive.GameVariableInfoFor("(unknown)").At(1), + 227: archive.GameVariableInfoFor("(unknown)").At(1), + 229: archive.GameVariableInfoFor("(unknown)").At(1), + 231: archive.GameVariableInfoFor("(unknown)").At(1), + 233: archive.GameVariableInfoFor("(unknown)").At(1), + 235: archive.GameVariableInfoFor("(unknown)").At(1), + 237: archive.GameVariableInfoFor("(unknown)").At(1), + 239: archive.GameVariableInfoFor("(unknown)").At(1), + 241: archive.GameVariableInfoFor("(unknown)").At(1), + 243: archive.GameVariableInfoFor("(unknown)").At(1), + 245: archive.GameVariableInfoFor("(unknown)").At(1), + 247: archive.GameVariableInfoFor("(unknown)").At(1), + 249: archive.GameVariableInfoFor("(unknown)").At(1), + 251: archive.GameVariableInfoFor("(unknown)").At(1), + 253: archive.GameVariableInfoFor("(unknown)").At(1), + 255: archive.GameVariableInfoFor("(unknown)").At(1), + 257: archive.GameVariableInfoFor("(unknown)").At(1), + 259: archive.GameVariableInfoFor("(unknown)").At(1), + 261: archive.GameVariableInfoFor("(unknown)").At(1), + 263: archive.GameVariableInfoFor("(unknown)").At(1), + 265: archive.GameVariableInfoFor("(unknown)").At(1), + 267: archive.GameVariableInfoFor("(unknown)").At(1), + 269: archive.GameVariableInfoFor("(unknown)").At(1), + 271: archive.GameVariableInfoFor("(unknown)").At(1), + 273: archive.GameVariableInfoFor("(unknown)").At(1), + 275: archive.GameVariableInfoFor("(unknown)").At(1), + 277: archive.GameVariableInfoFor("(unknown)").At(1), + 279: archive.GameVariableInfoFor("(unknown)").At(1), + 281: archive.GameVariableInfoFor("(unknown)").At(1), + 283: archive.GameVariableInfoFor("(unknown)").At(1), + 285: archive.GameVariableInfoFor("(unknown)").At(1), + 287: archive.GameVariableInfoFor("(unknown)").At(1), + 289: archive.GameVariableInfoFor("(unknown)").At(1), + 291: archive.GameVariableInfoFor("(unknown)").At(1), + 293: archive.GameVariableInfoFor("(unknown)").At(1), + 295: archive.GameVariableInfoFor("(unknown)").At(1), + 297: archive.GameVariableInfoFor("(unknown)").At(1), + 299: archive.GameVariableInfoFor("(unknown)").At(1), +} + +// IntegerVariable returns a variable info for integer variables. +func IntegerVariable(index int) *archive.GameVariableInfo { + varInfo := integerVariables.Lookup(index) + if varInfo != nil { + return varInfo + } + varInfo = archive.EngineIntegerVariable(index) + if varInfo == nil { + return nil + } + + if index == 31 { + varInfo.Name = "Reactor Code 1" + } + if index == 32 { + varInfo.Name = "Reactor Code 2" + } + return varInfo +} + +// BooleanVariable returns a variable info for boolean variables. +func BooleanVariable(index int) *archive.GameVariableInfo { + varInfo := booleanVariables.Lookup(index) + if varInfo != nil { + return varInfo + } + return archive.EngineBooleanVariable(index) +} + +// MissionVariables is a collector of mission-specific variable accessors. +type MissionVariables struct{} + +var unknownVar = archive.GameVariableInfoFor("(unknown)").At(0) + +// IntegerVariable returns a variable info for given index. +func (vars MissionVariables) IntegerVariable(index int) archive.GameVariableInfo { + varInfo := IntegerVariable(index) + if varInfo == nil { + return unknownVar + } + return *varInfo +} + +// BooleanVariable returns a variable info for given index. +func (vars MissionVariables) BooleanVariable(index int) archive.GameVariableInfo { + varInfo := BooleanVariable(index) + if varInfo == nil { + return unknownVar + } + return *varInfo +} diff --git a/modding/hacked/ss1/world/doc.go b/modding/hacked/ss1/world/doc.go new file mode 100644 index 0000000..c92ef61 --- /dev/null +++ b/modding/hacked/ss1/world/doc.go @@ -0,0 +1,4 @@ +/* +Package world and its subpackages contain everything a typical simulated world requires. +*/ +package world diff --git a/modding/hacked/ss1/world/ids/Filename.go b/modding/hacked/ss1/world/ids/Filename.go new file mode 100644 index 0000000..73cd70a --- /dev/null +++ b/modding/hacked/ss1/world/ids/Filename.go @@ -0,0 +1,61 @@ +package ids + +import ( + "strings" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +// Filename defines a wrapper for a file that is possibly language-specific. +type Filename interface { + // For returns the name of the file for the given language. + For(lang resource.Language) string + + // Matches returns true if the given filename matches the described one. + Matches(filename string) bool +} + +// I18nFile is for internationalized resource files - i.e., those that store resources per file. +type I18nFile [resource.LanguageCount]string + +// For returns the string per language index. +func (spec I18nFile) For(lang resource.Language) string { + return spec[int(lang)] +} + +// Matches returns true if the given filename matches one of the localized filenames. +func (spec I18nFile) Matches(filename string) bool { + lowercase := strings.ToLower(filename) + for _, entry := range spec { + if entry == lowercase { + return true + } + } + return false +} + +// AnyLanguage is for generic resource files that are language agnostic. +type AnyLanguage string + +// For returns the string itself. +func (any AnyLanguage) For(lang resource.Language) string { + return string(any) +} + +// Matches returns true if the given filename matches this one. +func (any AnyLanguage) Matches(filename string) bool { + return strings.ToLower(filename) == string(any) +} + +// FilenameList is a list of filenames. +type FilenameList []Filename + +// Matches returns true if the given filename matches any of the contained entries. +func (list FilenameList) Matches(filename string) bool { + for _, entry := range list { + if entry.Matches(filename) { + return true + } + } + return false +} diff --git a/modding/hacked/ss1/world/ids/Filenames.go b/modding/hacked/ss1/world/ids/Filenames.go new file mode 100644 index 0000000..cc76e7b --- /dev/null +++ b/modding/hacked/ss1/world/ids/Filenames.go @@ -0,0 +1,127 @@ +package ids + +import ( + "strings" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +// CybStrng contains all strings. +var CybStrng = I18nFile([resource.LanguageCount]string{"cybstrng.res", "frnstrng.res", "gerstrng.res"}) + +// MfdArt contains all MFD graphics. +var MfdArt = I18nFile([resource.LanguageCount]string{"mfdart.res", "mfdfrn.res", "mfdger.res"}) + +// CitALog contains all log audio. +var CitALog = I18nFile([resource.LanguageCount]string{"citalog.res", "frnalog.res", "geralog.res"}) + +// CitBark contains all bark audio. +var CitBark = I18nFile([resource.LanguageCount]string{"citbark.res", "frnbark.res", "gerbark.res"}) + +// LowIntr contains the low-res intro video. +var LowIntr = I18nFile([resource.LanguageCount]string{"lowintr.res", "lofrintr.res", "logeintr.res"}) + +// SvgaIntr contains the high-res intro video. +var SvgaIntr = I18nFile([resource.LanguageCount]string{"svgaintr.res", "svfrintr.res", "svgeintr.res"}) + +// Archive contains the game world. +var Archive = AnyLanguage("archive.dat") + +// GamePal contains the game palettes. +var GamePal = AnyLanguage("gamepal.res") + +// GameScr contains big bitmaps. +var GameScr = AnyLanguage("gamescr.res") + +// Texture contains all textures. +var Texture = AnyLanguage("texture.res") + +// VidMail contains all video mails. +var VidMail = AnyLanguage("vidmail.res") + +// Death contains another death video. +var Death = AnyLanguage("death.res") + +// Intro contains another intro video. +var Intro = AnyLanguage("intro.res") + +// Start1 contains ... ?. +var Start1 = AnyLanguage("start1.res") + +// Win1 contains ... ?. +var Win1 = AnyLanguage("win1.res") + +// LowDeth contains the low-res death video. +var LowDeth = AnyLanguage("lowdeth.res") + +// LowEnd contains the low-res end video. +var LowEnd = AnyLanguage("lowend.res") + +// SvgaDeth contains the high-res death video. +var SvgaDeth = AnyLanguage("svgadeth.res") + +// SvgaEnd contains the high-res end video. +var SvgaEnd = AnyLanguage("svgaend.res") + +// Obj3D contains 3D objects. +var Obj3D = AnyLanguage("obj3D.res") + +// ObjArt contains object art. +var ObjArt = AnyLanguage("objart.res") + +// ObjArt2 contains further object art. +var ObjArt2 = AnyLanguage("objart2.res") + +// ObjArt3 contains further object art. +var ObjArt3 = AnyLanguage("objart3.res") + +// CitMat contains materials for 3D objects. +var CitMat = AnyLanguage("citmat.res") + +// CutsPal contains palettes for the cutscenes. +var CutsPal = AnyLanguage("cutspal.res") + +// HandArt contains bitmaps for grabbed things. +var HandArt = AnyLanguage("handart.res") + +// SideArt contains bitmaps for the side buttons. +var SideArt = AnyLanguage("sideart.res") + +// DigiFX contains all the effect sounds. +var DigiFX = AnyLanguage("digifx.res") + +// Splash contains the splash screens. +var Splash = AnyLanguage("splash.res") + +// SplshPal contains the splash screen palettes. +var SplshPal = AnyLanguage("splspal.res") + +// LowResVideos returns the filename descriptors of all low-res videos. +func LowResVideos() FilenameList { + return []Filename{LowIntr, LowDeth, LowEnd, Intro, Start1, Win1} +} + +// HighResVideos returns the filename descriptors of all high-res videos. +func HighResVideos() FilenameList { + return []Filename{SvgaIntr, SvgaDeth, SvgaEnd} +} + +// LocalizedFiles returns the filename descriptors of all files that are localized. +func LocalizedFiles() []Filename { + return []Filename{CybStrng, MfdArt, CitALog, CitBark, LowIntr, SvgaIntr} +} + +// LocalizeFilename returns the language that the resource file would typically contain. +func LocalizeFilename(filename string) resource.Language { + all := LocalizedFiles() + lowercase := strings.ToLower(filename) + result := resource.LangAny + for _, lang := range resource.Languages() { + for _, file := range all { + if file.For(lang) == lowercase { + result = lang + } + } + } + return result +} diff --git a/modding/hacked/ss1/world/ids/Filenames_test.go b/modding/hacked/ss1/world/ids/Filenames_test.go new file mode 100644 index 0000000..900f8ee --- /dev/null +++ b/modding/hacked/ss1/world/ids/Filenames_test.go @@ -0,0 +1,73 @@ +package ids_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" + + "github.com/stretchr/testify/assert" +) + +func TestLocalizedResources(t *testing.T) { + tt := []struct { + filename string + expected resource.Language + }{ + {"citmat.res", resource.LangAny}, + {"digifx.res", resource.LangAny}, + {"gamescr.res", resource.LangAny}, + {"handart.res", resource.LangAny}, + {"intro.res", resource.LangAny}, + {"obj3d.res", resource.LangAny}, + {"objart2.res", resource.LangAny}, + {"objart3.res", resource.LangAny}, + {"sideart.res", resource.LangAny}, + {"texture.res", resource.LangAny}, + + {"cybstrng.res", resource.LangDefault}, + {"mfdart.res", resource.LangDefault}, + + {"frnstrng.res", resource.LangFrench}, + {"mfdfrn.res", resource.LangFrench}, + + {"gerstrng.res", resource.LangGerman}, + {"mfdger.res", resource.LangGerman}, + + {"archive.dat", resource.LangAny}, + {"cutspal.res", resource.LangAny}, + {"death.res", resource.LangAny}, + {"gamepal.res", resource.LangAny}, + {"intro.res", resource.LangAny}, + {"lowdeth.res", resource.LangAny}, + {"lowend.res", resource.LangAny}, + {"objart.res", resource.LangAny}, + {"splash.res", resource.LangAny}, + {"splshpal.res", resource.LangAny}, + {"start1.res", resource.LangAny}, + {"svgadeth.res", resource.LangAny}, + {"svgaend.res", resource.LangAny}, + {"vidmail.res", resource.LangAny}, + {"win1.res", resource.LangAny}, + + {"citalog.res", resource.LangDefault}, + {"citbark.res", resource.LangDefault}, + {"lowintr.res", resource.LangDefault}, + {"svgaintr.res", resource.LangDefault}, + + {"frnalog.res", resource.LangFrench}, + {"frnbark.res", resource.LangFrench}, + {"lofrintr.res", resource.LangFrench}, + {"svfrintr.res", resource.LangFrench}, + + {"geralog.res", resource.LangGerman}, + {"gerbark.res", resource.LangGerman}, + {"logeintr.res", resource.LangGerman}, + {"svgeintr.res", resource.LangGerman}, + } + + for _, tc := range tt { + result := ids.LocalizeFilename(tc.filename) + assert.Equal(t, tc.expected, result, "Wrong language for <"+tc.filename+">") + } +} diff --git a/modding/hacked/ss1/world/ids/Identifier.go b/modding/hacked/ss1/world/ids/Identifier.go new file mode 100644 index 0000000..6924150 --- /dev/null +++ b/modding/hacked/ss1/world/ids/Identifier.go @@ -0,0 +1,88 @@ +package ids + +import "github.com/inkyblackness/hacked/ss1/resource" + +// Palette identifier are listed below. +const ( + GamePalettesStart resource.ID = 0x02BC +) + +// Texture identifier are listed below. +const ( + IconTextures resource.ID = 0x004C + SmallTextures resource.ID = 0x004D + MediumTextures resource.ID = 0x02C3 + LargeTextures resource.ID = 0x03E8 + + ScreenTextures resource.ID = 0x0141 + + TextureNames resource.ID = 0x086A + TextureUsages resource.ID = 0x086B +) + +// Bitmap identifier are listed below. +const ( + ObjectBitmaps resource.ID = 0x0546 + ObjectTextureBitmaps resource.ID = 0x01DB + ObjectMaterialBitmaps resource.ID = 0x0884 + + IconBitmaps resource.ID = 0x004E + GraffitiBitmaps resource.ID = 0x004F + + MfdDataBitmaps resource.ID = 0x0028 +) + +// Animation and video identifier are listed below. +const ( + VideoMailBitmapsStart resource.ID = 0x0A40 + VideoMailAnimationsStart resource.ID = 0x0A4C +) + +// Movie identifier are listed below. +const ( + MovieIntro resource.ID = 0x0BD6 + MovieDeath resource.ID = 0x0BD7 + MovieEnd resource.ID = 0x0BD8 +) + +// Text identifier are listed below. +const ( + PaperTextsStart resource.ID = 0x003C + TrapMessageTexts resource.ID = 0x0867 + WordTexts resource.ID = 0x0868 + PanelNameTexts resource.ID = 0x0869 + LogCategoryTexts resource.ID = 0x0870 + VariousMessageTexts resource.ID = 0x0871 + ScreenMessageTexts resource.ID = 0x0877 + InfoNodeMessageTexts resource.ID = 0x0878 + AccessCardNameTexts resource.ID = 0x0879 + DataletMessageTexts resource.ID = 0x087A + + ObjectLongNames resource.ID = 0x0024 + ObjectShortNames resource.ID = 0x086D +) + +// Message identifier are listed below. +const ( + MailsStart resource.ID = 0x0989 + LogsStart resource.ID = 0x09B8 + FragmentsStart resource.ID = 0x0A98 + + MailsAudioStart resource.ID = 0x0989 + 300 + LogsAudioStart resource.ID = 0x09B8 + 300 +) + +// Sound identifier are listed below. +const ( + TrapMessagesAudioStart resource.ID = 0x0C1C + + SoundEffectsAudioStart resource.ID = 0x00C9 +) + +// Archive identifier are listed below. +const ( + ArchiveName resource.ID = 0x0FA0 + GameState resource.ID = 0x0FA1 + + LevelResourcesStart resource.ID = 4000 +) diff --git a/modding/hacked/ss1/world/ids/Info.go b/modding/hacked/ss1/world/ids/Info.go new file mode 100644 index 0000000..349f948 --- /dev/null +++ b/modding/hacked/ss1/world/ids/Info.go @@ -0,0 +1,161 @@ +package ids + +import ( + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/content/archive/level/lvlids" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// ResourceInfo describes a group of resources with their default serialization properties. +type ResourceInfo struct { + // StartID is the first ID of the resource block (inclusive). + StartID resource.ID + // EndID is the last ID of the resource block (exclusive). + EndID resource.ID + + // ContentType describes how to interpret resource data. + ContentType resource.ContentType + // Compound indicates whether the resource has a variable amount of blocks. Simple resources always have one block. + Compound bool + // Compressed indicates that the resource data shall be stored in compressed form. + Compressed bool + + // List is set for compound resources that have an atomic resource per block. + List bool + // MaxCount describes how many resources can be stored at maximum. Zero for unlimited / defined somewhere else. + MaxCount int + + // ResFile specifies the .res file in which this resource should be stored. + ResFile Filename +} + +// Info returns the resource information for the identified resource. +func Info(id resource.ID) (ResourceInfo, bool) { + info, existing := infoByID[id] + return info, existing +} + +func init() { + register := func(info ResourceInfo) { + count := info.EndID.Value() - info.StartID.Value() + for offset := 0; offset < int(count); offset++ { + infoByID[info.StartID.Plus(offset)] = info + } + } + for _, info := range infoList { + register(info) + } + levelInfo := func(lvl int, lvlResID int, compressed bool) ResourceInfo { + resID := LevelResourcesStart.Plus(lvl*lvlids.PerLevel + lvlResID) + return ResourceInfo{ + StartID: resID, + EndID: resID.Plus(1), + + ContentType: resource.Archive, + Compound: false, + Compressed: compressed, + + List: false, + MaxCount: 1, + + ResFile: Archive, + } + } + // In the following, the compression flags were set if the vanilla archive had + // the respective resource compressed in at least one level. + // Most of them were compressed uniform across all levels, some had exceptions. + for lvl := 0; lvl < archive.MaxLevels; lvl++ { + register(levelInfo(lvl, lvlids.MapVersionNumber, false)) + register(levelInfo(lvl, lvlids.ObjectVersionNumber, false)) + register(levelInfo(lvl, lvlids.Information, true)) + + register(levelInfo(lvl, lvlids.TileMap, true)) + register(levelInfo(lvl, lvlids.Schedules, false)) + register(levelInfo(lvl, lvlids.TextureAtlas, false)) + register(levelInfo(lvl, lvlids.ObjectMainTable, true)) + register(levelInfo(lvl, lvlids.ObjectCrossRefTable, true)) + for class := 0; class < object.ClassCount; class++ { + register(levelInfo(lvl, lvlids.ObjectClassTablesStart+class, true)) + register(levelInfo(lvl, lvlids.ObjectDefaultTablesStart+class, true)) + } + + register(levelInfo(lvl, lvlids.SavefileVersion, false)) + register(levelInfo(lvl, lvlids.Unused41, false)) + + register(levelInfo(lvl, lvlids.TextureAnimations, true)) + register(levelInfo(lvl, lvlids.SurveillanceSources, true)) + register(levelInfo(lvl, lvlids.SurveillanceSurrogates, true)) + register(levelInfo(lvl, lvlids.Parameters, true)) + register(levelInfo(lvl, lvlids.MapNotes, true)) + register(levelInfo(lvl, lvlids.MapNotesPointer, false)) + + register(levelInfo(lvl, lvlids.Unknown48, true)) + register(levelInfo(lvl, lvlids.Unknown49, true)) + register(levelInfo(lvl, lvlids.Unknown50, false)) + + register(levelInfo(lvl, lvlids.LoopConfiguration, true)) + + register(levelInfo(lvl, lvlids.Unknown52, false)) + register(levelInfo(lvl, lvlids.HeightSemaphores, true)) + } +} + +var infoByID = map[resource.ID]ResourceInfo{} + +var infoList = []ResourceInfo{ + {GamePalettesStart, GamePalettesStart.Plus(3), resource.Palette, false, false, false, 3, GamePal}, + + {IconTextures, IconTextures.Plus(1), resource.Bitmap, true, false, true, 293, Texture}, + {SmallTextures, SmallTextures.Plus(1), resource.Bitmap, true, false, true, 293, Texture}, + {MediumTextures, MediumTextures.Plus(293), resource.Bitmap, true, false, false, 293, Texture}, + {LargeTextures, LargeTextures.Plus(293), resource.Bitmap, true, false, false, 293, Texture}, + {TextureNames, TextureNames.Plus(1), resource.Text, true, false, true, 293, CybStrng}, + {TextureUsages, TextureUsages.Plus(1), resource.Text, true, false, true, 293, CybStrng}, + + {ObjectBitmaps, ObjectBitmaps.Plus(1), resource.Bitmap, true, false, true, 0, ObjArt}, + {ObjectTextureBitmaps, ObjectTextureBitmaps.Plus(64), resource.Bitmap, true, false, false, 64, CitMat}, + {ObjectMaterialBitmaps, ObjectMaterialBitmaps.Plus(32), resource.Bitmap, true, false, false, 32, CitMat}, + + {ScreenTextures, ScreenTextures.Plus(102), resource.Bitmap, true, false, false, 102, Texture}, + + {IconBitmaps, IconBitmaps.Plus(1), resource.Bitmap, true, false, true, 64, ObjArt3}, + {GraffitiBitmaps, GraffitiBitmaps.Plus(1), resource.Bitmap, true, false, true, 64, ObjArt3}, + + {MfdDataBitmaps, MfdDataBitmaps.Plus(1), resource.Bitmap, true, true, true, 256, MfdArt}, + + {VideoMailBitmapsStart, VideoMailBitmapsStart.Plus(12), resource.Bitmap, true, false, false, 12, VidMail}, + {VideoMailAnimationsStart, VideoMailAnimationsStart.Plus(12), resource.Animation, true, false, false, 12, VidMail}, + + {MovieIntro, MovieIntro.Plus(1), resource.Movie, false, false, false, 1, SvgaIntr}, + {MovieDeath, MovieDeath.Plus(1), resource.Movie, false, false, false, 1, SvgaDeth}, + {MovieEnd, MovieEnd.Plus(1), resource.Movie, false, false, false, 1, SvgaEnd}, + + {PaperTextsStart, PaperTextsStart.Plus(16), resource.Text, true, false, false, 16, CybStrng}, + + {TrapMessageTexts, TrapMessageTexts.Plus(1), resource.Text, true, false, true, 256, CybStrng}, + {TrapMessagesAudioStart, TrapMessagesAudioStart.Plus(256), resource.Movie, false, false, false, 256, CitBark}, + + {SoundEffectsAudioStart, SoundEffectsAudioStart.Plus(114), resource.Sound, false, false, false, 114, DigiFX}, + + {WordTexts, WordTexts.Plus(1), resource.Text, true, false, true, 512, CybStrng}, + {PanelNameTexts, PanelNameTexts.Plus(1), resource.Text, true, false, true, 256, CybStrng}, + {LogCategoryTexts, LogCategoryTexts.Plus(1), resource.Text, true, false, true, 16, CybStrng}, + {VariousMessageTexts, VariousMessageTexts.Plus(1), resource.Text, true, false, true, 256, CybStrng}, + {ScreenMessageTexts, ScreenMessageTexts.Plus(1), resource.Text, true, false, true, 120, CybStrng}, + {InfoNodeMessageTexts, InfoNodeMessageTexts.Plus(1), resource.Text, true, false, true, 256, CybStrng}, + {AccessCardNameTexts, AccessCardNameTexts.Plus(1), resource.Text, true, false, true, 32 * 2, CybStrng}, + {DataletMessageTexts, DataletMessageTexts.Plus(1), resource.Text, true, false, true, 256, CybStrng}, + + {MailsStart, MailsStart.Plus(47), resource.Text, true, false, false, 47, CybStrng}, + {LogsStart, LogsStart.Plus(136), resource.Text, true, false, false, 136, CybStrng}, + {FragmentsStart, FragmentsStart.Plus(16), resource.Text, true, false, false, 16, CybStrng}, + {MailsAudioStart, MailsAudioStart.Plus(47), resource.Movie, false, false, false, 47, CitALog}, + {LogsAudioStart, LogsAudioStart.Plus(224), resource.Movie, false, false, false, 224, CitALog}, + + {ObjectLongNames, ObjectLongNames.Plus(1), resource.Text, true, false, true, 0, CybStrng}, + {ObjectShortNames, ObjectShortNames.Plus(1), resource.Text, true, false, true, 0, CybStrng}, + + {ArchiveName, ArchiveName.Plus(1), resource.Archive, false, false, false, 1, Archive}, + {GameState, GameState.Plus(1), resource.Archive, false, true, false, 1, Archive}, +} diff --git a/modding/hacked/ss1/world/ids/Sounds.go b/modding/hacked/ss1/world/ids/Sounds.go new file mode 100644 index 0000000..5bb2cef --- /dev/null +++ b/modding/hacked/ss1/world/ids/Sounds.go @@ -0,0 +1,197 @@ +package ids + +// SoundEffectInfo describes one sound effect in the game. +type SoundEffectInfo struct { + // Name is the unique identifier for the effect source. + Name string + // Index refers to the audio index. Multiple effects may use the same audio. -1 indicates no audio mapped. + AudioIndex int +} + +// SoundEffectsForAudio returns the effect information that share the same audio. +func SoundEffectsForAudio(index int) []SoundEffectInfo { + var result []SoundEffectInfo + for _, effect := range soundEffects { + if effect.AudioIndex == index { + result = append(result, effect) + } + } + return result +} + +// The following table is based on the constants found in sfxlist.h (non-demo case). +var soundEffects = []SoundEffectInfo{ + // Doors + {Name: "DoorMetal", AudioIndex: 3}, + {Name: "DoorNormal", AudioIndex: 5}, + {Name: "DoorIris", AudioIndex: 6}, + {Name: "DoorBulkhead", AudioIndex: 67}, + {Name: "DoorGrating", AudioIndex: 90}, + + // Ambient + {Name: "Bridge1", AudioIndex: -1}, + {Name: "Bridge2", AudioIndex: -1}, + {Name: "Bridge3", AudioIndex: -1}, + {Name: "Bridge4", AudioIndex: -1}, + {Name: "Maint1", AudioIndex: 9}, + {Name: "Maint2", AudioIndex: -1}, + {Name: "Maint3", AudioIndex: -1}, + {Name: "Maint4", AudioIndex: -1}, + {Name: "Grove1", AudioIndex: 43}, + + // Critters + {Name: "Death1", AudioIndex: 11}, + {Name: "Death2", AudioIndex: 49}, + {Name: "Death3", AudioIndex: 50}, + {Name: "Death4", AudioIndex: 51}, + {Name: "Death5", AudioIndex: 53}, + {Name: "Death6", AudioIndex: 54}, + {Name: "Death7", AudioIndex: 68}, + {Name: "Death8", AudioIndex: 69}, + {Name: "Death9", AudioIndex: 88}, + {Name: "Death10", AudioIndex: 93}, + {Name: "Death11", AudioIndex: 101}, + {Name: "Attack1", AudioIndex: 12}, + {Name: "Attack4", AudioIndex: 46}, + {Name: "Attack5", AudioIndex: 48}, + {Name: "Attack6", AudioIndex: 52}, + {Name: "Attack7", AudioIndex: 55}, + {Name: "Attack8", AudioIndex: 63}, + {Name: "Attack9", AudioIndex: 16}, + {Name: "Notice1", AudioIndex: 58}, + {Name: "Notice2", AudioIndex: 59}, + {Name: "Notice3", AudioIndex: 74}, + {Name: "Notice4", AudioIndex: 75}, + {Name: "Notice5", AudioIndex: 100}, + {Name: "Near1", AudioIndex: 73}, + {Name: "Near2", AudioIndex: 56}, + {Name: "Near3", AudioIndex: 47}, + {Name: "Near4", AudioIndex: 25}, + + // Wacky Objects + {Name: "Repulsor", AudioIndex: -1}, + {Name: "ForceBridge", AudioIndex: 72}, + {Name: "TerrainElevLoop", AudioIndex: 15}, + {Name: "SparkingCable", AudioIndex: 87}, + {Name: "SurgeryMachine", AudioIndex: 102}, + + // Combat + {Name: "GunMinipistol", AudioIndex: 39}, + {Name: "GunDartpistol", AudioIndex: 86}, + {Name: "GunMagnum", AudioIndex: 40}, + {Name: "GunAssault", AudioIndex: 17}, + {Name: "GunRiot", AudioIndex: 41}, + {Name: "GunFlechette", AudioIndex: 38}, + {Name: "GunSkorpion", AudioIndex: 65}, + {Name: "GunMagpulse", AudioIndex: 45}, + {Name: "GunRailgun", AudioIndex: 29}, + {Name: "GunPipeHitMeat", AudioIndex: 4}, + {Name: "GunPipeHitMetal", AudioIndex: 21}, + {Name: "GunPipeMiss", AudioIndex: 24}, + {Name: "GunLaserepeeHit", AudioIndex: 31}, + {Name: "GunLaserepeeMiss", AudioIndex: 34}, + {Name: "GunPhaser", AudioIndex: 18}, + {Name: "GunBlaster", AudioIndex: 94}, + {Name: "GunIonbeam", AudioIndex: 95}, + {Name: "GunStungun", AudioIndex: 19}, + {Name: "GunPlasma", AudioIndex: 97}, + + {Name: "PlayerHurt", AudioIndex: 64}, + {Name: "Shield1", AudioIndex: 32}, + {Name: "Shield2", AudioIndex: 20}, + {Name: "ShieldUp", AudioIndex: 96}, + {Name: "ShieldDown", AudioIndex: 42}, + {Name: "MetalSpang", AudioIndex: 89}, + {Name: "Radiation", AudioIndex: 2}, + + {Name: "Reload1", AudioIndex: 22}, + {Name: "Reload2", AudioIndex: 23}, + {Name: "GrenadeArm", AudioIndex: 7}, + {Name: "BatteryUse", AudioIndex: 28}, + + {Name: "Explosion1", AudioIndex: 44}, + {Name: "Rumble", AudioIndex: 106}, + {Name: "Teleport", AudioIndex: 103}, + + {Name: "MonitorExplode", AudioIndex: 57}, + {Name: "CameraExplode", AudioIndex: 8}, + {Name: "CpuExplode", AudioIndex: 10}, + {Name: "DestroyCrate", AudioIndex: 37}, + {Name: "DestroyBarrel", AudioIndex: 109}, + + // Cspace + {Name: "Pulser", AudioIndex: -1}, + {Name: "Drill", AudioIndex: -1}, + {Name: "Disc", AudioIndex: -1}, + {Name: "Datastorm", AudioIndex: -1}, + + {Name: "Recall", AudioIndex: -1}, + {Name: "Turbo", AudioIndex: -1}, + {Name: "Fakeid", AudioIndex: -1}, + {Name: "Decoy", AudioIndex: -1}, + + {Name: "EnterCspace", AudioIndex: 27}, + {Name: "OttoShodan", AudioIndex: 30}, + {Name: "CyberDamage", AudioIndex: -1}, + {Name: "Cyberheal", AudioIndex: -1}, + {Name: "Cybertoggle", AudioIndex: -1}, + {Name: "IceDefense", AudioIndex: -1}, + + {Name: "CyberAttack1", AudioIndex: -1}, + {Name: "CyberAttack2", AudioIndex: -1}, + {Name: "CyberAttack3", AudioIndex: -1}, + + // MFD and UI Wackiness + {Name: "VideoDown", AudioIndex: 33}, + {Name: "MfdButton", AudioIndex: 35}, + {Name: "InventButton", AudioIndex: 79}, + {Name: "InventSelect", AudioIndex: 80}, + {Name: "InventAdd", AudioIndex: 81}, + {Name: "InventWare", AudioIndex: 82}, + {Name: "PatchUse", AudioIndex: 83}, + {Name: "ZoomBox", AudioIndex: 84}, + {Name: "MapZoom", AudioIndex: 85}, + {Name: "MfdKeypad", AudioIndex: 76}, + {Name: "MfdBuzz", AudioIndex: 77}, + {Name: "MfdSuccess", AudioIndex: 78}, + {Name: "Goggle", AudioIndex: 0}, + {Name: "Hudfrob", AudioIndex: 1}, + {Name: "Static", AudioIndex: 2}, + {Name: "Email", AudioIndex: 107}, + + // SHODAN + {Name: "ShodanBark", AudioIndex: 30}, + {Name: "ShodanWeak", AudioIndex: 30}, + {Name: "ShodanStrong", AudioIndex: 30}, + + // Other + {Name: "PanelSuccess", AudioIndex: 78}, + {Name: "PowerOut", AudioIndex: 26}, + {Name: "EnergyDrain", AudioIndex: 14}, + {Name: "EnergyRecharge", AudioIndex: 98}, + {Name: "Surge", AudioIndex: 60}, + {Name: "Vmail", AudioIndex: 92}, + {Name: "DropItem", AudioIndex: 99}, + + // 3d World + {Name: "Button", AudioIndex: 71}, + {Name: "MechButton", AudioIndex: 36}, + {Name: "Bigbutton", AudioIndex: 61}, + {Name: "NormalLever", AudioIndex: 62}, + {Name: "Biglever", AudioIndex: 62}, + {Name: "Klaxon", AudioIndex: 70}, + + // Plot + {Name: "GroveJett", AudioIndex: 66}, + + // Extra + {Name: "PanelConfirm", AudioIndex: 13}, + {Name: "Death12", AudioIndex: 104}, + {Name: "Death13", AudioIndex: 105}, + {Name: "Death14", AudioIndex: 108}, + {Name: "Attack10", AudioIndex: 91}, + {Name: "Attack11", AudioIndex: 110}, + {Name: "Near5", AudioIndex: 111}, + {Name: "Death15", AudioIndex: 112}, + {Name: "FunPackAttackingUs", AudioIndex: 113}, +} diff --git a/modding/hacked/ss1/world/ids/doc.go b/modding/hacked/ss1/world/ids/doc.go new file mode 100644 index 0000000..848dc6b --- /dev/null +++ b/modding/hacked/ss1/world/ids/doc.go @@ -0,0 +1,3 @@ +// Package ids contains constants for all necessary identifier used in the world. +// This package contains resource identifier, filenames and other constants necessary for storage. +package ids diff --git a/modding/hacked/ui/gui/Context.go b/modding/hacked/ui/gui/Context.go new file mode 100644 index 0000000..ea6252f --- /dev/null +++ b/modding/hacked/ui/gui/Context.go @@ -0,0 +1,420 @@ +package gui + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/opengl" +) + +type fontFileError struct { + Filename string +} + +func (err fontFileError) Error() string { + return fmt.Sprintf("could not load font '%s'", err.Filename) +} + +// ContextParameters describes how to create the context. +type ContextParameters struct { + // ConfigDir is used to store the UI state in. + ConfigDir string + // FontFile is the filename of a .TTF file to load instead of using default. + FontFile string + // FontSize is the requested size of the font. Defaults to 12. + FontSize float32 +} + +// BitmapTextureQuery resolves the texture and the palette to be used for a bitmap. +type BitmapTextureQuery func(id imgui.TextureID) (palette uint32, texture uint32) + +// Context describes a scope for a graphical user interface. +// It is based on ImGui. +type Context struct { + imguiContext *imgui.Context + window opengl.Window + + lastRenderTime time.Time + + mouseButtonWasDown [3]bool + mouseButtonIsDown [3]bool + + fontTexture uint32 + shaderHandle uint32 + attribLocationType int32 + attribLocationTex int32 + attribLocationPal int32 + attribLocationProjMtx int32 + attribLocationPosition int32 + attribLocationUV int32 + attribLocationColor int32 + vboHandle uint32 + elementsHandle uint32 +} + +// NewContext initializes a new UI context based on the provided OpenGL window. +func NewContext(window opengl.Window, param ContextParameters) (context *Context, err error) { + imgui.SetAssertHandler(nil) + context = &Context{ + imguiContext: imgui.CreateContext(nil), + window: window, + } + + imgui.CurrentIO().SetIniFilename(filepath.Join(param.ConfigDir, "imgui.ini")) + + err = context.createDeviceObjects(param) + if err != nil { + context.Destroy() + context = nil + } + + return +} + +// Destroy cleans up the resources of the graphical user interface. +func (context *Context) Destroy() { + context.destroyDeviceObjects(context.window.OpenGL()) + context.imguiContext.Destroy() +} + +// MouseButtonChanged is called when the state of a button has changed. +func (context *Context) MouseButtonChanged(buttonIndex int, down bool) { + if (buttonIndex >= 0) && (buttonIndex < len(context.mouseButtonIsDown)) { + context.mouseButtonIsDown[buttonIndex] = down + if down { + context.mouseButtonWasDown[buttonIndex] = down + } + } +} + +// IsUsingMouse returns true if the UI is using the mouse. +// The application should not process mouse events in this case. +func (context Context) IsUsingMouse() bool { + return imgui.CurrentIO().WantCaptureMouse() +} + +// IsUsingKeyboard returns true if the UI is currently capturing keyboard input. +// The application should not process keyboard input events in this case. +func (context Context) IsUsingKeyboard() bool { + return imgui.CurrentIO().WantTextInput() +} + +// NewFrame must be called at the start of rendering. +func (context *Context) NewFrame() { + io := imgui.CurrentIO() + + windowWidth, windowHeight := context.window.Size() + io.SetDisplaySize(imgui.Vec2{X: float32(windowWidth), Y: float32(windowHeight)}) + + now := time.Now() + if !context.lastRenderTime.IsZero() { + elapsed := now.Sub(context.lastRenderTime) + io.SetDeltaTime(float32(elapsed.Seconds())) + } + context.lastRenderTime = now + + for i := 0; i < len(context.mouseButtonWasDown); i++ { + down := context.mouseButtonWasDown[i] || context.mouseButtonIsDown[i] + io.SetMouseButtonDown(i, down) + context.mouseButtonWasDown[i] = false + } + + imgui.NewFrame() +} + +// Render must be called at the end of rendering. +func (context *Context) Render(bitmapTextureQuery BitmapTextureQuery) { + imgui.Render() + context.renderDrawData(imgui.RenderedDrawData(), bitmapTextureQuery) +} + +func (context *Context) createDeviceObjects(param ContextParameters) (err error) { + gl := context.window.OpenGL() + glslVersion := "#version 150" + + vertexShaderSource := glslVersion + ` +uniform mat4 ProjMtx; +in vec2 Position; +in vec2 UV; +in vec4 Color; +out vec2 Frag_UV; +out vec4 Frag_Color; +void main() +{ + Frag_UV = UV; + Frag_Color = Color; + gl_Position = ProjMtx * vec4(Position.xy,0,1); +} +` + fragmentShaderSource := glslVersion + ` +uniform int ImageType; +uniform sampler2D Texture; +uniform sampler2D Palette; +in vec2 Frag_UV; +in vec4 Frag_Color; +out vec4 Out_Color; +void main() +{ + if (ImageType == 1) + { + vec4 pixel = texture(Texture, Frag_UV.st); + Out_Color = Frag_Color * texture(Palette, vec2(pixel.r, 0.5)); + } + else if (ImageType == 2) + { + Out_Color = texture(Texture, Frag_UV.st) * Frag_Color; + } + else + { + Out_Color = vec4(Frag_Color.rgb, Frag_Color.a * texture( Texture, Frag_UV.st).r); + } +} +` + context.shaderHandle, err = opengl.LinkNewStandardProgram(gl, vertexShaderSource, fragmentShaderSource) + if err != nil { + return + } + + context.attribLocationType = gl.GetUniformLocation(context.shaderHandle, "ImageType") + context.attribLocationTex = gl.GetUniformLocation(context.shaderHandle, "Texture") + context.attribLocationPal = gl.GetUniformLocation(context.shaderHandle, "Palette") + context.attribLocationProjMtx = gl.GetUniformLocation(context.shaderHandle, "ProjMtx") + context.attribLocationPosition = gl.GetAttribLocation(context.shaderHandle, "Position") + context.attribLocationUV = gl.GetAttribLocation(context.shaderHandle, "UV") + context.attribLocationColor = gl.GetAttribLocation(context.shaderHandle, "Color") + + buffers := gl.GenBuffers(2) + context.vboHandle = buffers[0] + context.elementsHandle = buffers[1] + + return context.createFontsTexture(gl, param) +} + +func (context *Context) createFontsTexture(gl opengl.OpenGL, param ContextParameters) error { + io := imgui.CurrentIO() + fontAtlas := io.Fonts() + if len(param.FontFile) > 0 { + fontSize := float32(16.0) + if param.FontSize > 0.0 { + fontSize = param.FontSize + } + font := fontAtlas.AddFontFromFileTTF(param.FontFile, fontSize) + if font == imgui.DefaultFont { + return fontFileError{Filename: param.FontFile} + } + } + image := fontAtlas.TextureDataAlpha8() + + context.fontTexture = gl.GenTextures(1)[0] + gl.BindTexture(opengl.TEXTURE_2D, context.fontTexture) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MIN_FILTER, opengl.LINEAR) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MAG_FILTER, opengl.LINEAR) + gl.PixelStorei(opengl.UNPACK_ROW_LENGTH, 0) + gl.TexImage2D(opengl.TEXTURE_2D, 0, opengl.RED, int32(image.Width), int32(image.Height), + 0, opengl.RED, opengl.UNSIGNED_BYTE, image.Pixels) + + io.Fonts().SetTextureID(TextureIDForSimpleTexture(context.fontTexture)) + + gl.BindTexture(opengl.TEXTURE_2D, 0) + return nil +} + +func (context *Context) destroyDeviceObjects(gl opengl.OpenGL) { + if context.vboHandle != 0 { + gl.DeleteBuffers([]uint32{context.vboHandle}) + } + context.vboHandle = 0 + if context.elementsHandle != 0 { + gl.DeleteBuffers([]uint32{context.elementsHandle}) + } + context.elementsHandle = 0 + + if context.shaderHandle != 0 { + gl.DeleteProgram(context.shaderHandle) + } + context.shaderHandle = 0 + + if context.fontTexture != 0 { + gl.DeleteTextures([]uint32{context.fontTexture}) + imgui.CurrentIO().Fonts().SetTextureID(0) + context.fontTexture = 0 + } +} + +func (context *Context) renderDrawData(drawData imgui.DrawData, bitmapTextureQuery BitmapTextureQuery) { + gl := context.window.OpenGL() + + displayWidth, displayHeight := context.window.Size() + + // Backup GL state + var lastActiveTexture int32 + gl.GetIntegerv(opengl.ACTIVE_TEXTURE, &lastActiveTexture) + gl.ActiveTexture(opengl.TEXTURE0) + var lastProgram int32 + gl.GetIntegerv(opengl.CURRENT_PROGRAM, &lastProgram) + var lastTexture int32 + gl.GetIntegerv(opengl.TEXTURE_BINDING_2D, &lastTexture) + var lastSampler int32 + gl.GetIntegerv(opengl.SAMPLER_BINDING, &lastSampler) + var lastArrayBuffer int32 + gl.GetIntegerv(opengl.ARRAY_BUFFER_BINDING, &lastArrayBuffer) + var lastElementArrayBuffer int32 + gl.GetIntegerv(opengl.ELEMENT_ARRAY_BUFFER_BINDING, &lastElementArrayBuffer) + var lastVertexArray int32 + gl.GetIntegerv(opengl.VERTEX_ARRAY_BINDING, &lastVertexArray) + var lastPolygonMode [2]int32 + gl.GetIntegerv(opengl.POLYGON_MODE, &lastPolygonMode[0]) + var lastViewport [4]int32 + gl.GetIntegerv(opengl.VIEWPORT, &lastViewport[0]) + var lastScissorBox [4]int32 + gl.GetIntegerv(opengl.SCISSOR_BOX, &lastScissorBox[0]) + var lastBlendSrcRgb int32 + gl.GetIntegerv(opengl.BLEND_SRC_RGB, &lastBlendSrcRgb) + var lastBlendDstRgb int32 + gl.GetIntegerv(opengl.BLEND_DST_RGB, &lastBlendDstRgb) + var lastBlendSrcAlpha int32 + gl.GetIntegerv(opengl.BLEND_SRC_ALPHA, &lastBlendSrcAlpha) + var lastBlendDstAlpha int32 + gl.GetIntegerv(opengl.BLEND_DST_ALPHA, &lastBlendDstAlpha) + var lastBlendEquationRgb int32 + gl.GetIntegerv(opengl.BLEND_EQUATION_RGB, &lastBlendEquationRgb) + var lastBlendEquationAlpha int32 + gl.GetIntegerv(opengl.BLEND_EQUATION_ALPHA, &lastBlendEquationAlpha) + lastEnableBlend := gl.IsEnabled(opengl.BLEND) + lastEnableCullFace := gl.IsEnabled(opengl.CULL_FACE) + lastEnableDepthTest := gl.IsEnabled(opengl.DEPTH_TEST) + lastEnableScissorTest := gl.IsEnabled(opengl.SCISSOR_TEST) + + // Setup render state: alpha-blending enabled, no face culling, no depth testing, scissor enabled, polygon fill + gl.Enable(opengl.BLEND) + gl.BlendEquation(opengl.FUNC_ADD) + gl.BlendFunc(opengl.SRC_ALPHA, opengl.ONE_MINUS_SRC_ALPHA) + gl.Disable(opengl.CULL_FACE) + gl.Disable(opengl.DEPTH_TEST) + gl.Enable(opengl.SCISSOR_TEST) + gl.PolygonMode(opengl.FRONT_AND_BACK, opengl.FILL) + + // Setup viewport, orthographic projection matrix + gl.Viewport(0, 0, int32(displayWidth), int32(displayHeight)) + orthoProjection := [16]float32{ + 2.0 / float32(displayWidth), 0.0, 0.0, 0.0, + 0.0, 2.0 / float32(-displayHeight), 0.0, 0.0, + 0.0, 0.0, -1.0, 0.0, + -1.0, 1.0, 0.0, 1.0, + } + gl.UseProgram(context.shaderHandle) + gl.Uniform1i(context.attribLocationTex, 0) + gl.Uniform1i(context.attribLocationPal, 1) + gl.UniformMatrix4fv(context.attribLocationProjMtx, false, &orthoProjection) + gl.BindSampler(0, 0) // Rely on combined texture/sampler state. + + vaoHandle := gl.GenVertexArrays(1)[0] + gl.BindVertexArray(vaoHandle) + gl.BindBuffer(opengl.ARRAY_BUFFER, context.vboHandle) + gl.EnableVertexAttribArray(uint32(context.attribLocationPosition)) + gl.EnableVertexAttribArray(uint32(context.attribLocationUV)) + gl.EnableVertexAttribArray(uint32(context.attribLocationColor)) + vertexSize, vertexOffsetPos, vertexOffsetUv, vertexOffsetCol := imgui.VertexBufferLayout() + gl.VertexAttribOffset(uint32(context.attribLocationPosition), 2, opengl.FLOAT, false, int32(vertexSize), vertexOffsetPos) + gl.VertexAttribOffset(uint32(context.attribLocationUV), 2, opengl.FLOAT, false, int32(vertexSize), vertexOffsetUv) + gl.VertexAttribOffset(uint32(context.attribLocationColor), 4, opengl.UNSIGNED_BYTE, true, int32(vertexSize), vertexOffsetCol) + indexSize := imgui.IndexBufferLayout() + drawType := opengl.UNSIGNED_SHORT + if indexSize == 4 { + drawType = opengl.UNSIGNED_INT + } + + // Draw + for _, list := range drawData.CommandLists() { + var indexBufferOffset uintptr + + vertexBuffer, vertexBufferSize := list.VertexBuffer() + gl.BindBuffer(opengl.ARRAY_BUFFER, context.vboHandle) + gl.BufferData(opengl.ARRAY_BUFFER, vertexBufferSize, vertexBuffer, opengl.STREAM_DRAW) + + indexBuffer, indexBufferSize := list.IndexBuffer() + gl.BindBuffer(opengl.ELEMENT_ARRAY_BUFFER, context.elementsHandle) + gl.BufferData(opengl.ELEMENT_ARRAY_BUFFER, indexBufferSize, indexBuffer, opengl.STREAM_DRAW) + + for _, cmd := range list.Commands() { + if cmd.HasUserCallback() { + cmd.CallUserCallback(list) + } else { + textureID := cmd.TextureID() + imageType := ImageTypeFromID(textureID) + gl.Uniform1i(context.attribLocationType, int32(imageType)) + switch imageType { + case ImageTypeSimpleTexture: + gl.ActiveTexture(opengl.TEXTURE0 + uint32(0)) + gl.BindTexture(opengl.TEXTURE_2D, uint32(textureID)) + case ImageTypeColorTexture: + gl.ActiveTexture(opengl.TEXTURE0 + uint32(0)) + gl.BindTexture(opengl.TEXTURE_2D, uint32(textureID)) + case ImageTypeBitmapTexture: + palette, bitmap := bitmapTextureQuery(textureID) + gl.ActiveTexture(opengl.TEXTURE0 + uint32(0)) + gl.BindTexture(opengl.TEXTURE_2D, bitmap) + gl.ActiveTexture(opengl.TEXTURE0 + uint32(1)) + gl.BindTexture(opengl.TEXTURE_2D, palette) + default: + gl.ActiveTexture(opengl.TEXTURE0 + uint32(0)) + gl.BindTexture(opengl.TEXTURE_2D, 0) + gl.ActiveTexture(opengl.TEXTURE0 + uint32(1)) + gl.BindTexture(opengl.TEXTURE_2D, 0) + } + clipRect := cmd.ClipRect() + gl.Scissor(int32(clipRect.X), int32(displayHeight)-int32(clipRect.W), int32(clipRect.Z-clipRect.X), int32(clipRect.W-clipRect.Y)) + gl.DrawElements(opengl.TRIANGLES, int32(cmd.ElementCount()), uint32(drawType), indexBufferOffset) + } + indexBufferOffset += uintptr(cmd.ElementCount() * indexSize) + } + } + gl.DeleteVertexArrays([]uint32{vaoHandle}) + + // Restore modified GL state + gl.UseProgram(uint32(lastProgram)) + gl.BindTexture(opengl.TEXTURE_2D, uint32(lastTexture)) + gl.BindSampler(0, uint32(lastSampler)) + gl.ActiveTexture(uint32(lastActiveTexture)) + gl.BindVertexArray(uint32(lastVertexArray)) + gl.BindBuffer(opengl.ARRAY_BUFFER, uint32(lastArrayBuffer)) + gl.BindBuffer(opengl.ELEMENT_ARRAY_BUFFER, uint32(lastElementArrayBuffer)) + gl.BlendEquationSeparate(uint32(lastBlendEquationRgb), uint32(lastBlendEquationAlpha)) + gl.BlendFuncSeparate(uint32(lastBlendSrcRgb), uint32(lastBlendDstRgb), uint32(lastBlendSrcAlpha), uint32(lastBlendDstAlpha)) + if lastEnableBlend { + gl.Enable(opengl.BLEND) + } else { + gl.Disable(opengl.BLEND) + } + if lastEnableCullFace { + gl.Enable(opengl.CULL_FACE) + } else { + gl.Disable(opengl.CULL_FACE) + } + if lastEnableDepthTest { + gl.Enable(opengl.DEPTH_TEST) + } else { + gl.Disable(opengl.DEPTH_TEST) + } + if lastEnableScissorTest { + gl.Enable(opengl.SCISSOR_TEST) + } else { + gl.Disable(opengl.SCISSOR_TEST) + } + gl.PolygonMode(opengl.FRONT_AND_BACK, uint32(lastPolygonMode[0])) + gl.Viewport(lastViewport[0], lastViewport[1], lastViewport[2], lastViewport[3]) + gl.Scissor(lastScissorBox[0], lastScissorBox[1], lastScissorBox[2], lastScissorBox[3]) +} + +// SetMousePosition must be called to report the current mouse position. +func (context *Context) SetMousePosition(x, y float32) { + imgui.CurrentIO().SetMousePosition(imgui.Vec2{X: x, Y: y}) +} + +// MouseScroll must be called to report the current scroll delta. +func (context *Context) MouseScroll(dx, dy float32) { + imgui.CurrentIO().AddMouseWheelDelta(dx, dy) +} diff --git a/modding/hacked/ui/gui/ImageKey.go b/modding/hacked/ui/gui/ImageKey.go new file mode 100644 index 0000000..7966123 --- /dev/null +++ b/modding/hacked/ui/gui/ImageKey.go @@ -0,0 +1,30 @@ +package gui + +import "github.com/inkyblackness/imgui-go/v3" + +// ImageType is an identifier how an image is to be rendered. +type ImageType byte + +const ( + // ImageTypeSimpleTexture identifies textures that use the image key as OpenGL texture handle. + ImageTypeSimpleTexture ImageType = 0 + // ImageTypeBitmapTexture identifies bitmap textures. + ImageTypeBitmapTexture ImageType = 1 + // ImageTypeColorTexture identifies textures with own color information. + ImageTypeColorTexture ImageType = 2 +) + +// TextureIDForSimpleTexture returns a TextureID with ImageTypeSimpleTexture. +func TextureIDForSimpleTexture(handle uint32) imgui.TextureID { + return imgui.TextureID(ImageTypeSimpleTexture)<<56 | imgui.TextureID(handle) +} + +// TextureIDForColorTexture returns a TextureID with ImageTypeSimpleTexture. +func TextureIDForColorTexture(handle uint32) imgui.TextureID { + return imgui.TextureID(ImageTypeColorTexture)<<56 | imgui.TextureID(handle) +} + +// ImageTypeFromID returns the image type the given texture ID specifies. +func ImageTypeFromID(id imgui.TextureID) ImageType { + return ImageType(id >> 56) +} diff --git a/modding/hacked/ui/gui/ModalState.go b/modding/hacked/ui/gui/ModalState.go new file mode 100644 index 0000000..1824acb --- /dev/null +++ b/modding/hacked/ui/gui/ModalState.go @@ -0,0 +1,13 @@ +package gui + +// ModalState represents a modal dialog. +type ModalState interface { + // Render renders the dialog. + Render() +} + +// FileHandler is accepting filenames. +type FileHandler interface { + // HandleFiles is called for any dropped files. + HandleFiles(names []string) +} diff --git a/modding/hacked/ui/gui/ModalStateMachine.go b/modding/hacked/ui/gui/ModalStateMachine.go new file mode 100644 index 0000000..b6222c0 --- /dev/null +++ b/modding/hacked/ui/gui/ModalStateMachine.go @@ -0,0 +1,6 @@ +package gui + +// ModalStateMachine allows to set a new state. +type ModalStateMachine interface { + SetState(state ModalState) +} diff --git a/modding/hacked/ui/gui/ModalStateWrapper.go b/modding/hacked/ui/gui/ModalStateWrapper.go new file mode 100644 index 0000000..1e96e63 --- /dev/null +++ b/modding/hacked/ui/gui/ModalStateWrapper.go @@ -0,0 +1,28 @@ +package gui + +// ModalStateWrapper is a wrapper over a series of modal popup states. +// It implements both ModalState and ModalStateMachine to allow nested states, if necessary. +type ModalStateWrapper struct { + State ModalState +} + +// SetState sets the new state. +func (machine *ModalStateWrapper) SetState(state ModalState) { + machine.State = state +} + +// Render renders the current state. +func (machine *ModalStateWrapper) Render() { + if machine.State != nil { + machine.State.Render() + } +} + +// HandleFiles forwards the given filenames to the current state. +func (machine *ModalStateWrapper) HandleFiles(filenames []string) { + handler, isHandler := machine.State.(FileHandler) + if !isHandler { + return + } + handler.HandleFiles(filenames) +} diff --git a/modding/hacked/ui/gui/StepSliderInt.go b/modding/hacked/ui/gui/StepSliderInt.go new file mode 100644 index 0000000..ad120b2 --- /dev/null +++ b/modding/hacked/ui/gui/StepSliderInt.go @@ -0,0 +1,42 @@ +package gui + +import ( + "github.com/inkyblackness/imgui-go/v3" +) + +// StepSliderIntV creates a SliderInt with additional buttons to make single digit steps. +// min and max parameters are inclusive. Returns true on change. +func StepSliderIntV(label string, value *int, min, max int, format string) bool { + imgui.PushID(label) + changed := false + if imgui.Button("-") && (*value > min) { + *value-- + changed = true + } + innerSpacing := imgui.CurrentStyle().ItemInnerSpacing() + imgui.SameLineV(0, innerSpacing.X) + if imgui.Button("+") && (*value < max) { + *value++ + changed = true + } + imgui.SameLineV(0, innerSpacing.X) + value32 := int32(*value) + if imgui.SliderIntV(label, &value32, int32(min), int32(max), format, imgui.SlidersFlagsNone) { + switch { + case int(value32) < min: + *value = min + case int(value32) > max: + *value = max + default: + *value = int(value32) + } + changed = true + } + imgui.PopID() + return changed +} + +// StepSliderInt calls StepSliderIntV(label, value, min, max, "%d"). +func StepSliderInt(label string, value *int, min, max int) bool { + return StepSliderIntV(label, value, min, max, "%d") +} diff --git a/modding/hacked/ui/input/Key.go b/modding/hacked/ui/input/Key.go new file mode 100644 index 0000000..85291bf --- /dev/null +++ b/modding/hacked/ui/input/Key.go @@ -0,0 +1,95 @@ +package input + +// Key describes a named key on the keyboard. These are keys which are +// unspecific to layout or language, or are universal. +// Or, described in another way: keys that don't end up as printable characters. +type Key int + +// Constants for commonly known named keys. +const ( + KeyEnter = Key(300) + KeyEscape = Key(301) + KeyBackspace = Key(302) + KeyTab = Key(303) + + KeyDown = Key(310) + KeyLeft = Key(311) + KeyRight = Key(312) + KeyUp = Key(313) + + KeyDelete = Key(320) + KeyEnd = Key(321) + KeyHome = Key(322) + KeyInsert = Key(323) + KeyPageDown = Key(324) + KeyPageUp = Key(325) + + KeyAlt = Key(330) + KeyControl = Key(331) + KeyShift = Key(332) + KeySuper = Key(333) + + KeyPause = Key(340) + KeyPrintScreen = Key(341) + KeyCapsLock = Key(342) + KeyScrollLock = Key(343) + + KeyF1 = Key(351) + KeyF10 = Key(360) + KeyF11 = Key(361) + KeyF12 = Key(362) + KeyF13 = Key(363) + KeyF14 = Key(364) + KeyF15 = Key(365) + KeyF16 = Key(366) + KeyF17 = Key(367) + KeyF18 = Key(368) + KeyF19 = Key(369) + KeyF2 = Key(352) + KeyF20 = Key(370) + KeyF21 = Key(371) + KeyF22 = Key(372) + KeyF23 = Key(373) + KeyF24 = Key(374) + KeyF25 = Key(375) + KeyF3 = Key(353) + KeyF4 = Key(354) + KeyF5 = Key(355) + KeyF6 = Key(356) + KeyF7 = Key(357) + KeyF8 = Key(358) + KeyF9 = Key(359) + + KeyCopy = Key(380) + KeyCut = Key(381) + KeyPaste = Key(382) + + KeyUndo = Key(390) + KeyRedo = Key(391) + + KeySave = Key(400) + KeyNew = Key(401) + + KeyA = Key(410) + KeyC = Key(411) + KeyV = Key(412) + KeyX = Key(413) +) + +var keyToModifier = map[Key]Modifier{ + KeyShift: ModShift, + KeyControl: ModControl, + KeyAlt: ModAlt, + KeySuper: ModSuper, +} + +// AsModifier returns the modifier equivalent for the key - if applicable. +func (key Key) AsModifier() Modifier { + mod, isModifier := keyToModifier[key] + + if !isModifier { + mod = ModNone + } + + return mod +} diff --git a/modding/hacked/ui/input/Modifier.go b/modding/hacked/ui/input/Modifier.go new file mode 100644 index 0000000..2269651 --- /dev/null +++ b/modding/hacked/ui/input/Modifier.go @@ -0,0 +1,34 @@ +package input + +// Modifier is a collection of currently pressed modifier keys. +type Modifier uint32 + +// Constants for common modifier. +const ( + ModNone = Modifier(0x00000000) + ModShift = Modifier(0x00000001) + ModControl = Modifier(0x00000002) + ModAlt = Modifier(0x00000004) + ModSuper = Modifier(0x00000008) +) + +// IsClear returns true if nothing is set. +func (mod Modifier) IsClear() bool { + return mod == ModNone +} + +// With returns a combination of this modifier and the given one. +func (mod Modifier) With(other Modifier) Modifier { + return Modifier(uint32(mod) | uint32(other)) +} + +// Without returns the result of this modifier without the given one. +func (mod Modifier) Without(other Modifier) Modifier { + return Modifier(uint32(mod) & ^uint32(other)) +} + +// Has returns true if the given modifier is included in this set. +// This modifier can have more keys set than the requested and still return true. +func (mod Modifier) Has(other Modifier) bool { + return (uint32(mod) | uint32(other)) == uint32(mod) +} diff --git a/modding/hacked/ui/input/Modifier_test.go b/modding/hacked/ui/input/Modifier_test.go new file mode 100644 index 0000000..bceec20 --- /dev/null +++ b/modding/hacked/ui/input/Modifier_test.go @@ -0,0 +1,41 @@ +package input_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ui/input" + + "github.com/stretchr/testify/assert" +) + +func TestHasReturnsTrueForThemselves(t *testing.T) { + mods := []input.Modifier{input.ModShift, input.ModControl, input.ModAlt, input.ModSuper} + + for _, mod := range mods { + assert.True(t, mod.Has(mod)) + } +} + +func TestHasReturnsFalseForDifferentModifiers(t *testing.T) { + mods := []input.Modifier{input.ModShift, input.ModControl, input.ModAlt, input.ModSuper} + + for index, mod := range mods { + nextMod := mods[(index+1)%len(mods)] + assert.False(t, mod.Has(nextMod)) + } +} + +func TestHasReturnsFalseForSetsIncludingOthers(t *testing.T) { + mods := []input.Modifier{input.ModShift, input.ModControl, input.ModAlt, input.ModSuper} + + for index, mod := range mods { + nextMod := mods[(index+1)%len(mods)] + assert.False(t, mod.Has(mod.With(nextMod))) + } +} + +func TestWithoutReturnsReduction(t *testing.T) { + mod := input.ModShift.With(input.ModAlt).Without(input.ModShift) + + assert.Equal(t, input.ModAlt, mod) +} diff --git a/modding/hacked/ui/input/Shortcut.go b/modding/hacked/ui/input/Shortcut.go new file mode 100644 index 0000000..c9e3ba0 --- /dev/null +++ b/modding/hacked/ui/input/Shortcut.go @@ -0,0 +1,34 @@ +package input + +import "strings" + +type shortcut struct { + keyName string + modifier Modifier + key Key +} + +var shortcuts = []shortcut{ + {keyName: "c", modifier: ModControl, key: KeyCopy}, + {keyName: "x", modifier: ModControl, key: KeyCut}, + {keyName: "v", modifier: ModControl, key: KeyPaste}, + {keyName: "z", modifier: ModControl, key: KeyUndo}, + {keyName: "z", modifier: ModControl.With(ModShift), key: KeyRedo}, + {keyName: "y", modifier: ModControl, key: KeyRedo}, + {keyName: "s", modifier: ModControl, key: KeySave}, + {keyName: "n", modifier: ModControl, key: KeyNew}, +} + +// ResolveShortcut tries to map the given name and modifier combination to a +// known (common) shortcut key. For instance, Ctrl+C is KeyCopy. +func ResolveShortcut(keyName string, modifier Modifier) (key Key, knownKey bool) { + lowercaseName := strings.ToLower(keyName) + for _, entry := range shortcuts { + if (entry.keyName == lowercaseName) && (entry.modifier == modifier) { + knownKey = true + key = entry.key + } + } + + return +} diff --git a/modding/hacked/ui/input/Shortcut_test.go b/modding/hacked/ui/input/Shortcut_test.go new file mode 100644 index 0000000..9cad16e --- /dev/null +++ b/modding/hacked/ui/input/Shortcut_test.go @@ -0,0 +1,22 @@ +package input_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ui/input" + + "github.com/stretchr/testify/assert" +) + +func TestResolveShortcutReturnsValuesOfKnownCombo(t *testing.T) { + key, knownKey := input.ResolveShortcut("c", input.ModControl) + + assert.True(t, knownKey) + assert.Equal(t, input.KeyCopy, key) +} + +func TestResolveShortcutReturnsFalseForUnknownCombo(t *testing.T) { + _, knownKey := input.ResolveShortcut("l", input.ModControl.With(input.ModShift)) + + assert.False(t, knownKey) +} diff --git a/modding/hacked/ui/input/StickyKeyBuffer.go b/modding/hacked/ui/input/StickyKeyBuffer.go new file mode 100644 index 0000000..9c542ac --- /dev/null +++ b/modding/hacked/ui/input/StickyKeyBuffer.go @@ -0,0 +1,101 @@ +package input + +// StickyKeyListener is the listener interface for receiving key events. +type StickyKeyListener interface { + // KeyPress is called for a pressed key. + KeyPress(key Key, modifier Modifier) + // KeyRelease is called for a released key. + KeyRelease(key Key, modifier Modifier) + // Modifier is called when the currently active modifier changed. + Modifier(modifier Modifier) +} + +// StickyKeyBuffer is a buffer to keep track of several identically named keys. +// Keys can be reported being pressed or released. Their state will be forwarded +// to a StickyKeyListener instance. If a specific key is reported to be pressed +// more than once, the listener will have received the down state only once. +type StickyKeyBuffer struct { + pressedKeys map[Key]int + pressedModifier map[Modifier]int + activeModifier Modifier + listener StickyKeyListener +} + +// NewStickyKeyBuffer returns a new instance of a sticky key buffer. +func NewStickyKeyBuffer(listener StickyKeyListener) *StickyKeyBuffer { + buffer := &StickyKeyBuffer{ + pressedKeys: make(map[Key]int), + pressedModifier: make(map[Modifier]int), + activeModifier: ModNone, + listener: listener, + } + + return buffer +} + +// ActiveModifier returns the currently pressed modifier set. +func (buffer *StickyKeyBuffer) ActiveModifier() Modifier { + return buffer.activeModifier +} + +// KeyDown registers a pressed key state. Multiple down states can be +// registered for the same key and result in only one key event. +func (buffer *StickyKeyBuffer) KeyDown(key Key, modifier Modifier) { + keyAsModifier := key.AsModifier() + updatedModifier := modifier.With(keyAsModifier) + + oldCount := buffer.pressedKeys[key] + buffer.pressedKeys[key] = oldCount + 1 + if oldCount == 0 { + buffer.listener.KeyPress(key, updatedModifier) + } + + buffer.setActiveModifier(updatedModifier) + if keyAsModifier != ModNone { + buffer.pressedModifier[keyAsModifier]++ + } +} + +// KeyUp registers a released key state. Multiple up states can be registered +// for the same key, as long as enough down states were reported. +func (buffer *StickyKeyBuffer) KeyUp(key Key, modifier Modifier) { + updatedModifier := modifier + keyAsModifier := key.AsModifier() + if keyAsModifier != ModNone { + oldCount := buffer.pressedModifier[keyAsModifier] + if oldCount > 0 { + buffer.pressedModifier[keyAsModifier] = oldCount - 1 + if oldCount == 1 { + updatedModifier = updatedModifier.Without(keyAsModifier) + } + } + } + + oldCount := buffer.pressedKeys[key] + if oldCount > 0 { + buffer.pressedKeys[key] = oldCount - 1 + if oldCount == 1 { + buffer.listener.KeyRelease(key, updatedModifier) + } + } + buffer.setActiveModifier(updatedModifier) +} + +// ReleaseAll notifies the listener of the reset of all modifiers. +// Key states are reset to accept new down states. +func (buffer *StickyKeyBuffer) ReleaseAll() { + buffer.pressedKeys = make(map[Key]int) + buffer.setActiveModifier(ModNone) +} + +func (buffer *StickyKeyBuffer) setActiveModifier(modifier Modifier) { + if buffer.activeModifier != modifier { + for mod := range buffer.pressedModifier { + if !modifier.Has(mod) { + buffer.pressedModifier[mod] = 0 + } + } + buffer.activeModifier = modifier + buffer.listener.Modifier(buffer.activeModifier) + } +} diff --git a/modding/hacked/ui/input/StickyKeyBuffer_test.go b/modding/hacked/ui/input/StickyKeyBuffer_test.go new file mode 100644 index 0000000..5be51c5 --- /dev/null +++ b/modding/hacked/ui/input/StickyKeyBuffer_test.go @@ -0,0 +1,261 @@ +package input_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ui/input" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type keyEvent struct { + event string + key input.Key + mod input.Modifier +} + +type testingStickyKeysListener struct { + eventMap map[input.Key][]keyEvent + events []keyEvent +} + +func (listener *testingStickyKeysListener) KeyPress(key input.Key, modifier input.Modifier) { + listener.addEvent(keyEvent{event: "P", key: key, mod: modifier}) +} + +func (listener *testingStickyKeysListener) KeyRelease(key input.Key, modifier input.Modifier) { + listener.addEvent(keyEvent{event: "R", key: key, mod: modifier}) +} + +func (listener *testingStickyKeysListener) Modifier(modifier input.Modifier) { + listener.addEvent(keyEvent{event: "M", key: 0, mod: modifier}) +} + +func (listener *testingStickyKeysListener) addEvent(event keyEvent) { + listener.eventMap[event.key] = append(listener.eventMap[event.key], event) + listener.events = append(listener.events, event) +} + +type StickyKeyBufferSuite struct { + suite.Suite + buffer *input.StickyKeyBuffer + listener *testingStickyKeysListener +} + +func TestProviderBackedStoreSuite(t *testing.T) { + suite.Run(t, new(StickyKeyBufferSuite)) +} + +func (suite *StickyKeyBufferSuite) SetupTest() { + suite.listener = &testingStickyKeysListener{ + eventMap: make(map[input.Key][]keyEvent)} + suite.buffer = input.NewStickyKeyBuffer(suite.listener) +} + +func (suite *StickyKeyBufferSuite) TestRegularEventsAreForwarded_A() { + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyDown(input.KeyF2, input.ModNone) + suite.buffer.KeyUp(input.KeyF2, input.ModNone) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF1]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF2, mod: input.ModNone}, + {event: "R", key: input.KeyF2, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF2]) +} + +func (suite *StickyKeyBufferSuite) TestRegularEventsAreForwarded_B() { + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyDown(input.KeyF2, input.ModNone) + suite.buffer.KeyUp(input.KeyF2, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF1]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF2, mod: input.ModNone}, + {event: "R", key: input.KeyF2, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF2]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "P", key: input.KeyF2, mod: input.ModNone}, + {event: "R", key: input.KeyF2, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestIdenticalKeysAreReportedOnlyOnce() { + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF1]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestSuperfluousReleasesAreIgnored() { + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyDown(input.KeyF2, input.ModNone) + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF1]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + {event: "P", key: input.KeyF2, mod: input.ModNone}, + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestReleaseAllResetsRegularKeys() { + suite.buffer.KeyDown(input.KeyEnter, input.ModNone) + suite.buffer.KeyDown(input.KeyEnter, input.ModNone) + suite.buffer.KeyDown(input.KeyTab, input.ModNone) + suite.buffer.ReleaseAll() + suite.buffer.KeyDown(input.KeyTab, input.ModNone) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyEnter, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyEnter]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyTab, mod: input.ModNone}, + {event: "P", key: input.KeyTab, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyTab]) +} + +func (suite *StickyKeyBufferSuite) TestModifierEventsAreConvertedToModifierStates_DownAugmented() { + suite.buffer.KeyDown(input.KeyShift, input.ModShift) + suite.buffer.KeyDown(input.KeyAlt, input.ModShift.With(input.ModAlt)) + suite.buffer.KeyUp(input.KeyAlt, input.ModShift) + suite.buffer.KeyUp(input.KeyShift, input.ModNone) + + assert.Equal(suite.T(), 2, len(suite.listener.eventMap[input.KeyShift])) + assert.Equal(suite.T(), 2, len(suite.listener.eventMap[input.KeyAlt])) + assert.Equal(suite.T(), + []keyEvent{ + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "P", key: input.KeyAlt, mod: input.ModShift.With(input.ModAlt)}, + {event: "M", key: 0, mod: input.ModShift.With(input.ModAlt)}, + {event: "R", key: input.KeyAlt, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "R", key: input.KeyShift, mod: input.ModNone}, + {event: "M", key: 0, mod: input.ModNone}, + }, + suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestModifierEventsAreConvertedToModifierStates_UpAugmented() { + suite.buffer.KeyDown(input.KeyShift, input.ModNone) + suite.buffer.KeyDown(input.KeyAlt, input.ModShift) + suite.buffer.KeyUp(input.KeyAlt, input.ModShift.With(input.ModAlt)) + suite.buffer.KeyUp(input.KeyShift, input.ModShift) + + assert.Equal(suite.T(), 2, len(suite.listener.eventMap[input.KeyShift])) + assert.Equal(suite.T(), 2, len(suite.listener.eventMap[input.KeyAlt])) + assert.Equal(suite.T(), + []keyEvent{ + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "P", key: input.KeyAlt, mod: input.ModShift.With(input.ModAlt)}, + {event: "M", key: 0, mod: input.ModShift.With(input.ModAlt)}, + {event: "R", key: input.KeyAlt, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "R", key: input.KeyShift, mod: input.ModNone}, + {event: "M", key: 0, mod: input.ModNone}, + }, + suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestReleaseAllReleasesModifiers() { + suite.buffer.KeyDown(input.KeyShift, input.ModNone) + suite.buffer.KeyDown(input.KeyShift, input.ModNone) + suite.buffer.KeyDown(input.KeyAlt, input.ModShift) + suite.buffer.ReleaseAll() + + assert.Equal(suite.T(), + []keyEvent{ + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "P", key: input.KeyAlt, mod: input.ModShift.With(input.ModAlt)}, + {event: "M", key: 0, mod: input.ModShift.With(input.ModAlt)}, + {event: "M", key: 0, mod: input.ModNone}, + }, + suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestActiveModifierReturnsCurrentModifier() { + suite.buffer.KeyDown(input.KeyShift, input.ModNone) + suite.buffer.KeyDown(input.KeyControl, input.ModShift) + suite.buffer.KeyDown(input.KeyAlt, input.ModShift.With(input.ModControl)) + suite.buffer.KeyUp(input.KeyControl, input.ModShift.With(input.ModControl).With(input.ModAlt)) + + assert.Equal(suite.T(), input.ModShift.With(input.ModAlt), suite.buffer.ActiveModifier()) +} + +func (suite *StickyKeyBufferSuite) TestModifierAreUpdatedOnOtherKeys_A() { + suite.buffer.KeyDown(input.KeyShift, input.ModShift) + suite.buffer.KeyDown(input.KeyTab, input.ModNone) + + assert.Equal(suite.T(), + []keyEvent{ + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "P", key: input.KeyTab, mod: input.ModNone}, + {event: "M", key: 0, mod: input.ModNone}, + }, + suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestModifierAreUpdatedOnOtherKeys_B() { + suite.buffer.KeyDown(input.KeyTab, input.ModNone) + suite.buffer.KeyDown(input.KeyShift, input.ModShift) + suite.buffer.KeyUp(input.KeyTab, input.ModNone) + + assert.Equal(suite.T(), + []keyEvent{ + {event: "P", key: input.KeyTab, mod: input.ModNone}, + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "R", key: input.KeyTab, mod: input.ModNone}, + {event: "M", key: 0, mod: input.ModNone}, + }, + suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestModifierAreUpdatedOnOtherKeysWithoutThemselves() { + suite.buffer.KeyDown(input.KeyShift, input.ModShift) + suite.buffer.KeyDown(input.KeyShift, input.ModShift) + suite.buffer.KeyUp(input.KeyShift, input.ModShift) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + }, suite.listener.events) +} diff --git a/modding/hacked/ui/input/constants.go b/modding/hacked/ui/input/constants.go new file mode 100644 index 0000000..c3492a6 --- /dev/null +++ b/modding/hacked/ui/input/constants.go @@ -0,0 +1,8 @@ +package input + +const ( + // MousePrimary specifies the primary mouse button (by default, the left one). + MousePrimary = uint32(1) + // MouseSecondary specifies the secondary mouse button (by default, the right one). + MouseSecondary = uint32(2) +) diff --git a/modding/hacked/ui/native/NativeKeys.go b/modding/hacked/ui/native/NativeKeys.go new file mode 100644 index 0000000..700f79c --- /dev/null +++ b/modding/hacked/ui/native/NativeKeys.go @@ -0,0 +1,73 @@ +package native + +import ( + "github.com/go-gl/glfw/v3.2/glfw" + + "github.com/inkyblackness/hacked/ui/input" +) + +var keyMap = map[glfw.Key]input.Key{ + glfw.KeyEnter: input.KeyEnter, + glfw.KeyKPEnter: input.KeyEnter, + glfw.KeyEscape: input.KeyEscape, + glfw.KeyBackspace: input.KeyBackspace, + glfw.KeyTab: input.KeyTab, + + glfw.KeyDown: input.KeyDown, + glfw.KeyLeft: input.KeyLeft, + glfw.KeyRight: input.KeyRight, + glfw.KeyUp: input.KeyUp, + + glfw.KeyDelete: input.KeyDelete, + glfw.KeyEnd: input.KeyEnd, + glfw.KeyHome: input.KeyHome, + glfw.KeyInsert: input.KeyInsert, + glfw.KeyPageDown: input.KeyPageDown, + glfw.KeyPageUp: input.KeyPageUp, + + glfw.KeyLeftAlt: input.KeyAlt, + glfw.KeyLeftControl: input.KeyControl, + glfw.KeyLeftShift: input.KeyShift, + glfw.KeyLeftSuper: input.KeySuper, + glfw.KeyRightAlt: input.KeyAlt, + glfw.KeyRightControl: input.KeyControl, + glfw.KeyRightShift: input.KeyShift, + glfw.KeyRightSuper: input.KeySuper, + + glfw.KeyPause: input.KeyPause, + glfw.KeyPrintScreen: input.KeyPrintScreen, + + glfw.KeyCapsLock: input.KeyCapsLock, + glfw.KeyScrollLock: input.KeyScrollLock, + + glfw.KeyF1: input.KeyF1, + glfw.KeyF10: input.KeyF10, + glfw.KeyF11: input.KeyF11, + glfw.KeyF12: input.KeyF12, + glfw.KeyF13: input.KeyF13, + glfw.KeyF14: input.KeyF14, + glfw.KeyF15: input.KeyF15, + glfw.KeyF16: input.KeyF16, + glfw.KeyF17: input.KeyF17, + glfw.KeyF18: input.KeyF18, + glfw.KeyF19: input.KeyF19, + glfw.KeyF2: input.KeyF2, + glfw.KeyF20: input.KeyF20, + glfw.KeyF21: input.KeyF21, + glfw.KeyF22: input.KeyF22, + glfw.KeyF23: input.KeyF23, + glfw.KeyF24: input.KeyF24, + glfw.KeyF25: input.KeyF25, + glfw.KeyF3: input.KeyF3, + glfw.KeyF4: input.KeyF4, + glfw.KeyF5: input.KeyF5, + glfw.KeyF6: input.KeyF6, + glfw.KeyF7: input.KeyF7, + glfw.KeyF8: input.KeyF8, + glfw.KeyF9: input.KeyF9, + + glfw.KeyA: input.KeyA, + glfw.KeyC: input.KeyC, + glfw.KeyV: input.KeyV, + glfw.KeyX: input.KeyX, +} diff --git a/modding/hacked/ui/native/OpenGl.go b/modding/hacked/ui/native/OpenGl.go new file mode 100644 index 0000000..a1d0f44 --- /dev/null +++ b/modding/hacked/ui/native/OpenGl.go @@ -0,0 +1,378 @@ +package native + +import ( + "strings" + "unsafe" + + "github.com/go-gl/gl/v3.2-core/gl" +) + +// OpenGL wraps the native GL API into a common interface. +type OpenGL struct { +} + +// NewOpenGL initializes the Gl bindings and returns an OpenGL instance. +func NewOpenGL() *OpenGL { + opengl := &OpenGL{} + + if err := gl.Init(); err != nil { + panic(err) + } + + return opengl +} + +// ActiveTexture implements the opengl.OpenGL interface. +func (native *OpenGL) ActiveTexture(texture uint32) { + gl.ActiveTexture(texture) +} + +// AttachShader implements the opengl.OpenGL interface. +func (native *OpenGL) AttachShader(program uint32, shader uint32) { + gl.AttachShader(program, shader) +} + +// BindAttribLocation implements the opengl.OpenGL interface. +func (native *OpenGL) BindAttribLocation(program uint32, index uint32, name string) { + gl.BindAttribLocation(program, index, gl.Str(name+"\x00")) +} + +// BindBuffer implements the opengl.OpenGL interface. +func (native *OpenGL) BindBuffer(target uint32, buffer uint32) { + gl.BindBuffer(target, buffer) +} + +// BindFramebuffer implements the opengl.OpenGL interface. +func (native *OpenGL) BindFramebuffer(target uint32, buffer uint32) { + gl.BindFramebuffer(target, buffer) +} + +// BindRenderbuffer implements the opengl.OpenGL interface. +func (native *OpenGL) BindRenderbuffer(target uint32, buffer uint32) { + gl.BindRenderbuffer(target, buffer) +} + +// BindSampler implements the opengl.OpenGL interface. +func (native *OpenGL) BindSampler(unit uint32, sampler uint32) { + gl.BindSampler(unit, sampler) +} + +// BindTexture implements the opengl.OpenGL interface. +func (native *OpenGL) BindTexture(target uint32, texture uint32) { + gl.BindTexture(target, texture) +} + +// BindVertexArray implements the opengl.OpenGL interface. +func (native *OpenGL) BindVertexArray(array uint32) { + gl.BindVertexArray(array) +} + +// BlendEquation implements the opengl.OpenGL interface. +func (native *OpenGL) BlendEquation(mode uint32) { + gl.BlendEquation(mode) +} + +// BlendEquationSeparate implements the opengl.OpenGL interface. +func (native *OpenGL) BlendEquationSeparate(modeRGB uint32, modeAlpha uint32) { + gl.BlendEquationSeparate(modeRGB, modeAlpha) +} + +// BlendFunc implements the opengl.OpenGL interface. +func (native *OpenGL) BlendFunc(sfactor uint32, dfactor uint32) { + gl.BlendFunc(sfactor, dfactor) +} + +// BlendFuncSeparate implements the opengl.OpenGL interface. +func (native *OpenGL) BlendFuncSeparate(srcRGB uint32, dstRGB uint32, srcAlpha uint32, dstAlpha uint32) { + gl.BlendFuncSeparate(srcRGB, dstRGB, srcAlpha, dstAlpha) +} + +// BufferData implements the opengl.OpenGL interface. +func (native *OpenGL) BufferData(target uint32, size int, data interface{}, usage uint32) { + dataPtr, isPtr := data.(unsafe.Pointer) + if isPtr { + gl.BufferData(target, size, dataPtr, usage) + } else { + gl.BufferData(target, size, gl.Ptr(data), usage) + } +} + +// CheckFramebufferStatus implements the opengl.OpenGL interface. +func (native *OpenGL) CheckFramebufferStatus(target uint32) uint32 { + return gl.CheckFramebufferStatus(target) +} + +// Clear implements the opengl.OpenGL interface. +func (native *OpenGL) Clear(mask uint32) { + gl.Clear(mask) +} + +// ClearColor implements the opengl.OpenGL interface. +func (native *OpenGL) ClearColor(red float32, green float32, blue float32, alpha float32) { + gl.ClearColor(red, green, blue, alpha) +} + +// CompileShader implements the opengl.OpenGL interface. +func (native *OpenGL) CompileShader(shader uint32) { + gl.CompileShader(shader) +} + +// CreateProgram implements the opengl.OpenGL interface. +func (native *OpenGL) CreateProgram() uint32 { + return gl.CreateProgram() +} + +// CreateShader implements the opengl.OpenGL interface. +func (native *OpenGL) CreateShader(shaderType uint32) uint32 { + return gl.CreateShader(shaderType) +} + +// DeleteBuffers implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteBuffers(buffers []uint32) { + gl.DeleteBuffers(int32(len(buffers)), &buffers[0]) +} + +// DeleteFramebuffers implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteFramebuffers(buffers []uint32) { + gl.DeleteFramebuffers(int32(len(buffers)), &buffers[0]) +} + +// DeleteProgram implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteProgram(program uint32) { + gl.DeleteProgram(program) +} + +// DeleteShader implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteShader(shader uint32) { + gl.DeleteShader(shader) +} + +// DeleteTextures implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteTextures(textures []uint32) { + gl.DeleteTextures(int32(len(textures)), &textures[0]) +} + +// DeleteVertexArrays implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteVertexArrays(arrays []uint32) { + gl.DeleteVertexArrays(int32(len(arrays)), &arrays[0]) +} + +// Disable implements the opengl.OpenGL interface. +func (native *OpenGL) Disable(capability uint32) { + gl.Disable(capability) +} + +// DrawArrays implements the opengl.OpenGL interface. +func (native *OpenGL) DrawArrays(mode uint32, first int32, count int32) { + gl.DrawArrays(mode, first, count) +} + +// DrawBuffers implements the opengl.OpenGL interface. +func (native *OpenGL) DrawBuffers(buffers []uint32) { + gl.DrawBuffers(int32(len(buffers)), &buffers[0]) +} + +// DrawElements implements the opengl.OpenGL interface. +func (native *OpenGL) DrawElements(mode uint32, count int32, elementType uint32, indices uintptr) { + gl.DrawElements(mode, count, elementType, unsafe.Pointer(indices)) // nolint: govet,gas +} + +// Enable implements the opengl.OpenGL interface. +func (native *OpenGL) Enable(capability uint32) { + gl.Enable(capability) +} + +// EnableVertexAttribArray implements the opengl.OpenGL interface. +func (native *OpenGL) EnableVertexAttribArray(index uint32) { + gl.EnableVertexAttribArray(index) +} + +// FramebufferRenderbuffer implements the opengl.OpenGL interface. +func (native *OpenGL) FramebufferRenderbuffer(target uint32, attachment uint32, renderbuffertarget uint32, renderbuffer uint32) { + gl.FramebufferRenderbuffer(target, attachment, renderbuffertarget, renderbuffer) +} + +// FramebufferTexture implements the opengl.OpenGL interface. +func (native *OpenGL) FramebufferTexture(target uint32, attachment uint32, texture uint32, level int32) { + gl.FramebufferTexture(target, attachment, texture, level) +} + +// GenerateMipmap implements the opengl.OpenGL interface. +func (native *OpenGL) GenerateMipmap(target uint32) { + gl.GenerateMipmap(target) +} + +// GenBuffers implements the opengl.OpenGL interface. +func (native *OpenGL) GenBuffers(n int32) []uint32 { + buffers := make([]uint32, n) + gl.GenBuffers(n, &buffers[0]) + return buffers +} + +// GenFramebuffers implements the opengl.OpenGL interface. +func (native *OpenGL) GenFramebuffers(n int32) []uint32 { + buffers := make([]uint32, n) + gl.GenFramebuffers(n, &buffers[0]) + return buffers +} + +// GenRenderbuffers implements the opengl.OpenGL interface. +func (native *OpenGL) GenRenderbuffers(n int32) []uint32 { + buffers := make([]uint32, n) + gl.GenRenderbuffers(n, &buffers[0]) + return buffers +} + +// GenTextures implements the opengl.OpenGL interface. +func (native *OpenGL) GenTextures(n int32) []uint32 { + ids := make([]uint32, n) + gl.GenTextures(n, &ids[0]) + return ids +} + +// GenVertexArrays implements the opengl.OpenGL interface. +func (native *OpenGL) GenVertexArrays(n int32) []uint32 { + ids := make([]uint32, n) + gl.GenVertexArrays(n, &ids[0]) + return ids +} + +// GetAttribLocation implements the opengl.OpenGL interface. +func (native *OpenGL) GetAttribLocation(program uint32, name string) int32 { + return gl.GetAttribLocation(program, gl.Str(name+"\x00")) +} + +// GetError implements the opengl.OpenGL interface. +func (native *OpenGL) GetError() uint32 { + return gl.GetError() +} + +// GetIntegerv implements the opengl.OpenGL interface. +func (native *OpenGL) GetIntegerv(name uint32, data *int32) { + gl.GetIntegerv(name, data) +} + +// GetProgramInfoLog implements the opengl.OpenGL interface. +func (native *OpenGL) GetProgramInfoLog(program uint32) string { + logLength := native.GetProgramParameter(program, gl.INFO_LOG_LENGTH) + log := strings.Repeat("\x00", int(logLength+1)) + gl.GetProgramInfoLog(program, logLength, nil, gl.Str(log)) + return log +} + +// GetProgramParameter implements the opengl.OpenGL interface. +func (native *OpenGL) GetProgramParameter(program uint32, param uint32) int32 { + result := int32(0) + gl.GetProgramiv(program, param, &result) + return result +} + +// GetShaderInfoLog implements the opengl.OpenGL interface. +func (native *OpenGL) GetShaderInfoLog(shader uint32) string { + logLength := native.GetShaderParameter(shader, gl.INFO_LOG_LENGTH) + log := strings.Repeat("\x00", int(logLength+1)) + gl.GetShaderInfoLog(shader, logLength, nil, gl.Str(log)) + return log +} + +// GetShaderParameter implements the opengl.OpenGL interface. +func (native *OpenGL) GetShaderParameter(shader uint32, param uint32) int32 { + result := int32(0) + gl.GetShaderiv(shader, param, &result) + return result +} + +// GetUniformLocation implements the opengl.OpenGL interface. +func (native *OpenGL) GetUniformLocation(program uint32, name string) int32 { + return gl.GetUniformLocation(program, gl.Str(name+"\x00")) +} + +// IsEnabled implements the OpenGL interface. +func (native *OpenGL) IsEnabled(capability uint32) bool { + return gl.IsEnabled(capability) +} + +// LinkProgram implements the opengl.OpenGL interface. +func (native *OpenGL) LinkProgram(program uint32) { + gl.LinkProgram(program) +} + +// PixelStorei implements the OpenGL interface. +func (native *OpenGL) PixelStorei(name uint32, param int32) { + gl.PixelStorei(name, param) +} + +// PolygonMode implements the opengl.OpenGL interface. +func (native *OpenGL) PolygonMode(face uint32, mode uint32) { + gl.PolygonMode(face, mode) +} + +// ReadPixels implements the opengl.OpenGL interface. +func (native *OpenGL) ReadPixels(x int32, y int32, width int32, height int32, format uint32, pixelType uint32, pixels interface{}) { + gl.ReadPixels(x, y, width, height, format, pixelType, gl.Ptr(pixels)) +} + +// RenderbufferStorage implements the opengl.OpenGL interface. +func (native *OpenGL) RenderbufferStorage(target uint32, internalFormat uint32, width int32, height int32) { + gl.RenderbufferStorage(target, internalFormat, width, height) +} + +// Scissor implements the opengl.OpenGL interface. +func (native *OpenGL) Scissor(x, y int32, width, height int32) { + gl.Scissor(x, y, width, height) +} + +// ShaderSource implements the opengl.OpenGL interface. +func (native *OpenGL) ShaderSource(shader uint32, source string) { + csources, free := gl.Strs(source + "\x00") + defer free() + + gl.ShaderSource(shader, 1, csources, nil) +} + +// TexImage2D implements the opengl.OpenGL interface. +func (native *OpenGL) TexImage2D(target uint32, level int32, internalFormat uint32, width int32, height int32, + border int32, format uint32, xtype uint32, pixels interface{}) { + ptr, isPointer := pixels.(unsafe.Pointer) + if isPointer { + gl.TexImage2D(target, level, int32(internalFormat), width, height, border, format, xtype, ptr) + } else { + gl.TexImage2D(target, level, int32(internalFormat), width, height, border, format, xtype, gl.Ptr(pixels)) + } +} + +// TexParameteri implements the opengl.OpenGL interface. +func (native *OpenGL) TexParameteri(target uint32, pname uint32, param int32) { + gl.TexParameteri(target, pname, param) +} + +// Uniform1i implements the opengl.OpenGL interface. +func (native *OpenGL) Uniform1i(location int32, value int32) { + gl.Uniform1i(location, value) +} + +// Uniform4fv implements the opengl.OpenGL interface. +func (native *OpenGL) Uniform4fv(location int32, value *[4]float32) { + gl.Uniform4fv(location, 1, &value[0]) +} + +// UniformMatrix4fv implements the opengl.OpenGL interface. +func (native *OpenGL) UniformMatrix4fv(location int32, transpose bool, value *[16]float32) { + count := int32(1) + gl.UniformMatrix4fv(location, count, transpose, &value[0]) +} + +// UseProgram implements the opengl.OpenGL interface. +func (native *OpenGL) UseProgram(program uint32) { + gl.UseProgram(program) +} + +// VertexAttribOffset implements the opengl.OpenGL interface. +func (native *OpenGL) VertexAttribOffset(index uint32, size int32, attribType uint32, normalized bool, stride int32, offset int) { + gl.VertexAttribPointer(index, size, attribType, normalized, stride, gl.PtrOffset(offset)) +} + +// Viewport implements the opengl.OpenGL interface. +func (native *OpenGL) Viewport(x int32, y int32, width int32, height int32) { + gl.Viewport(x, y, width, height) +} diff --git a/modding/hacked/ui/native/OpenGlWindow.go b/modding/hacked/ui/native/OpenGlWindow.go new file mode 100644 index 0000000..d7e128e --- /dev/null +++ b/modding/hacked/ui/native/OpenGlWindow.go @@ -0,0 +1,288 @@ +package native + +import ( + "time" + + "github.com/go-gl/glfw/v3.2/glfw" + + "github.com/inkyblackness/hacked/ui/input" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var buttonsByIndex = map[glfw.MouseButton]uint32{ + glfw.MouseButton1: input.MousePrimary, + glfw.MouseButton2: input.MouseSecondary} + +// OpenGLWindow represents a native OpenGL surface. +type OpenGLWindow struct { + opengl.WindowEventDispatcher + + keyBuffer *input.StickyKeyBuffer + + glfwWindow *glfw.Window + glWrapper *OpenGL + + titleBase string + titleSuffix string + projectModified bool + + framesPerSecond float64 + frameTime time.Duration + nextRenderTick time.Time +} + +// NewOpenGLWindow tries to initialize the OpenGL environment and returns a +// new window instance. +func NewOpenGLWindow(title string, framesPerSecond float64) (*OpenGLWindow, error) { + err := glfw.Init() + if err != nil { + return nil, err + } + + glfw.WindowHint(glfw.Resizable, 1) + glfw.WindowHint(glfw.Decorated, 1) + glfw.WindowHint(glfw.ClientAPI, glfw.OpenGLAPI) + glfw.WindowHint(glfw.ContextVersionMajor, 3) + glfw.WindowHint(glfw.ContextVersionMinor, 2) + glfw.WindowHint(glfw.OpenGLProfile, glfw.OpenGLCoreProfile) + glfw.WindowHint(glfw.OpenGLForwardCompatible, glfw.True) + glfw.WindowHint(glfw.DoubleBuffer, glfw.True) + glfwWindow, err := glfw.CreateWindow(1280, 720, title, nil, nil) + if err != nil { + return nil, err + } + glfwWindow.MakeContextCurrent() + glfw.SwapInterval(0) + + window := &OpenGLWindow{ + WindowEventDispatcher: opengl.NullWindowEventDispatcher(), + glfwWindow: glfwWindow, + glWrapper: NewOpenGL(), + titleBase: title, + framesPerSecond: framesPerSecond, + frameTime: time.Duration(int64(float64(time.Second) / framesPerSecond)), + nextRenderTick: time.Now()} + + window.keyBuffer = input.NewStickyKeyBuffer(window.StickyKeyListener()) + + glfwWindow.SetCursorPosCallback(window.onCursorPos) + glfwWindow.SetMouseButtonCallback(window.onMouseButton) + glfwWindow.SetScrollCallback(window.onMouseScroll) + glfwWindow.SetFramebufferSizeCallback(window.onFramebufferResize) + glfwWindow.SetKeyCallback(window.onKey) + glfwWindow.SetCharCallback(window.onChar) + glfwWindow.SetDropCallback(window.onDrop) + glfwWindow.SetCloseCallback(window.onClosing) + + return window, nil +} + +// ShouldClose returns true if the user requested the window to close. +func (window *OpenGLWindow) ShouldClose() bool { + return window.glfwWindow.ShouldClose() +} + +// Close closes the window and releases its resources. +func (window *OpenGLWindow) Close() { + window.CallClosed() + window.glfwWindow.Destroy() + glfw.Terminate() +} + +// StateSnapshot returns the current state of the window. +func (window OpenGLWindow) StateSnapshot() opengl.WindowState { + var state opengl.WindowState + if window.glfwWindow.GetAttrib(glfw.Maximized) != 0 { + state.Maximized = true + // restore window to retrieve restored details, otherwise the maximized details will be read. + _ = window.glfwWindow.Restore() + } + state.Left, state.Top = window.glfwWindow.GetPos() + state.Width, state.Height = window.glfwWindow.GetSize() + if state.Maximized { + _ = window.glfwWindow.Maximize() + } + return state +} + +// RestoreState puts the window into the same state when StateSnapshot() was called. +func (window OpenGLWindow) RestoreState(state opengl.WindowState) { + _ = window.glfwWindow.Restore() + window.glfwWindow.SetPos(state.Left, state.Top) + window.glfwWindow.SetSize(state.Width, state.Height) + if state.Maximized { + _ = window.glfwWindow.Maximize() + } +} + +// ClipboardString returns the current value of the clipboard, if it is compatible with UTF-8. +func (window OpenGLWindow) ClipboardString() (string, error) { + return window.glfwWindow.GetClipboardString() +} + +// SetClipboardString sets the current value of the clipboard as UTF-8 string. +func (window OpenGLWindow) SetClipboardString(value string) { + window.glfwWindow.SetClipboardString(value) +} + +// Update must be called from within the main thread as often as possible. +func (window *OpenGLWindow) Update() { + glfw.PollEvents() + + now := time.Now() + delta := now.Sub(window.nextRenderTick) + if delta.Nanoseconds() < 0 { + // detected a change of wallclock time into the past; realign + delta = window.frameTime + window.nextRenderTick = now + } + + if delta.Nanoseconds() >= window.frameTime.Nanoseconds() { + window.glfwWindow.MakeContextCurrent() + window.CallRender() + window.glfwWindow.SwapBuffers() + framesCovered := delta.Nanoseconds() / window.frameTime.Nanoseconds() + window.nextRenderTick = window.nextRenderTick.Add(time.Duration(framesCovered * window.frameTime.Nanoseconds())) + } +} + +// OpenGL returns the OpenGL API. +func (window *OpenGLWindow) OpenGL() opengl.OpenGL { + return window.glWrapper +} + +// Size returns the dimension of the frame buffer of this window. +func (window *OpenGLWindow) Size() (width int, height int) { + return window.glfwWindow.GetFramebufferSize() +} + +// SetCursorVisible toggles the visibility of the cursor. +func (window *OpenGLWindow) SetCursorVisible(visible bool) { + if visible { + window.glfwWindow.SetInputMode(glfw.CursorMode, glfw.CursorNormal) + } else { + window.glfwWindow.SetInputMode(glfw.CursorMode, glfw.CursorHidden) + } +} + +// SetFullScreen toggles the windowed mode. +func (window *OpenGLWindow) SetFullScreen(on bool) { + if on { + monitor := glfw.GetPrimaryMonitor() + videoMode := monitor.GetVideoMode() + window.glfwWindow.SetMonitor(monitor, 0, 0, videoMode.Width, videoMode.Height, glfw.DontCare) + } else { + window.glfwWindow.SetMonitor(nil, 0, 0, 1280, 720, glfw.DontCare) + } +} + +// SetTitleSuffix appends a text to the current window title. +func (window *OpenGLWindow) SetTitleSuffix(value string) { + window.titleSuffix = value + window.updateTitle() +} + +// SetProjectModified sets an indicator in the window frame that the project has not been saved. +func (window *OpenGLWindow) SetProjectModified(modified bool) { + window.projectModified = modified + window.updateTitle() +} + +func (window *OpenGLWindow) updateTitle() { + title := window.titleBase + if len(window.titleSuffix) > 0 { + title += " - " + window.titleSuffix + } + if window.projectModified { + title += " (unsaved changes)" + } + window.glfwWindow.SetTitle(title) +} + +// SetCloseRequest sets the should-close property of the window. +func (window *OpenGLWindow) SetCloseRequest(shouldClose bool) { + window.glfwWindow.SetShouldClose(shouldClose) +} + +func (window *OpenGLWindow) onClosing(rawWindow *glfw.Window) { + window.CallClosing() +} + +func (window *OpenGLWindow) onFramebufferResize(rawWindow *glfw.Window, width int, height int) { + window.CallResize(width, height) +} + +func (window *OpenGLWindow) onCursorPos(rawWindow *glfw.Window, x float64, y float64) { + window.CallOnMouseMove(float32(x), float32(y)) +} + +func (window *OpenGLWindow) onMouseButton(rawWindow *glfw.Window, rawButton glfw.MouseButton, action glfw.Action, mods glfw.ModifierKey) { + button, knownButton := buttonsByIndex[rawButton] + + if knownButton { + modifier := window.mapModifier(mods) + + if action == glfw.Press { + window.CallOnMouseButtonDown(button, modifier) + } else if action == glfw.Release { + window.CallOnMouseButtonUp(button, modifier) + } + } +} + +func (window *OpenGLWindow) onMouseScroll(rawWindow *glfw.Window, dx float64, dy float64) { + window.CallOnMouseScroll(float32(dx), float32(dy)) +} + +func (window *OpenGLWindow) onKey(rawWindow *glfw.Window, glfwKey glfw.Key, scancode int, action glfw.Action, mods glfw.ModifierKey) { + modifier := window.mapModifier(mods) + key, knownKey := keyMap[glfwKey] + if knownKey { + window.reportKey(action, key, modifier) + } + + keyName := glfw.GetKeyName(glfwKey, scancode) + key, knownKey = input.ResolveShortcut(keyName, modifier) + if knownKey { + window.reportKey(action, key, modifier) + } +} + +func (window *OpenGLWindow) reportKey(action glfw.Action, key input.Key, modifier input.Modifier) { + switch action { + case glfw.Press: + window.keyBuffer.KeyDown(key, modifier) + case glfw.Repeat: + window.keyBuffer.KeyUp(key, modifier) + window.keyBuffer.KeyDown(key, modifier) + case glfw.Release: + window.keyBuffer.KeyUp(key, modifier) + } +} + +func (window *OpenGLWindow) onChar(rawWindow *glfw.Window, char rune) { + window.CallCharCallback(char) +} + +func (window *OpenGLWindow) mapModifier(mods glfw.ModifierKey) input.Modifier { + modifier := input.ModNone + + if (mods & glfw.ModControl) != 0 { + modifier = modifier.With(input.ModControl) + } + if (mods & glfw.ModShift) != 0 { + modifier = modifier.With(input.ModShift) + } + if (mods & glfw.ModAlt) != 0 { + modifier = modifier.With(input.ModAlt) + } + if (mods & glfw.ModSuper) != 0 { + modifier = modifier.With(input.ModSuper) + } + + return modifier +} + +func (window *OpenGLWindow) onDrop(rawWindow *glfw.Window, filePaths []string) { + window.CallFileDropCallback(filePaths) +} diff --git a/modding/hacked/ui/native/Run.go b/modding/hacked/ui/native/Run.go new file mode 100644 index 0000000..506c6a1 --- /dev/null +++ b/modding/hacked/ui/native/Run.go @@ -0,0 +1,34 @@ +package native + +import ( + "runtime" + "time" + + "github.com/inkyblackness/hacked/ui/opengl" +) + +// Run creates a native OpenGL window, initializes it with the given function and +// then runs the event loop until the window shall be closed. +func Run(initializer func(opengl.Window) error, title string, framesPerSecond float64) error { + runtime.LockOSThread() + + window, err := NewOpenGLWindow(title, framesPerSecond) + if err != nil { + return err + } + defer window.Close() + + err = initializer(window) + if err != nil { + return err + } + + ticker := time.NewTicker(time.Millisecond) + for !window.ShouldClose() { + <-ticker.C + window.Update() + } + ticker.Stop() + + return nil +} diff --git a/modding/hacked/ui/opengl/CompileNewShader.go b/modding/hacked/ui/opengl/CompileNewShader.go new file mode 100644 index 0000000..86196e3 --- /dev/null +++ b/modding/hacked/ui/opengl/CompileNewShader.go @@ -0,0 +1,18 @@ +package opengl + +// CompileNewShader creates a shader of given type and compiles the provided source. +func CompileNewShader(gl OpenGL, shaderType uint32, source string) (shader uint32, err error) { + shader = gl.CreateShader(shaderType) + + gl.ShaderSource(shader, source) + gl.CompileShader(shader) + + compileStatus := gl.GetShaderParameter(shader, COMPILE_STATUS) + if compileStatus == 0 { + err = ShaderError{Log: gl.GetShaderInfoLog(shader)} + gl.DeleteShader(shader) + shader = 0 + } + + return +} diff --git a/modding/hacked/ui/opengl/DebugBuilder.go b/modding/hacked/ui/opengl/DebugBuilder.go new file mode 100644 index 0000000..9614db6 --- /dev/null +++ b/modding/hacked/ui/opengl/DebugBuilder.go @@ -0,0 +1,63 @@ +package opengl + +// DebuggingEntryFunc is a function to be called on function entry. +type DebuggingEntryFunc func(name string, param ...interface{}) + +// DebuggingExitFunc is a function to be called after an OpenGL function has returned. +// result may be several parameters for functions with multiple return values. +type DebuggingExitFunc func(name string, result ...interface{}) + +// DebuggingErrorFunc is a function which is called if a previous OpenGL call caused an error state. +type DebuggingErrorFunc func(name string, errorCodes []uint32) + +// DebugBuilder is a builder for an OpenGL implementation for debugging. +type DebugBuilder struct { + wrapped OpenGL + + onEntry DebuggingEntryFunc + onExit DebuggingExitFunc + onError DebuggingErrorFunc +} + +// NewDebugBuilder wraps the provided OpenGL instance and returns a new builder +// instance. +func NewDebugBuilder(wrapped OpenGL) *DebugBuilder { + builder := &DebugBuilder{ + wrapped: wrapped, + onEntry: func(string, ...interface{}) {}, + onExit: func(string, ...interface{}) {}, + onError: func(string, []uint32) {}} + + return builder +} + +// Build creates a new instance of the debugging OpenGL implementation. +// The builder can be reused to create another instance with different parameters. +func (builder *DebugBuilder) Build() OpenGL { + opengl := &debuggingOpenGL{ + gl: builder.wrapped, + + onEntry: builder.onEntry, + onExit: builder.onExit, + onError: builder.onError} + + return opengl +} + +// OnEntry registers a callback function to be called before an OpenGL function is called. +func (builder *DebugBuilder) OnEntry(callback DebuggingEntryFunc) *DebugBuilder { + builder.onEntry = callback + return builder +} + +// OnExit registers a callback function to be called after an OpenGL function returned. +func (builder *DebugBuilder) OnExit(callback DebuggingExitFunc) *DebugBuilder { + builder.onExit = callback + return builder +} + +// OnError registers a callback function to be called when an OpenGL function set an error state. +func (builder *DebugBuilder) OnError(callback DebuggingErrorFunc) *DebugBuilder { + builder.onError = callback + return builder +} diff --git a/modding/hacked/ui/opengl/DebuggingOpenGl.go b/modding/hacked/ui/opengl/DebuggingOpenGl.go new file mode 100644 index 0000000..55d1554 --- /dev/null +++ b/modding/hacked/ui/opengl/DebuggingOpenGl.go @@ -0,0 +1,490 @@ +package opengl + +type debuggingOpenGL struct { + gl OpenGL + + onEntry DebuggingEntryFunc + onExit DebuggingExitFunc + onError DebuggingErrorFunc +} + +func (debugging *debuggingOpenGL) recordEntry(name string, param ...interface{}) { + debugging.onEntry(name, param...) +} + +func (debugging *debuggingOpenGL) recordExit(name string, result ...interface{}) { + var errorCodes []uint32 + + for errorCode := debugging.gl.GetError(); errorCode != NO_ERROR; errorCode = debugging.gl.GetError() { + errorCodes = append(errorCodes, errorCode) + } + debugging.onExit(name, result...) + if len(errorCodes) > 0 { + debugging.onError(name, errorCodes) + } +} + +// ActiveTexture implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) ActiveTexture(texture uint32) { + debugging.recordEntry("ActiveTexture", texture) + debugging.gl.ActiveTexture(texture) + debugging.recordExit("ActiveTexture") +} + +// AttachShader implements the OpenGL interface. +func (debugging *debuggingOpenGL) AttachShader(program uint32, shader uint32) { + debugging.recordEntry("AttachShader", program, shader) + debugging.gl.AttachShader(program, shader) + debugging.recordExit("AttachShader") +} + +// BindAttribLocation implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindAttribLocation(program uint32, index uint32, name string) { + debugging.recordEntry("BindAttribLocation", program, index, name) + debugging.gl.BindAttribLocation(program, index, name) + debugging.recordExit("BindAttribLocation") +} + +// BindBuffer implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindBuffer(target uint32, buffer uint32) { + debugging.recordEntry("BindBuffer", target, buffer) + debugging.gl.BindBuffer(target, buffer) + debugging.recordExit("BindBuffer") +} + +// BindFramebuffer implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindFramebuffer(target uint32, buffer uint32) { + debugging.recordEntry("BindFramebuffer", target, buffer) + debugging.gl.BindFramebuffer(target, buffer) + debugging.recordExit("BindFramebuffer") +} + +// BindRenderbuffer implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindRenderbuffer(target uint32, buffer uint32) { + debugging.recordEntry("BindRenderbuffer", target, buffer) + debugging.gl.BindRenderbuffer(target, buffer) + debugging.recordExit("BindRenderbuffer") +} + +// BindSampler implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindSampler(unit uint32, sampler uint32) { + debugging.recordEntry("BindSampler", unit, sampler) + debugging.gl.BindSampler(unit, sampler) + debugging.recordExit("BindSampler") +} + +// BindTexture implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindTexture(target uint32, texture uint32) { + debugging.recordEntry("BindTexture", target, texture) + debugging.gl.BindTexture(target, texture) + debugging.recordExit("BindTexture") +} + +// BindVertexArray implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindVertexArray(array uint32) { + debugging.recordEntry("BindVertexArray", array) + debugging.gl.BindVertexArray(array) + debugging.recordExit("BindVertexArray") +} + +// BlendEquation implements the OpenGL interface. +func (debugging *debuggingOpenGL) BlendEquation(mode uint32) { + debugging.recordEntry("BlendEquation", mode) + debugging.gl.BlendEquation(mode) + debugging.recordExit("BlendEquation") +} + +// BlendEquationSeparate implements the OpenGL interface. +func (debugging *debuggingOpenGL) BlendEquationSeparate(modeRGB uint32, modeAlpha uint32) { + debugging.recordEntry("BlendEquationSeparate", modeRGB, modeAlpha) + debugging.gl.BlendEquationSeparate(modeRGB, modeAlpha) + debugging.recordExit("BlendEquationSeparate") +} + +// BlendFunc implements the OpenGL interface. +func (debugging *debuggingOpenGL) BlendFunc(sfactor uint32, dfactor uint32) { + debugging.recordEntry("BlendFunc", sfactor, dfactor) + debugging.gl.BlendFunc(sfactor, dfactor) + debugging.recordExit("BlendFunc") +} + +// BlendFuncSeparate implements the OpenGL interface. +func (debugging *debuggingOpenGL) BlendFuncSeparate(srcRGB uint32, dstRGB uint32, srcAlpha uint32, dstAlpha uint32) { + debugging.recordEntry("BlendFuncSeparate", srcRGB, dstRGB, srcAlpha, dstAlpha) + debugging.gl.BlendFuncSeparate(srcRGB, dstRGB, srcAlpha, dstAlpha) + debugging.recordExit("BlendFuncSeparate") +} + +// BufferData implements the OpenGL interface. +func (debugging *debuggingOpenGL) BufferData(target uint32, size int, data interface{}, usage uint32) { + debugging.recordEntry("BufferData", target, size, data, usage) + debugging.gl.BufferData(target, size, data, usage) + debugging.recordExit("BufferData") +} + +// CheckFramebufferStatus implements the OpenGL interface. +func (debugging *debuggingOpenGL) CheckFramebufferStatus(target uint32) uint32 { + debugging.recordEntry("CheckFramebufferStatus", target) + result := debugging.gl.CheckFramebufferStatus(target) + debugging.recordExit("CheckFramebufferStatus") + return result +} + +// Clear implements the OpenGL interface. +func (debugging *debuggingOpenGL) Clear(mask uint32) { + debugging.recordEntry("Clear", mask) + debugging.gl.Clear(mask) + debugging.recordExit("Clear") +} + +// ClearColor implements the OpenGL interface. +func (debugging *debuggingOpenGL) ClearColor(red float32, green float32, blue float32, alpha float32) { + debugging.recordEntry("ClearColor", red, green, blue, alpha) + debugging.gl.ClearColor(red, green, blue, alpha) + debugging.recordExit("ClearColor") +} + +// CompileShader implements the OpenGL interface. +func (debugging *debuggingOpenGL) CompileShader(shader uint32) { + debugging.recordEntry("CompileShader", shader) + debugging.gl.CompileShader(shader) + debugging.recordExit("CompileShader") +} + +// CreateProgram implements the OpenGL interface. +func (debugging *debuggingOpenGL) CreateProgram() uint32 { + debugging.recordEntry("CreateProgram") + result := debugging.gl.CreateProgram() + debugging.recordExit("CreateProgram", result) + return result +} + +// CreateShader implements the OpenGL interface. +func (debugging *debuggingOpenGL) CreateShader(shaderType uint32) uint32 { + debugging.recordEntry("CreateShader", shaderType) + result := debugging.gl.CreateShader(shaderType) + debugging.recordExit("CreateShader", result) + return result +} + +// DeleteBuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteBuffers(buffers []uint32) { + debugging.recordEntry("DeleteBuffers", buffers) + debugging.gl.DeleteBuffers(buffers) + debugging.recordExit("DeleteBuffers") +} + +// DeleteFramebuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteFramebuffers(buffers []uint32) { + debugging.recordEntry("DeleteFramebuffers", buffers) + debugging.gl.DeleteFramebuffers(buffers) + debugging.recordExit("DeleteFramebuffers") +} + +// DeleteProgram implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteProgram(program uint32) { + debugging.recordEntry("DeleteProgram", program) + debugging.gl.DeleteProgram(program) + debugging.recordExit("DeleteProgram") +} + +// DeleteShader implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteShader(shader uint32) { + debugging.recordEntry("DeleteShader", shader) + debugging.gl.DeleteShader(shader) + debugging.recordExit("DeleteShader") +} + +// DeleteTextures implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteTextures(textures []uint32) { + debugging.recordEntry("DeleteTextures", textures) + debugging.gl.DeleteTextures(textures) + debugging.recordExit("DeleteTextures") +} + +// DeleteVertexArrays implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteVertexArrays(arrays []uint32) { + debugging.recordEntry("DeleteVertexArrays", arrays) + debugging.gl.DeleteVertexArrays(arrays) + debugging.recordExit("DeleteVertexArrays") +} + +// Disable implements the OpenGL interface. +func (debugging *debuggingOpenGL) Disable(capability uint32) { + debugging.recordEntry("Disable", capability) + debugging.gl.Disable(capability) + debugging.recordExit("Disable") +} + +// DrawArrays implements the OpenGL interface. +func (debugging *debuggingOpenGL) DrawArrays(mode uint32, first int32, count int32) { + debugging.recordEntry("DrawArrays", first, count) + debugging.gl.DrawArrays(mode, first, count) + debugging.recordExit("DrawArrays") +} + +// DrawBuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) DrawBuffers(buffers []uint32) { + debugging.recordEntry("DrawBuffers", buffers) + debugging.gl.DrawBuffers(buffers) + debugging.recordExit("DrawBuffers") +} + +// DrawElements implements the OpenGL interface. +func (debugging *debuggingOpenGL) DrawElements(mode uint32, count int32, elementType uint32, indices uintptr) { + debugging.recordEntry("DrawElements", mode, count, elementType, indices) + debugging.gl.DrawElements(mode, count, elementType, indices) + debugging.recordExit("DrawElements") +} + +// Enable implements the OpenGL interface. +func (debugging *debuggingOpenGL) Enable(capability uint32) { + debugging.recordEntry("Enable", capability) + debugging.gl.Enable(capability) + debugging.recordExit("Enable") +} + +// EnableVertexAttribArray implements the OpenGL interface. +func (debugging *debuggingOpenGL) EnableVertexAttribArray(index uint32) { + debugging.recordEntry("EnableVertexAttribArray", index) + debugging.gl.EnableVertexAttribArray(index) + debugging.recordExit("EnableVertexAttribArray") +} + +// FramebufferRenderbuffer implements the OpenGL interface. +func (debugging *debuggingOpenGL) FramebufferRenderbuffer(target uint32, attachment uint32, renderbuffertarget uint32, renderbuffer uint32) { + debugging.recordEntry("FramebufferRenderbuffer", target, attachment, renderbuffertarget, renderbuffer) + debugging.gl.FramebufferRenderbuffer(target, attachment, renderbuffertarget, renderbuffer) + debugging.recordExit("FramebufferRenderbuffer") +} + +// FramebufferTexture implements the OpenGL interface. +func (debugging *debuggingOpenGL) FramebufferTexture(target uint32, attachment uint32, texture uint32, level int32) { + debugging.recordEntry("FramebufferTexture", target, attachment, texture, level) + debugging.gl.FramebufferTexture(target, attachment, texture, level) + debugging.recordExit("FramebufferTexture") +} + +// GenerateMipmap implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) GenerateMipmap(target uint32) { + debugging.recordEntry("GenerateMipmap", target) + debugging.gl.GenerateMipmap(target) + debugging.recordExit("GenerateMipmap") +} + +// GenBuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) GenBuffers(n int32) []uint32 { + debugging.recordEntry("GenBuffers", n) + result := debugging.gl.GenBuffers(n) + debugging.recordExit("GenBuffers", result) + return result +} + +// GenFramebuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) GenFramebuffers(n int32) []uint32 { + debugging.recordEntry("GenFramebuffers", n) + result := debugging.gl.GenFramebuffers(n) + debugging.recordExit("GenFramebuffers", result) + return result +} + +// GenRenderbuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) GenRenderbuffers(n int32) []uint32 { + debugging.recordEntry("GenRenderbuffers", n) + result := debugging.gl.GenRenderbuffers(n) + debugging.recordExit("GenRenderbuffers", result) + return result +} + +// GenTextures implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) GenTextures(n int32) []uint32 { + debugging.recordEntry("GenTextures", n) + result := debugging.gl.GenTextures(n) + debugging.recordExit("GenTextures", result) + return result +} + +// GenVertexArrays implements the OpenGL interface. +func (debugging *debuggingOpenGL) GenVertexArrays(n int32) []uint32 { + debugging.recordEntry("GenVertexArrays", n) + result := debugging.gl.GenVertexArrays(n) + debugging.recordExit("GenVertexArrays", result) + return result +} + +// GetAttribLocation implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetAttribLocation(program uint32, name string) int32 { + debugging.recordEntry("GetAttribLocation", program, name) + result := debugging.gl.GetAttribLocation(program, name) + debugging.recordExit("GetAttribLocation", result) + return result +} + +// GetError implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetError() uint32 { + debugging.recordEntry("GetError") + result := debugging.gl.GetError() + debugging.recordExit("GetError", result) + return result +} + +// GetIntegerv implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetIntegerv(name uint32, data *int32) { + debugging.recordEntry("GetIntegerv", name, data) + debugging.gl.GetIntegerv(name, data) + debugging.recordExit("GetIntegerv") +} + +// GetProgramInfoLog implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetProgramInfoLog(program uint32) string { + debugging.recordEntry("GetProgramInfoLog", program) + result := debugging.gl.GetProgramInfoLog(program) + debugging.recordExit("GetProgramInfoLog", result) + return result +} + +// GetProgramParameter implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetProgramParameter(program uint32, param uint32) int32 { + debugging.recordEntry("GetProgramParameter", program, param) + result := debugging.gl.GetProgramParameter(program, param) + debugging.recordExit("GetProgramParameter", result) + return result +} + +// GetShaderInfoLog implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetShaderInfoLog(shader uint32) string { + debugging.recordEntry("GetShaderInfoLog", shader) + result := debugging.gl.GetShaderInfoLog(shader) + debugging.recordExit("GetShaderInfoLog", result) + return result +} + +// GetShaderParameter implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetShaderParameter(shader uint32, param uint32) int32 { + debugging.recordEntry("GetShaderParameter", shader, param) + result := debugging.gl.GetShaderParameter(shader, param) + debugging.recordExit("GetShaderParameter", result) + return result +} + +// GetUniformLocation implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetUniformLocation(program uint32, name string) int32 { + debugging.recordEntry("GetUniformLocation", program, name) + result := debugging.gl.GetUniformLocation(program, name) + debugging.recordExit("GetUniformLocation", result) + return result +} + +// IsEnabled implements the OpenGL interface. +func (debugging *debuggingOpenGL) IsEnabled(capability uint32) bool { + debugging.recordEntry("IsEnabled", capability) + result := debugging.gl.IsEnabled(capability) + debugging.recordExit("IsEnabled", result) + return result +} + +// LinkProgram implements the OpenGL interface. +func (debugging *debuggingOpenGL) LinkProgram(program uint32) { + debugging.recordEntry("LinkProgram", program) + debugging.gl.LinkProgram(program) + debugging.recordExit("LinkProgram") +} + +// PixelStorei implements the OpenGL interface. +func (debugging *debuggingOpenGL) PixelStorei(name uint32, param int32) { + debugging.recordEntry("PixelStorei", name, param) + debugging.gl.PixelStorei(name, param) + debugging.recordExit("PixelStorei") +} + +// PolygonMode implements the OpenGL interface. +func (debugging *debuggingOpenGL) PolygonMode(face uint32, mode uint32) { + debugging.recordEntry("PolygonMode", face, mode) + debugging.gl.PolygonMode(face, mode) + debugging.recordExit("PolygonMode") +} + +// ReadPixels implements the OpenGL interface. +func (debugging *debuggingOpenGL) ReadPixels(x int32, y int32, width int32, height int32, format uint32, pixelType uint32, pixels interface{}) { + debugging.recordEntry("ReadPixels", x, y, width, height, format, pixelType, pixels) + debugging.gl.ReadPixels(x, y, width, height, format, pixelType, pixels) + debugging.recordExit("ReadPixels") +} + +// RenderbufferStorage implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) RenderbufferStorage(target uint32, internalFormat uint32, width int32, height int32) { + debugging.recordEntry("RenderbufferStorage", target, internalFormat, width, height) + debugging.gl.RenderbufferStorage(target, internalFormat, width, height) + debugging.recordExit("RenderbufferStorage") +} + +// Scissor implements the OpenGL interface. +func (debugging *debuggingOpenGL) Scissor(x, y int32, width, height int32) { + debugging.recordEntry("Scissor", x, y, width, height) + debugging.gl.Scissor(x, y, width, height) + debugging.recordExit("Scissor") +} + +// ShaderSource implements the OpenGL interface. +func (debugging *debuggingOpenGL) ShaderSource(shader uint32, source string) { + debugging.recordEntry("ShaderSource", shader, source) + debugging.gl.ShaderSource(shader, source) + debugging.recordExit("ShaderSource") +} + +// TexImage2D implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) TexImage2D(target uint32, level int32, internalFormat uint32, width int32, height int32, + border int32, format uint32, xtype uint32, pixels interface{}) { + debugging.recordEntry("TexImage2D", target, level, internalFormat, width, height, border, format, xtype, pixels) + debugging.gl.TexImage2D(target, level, internalFormat, width, height, border, format, xtype, pixels) + debugging.recordExit("TexImage2D") +} + +// TexParameteri implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) TexParameteri(target uint32, pname uint32, param int32) { + debugging.recordEntry("TexParameteri", target, pname, param) + debugging.gl.TexParameteri(target, pname, param) + debugging.recordExit("TexParameteri") +} + +// Uniform1i implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) Uniform1i(location int32, value int32) { + debugging.recordEntry("Uniform1i", location, value) + debugging.gl.Uniform1i(location, value) + debugging.recordExit("Uniform1i") +} + +// Uniform4fv implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) Uniform4fv(location int32, value *[4]float32) { + debugging.recordEntry("Uniform4fv", location, value) + debugging.gl.Uniform4fv(location, value) + debugging.recordExit("Uniform4fv") +} + +// UniformMatrix4fv implements the OpenGL interface. +func (debugging *debuggingOpenGL) UniformMatrix4fv(location int32, transpose bool, value *[16]float32) { + debugging.recordEntry("UniformMatrix4fv", location, transpose, value) + debugging.gl.UniformMatrix4fv(location, transpose, value) + debugging.recordExit("UniformMatrix4fv") +} + +// UseProgram implements the OpenGL interface. +func (debugging *debuggingOpenGL) UseProgram(program uint32) { + debugging.recordEntry("UseProgram", program) + debugging.gl.UseProgram(program) + debugging.recordExit("UseProgram") +} + +// VertexAttribOffset implements the OpenGL interface. +func (debugging *debuggingOpenGL) VertexAttribOffset(index uint32, size int32, attribType uint32, normalized bool, stride int32, offset int) { + debugging.recordEntry("VertexAttribOffset", index, size, attribType, normalized, stride, offset) + debugging.gl.VertexAttribOffset(index, size, attribType, normalized, stride, offset) + debugging.recordExit("VertexAttribOffset") +} + +// Viewport implements the OpenGL interface. +func (debugging *debuggingOpenGL) Viewport(x int32, y int32, width int32, height int32) { + debugging.recordEntry("Viewport", x, y, width, height) + debugging.gl.Viewport(x, y, width, height) + debugging.recordExit("Viewport") +} diff --git a/modding/hacked/ui/opengl/Errors.go b/modding/hacked/ui/opengl/Errors.go new file mode 100644 index 0000000..813a4d2 --- /dev/null +++ b/modding/hacked/ui/opengl/Errors.go @@ -0,0 +1,54 @@ +package opengl + +import ( + "fmt" +) + +var errorStrings = map[uint32]string{ + NO_ERROR: "NO_ERROR", + INVALID_ENUM: "INVALID_ENUM", + INVALID_VALUE: "INVALID_VALUE", + INVALID_OPERATION: "INVALID_OPERATION", + STACK_OVERFLOW: "STACK_OVERFLOW", + STACK_UNDERFLOW: "STACK_UNDERFLOW", + OUT_OF_MEMORY: "OUT_OF_MEMORY", + INVALID_FRAMEBUFFER_OPERATION: "INVALID_FRAMEBUFFER_OPERATION"} + +// ErrorString returns a readable version of the provided error code. If the +// code is unknown, the textual representation of the hexadecimal value is +// returned. +func ErrorString(errorCode uint32) string { + value, exists := errorStrings[errorCode] + + if !exists { + value = fmt.Sprintf("0x%04X", errorCode) + } + + return value +} + +// ShaderError describes a problem with a log. +type ShaderError struct { + Log string +} + +// Error returns the log. +func (err ShaderError) Error() string { + return err.Log +} + +// NamedShaderError is an error with a name. +type NamedShaderError struct { + Name string + Nested error +} + +// Error returns the name with the nested error information. +func (err NamedShaderError) Error() string { + return fmt.Sprintf("%s failed: %v", err.Name, err.Nested) +} + +// Unwrap returns the nested error. +func (err NamedShaderError) Unwrap() error { + return err.Nested +} diff --git a/modding/hacked/ui/opengl/OpenGl.go b/modding/hacked/ui/opengl/OpenGl.go new file mode 100644 index 0000000..ccb6dca --- /dev/null +++ b/modding/hacked/ui/opengl/OpenGl.go @@ -0,0 +1,90 @@ +package opengl + +// OpenGL describes an Open GL interface usable for all environments of this +// application. +type OpenGL interface { + ActiveTexture(texture uint32) + AttachShader(program uint32, shader uint32) + + BindAttribLocation(program uint32, index uint32, name string) + BindBuffer(target uint32, buffer uint32) + BindFramebuffer(target uint32, buffer uint32) + BindRenderbuffer(target uint32, buffer uint32) + BindSampler(unit uint32, sampler uint32) + BindTexture(target uint32, texture uint32) + BindVertexArray(array uint32) + BlendEquation(mode uint32) + BlendEquationSeparate(modeRGB uint32, modeAlpha uint32) + BlendFunc(sfactor uint32, dfactor uint32) + BlendFuncSeparate(srcRGB uint32, dstRGB uint32, srcAlpha uint32, dstAlpha uint32) + BufferData(target uint32, size int, data interface{}, usage uint32) + + CheckFramebufferStatus(target uint32) uint32 + + Clear(mask uint32) + ClearColor(red float32, green float32, blue float32, alpha float32) + + CompileShader(shader uint32) + + CreateProgram() uint32 + CreateShader(shaderType uint32) uint32 + + DeleteBuffers(buffers []uint32) + DeleteFramebuffers(buffers []uint32) + DeleteProgram(program uint32) + DeleteShader(shader uint32) + DeleteTextures(textures []uint32) + DeleteVertexArrays(arrays []uint32) + Disable(capability uint32) + + DrawArrays(mode uint32, first int32, count int32) + DrawBuffers(buffers []uint32) + DrawElements(mode uint32, count int32, elementType uint32, indices uintptr) + + Enable(capability uint32) + EnableVertexAttribArray(index uint32) + + FramebufferRenderbuffer(target uint32, attachment uint32, renderbuffertarget uint32, renderbuffer uint32) + FramebufferTexture(target uint32, attachment uint32, texture uint32, level int32) + + GenerateMipmap(target uint32) + GenBuffers(n int32) []uint32 + GenFramebuffers(n int32) []uint32 + GenRenderbuffers(n int32) []uint32 + GenTextures(n int32) []uint32 + GenVertexArrays(n int32) []uint32 + + GetAttribLocation(program uint32, name string) int32 + GetError() uint32 + GetIntegerv(name uint32, data *int32) + GetShaderInfoLog(shader uint32) string + GetShaderParameter(shader uint32, param uint32) int32 + GetProgramInfoLog(program uint32) string + GetProgramParameter(program uint32, param uint32) int32 + GetUniformLocation(program uint32, name string) int32 + + IsEnabled(capability uint32) bool + + LinkProgram(program uint32) + + PixelStorei(name uint32, param int32) + PolygonMode(face uint32, mode uint32) + + ReadPixels(x int32, y int32, width int32, height int32, format uint32, pixelType uint32, pixels interface{}) + RenderbufferStorage(target uint32, internalFormat uint32, width int32, height int32) + + Scissor(x, y int32, width, height int32) + ShaderSource(shader uint32, source string) + + TexImage2D(target uint32, level int32, internalFormat uint32, width int32, height int32, + border int32, format uint32, xtype uint32, pixels interface{}) + TexParameteri(target uint32, pname uint32, param int32) + + Uniform1i(location int32, value int32) + Uniform4fv(location int32, value *[4]float32) + UniformMatrix4fv(location int32, transpose bool, value *[16]float32) + UseProgram(program uint32) + + VertexAttribOffset(index uint32, size int32, attribType uint32, normalized bool, stride int32, offset int) + Viewport(x int32, y int32, width int32, height int32) +} diff --git a/modding/hacked/ui/opengl/Program.go b/modding/hacked/ui/opengl/Program.go new file mode 100644 index 0000000..939c71d --- /dev/null +++ b/modding/hacked/ui/opengl/Program.go @@ -0,0 +1,35 @@ +package opengl + +// LinkNewProgram creates a new shader program based on the provided shaders. +func LinkNewProgram(gl OpenGL, shaders ...uint32) (program uint32, err error) { + program = gl.CreateProgram() + + for _, shader := range shaders { + gl.AttachShader(program, shader) + } + gl.LinkProgram(program) + + if gl.GetProgramParameter(program, LINK_STATUS) == 0 { + err = ShaderError{Log: gl.GetProgramInfoLog(program)} + gl.DeleteProgram(program) + program = 0 + } + + return +} + +// LinkNewStandardProgram creates a new shader based on two shader sources. +func LinkNewStandardProgram(gl OpenGL, vertexShaderSource, fragmentShaderSource string) (program uint32, err error) { + vertexShader, vertexErr := CompileNewShader(gl, VERTEX_SHADER, vertexShaderSource) + if vertexErr != nil { + return 0, NamedShaderError{Name: "vertexShader", Nested: vertexErr} + } + defer gl.DeleteShader(vertexShader) + fragmentShader, fragmentErr := CompileNewShader(gl, FRAGMENT_SHADER, fragmentShaderSource) + if fragmentErr != nil { + return 0, NamedShaderError{Name: "fragmentShader", Nested: fragmentErr} + } + defer gl.DeleteShader(fragmentShader) + + return LinkNewProgram(gl, vertexShader, fragmentShader) +} diff --git a/modding/hacked/ui/opengl/Uniforms.go b/modding/hacked/ui/opengl/Uniforms.go new file mode 100644 index 0000000..a50e3bf --- /dev/null +++ b/modding/hacked/ui/opengl/Uniforms.go @@ -0,0 +1,25 @@ +package opengl + +import ( + mgl "github.com/go-gl/mathgl/mgl32" +) + +// UniformLocation represents a uniform parameter for a shader program. +type UniformLocation int32 + +// Matrix4Uniform represents one uniform parameter for a 4x4 matrix. +type Matrix4Uniform UniformLocation + +// Set stores the provided matrix as the current uniform value. +func (location Matrix4Uniform) Set(gl OpenGL, matrix *mgl.Mat4) { + matrixArray := (*[16]float32)(matrix) + gl.UniformMatrix4fv(int32(location), false, matrixArray) +} + +// Vector4Uniform represents one uniform parameter for a 4-dimensional vector. +type Vector4Uniform UniformLocation + +// Set stores the provided 4-dimensional vector as the current uniform value. +func (location Vector4Uniform) Set(gl OpenGL, value *[4]float32) { + gl.Uniform4fv(int32(location), value) +} diff --git a/modding/hacked/ui/opengl/VertexArrayObject.go b/modding/hacked/ui/opengl/VertexArrayObject.go new file mode 100644 index 0000000..c977eea --- /dev/null +++ b/modding/hacked/ui/opengl/VertexArrayObject.go @@ -0,0 +1,62 @@ +package opengl + +// AttributeSetter is a callback function for a VertexArrayObject. +type AttributeSetter func(OpenGL) + +// VertexArrayObject registers common vertex properties which are pre-set +// before regular rendering loops. +// Depending on the supported level of the Open GL API, these settings are +// either buffered (using a VAO - vertex array object), or replayed for +// every render call. +type VertexArrayObject struct { + gl OpenGL + program uint32 + handle uint32 + setter AttributeSetter +} + +// NewVertexArrayObject returns a new instance. +func NewVertexArrayObject(gl OpenGL, program uint32) *VertexArrayObject { + vao := &VertexArrayObject{ + gl: gl, + program: program, + handle: gl.GenVertexArrays(1)[0], + setter: func(OpenGL) {}} + + return vao +} + +// Dispose drops any associated resources. +func (vao *VertexArrayObject) Dispose() { + vao.gl.DeleteVertexArrays([]uint32{vao.handle}) + vao.handle = 0 +} + +// WithSetter registers the setter function for this object. +// Depending on the support of the OpenGL API, this setter may be called +// once immediately, or for each render call. +func (vao *VertexArrayObject) WithSetter(setter AttributeSetter) { + if vao.handle != 0 { + vao.OnShader(func() { setter(vao.gl) }) + } + vao.setter = setter +} + +// OnShader executes the provided task with an activated shader program +// and the vertex array object initialized. +func (vao *VertexArrayObject) OnShader(task func()) { + gl := vao.gl + + gl.UseProgram(vao.program) + gl.BindVertexArray(vao.handle) + + defer func() { + gl.BindVertexArray(0) + gl.UseProgram(0) + }() + + if vao.handle == 0 { + vao.setter(gl) + } + task() +} diff --git a/modding/hacked/ui/opengl/Window.go b/modding/hacked/ui/opengl/Window.go new file mode 100644 index 0000000..af91353 --- /dev/null +++ b/modding/hacked/ui/opengl/Window.go @@ -0,0 +1,147 @@ +package opengl + +import ( + "encoding/json" + "io/ioutil" + + "github.com/inkyblackness/hacked/ui/input" +) + +// ClosingCallback is the function to handle close requests by the user. +type ClosingCallback func() + +// ClosedCallback is the function to clean up resources when the window is being closed. +type ClosedCallback func() + +// RenderCallback is the function to receive render events. When the callback +// returns, the window will swap the internal buffer. +type RenderCallback func() + +// MouseMoveCallback is the function to receive the current mouse coordinate while moving. +// Movement is reported while the cursor is within the client area of the window, and +// beyond the window as long as at least one captured button is pressed. +// Reported values are with sub-pixel precision, if possible. +type MouseMoveCallback func(x float32, y float32) + +// MouseButtonCallback is the function to receive button up/down events. +// An Up event is sent for every reported Down event, even if the mouse cursor is outside +// the client area. +type MouseButtonCallback func(buttonMask uint32, modifier input.Modifier) + +// MouseScrollCallback is the function to receive scroll events. +// Delta values are right-hand oriented: positive values go right/down/far. +type MouseScrollCallback func(dx float32, dy float32) + +// ResizeCallback is called for a change of window dimensions. +type ResizeCallback func(width int, height int) + +// CharCallback is called for typing a character. +type CharCallback func(char rune) + +// KeyCallback is called for pressing or releasing a key on the keyboard. +type KeyCallback func(key input.Key, modifier input.Modifier) + +// ModifierCallback is called when the currently active modifier changed. +type ModifierCallback func(modifier input.Modifier) + +// FileDropCallback is called when one or more files were dropped into the window. +type FileDropCallback func(filePaths []string) + +// WindowState describes how the window is currently presented to the user. +type WindowState struct { + // Maximized is set to true if the window is currently resized to fill the screen. + Maximized bool + // Top marks the screen position of the upper border of the window. + Top int + // Left marks the screen position of the left border of the window. + Left int + // Width marks the width of the non-maximized window. + Width int + // Height marks the height of the non-maximized window. + Height int +} + +// WindowStateFromFile attempts to load the state from a file with given name. +// If this does not work, the given default state will be returned. +func WindowStateFromFile(filename string, defaultState WindowState) WindowState { + data, err := ioutil.ReadFile(filename) + if err != nil { + return defaultState + } + var state WindowState + err = json.Unmarshal(data, &state) + if err != nil { + return defaultState + } + return state +} + +// SaveTo stores the state in a file with given filename. +func (state WindowState) SaveTo(filename string) error { + bytes, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return ioutil.WriteFile(filename, bytes, 0640) +} + +// Window represents an OpenGL render surface. +type Window interface { + // StateSnapshot returns the current state of the window. + StateSnapshot() WindowState + // RestoreState puts the window into the same state when StateSnapshot() was called. + RestoreState(state WindowState) + + // ClipboardString returns the current value of the clipboard, if it is compatible with UTF-8. + ClipboardString() (string, error) + // SetClipboardString sets the current value of the clipboard as UTF-8 string. + SetClipboardString(value string) + + // OnClosing registers a callback function which shall be called when the user requests to close the window. + OnClosing(callback ClosingCallback) + // OnClosed registers a callback function which shall be called when the window is being closed. + OnClosed(callback ClosedCallback) + // SetCloseRequest notifies the window system whether the window shall be closed. + // It can be called during the callback of OnClosing() to abort a close request by the user. + SetCloseRequest(shouldClose bool) + + // OpenGL returns the OpenGL API wrapper for this window. + OpenGL() OpenGL + // OnRender registers a callback function which shall be called to update the scene. + OnRender(callback RenderCallback) + + // OnResize registers a callback function for sizing events. + OnResize(callback ResizeCallback) + // Size returns the dimensions of the window display area in pixel. + Size() (width int, height int) + // SetFullScreen sets the full screen state of the window. + SetFullScreen(on bool) + // SetTitleSuffix appends a text to the current window title. + SetTitleSuffix(value string) + // SetProjectModified sets an indicator in the window frame that the project has not been saved. + SetProjectModified(modified bool) + + // SetCursorVisible controls whether the mouse cursor is currently visible. + SetCursorVisible(visible bool) + + // OnMouseMove registers a callback function for mouse move events. + OnMouseMove(callback MouseMoveCallback) + // OnMouseButtonDown registers a callback function for mouse button down events. + OnMouseButtonDown(callback MouseButtonCallback) + // OnMouseButtonUp registers a callback function for mouse button up events. + OnMouseButtonUp(callback MouseButtonCallback) + // OnMouseScroll registers a callback function for mouse scroll events. + OnMouseScroll(callback MouseScrollCallback) + + // OnKeyPress registers a callback function for key events. + OnKeyPress(callback KeyCallback) + // OnKeyRelease registers a callback function for key events. + OnKeyRelease(callback KeyCallback) + // OnModifier registers a callback function for change of modifier events. + OnModifier(callback ModifierCallback) + // OnCharCallback registers a callback function for typed characters. + OnCharCallback(callback CharCallback) + + // OnFileDropCallback registers a callback function for dropped files. + OnFileDropCallback(callback FileDropCallback) +} diff --git a/modding/hacked/ui/opengl/WindowEventDispatcher.go b/modding/hacked/ui/opengl/WindowEventDispatcher.go new file mode 100644 index 0000000..ed394db --- /dev/null +++ b/modding/hacked/ui/opengl/WindowEventDispatcher.go @@ -0,0 +1,128 @@ +package opengl + +import ( + "github.com/inkyblackness/hacked/ui/input" +) + +type keyDeferrer struct { + window *WindowEventDispatcher +} + +func (def *keyDeferrer) KeyPress(key input.Key, modifier input.Modifier) { + def.window.CallKeyPress(key, modifier) +} + +func (def *keyDeferrer) KeyRelease(key input.Key, modifier input.Modifier) { + def.window.CallKeyRelease(key, modifier) +} + +func (def *keyDeferrer) Modifier(modifier input.Modifier) { + def.window.CallModifier(modifier) +} + +// WindowEventDispatcher implements the common, basic functionality of Window. +type WindowEventDispatcher struct { + CallClosing ClosingCallback + CallClosed ClosedCallback + CallRender RenderCallback + CallResize ResizeCallback + CallOnMouseMove MouseMoveCallback + CallOnMouseButtonUp MouseButtonCallback + CallOnMouseButtonDown MouseButtonCallback + CallOnMouseScroll MouseScrollCallback + CallModifier ModifierCallback + CallKeyPress KeyCallback + CallKeyRelease KeyCallback + CallCharCallback CharCallback + CallFileDropCallback FileDropCallback +} + +// NullWindowEventDispatcher returns an initialized instance with empty callbacks. +func NullWindowEventDispatcher() WindowEventDispatcher { + return WindowEventDispatcher{ + CallClosing: func() {}, + CallClosed: func() {}, + CallRender: func() {}, + CallResize: func(int, int) {}, + CallOnMouseMove: func(float32, float32) {}, + CallOnMouseButtonUp: func(uint32, input.Modifier) {}, + CallOnMouseButtonDown: func(uint32, input.Modifier) {}, + CallOnMouseScroll: func(float32, float32) {}, + CallKeyPress: func(input.Key, input.Modifier) {}, + CallKeyRelease: func(input.Key, input.Modifier) {}, + CallModifier: func(input.Modifier) {}, + CallCharCallback: func(rune) {}, + CallFileDropCallback: func([]string) {}, + } +} + +// StickyKeyListener returns an instance of a listener acting as an adapter +// for the key-down/-up callbacks. +func (window *WindowEventDispatcher) StickyKeyListener() input.StickyKeyListener { + return &keyDeferrer{window: window} +} + +// OnClosing implements the Window interface. +func (window *WindowEventDispatcher) OnClosing(callback ClosingCallback) { + window.CallClosing = callback +} + +// OnClosed implements the Window interface. +func (window *WindowEventDispatcher) OnClosed(callback ClosedCallback) { + window.CallClosed = callback +} + +// OnRender implements the Window interface. +func (window *WindowEventDispatcher) OnRender(callback RenderCallback) { + window.CallRender = callback +} + +// OnResize implements the Window interface. +func (window *WindowEventDispatcher) OnResize(callback ResizeCallback) { + window.CallResize = callback +} + +// OnMouseMove implements the Window interface. +func (window *WindowEventDispatcher) OnMouseMove(callback MouseMoveCallback) { + window.CallOnMouseMove = callback +} + +// OnMouseButtonDown implements the Window interface. +func (window *WindowEventDispatcher) OnMouseButtonDown(callback MouseButtonCallback) { + window.CallOnMouseButtonDown = callback +} + +// OnMouseButtonUp implements the Window interface. +func (window *WindowEventDispatcher) OnMouseButtonUp(callback MouseButtonCallback) { + window.CallOnMouseButtonUp = callback +} + +// OnMouseScroll implements the Window interface. +func (window *WindowEventDispatcher) OnMouseScroll(callback MouseScrollCallback) { + window.CallOnMouseScroll = callback +} + +// OnKeyPress implements the Window interface. +func (window *WindowEventDispatcher) OnKeyPress(callback KeyCallback) { + window.CallKeyPress = callback +} + +// OnKeyRelease implements the Window interface. +func (window *WindowEventDispatcher) OnKeyRelease(callback KeyCallback) { + window.CallKeyRelease = callback +} + +// OnModifier implements the WindowEventDispatcher interface. +func (window *WindowEventDispatcher) OnModifier(callback ModifierCallback) { + window.CallModifier = callback +} + +// OnCharCallback implements the Window interface. +func (window *WindowEventDispatcher) OnCharCallback(callback CharCallback) { + window.CallCharCallback = callback +} + +// OnFileDropCallback implements the Window interface. +func (window *WindowEventDispatcher) OnFileDropCallback(callback FileDropCallback) { + window.CallFileDropCallback = callback +} diff --git a/modding/hacked/ui/opengl/constants.go b/modding/hacked/ui/opengl/constants.go new file mode 100644 index 0000000..c67fef0 --- /dev/null +++ b/modding/hacked/ui/opengl/constants.go @@ -0,0 +1,156 @@ +package opengl + +// Buffer Bits +// nolint: golint,megacheck +const ( + DEPTH_BUFFER_BIT uint32 = 0x00000100 + STENCIL_BUFFER_BIT = 0x00000400 + COLOR_BUFFER_BIT = 0x00004000 +) + +// Draw Types +// nolint: golint,megacheck +const ( + POINTS uint32 = 0x0000 + LINES = 0x0001 + LINE_LOOP = 0x0002 + LINE_STRIP = 0x0003 + TRIANGLES = 0x0004 + TRIANGLE_STRIP = 0x0005 + TRIANGLE_FAN = 0x0006 +) + +// Shader Types +// nolint: golint,megacheck +const ( + FRAGMENT_SHADER uint32 = 0x8B30 + VERTEX_SHADER = 0x8B31 +) + +// Status Values +// nolint: golint,megacheck +const ( + COMPILE_STATUS uint32 = 0x8B81 + LINK_STATUS = 0x8B82 +) + +// Buffer Types +// nolint: golint,megacheck +const ( + ARRAY_BUFFER uint32 = 0x8892 + ELEMENT_ARRAY_BUFFER = 0x8893 +) + +// Framebuffer Types +// nolint: golint,megacheck +const ( + FRAMEBUFFER = 0x8D40 + RENDERBUFFER = 0x8D41 + + DEPTH_COMPONENT24 = 0x81A6 + + COLOR_ATTACHMENT0 = 0x8CE0 + DEPTH_ATTACHMENT = 0x8D00 +) + +// Draw Types +// nolint: golint,megacheck +const ( + STREAM_DRAW uint32 = 0x88E0 + STATIC_DRAW = 0x88E4 + DYNAMIC_DRAW = 0x88E8 +) + +// Features +// nolint: golint,megacheck +const ( + BLEND uint32 = 0x0BE2 + DEPTH_TEST = 0x0B71 + CULL_FACE = 0x0B44 + SCISSOR_TEST = 0x0C11 + + NICEST = 0x1102 + LINE_SMOOTH = 0x0B20 + LINE_SMOOTH_HINT = 0x0C52 + + ACTIVE_TEXTURE = 0x84E0 + CURRENT_PROGRAM = 0x8B8D + TEXTURE_BINDING_2D = 0x8069 + SAMPLER_BINDING = 0x8919 + ARRAY_BUFFER_BINDING = 0x8894 + ELEMENT_ARRAY_BUFFER_BINDING = 0x8895 + VERTEX_ARRAY_BINDING = 0x85B5 + POLYGON_MODE = 0x0B40 + VIEWPORT = 0x0BA2 + SCISSOR_BOX = 0x0C10 + BLEND_SRC_RGB = 0x80C9 + BLEND_DST_RGB = 0x80C8 + BLEND_SRC_ALPHA = 0x80CB + BLEND_DST_ALPHA = 0x80CA + BLEND_EQUATION_RGB = 0x8009 + BLEND_EQUATION_ALPHA = 0x883D + FRONT_AND_BACK = 0x0408 + FILL = 0x1B02 +) + +// Alpha constants +// nolint: golint,megacheck +const ( + SRC_ALPHA uint32 = 0x0302 + ONE_MINUS_SRC_ALPHA = 0x0303 + ONE_MINUS_SRC_COLOR = 0x0301 + + FUNC_ADD = 0x8006 +) + +// Data Types +// nolint: golint,megacheck +const ( + BYTE uint32 = 0x1400 + UNSIGNED_BYTE = 0x1401 + SHORT = 0x1402 + UNSIGNED_SHORT = 0x1403 + INT = 0x1404 + UNSIGNED_INT = 0x1405 + FLOAT = 0x1406 +) + +// Texture Constants +// nolint: golint,megacheck +const ( + TEXTURE_2D uint32 = 0x0DE1 + + TEXTURE0 = 0x84C0 + + NEAREST = 0x2600 + TEXTURE_MAG_FILTER = 0x2800 + TEXTURE_MIN_FILTER = 0x2801 + + UNPACK_ROW_LENGTH = 0x0CF2 + + LINEAR = 0x2601 +) + +// Errors +// nolint: golint,megacheck +const ( + NO_ERROR uint32 = 0 + INVALID_ENUM = 0x0500 + INVALID_VALUE = 0x0501 + INVALID_OPERATION = 0x0502 + STACK_OVERFLOW = 0x0503 + STACK_UNDERFLOW = 0x0504 + OUT_OF_MEMORY = 0x0505 + INVALID_FRAMEBUFFER_OPERATION = 0x0506 +) + +// Color Types +// nolint: golint,megacheck +const ( + ALPHA uint32 = 0x1906 + RGBA = 0x1908 + RED = 0x1903 + + RGBA8 = 0x8058 + R8 = 0x8229 +) diff --git a/modding/ss-specs/CONTRIBUTING.md b/modding/ss-specs/CONTRIBUTING.md new file mode 100644 index 0000000..bf22415 --- /dev/null +++ b/modding/ss-specs/CONTRIBUTING.md @@ -0,0 +1,52 @@ +## Contributing Guidelines + +Thank you for considering to contribute to this documentation! +Such a contribution can be useful for modders, tool-, and engine developers. + +The following text lists guidelines for contributing to this documentation. +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 meta-information about the engine or the data files, please refer to the [systemshock.org](https://www.systemshock.org) forums. +The following sub-sections will most likely be applicable for your question: + +* [Engineering](https://www.systemshock.org/index.php?board=36.0) - for modding the game or creating fan-missions in general +* [Deck 13](https://www.systemshock.org/index.php?board=13.0) - for engine source-code, ports, and tool-development related topics +* [System Shock source code](https://github.com/nothings/shockmac) - source code of System Shock for Mac OS with notes from Sean Barrett + +### Scope + +This documentation is meant to describe the "meaningful" resource files of the original System Shock (1994) and related engine behaviour. +It is meant to give modders and tool-developers (possibly engine developers) a reference on what data to expect. + +What does "meaningful" mean? Data that has effect on the players experience. As a rule of thumb, there are several fields in various structures that are unused. Even with the source code of the engine available, it may be academic to try to document fields and their fictionial behaviour just by their name alone. If a field is never used, it may simply be stated as such and nothing more. +Exceptions are listed on the main readme - for instance, not covering music or Mac-specific files. + +What does "original" mean? The game System Shock from 1994 and its direct derivatives, such as the "Enhanced Edition" or possible (future) source ports that claim to be compatible with the data files. It may be that future source ports extend the behaviour of the original engine. If this is the case, this documentation could be extended alongside, stating the compatibilities. + +This documentation is NOT a documentation on the engine itself. There may be some gray areas if related to behaviour, yet you won't find a documentation on how the renderer is working for instance. + + +### Conventions + +This documentation is meant to be read by a Markdown-compatible viewer, though for the most part, this will remain GitHub itself. +Refer to the [conventions](conventions.md) document for formatting and content. + +To reiterate from this document: Please avoid adding information "on a hunch" to the documentation. Meanwhile the source code has already been released, so if there isn't a test, or a successful tool showcasing information, the documentation should at least hold up to the original source code. + +It is acceptable to change field/type names if they are "near" synonyms (or simply words written in full). The documentation should aim to stay close to the original source, not a particular fan-project (not even tools from InkyBlackness - their code should follow as well.) + + +### Information + +As a rule of thumb, this documentation describes types (data structures) with their fields, names, and related behaviour of the engine. +This is typically a combination of a dry listing of a data structure, as described in the conventions, and then some paragraphs of prose text. + +It should not contain any source code - especially copied from the original source, which uses a different license. The only algorithms explained in this documentation are that of compression, and even these could be covered in prose. + + +### Adding/Changing the documentation + +If you find typos, new information, better (more expressive) descriptions that you would like to see handled, feel free to create an issue, or possibly even a pull-request about this. diff --git a/modding/ss-specs/LICENSE.txt b/modding/ss-specs/LICENSE.txt new file mode 100644 index 0000000..61ab961 --- /dev/null +++ b/modding/ss-specs/LICENSE.txt @@ -0,0 +1 @@ +This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/ or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA. diff --git a/modding/ss-specs/README.md b/modding/ss-specs/README.md new file mode 100644 index 0000000..916b785 --- /dev/null +++ b/modding/ss-specs/README.md @@ -0,0 +1,32 @@ +# THE UNOFFICIAL SYSTEM SHOCK 1 SPECIFICATIONS + +This is a sub-project as part of the [InkyBlackness](https://inkyblackness.github.io) project. It aims to document the data (resource) files of the video game [System Shock](http://en.wikipedia.org/wiki/System_Shock). It is the continuation of the previous work performed as part of "The System Shock Hack Project" ([TSSHP](http://tsshp.sourceforge.net/)). + +The goal is to document all meaningful bits and bytes of the original data files. + +Start reading with the [main page](index.md) (the index). + +If you would like to contribute to the documentation, please consider [the guidelines](CONTRIBUTING.md). + +## Status +The content of this documentation supersedes the information available from TSSHP (the [ss-specs.txt](http://tsshp.cvs.sourceforge.net/viewvc/tsshp/tsshp/doc/ss-specs.txt?view=markup) file). In some instances, this documentation even provides more accurate descriptions. + +The format of all raw media content is described, with the exception of a few unknown fields. The same counts for the game objects. +See the [issue list](https://github.com/inkyblackness/ss-specs/issues) for an overview about what is still actively analyzed. +With the release of the original source (see credits below), many so far unknown things can be clarified. + +Currently not in scope of this documentation project are music files (See [here for details](other/Music.md)) and the Macintosh release. + + +## Credits +* This project, as well as InkyBlackness itself, wouldn't be possible without the work of TSSHP and the effort of the people before that. +* [Source release](https://github.com/NightDiveStudios/shockmac) of the Mac version, which finally allowed to figure out unknown details, and set some guesswork naming right. + +## License + +[![License][license-image]][license-url] + +This work is licensed under a [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License](http://creativecommons.org/licenses/by-nc-sa/4.0/). + +[license-url]: http://creativecommons.org/licenses/by-nc-sa/4.0/ +[license-image]: https://i.creativecommons.org/l/by-nc-sa/4.0/88x31.png diff --git a/modding/ss-specs/archives/gameState.md b/modding/ss-specs/archives/gameState.md new file mode 100644 index 0000000..0a74611 --- /dev/null +++ b/modding/ss-specs/archives/gameState.md @@ -0,0 +1,336 @@ +## Game state and hacker information, resource 0x0FA1 (`Player` struct) + +This resource contains everything about the hacker, together with general game-state information. + + + 0000 20xbyte Hacker name (incl. 0x00 termination char). Game only allows 12 characters for input at start. + 0014 byte Realspace Level; The level to return to after exiting cyberspace. + + 0015 4xbyte Difficulties (Game rating): combat, mission, puzzle, cyber. All 0..3 + 0019 3xbyte Unused. + + 001C uint32 Game time + 0020 uint32 Last second update + 0024 uint32 Last drug update + 0028 uint32 Last ware update + 002C uint32 Last animation check + 0030 int32 Queue Time + 0034 int32 Delta Time + 0038 byte Detail Level + + 0039 byte Current level identifier + 003A 22xint16 Initial SHODAN levels + 0066 6xbyte Controls + 006C int16 Player Object ID + 006E [8]byte Realspace location; The point to return to after exiting cyberspace. + 0076 int32 Version number of the data structure; Value: 6 + 007A [14]int16 General inventory references, Object IDs + + 0096 byte Posture + 0097 byte Foot planted; 1: on the floor + 0098 byte Lean X + 0099 byte Lean Y + 009A int16 Unused + + 009C byte Health; 0x00..0xFF + 009D byte Cyberspace Integrity + 009E uint16 Hitpoint regeneration per minute + 00A0 8xbyte Damage rate per minute [explosion, energy, magnetic, radiation, gas, tranq, needle, bio] + 00A8 uint16 Bio post expose + 00AA uint16 Rad post expose + 00AC byte Power 0x00..0xFF + 00AD byte Power usage in JPM + 00AE byte Power regeneration + 00AF byte Energy out; 1: is out of energy + 00B0 int16 Cyberspace trips + 00B2 int32 Cyberspace time base + + 00B6 [64]uint8 512 Boolean game variables; Bytes are counted up, bits within byte counted from LSB; Example: bit[10] = 0x00 0x04 0x.. + 00F6 [64]int16 64 Integer game variables + + 0176 uint32 HUD modes; Bitfield as per hud.h + 017A byte Unused + 017B sint32 Fatigue + 017F uint16 Fatigue spending; Current rate of fatigue expenditure in pts/sec + 0181 uint16 Fatigue regeneration + 0183 uint16 Fatigue regeneration base + 0185 uint16 Fatigue regeneration maximum + 0187 sint8 Accuracy + 0188 byte Shield absorb rate, in percent + 0189 byte Shield threshold + 018A byte Light value; setting of lamp + + Multi-Function-Display (MFD) state + 018B [10]byte 2x5 MFD slot indices + 0195 [7]byte MFD status + 019C [7]byte MFD function IDs + 01A3 [32]byte MFD function flags + 01C3 [256]byte MFD function data + 02C3 [2]byte current MFDs + 02C5 [2]byte MFD empty functions + 02C7 [64]byte MFD access puzzles + 0307 [2]byte MFD save slots + + + Installed Hardware (0: not installed): + 0309 byte version for infrared night vision + 030A byte version for target info + 030B byte version for Sensaround + 030C byte version for "Aim Enhancement Hardware" + 030D byte version for HUD (unknown function) + 030E byte version for bioscan + 030F byte version for navigation unit + 0310 byte version for shield + 0311 byte version for data reader + 0312 byte version for lantern + 0313 byte version for view control + 0314 byte version for enviro suit + 0315 byte version for booster + 0316 byte version for jump jet + 0317 byte version for system status + + Installed Software(0: not installed): + 0318 [7]byte offensive software + 031F [3]byte defensive software + 0322 [9]byte misc software + + Full clips of ammunition + 032B byte ML STANDARD ROUNDS + 032C byte ML TEFLON COATED ROUNDS + 032D byte SV NEEDLE DARTS + 032E byte SV TRANQ DARTS + 032F byte HOLLOW-TIP 2100 CLIP + 0330 byte HEAVY SLUG 2100 CLIP + 0331 byte DC RUBBER SLUGS + 0332 byte MARK3 MAGNESIUM-TIP SHELLS + 0333 byte MARK3 PENETRATOR SHELLS + 0334 byte AM HORNET CLIP + 0335 byte AM SPLINTER CLIP + 0336 byte RF SLAG CLIP + 0337 byte RF LARGE SLAG CLIP + 0338 byte SG MAG PULSE CART + 0339 byte MM RAIL CLIP + + Ammunition not in full clips + 033A byte ML STANDARD ROUNDS + 033B byte ML TEFLON COATED ROUNDS + 033C byte SV NEEDLE DARTS + 033D byte SV TRANQ DARTS + 033E byte HOLLOW-TIP 2100 CLIP + 033F byte HEAVY SLUG 2100 CLIP + 0340 byte DC RUBBER SLUGS + 0341 byte MARK3 MAGNESIUM-TIP SHELLS + 0342 byte MARK3 PENETRATOR SHELLS + 0343 byte AM HORNET CLIP + 0344 byte AM SPLINTER CLIP + 0345 byte RF SLAG CLIP + 0346 byte RF LARGE SLAG CLIP + 0347 byte SG MAG PULSE CART + 0348 byte MM RAIL CLIP + + Patches + 0349 byte STAMINUP STIMULANT + 034A byte SIGHT VISION ENCHANCEMENT + 034B byte BERSERK COMBAT BOOSTER + 034C byte MEDIPATCH HEALING AGENT + 034D byte REFLEX REACTION AID + 034E byte GENIUS MIND-ENCHANCER + 034F byte DETOX UNIVERSAL ANTIDOTE + + Explosives + 0350 byte FRAGMENTATION GRENADE + 0351 byte EMP GRENADE + 0352 byte GAS GRENADE + 0353 byte CONCUSSION BOMB + 0354 byte LAND MINE + 0355 byte NITROPACK + 0356 byte EARTH SHAKER EXPLOSIVE + + 0357 [294]byte Message status bitfield; 47 emails + 23 data fragments + (16 logs * 14 levels) + 047D [14]byte Level log marker; On which level logs are in inventory + + 048B [5]byte First weapon + +0 byte Subclass (0xFF means no weapon) + +1 byte Type + +2 byte Rounds / Heat + +3 byte Ammunition type / Energy setting (0x80 bit is overload) + +4 byte Unused + + 0490 [5]byte Second weapon + 0495 [5]byte Third weapon + 049A [5]byte Fourth weapon + 049F [5]byte Fifth weapon + 04A4 [5]byte Sixth weapon + 04A9 [5]byte Seventh weapon + + 04AE [15]byte Hardware on/off + 04BD [19]byte Software status on/off + + 04D0 byte Jumpjet energy fraction + 04D1 [32]byte email sender count + 04F1 [7]byte Patches time remainder + 04F8 [7]byte Patches intensity + + 04FF [7]uint16 Timer settings of explosives, in 0.1 seconds; Default: 0x46 = 7 seconds + + 050D uint16 Unused + 050F uint16 Time to completion of current program (seconds). Essentially unused, although used to print some strings + + 0511 uint16 ObjectID of current target + 0513 uint32 Last weapon fire time + 0517 uint16 Weapon fire rate; Time needed before next fire. + + 0519 [10]byte HUD actives: weapon, grenade, drug, cart, hardware, combat soft, defense soft, misc soft, general, email + + 0523 uint16 Object under cursor before cyberspace + 0525 uint16 Last panel reference + + 0527 sint32 Kill count + 052B sint32 Time in cyberspace + 052F sint32 Number of shots fired + 0533 sint32 Number of hits + + 0537 sint32 Death count; How often hacker was resurrected. + 053B int32 Tilt (eye position) + + 053F [12]fix Physics state (EDMS) - see below + + 056F byte Currently active inventory category + 0570 byte Active bio tracks + + 0571 int16 current email + + 0573 [6]byte version + 0579 byte dead flag + 057A uint16 lean filter state + 057C [2]byte unused + 057E byte MFD save field + + 057F uint32 Auto fire click + 0583 uint32 Posture state + 0587 byte Option: Text Length (0: normal, 1: terse) + 0588 uint32 Last head bob + 058C [9]byte Padding + + +> In archive.dat, this table is (nearly) all 0x00, and also slightly smaller than necessary. + + +### Physics state (EDMS `State` structure, 48 bytes) + + 0000 fix X Coordinate (East -> West) + 0004 fix Y Coordinate (North -> South) + 0008 fix Z Coordinate (above ground) + 000C fix Yaw; 0 is facing East, going counter-clockwise + 0010 fix Upper body back force (?) + 0014 fix Upper body left force (?) + 0018 fix Forward force (?) -- X dot product + 001C fix Left force (?) -- Y dot product + 0020 fix Unknown -- Z dot product + 0024 fix Unknown + 0028 fix Unknown + 002C fix Unknown + +Rotation values are in range 0..2PI. + +### Message status enumeration (1 byte) + + 0x80 Message received + 0x40 Message read + 0x3F Unused + + +### Game variables + +Game variables are described on a [separate page](gameVariables.md) + +### Initial game state + +The original engine does not allow for having `archive.dat` specify the initial state for both the game and hacker. + +The `System Shock Enhanced Edition Source Port` introduced a modified start behaviour, +which allows for (almost) full control on how and where the protagonist starts: +When the first field of the `physics state` (the `X` coordinate of EDMS state) is not zero, +then most default settings are skipped, and data is taken from `archive.dat`. +Still, some minimal setup is performed either way, in order to have a stable game. + +> This behaviour exists already in the original engine, but it only considers the physics structure itself, +> and no further information, such as the current level or any game variable. + +#### Mission-independent hardcoded defaults + +The following defaults are being applied regardless of existing data in `archive.dat`. + +##### Game state fields + +| Offset | Name | Initial value | +|--------|----------------------------|-----------------------------------------------------| +| 0000 | Hacker name | as per start menu | +| 0015 | Difficulties | as per start menu | +| 0038 | Detail Level | as per configuration | +| 003A | Initial SHODAN levels | all to `-1` | +| 006C | Player Object ID | `0` | +| 00A0 | Damage rate per minute | all to `0` | +| 0511 | ObjectID of current target | `0` | +| 0513 | Last weapon fire time | `0` | +| ** | Various MFD fields | To a consistent (empty) MFD display | +| 0309 | Installed Hardware[10] | `1` to hardcode "fullscreen hardware" | +| 0570 | Active bio tracks | `0xFF` | +| 00F6 | Integer game variables[48] | Language, as per configuration | + +The two "random" game variables (index 31 and 32) are randomized only if they are equal. + + +#### Mission-dependent defaults + +The following defaults are being applied *only* if `physics state` field `X` is zero. + +##### Game state fields + +| Offset | Name | Initial value | +|--------|----------------------------|-----------------------------------------------------| +| 0039 | Current level identifier | `1` | +| 007A | General Inventory | all to `0` | +| 009C | Health | `212` | +| 009D | Cyberspace Integrity | `255` | +| 009E | Hitpoint regeneration | `0` | +| 00AC | Power | `255` | +| 00B2 | Cyberspace time base | `504000` (30 minutes in 280 ticks-per-second) | +| 0181 | Fatigue regeneration | `0` | +| 0183 | Fatigue regeneration base | `100` | +| 0183 | Fatigue regeneration max | `400` | +| 0187 | Accuracy | `100` | +| 0188 | Shield absorb rate | `0` | +| 0357 | Message status[26] | `0x80`, to mark email from Rebecca received | +| 048B | Weapons[*].type | `0xFF`, to mark no weapon in slot | +| 04FF | Explosives timer[*] | `70` (= 7 seconds) | +| 0519 | HUD actives[9] | `0xFF`, to mark email being active | +| 053F | Physics state (EDMS) | X=30.5, Y=22.5, Z=planted at floor, Alpha=West | + + +##### Variables + +The following integer variables are set: + +| Integer | Value | +|---------|--------| +| 3 | 2 | +| 12 | 3 | +| 51 | 256 | + +And the following boolean variables are set to `true`: + +``` +0x001, 0x002, 0x003, 0x010, 0x012, 0x015, 0x016, 0x017, 0x018, 0x019, 0x01A, +0x020, 0x021, 0x024, 0x025, +0x04B, 0x04C, 0x04D, 0x04E, 0x04F, +0x050, 0x051, 0x052, 0x053, 0x054, 0x055, 0x056, 0x057, 0x058, 0x059, 0x05A, 0x05B, 0x05C, 0x05D, 0x05E, 0x05F, +0x070, 0x071, 0x072, 0x073, 0x074, 0x075, 0x076, 0x077, 0x078, 0x079, 0x07A, 0x07B, 0x07C, 0x07D, 0x07E, 0x07F, +0x0A0, 0x0A1, 0x0A2, 0x0A3, 0x0A4, 0x0A5, 0x0A6, 0x0A7, 0x0A8, 0x0A9, +0x0C0, 0x0C1, 0x0C2, 0x0C3, 0x0C4, 0x0C5, 0x0C6, 0x0C7, 0x0C8, 0x0C9, 0x0CA, 0x0CB, 0x0CC, 0x0CD, 0x0CE, 0x0CF, +0x0E1, 0x0E3, 0x0E5, 0x0E7, 0x0E9, 0x0EB, 0x0ED, 0x0EF, +0x0F1, 0x0F3, 0x0F5, 0x0F7, 0x0F9, 0x0FB, 0x0FD, 0x0FF, +0x101, 0x103, 0x105, 0x107, 0x109, 0x10B, 0x10D, 0x10F, +0x111, 0x113, 0x115, 0x117, 0x119, 0x11B, 0x11D, 0x11F, +0x121, 0x123, 0x125, 0x127, 0x129, 0x12B, +``` diff --git a/modding/ss-specs/archives/gameVariables.md b/modding/ss-specs/archives/gameVariables.md new file mode 100644 index 0000000..5cf307a --- /dev/null +++ b/modding/ss-specs/archives/gameVariables.md @@ -0,0 +1,208 @@ +## Game variables + +This page lists all game variables with their index, their initital value, and their usage in the original game. + +> At the moment not all variables have been catalogued. Only the initial values have been determined. + +### Boolean variables + +| Index | Initial Value | Usage | +|------:|--------------:|----------------------------------------------------------------------------------------------| +| 0 | 0 | Must always be 0, otherwise default conditions may break | +| 1 | 1 | Must always be 1, otherwise some locks may fail | +| 2 | 1 | Unknown | +| 3 | 1 | Unknown | +| 6 | 0 | Shield status (+ System Analyzer) | +| 8 | 0 | Laser status (+ System Analyzer); 0: charging, 1: destroyed | +| 10 | 0 | Delta grove launch enable (+ System Analyzer) | +| 11 | 0 | Alpha grove launch enable (+ System Analyzer) | +| 12 | 0 | Beta grove launch enable (+ System Analyzer) | +| 13 | 0 | Relay 428 report status; 0: functioning, 1: faulty; Set by main switch, checked by diag. | +| 14 | 0 | Relay 428 repair status; 0: faulty, 1: fixed; Set by panel, checked by main switch. | +| 15 | 0 | Beta grove jettisoned (+ System Analyzer) | +| 16 | 1 | Unknown | +| 18 | 1 | Unknown | +| 20 | 0 | Reactor on destruct, pods enabled; Quakes (+ System Analyzer) | +| 21 | 1 | Unknown | +| 22 | 1 | Unknown | +| 23 | 1 | Unknown | +| 24 | 1 | Unknown | +| 25 | 1 | Unknown | +| 26 | 1 | Medical armory lock on doors in level 1 | +| 30 | 0 | Switch to open armory doors on level 1 via cyberspace level 14 | +| 32 | 1 | Unknown | +| 33 | 1 | Unknown | +| 36 | 1 | Unknown | +| 37 | 1 | Unknown | +| 75 | 1 | Unknown | +| 76 | 1 | Unknown | +| 77 | 1 | Unknown | +| 78 | 1 | Unknown | +| 79 | 1 | Unknown | +| 80 | 1 | Unknown | +| 81 | 1 | Unknown | +| 82 | 1 | Unknown | +| 83 | 1 | Unknown | +| 84 | 1 | Unknown | +| 85 | 1 | Unknown | +| 86 | 1 | Unknown | +| 87 | 1 | Unknown | +| 88 | 1 | Unknown | +| 89 | 1 | Unknown | +| 90 | 1 | Unknown | +| 91 | 1 | Unknown | +| 92 | 1 | Unknown | +| 93 | 1 | Unknown | +| 94 | 1 | Unknown | +| 95 | 1 | Unknown | +| 112 | 1 | Unknown | +| 113 | 1 | Unknown | +| 114 | 1 | Unknown | +| 115 | 1 | Unknown | +| 116 | 1 | Unknown | +| 117 | 1 | Unknown | +| 118 | 1 | Unknown | +| 119 | 1 | Unknown | +| 120 | 1 | Unknown | +| 121 | 1 | Unknown | +| 122 | 1 | Unknown | +| 123 | 1 | Unknown | +| 124 | 1 | Unknown | +| 125 | 1 | Unknown | +| 126 | 1 | Unknown | +| 127 | 1 | Unknown | +| 145 | 0 | "Online" help (tooltips); 0: show, 1: off | +| 160 | 1 | Unknown | +| 161 | 1 | Unknown | +| 162 | 1 | Unknown | +| 163 | 1 | Unknown | +| 164 | 1 | Unknown | +| 165 | 1 | Unknown | +| 166 | 1 | Unknown | +| 167 | 1 | Unknown | +| 168 | 1 | Unknown | +| 169 | 1 | Unknown | +| 192 | 1 | Unknown | +| 193 | 1 | Unknown | +| 194 | 1 | Unknown | +| 195 | 1 | Unknown | +| 196 | 1 | Unknown | +| 197 | 1 | Unknown | +| 198 | 1 | Unknown | +| 199 | 1 | Unknown | +| 200 | 1 | Unknown | +| 201 | 1 | Unknown | +| 202 | 1 | Unknown | +| 203 | 1 | Unknown | +| 204 | 1 | Unknown | +| 205 | 1 | Unknown | +| 206 | 1 | Unknown | +| 207 | 1 | Unknown | +| 225 | 1 | Unknown | +| 227 | 1 | Unknown | +| 229 | 1 | Unknown | +| 231 | 1 | Unknown | +| 233 | 1 | Unknown | +| 235 | 1 | Unknown | +| 237 | 1 | Unknown | +| 239 | 1 | Unknown | +| 241 | 1 | Unknown | +| 243 | 1 | Unknown | +| 245 | 1 | Unknown | +| 247 | 1 | Unknown | +| 249 | 1 | Unknown | +| 251 | 1 | Unknown | +| 253 | 1 | Unknown | +| 255 | 1 | Unknown | +| 257 | 1 | Unknown | +| 259 | 1 | Unknown | +| 261 | 1 | Unknown | +| 263 | 1 | Unknown | +| 265 | 1 | Unknown | +| 267 | 1 | Unknown | +| 269 | 1 | Unknown | +| 271 | 1 | Unknown | +| 273 | 1 | Unknown | +| 275 | 1 | Unknown | +| 277 | 1 | Unknown | +| 279 | 1 | Unknown | +| 281 | 1 | Unknown | +| 283 | 1 | Unknown | +| 285 | 1 | Unknown | +| 287 | 1 | Unknown | +| 289 | 1 | Unknown | +| 291 | 1 | Unknown | +| 293 | 1 | Unknown | +| 295 | 1 | Unknown | +| 297 | 1 | Unknown | +| 299 | 1 | Unknown | +| 300 | 0 | Set to 1 while the data reader icon is flashing & beeping | + + +### Integer variables + +| Index | Initial Value | Usage | +|------:|--------------:|---------------------------------------------------------| +| 1 | 0 | Incremented for each destroyed computer node lvls 1-7 | +| 3 | 2 | Engine state | +| 9 | 0 | Incremented by 1 for laser, beta grove, reactor | +| 12 | 3 | Number of groves | +| 13 | Mission value | Unknown | +| 14 | Cyber value | Unknown | +| 15 | Combat value | Unknown | +| 16 | L00 security | Security value of level | +| 17 | L01 security | Security value of level | +| 18 | L02 security | Security value of level | +| 19 | L03 security | Security value of level | +| 20 | L04 security | Security value of level | +| 21 | L05 security | Security value of level | +| 22 | L06 security | Security value of level | +| 23 | L07 security | Security value of level | +| 24 | L08 security | Security value of level | +| 25 | L09 security | Security value of level | +| 26 | L10 security | Security value of level | +| 27 | L11 security | Security value of level | +| 28 | L12 security | Security value of level | +| 29 | L13 security | Security value of level | +| 30 | Puzzle value | Unknown | +| 31 | random | Reactor code 1 (3-digit BCD) | +| 32 | random | Reactor code 2 (3-digit BCD) | +| 33 | 0 | Destroyed comp. nodes on lvl1 before showing digit | +| 34 | 0 | Destroyed comp. nodes on lvl2 before showing digit | +| 35 | 0 | Destroyed comp. nodes on lvl3 before showing digit | +| 36 | 0 | Destroyed comp. nodes on lvl4 before showing digit | +| 37 | 0 | Destroyed comp. nodes on lvl5 before showing digit | +| 38 | 0 | Destroyed comp. nodes on lvl6 before showing digit | +| 39 | 0 | Destroyed cyberguards in large cspace / open barrier | +| 40 | | Unknown | +| 41 | as per config | Audio music volume (0x00 .. 0x64) | +| 42 | 0x4A3D | Video gamma | +| 43 | as per config | Audio digital FX volume (0x00 .. 0x64) | +| 44 | as per config | Mouse handedness (Right: 0, Left: 1) | +| 45 | | Unknown | +| 46 | | Unknown | +| 47 | 0x5555 | Double click time | +| 48 | as per config | Selected language (0: ENG, 1: FRA, 2: GER) | +| 49 | as per config | Audio message volume (0x00 .. 0x64) | +| 50 | as per config | Video resolution | +| 51 | 256 | Joystick sensitivity | +| 52 | | Unknown | +| 53 | as per config | Audio messages (0: Text, 1: Speech, 2: Both) | +| 54 | | Unknown | +| 55 | | Unknown | +| 56 | | Unknown | +| 57 | | Unknown | +| 58 | as per config | Audio channels (0: 2ch, 1: 4ch, 2: 8ch) | +| 59 | | Unknown | +| 60 | | Unknown | +| 61 | | Unknown | +| 62 | | Unknown | +| 63 | | Unknown | + + +The two random codes (index 31, 32) will be randomized if they are equal. + +> The two random codes are stored in [Binary-Coded-Decimal](https://en.wikipedia.org/wiki/Binary-coded_decimal). + +> The integer values from index 40 on seem to be all about in-game options. +> This may be the reason why it was earlier thought that there are only 40 game variables. diff --git a/modding/ss-specs/archives/index.md b/modding/ss-specs/archives/index.md new file mode 100644 index 0000000..9fbcffe --- /dev/null +++ b/modding/ss-specs/archives/index.md @@ -0,0 +1,80 @@ +## Archives +This section covers the archive files. There is one template archive file, called ```archive.dat```, and a number of save-game files, called ```savgamXX.dat```. All of these files are regular resource files. + +Any new game is based on the archive.dat file. All these archive files contain the complete game state, including the level layout. In other words: Citadel itself, with all its content, is represented through such a file; Properties and media for the content is in the other resource files. + +## Map specific data +Each level has 98 resources reserved, but only 51 in use. The level-specific resources are grouped according to the level identifier. For example, on Citadel level R, the resources start with ID ```4002``` (decimal) up to ```4053```. Level 1 uses ```4102``` to ```4153``` and so forth. + +On the following pages, resources identified as ```LXX``` refer to level-specific resource ```XX``` for any level. These numbers will always be given in decimal. + +## Extra Resources + +### Name of archive, resource 0x0FA0 (4000) +This resource contains the name of the archive (title of the save-game). It is a 0x00 terminated string, with a dynamic length of up to 32 bytes (including the termination character). + +> The archive.dat file contains extra (unknown) data after the name in this resource, up to a complete length of 128 bytes. A save-game file has only the name in this resource. The name of the template archive is "Starting Game". + +### [Game state resource 0x0FA1 (4001)](gameState.md) +This resource contains information about the game and the hacker. +It also contains the [level variables](levelVariables.md). + +### Additional Resources + +Save-game files contain 4 further resources that are not present in archive.dat : + +* 0x0000 Unknown int32. ```0x00000000``` at first investigation +* 0x024E Unknown. +* 0x024F Unknown. +* 0x0F9F Unknown int32. ```0x00004444``` at first investigation + +## Index + +### By topic + * [Map Information](mapInformation.md) + * [Level Objects](levelObjects.md) + * [Surveillance Cameras](surveillanceCameras.md) + * [Loop Configuration](loopConfiguration.md) + + +### By level-specific resource ID + * ```L02``` Map version number. int32 with value ```0x0000000B``` for all maps. + * ```L03``` Object version number. int32 with value ```0x0000001B``` for all maps. + * ```L04``` [Basic level information](mapInformation.md) + * ```L05``` [Map Layout](mapInformation.md) + * ```L06``` [Level schedules](levelSchedules.md) + * ```L07``` [Level texture map](mapInformation.md) + * ```L08``` [Main object table](levelObjects.md#level-object-table) + * ```L09``` [Level object cross-reference table](levelObjects.md#level-object-cross-reference-table) + * ```L10``` to ```L24``` 15 object [class specific tables](levelObjects.md#class-tables-and-entries) + * ```L25``` to ```L39``` 15 object [class default info](levelObjects.md#class-default-information) + + * ```L40``` Miscellaneous savefile version number. int32. For all maps: ```CD-Release```: ```0x0000000D```. All other: ```0x0000000B``` + * ```L41``` Unused entry. byte with value ```0x00``` for all maps. + + * ```L42``` [Texture animation](textureAnimation.md) + * ```L43``` [Surveillance Sources](surveillanceCameras.md) + * ```L44``` [Surveillance Surrogates](surveillanceCameras.md) + * ```L45``` [Level variables](levelVariables.md) + * ```L46``` [Map notes](mapInformation.md#map-notes) + * ```L47``` [Map notes pointer](mapInformation.md#map-notes) + + * ```L48``` Unknown. 0x30 byte length. + * ```L49``` Unknown. 0x1C0 byte length. + * ```L50``` Unknown int16. ```0x0000``` for all maps. + * ```L51``` [Loop Configuration](loopConfiguration.md) + * ```L52``` Unknown int16. (Only present in ```CD-Release```) + * ```L53``` Unknown. 0x40 byte length, all zeroes. (Only present in ```CD-Release```) + + +### Levels of Citadel +This is the level list of the base game: + +* Level 00: Reactor level +* Level 01-09: Levels 1-9 +* Level 10: SHODAN cyberspace +* Level 11: Delta grove +* Level 12: Alpha grove +* Level 13: Beta grove +* Level 14: Cyberspace for levels 1 and 2 +* Level 15: Cyberspace diff --git a/modding/ss-specs/archives/levelObjects.md b/modding/ss-specs/archives/levelObjects.md new file mode 100644 index 0000000..2fafb0f --- /dev/null +++ b/modding/ss-specs/archives/levelObjects.md @@ -0,0 +1,175 @@ +## Level Objects + +Instance data of [Level Objects](../levelObjects/index.md) is stored as part of an archive. Two resources describe general information, then further resources are object class specific. + +### General Information + +#### Level Object Table + +```L08```, a compressed resource, contains a table with general entries about the objects in the current level. +This table contains 872 entries, which houses two lists. + +**Level Object Entry** (`Obj` struct) (27 bytes) + + 0000 byte In use flag. 0: free, 1: in use + 0001 byte Object class + 0002 byte Object subclass + 0003 int16 Class table index + 0005 int16 Cross-Reference table index (also: used list head) + 0007 int16 Next + 0009 int16 Previous + 000B uint16 X Coordinate + 000D uint16 Y Coordinate + 000F byte Z Coordinate (height) + 0010 byte Pitch rotation (around X axis), increasing values turn upwards + 0011 byte Yaw/Heading rotation (around Z axis), increasing values turn rightwards + 0012 byte Roll/Bank rotation (around Y axis), increasing values turn rightwards + 0013 byte Physics (0xFF for unaffected, the default) + 0014 byte Object type + 0015 sint16 Hitpoints + 0017 [4]byte Extra + +The starting point for both lists is the first entry (index zero). This entry is not in use (```In use flag``` is zero). + +The `Cross-Reference table index` field of the starting point refers to the first object of a list of objects in use. For all object entries in use, +their ```Previous``` and ```Next``` fields form a double-linked list, with the exception of the starting point: +The last used object entry refers to the starting point with its ```Next``` value without being referred-to from the starting point. + +The `Next` index of the starting point refers to the first object of a list of objects not in use. +For all object entries not in use, their `Next` field forms a single-linked list. +The `Previous` field of unused objects is not defined and contains arbitrary data. +The last unused object entry refers to the starting point with its ```Next``` value. + +If an object is placed in the world, it has the X, Y and Z coordinates set, as well as the ```Cross-Reference table index``` pointing to a corresponding entry in the cross-reference table. +If an object was spawned by the engine without a location in the world, such as loot from corpses, +then the ```X``` field is ```0xFFFF```, ```Y``` and ```Z``` fields are zero, and the ```Cross-Reference table index``` is zero as well. + +The ```extra``` field is a multi-purpose field, which may hold further properties and/or state. The exact features are environment specific and may also be dependent on the classification of the object. + +##### Global extra + +For some objects, the ```extra``` field is dependent on the object type or class, and the field is applicable to both real world and cyberspace. + +**Basic Extra** (4 bytes) + + 0000 byte Make Info - a general index + 0001 byte Current Frame - for animations + 0002 byte Time Remainder - for animations + 0003 byte Instance Flags - for various purposes + +This is the layout as defined by the engine. Yet, these values may be interpreted/used specifically according to type and/or environment. + + +**Panel Extra** (4 bytes) + + 0000 byte Panel name index; Index into cybstrng 0x0869 + 0001 [3]byte Other + +If the ```Panel name``` resolves to an unknown block or empty string, then the regular object name is displayed. + +**Surfaces Extra* (4 bytes) + + 0000 byte Unknown + 0001 byte Bitmap Index + 0002 [2]byte Unknown + +This is used for simple surfaces, such as icons (`7/2/1`) and paintings (`7/2/4`). + +##### Cyberspace extra + +In case the level is cyberspace, the ```extra``` field contains the following properties. + +**Cyberspace Level Object Extra** (4 bytes) + + 0000 byte Other + 0001 byte ICE presence + 0002 byte Unknown + 0003 byte ICE level + +Level objects in cyberspace can be locked by ICE (Intrusion Countermeasures Electronics). ICE need to be defeated using the drill tool before the locked object can be acquired/used. The ```ICE presence``` field determines whether ICE is present. Any value != ```0x00``` has ICE present. +If present, the health of the ICE is determined by the health of the object. +The ```ICE level``` then determines how aggressive and powerful the ICE is. ```0x00``` is the weakest, ```0xFF``` is the highest. + +> Although the vanilla game uses ICE only on software objects, the engine also supports ICE on other objects, such as cyberspace exits. + +#### Level Object Cross-Reference Table + +```L09```, a compressed resource, is a table that links level objects and the tiles they are in. The index field in the [Tile Map Entry](mapInformation.md) refers to this table, as well as the table index field in the **Level Object Entry**. This table contains 1600 entries. + +**Level Object Cross-Reference Entry** (10 bytes) + + 0000 int16 Tile X + 0002 int16 Tile Y + 0004 int16 Index into level object entry table + 0006 int16 Index for next object + 0008 int16 Index for next tile of same object + +Objects can extend over several tiles. The last field points to the next reference entry about the same object, with different tile coordinates. +This field creates a single-linked list, with the last entry pointing to the first one. +If the object is contained within one tile only, this field points to its own entry. + +The major coordinates of the main object entry match those of the last entry of the cross-reference list. The cross-reference list only contains the X/Y values of the tiles - not sub-tile coordinates. + +The ```Index for next object``` field creates a single-linked list, terminating with a zero value. +This list either refers to all objects within the same tile (if referred to from a tile), or creates a pool of unused cross-reference entries. The start of this pool is the first entry (index zero). +Unused cross-reference entries contain arbitrary data and only the next object index field is valid. + +### Class Specific Information + +In the following resources, all tables have a static count of entries. Each entry has a common prefix. + +**Level Object Prefix** (6 bytes) + + 0000 int16 Index into level object entry table + 0002 int16 Previous + 0004 int16 Next + +The tables contain two lists each, similar to the main object table: +The starting point is the first entry (index zero). The starting point refers to a double-linked list of used entries (via ```Next```), and a single-linked list of unused entries (via ```Previous```). Both lists are terminated by referring back to index zero. + +The ```Index``` field of the starting point does not refer to a level object, it is the index within the same table of the last used entry. (The actual "previous" link to the double-linked list of used objects.) + +> The sum of all possible entries is slightly higher than the total count of possible **Level Object Entries**. + +#### Class Tables and Entries + +The following table lists the the level resources per class type. It also lists how many entries each table contains and how big one entry is. +The name column links to the corresponding details. + +| Class | Level Res | Name | Entry Count | Entry Size (incl. prefix) | +|:-----:|-------------|-------------------------------------------|-------------|---------------------------| +| 0 | ```L10``` | [Gun][L10] | 16 | 8 | +| 1 | ```L11``` | [Ammo][L11] | 32 | 6 | +| 2 | ```L12``` | Physics | 32 | 40 | +| 3 | ```L13``` | [Grenade][L13] | 32 | 12 | +| 4 | ```L14``` | [Drug][L14] | 32 | 6 | +| 5 | ```L15``` | [Hardware][L15] | 8 | 7 | +| 6 | ```L16``` | [Software][L16] | 16 | 9 | +| 7 | ```L17``` | [Big Stuff][L17] | 176 | 16 | +| 8 | ```L18``` | [Small Stuff][L18] | 128 | 16 | +| 9 | ```L19``` | [Fixture][L19] | 64 | 30 | +| 10 | ```L20``` | [Door][L20] | 64 | 14 | +| 11 | ```L21``` | Animating | 32 | 10 | +| 12 | ```L22``` | [Trap][L22] | 160 | 28 | +| 13 | ```L23``` | [Container][L23] | 64 | 21 | +| 14 | ```L24``` | [Critter][L24] | 64 | 46 | + + +[L10]: ../levelObjects/00_Gun/levelGunEntry.md +[L11]: ../levelObjects/01_Ammo/levelAmmoEntry.md +[L13]: ../levelObjects/03_Grenade/levelGrenadeEntry.md +[L14]: ../levelObjects/04_Drug/levelDrugEntry.md +[L15]: ../levelObjects/05_Hardware/levelHardwareEntry.md +[L16]: ../levelObjects/06_Software/levelSoftwareEntry.md +[L17]: ../levelObjects/07_BigStuff/levelBigStuffEntry.md +[L18]: ../levelObjects/08_SmallStuff/levelSmallStuffEntry.md +[L19]: ../levelObjects/09_Fixture/levelFixtureEntry.md +[L20]: ../levelObjects/10_Door/levelDoorEntry.md +[L22]: ../levelObjects/12_Trap/levelTrapEntry.md +[L23]: ../levelObjects/13_Container/levelContainerEntry.md +[L24]: ../levelObjects/14_Critter/levelCritterEntry.md + +### Class Default Information + +Each level has 15 further resources, ```L25``` to ```L39```, one for each object class. The length of these resources is the same as for one class entry of the corresponding specific entries. +This information is used as default for creating new objects. Most of the time, all their bytes are 0x00. diff --git a/modding/ss-specs/archives/levelSchedules.md b/modding/ss-specs/archives/levelSchedules.md new file mode 100644 index 0000000..a7fad14 --- /dev/null +++ b/modding/ss-specs/archives/levelSchedules.md @@ -0,0 +1,119 @@ +## Level Schedules + +```L06``` is a dynamically sized list of currently active schedules. A schedule performs some delayed action at a specific time in the future. +It contains a number of live entries equal to the ```timer count``` field from ```L04``` [(Level Information)](mapInformation.md#Level-information). +Unless the list is full, this list always has one extra "terminator" entry at the end. + +**Level Schedule Entry** (`SchedEvent` struct) (8 bytes) + + 0000 int16 Timestamp, with a scale of 0x100 = 10 seconds + 0002 int16 Type + 0004 byte[4] Data + +```Time``` specifies the timestamp accoring to in-game time. Should the level become activated (either through loading of savegame load or level transition) and the current time is beyond the trigger time, the object is immediately triggered. + +```Type``` specifies how the schedule should be handled on timeout. ```Data``` is then type-specific. + +> The one terminator entry is required to exist, and does not have to contain any useful information at the beginning (can be all zero). +> The engine sorts the schedule entries according to their ```trigger time```. + +For the schedule data, any object index refers to the main object table in the level. + +### Schedule Type 0: Null Schedule Event + +This event does nothing. + + +### Schedule Type 1: Grenade Schedule Event (`GrenSchedEvent` struct) + +**Grenade Schedule Event Data** (4 bytes) + + 0000 int16 Grenade object index + 0002 byte Unique identifier + 0003 byte Unused + +The ```unique identifier``` must match the value from the (live) grenade object in the level to be handled. +This event removes the grenade object on timeout. + + +### Schedule Type 2: Explosion Schedule Event + +This event does nothing. + + +### Schedule Type 3: Door Schedule Event (`DoorSchedEvent` struct) + +**Door Schedule Event Data** (4 bytes) + + 0000 int16 Object index + 0002 uint16 Unique identifier + +If the ```object index``` references an open door, then this door will be triggered to close. + +If the ```object index``` references a "plastiqued antenna relay", then this event causes the explosion and the special handling of these fixtures. + + +### Schedule Type 4: Trap Schedule Event (`TrapSchedEvent` struct) + +**Trap Schedule Event Data** (4 bytes) + + 0000 int16 Target object index + 0002 int16 Source object index + +Triggers the ```target object``` first (without condition check). +If ```source object``` is not -1, it is attempted next, including condition checks. + + +### Schedule Type 5: Exposure Schedule Event + +**Exposure Schedule Event Data** (`SchedExposeData` struct) (4 bytes) + + 0000 sint8 Damage + 0001 uint8 Type + 0002 uint8 TSecs + 0003 uint8 Count -- unused + +This event exposes ```type``` to the hacker for half of the amount given in ```damage```. Damage values are rounded towards the greater number. +If the calculated half is not equal to the ```damage``` value, then another event is re-scheduled with an offset of ```tSecs```. + +> An earlier version of the engine appeared to use ```count``` as a counter towards zero, though this was replaced with the damage-halving algorithm. + + +### Schedule Types 6, 7: Floor, Ceiling Schedule Events (`HeightSchedEvent` struct) + +These events share the same event data and algorithm. + +**Height Schedule Event Data** (4 bytes) + + 0000 sint8 Semaphor + 0001 sint8 Key + 0002 sint8 Steps Remaining + 0003 sint8 SFX Code -- unused + +This event references the height transition referenced by ```semaphor``` (stored in ```L53```) which has to have given ```key``` to be valid. + +```Steps remaining``` indicates both the direction, as well as how many further steps to take. When this counter reaches zero, movement stops. + + +### Schedule Type 8: Light Schedule Event + +This event has no specific data. + +It turns off any muzzle light source and restores the state of the lamp hardware. + + +### Schedule Type 9: Bark Schedule Event (`BarkSchedEvent` struct) + +This event has no specific data. + +It is scheduled by specific ```trap message``` actions (action type 22) to automatically remove the side-image after a certain time from the side-MFD. + + +### Schedule Type 10: EMail Schedule Event (`EmailSchedEvent` struct) + +**EMail Schedule Event Data** (4 bytes) + + 0000 sint16 Data Munge (EMail index) + 0002 byte[2] Unused + +This event is the delayed action of ```receive email``` actions (action type 15). This event is used if the action has a delay higher than zero. diff --git a/modding/ss-specs/archives/levelVariables.md b/modding/ss-specs/archives/levelVariables.md new file mode 100644 index 0000000..f9cd804 --- /dev/null +++ b/modding/ss-specs/archives/levelVariables.md @@ -0,0 +1,36 @@ +## Level Variables + +```L45``` contains level variables, including environmental settings. + +**Level Variables** (94 bytes) + + 0000 int16 Data Length. = 94 + 0002 byte Mist; Unused (0x00) + 0003 byte Gravity; Unused (0x00) + 0004 byte Radiation poisoning level, in 0.5 LBP + 0005 byte Gravity, in 25% OR bio contamination level, in 0.5 LBP + 0006 byte Gravity switch. If 0, ```0005``` is bio contamination. Gravity mod otherwise. + 0007 byte Bio contamination register. Must be >1 to have contamination to register + 0008 byte Radiation register. Must be >1 to have radiation to register + 0009 uint32 Exit Time; Unused in savegame + 000D [3*27]byte 3 Automap Variables entries, for first, second, full-screen (see below) + + +> Hazards are notified from value 0x01, but affect only starting from value 0x02 (1 LBP). + + +**Automap Variables** (27 bytes) + + 0000 byte Initialized. + 0001 byte Zoom; 1 (farthest) .. 5 (nearest) + 0002 sint32 X-coordinate; (as fix32 value) + 0006 sint32 Y-coordinate; (as fix32 value) + 000A uint16 Unknown + 000C uint16 Unknown + 000E uint16 Follow object; The object to focus on; Always 0x0001 (hacker) + 0010 uint16 Sensor object + 0012 uint16 Note object + 0014 uint16 Flags + 0016 uint16 Available flags + 0018 byte Version ID + 0019 uint16 Sensor radius; Scan radius, in tiles and sub-tiles diff --git a/modding/ss-specs/archives/loopConfiguration.md b/modding/ss-specs/archives/loopConfiguration.md new file mode 100644 index 0000000..b2c5fd9 --- /dev/null +++ b/modding/ss-specs/archives/loopConfiguration.md @@ -0,0 +1,24 @@ +## Loop Configuration + +```L51``` is a table containing loop configuration. 64 entries are stored in this table, with unused entries set to 0x00. + +> This table appears to be automatically filled by displaying objects, such as screens and the like. +> Although ```archive.dat``` contains valid information in this table, it is rewritten by the information from the level object properties. + +**LoopConfigEntry** (15 bytes) + + 0000 int16 Object Table Index + 0002 byte Loop type + 0003 [10]byte Unused + 000D int16 Frame time -- 0x0100 is 0.9 seconds; Default: 0x0080 + +**Loop Type** + + 0 Don't loop, stick with first frame + 1 Loop forward + 2 Play once (on finish, stick with last frame) + 3 Loop backward + 4 Loop back and forth + 5 Loop back and forth + 6 Loop back and forth + 7 Loop back and forth diff --git a/modding/ss-specs/archives/mapInformation.md b/modding/ss-specs/archives/mapInformation.md new file mode 100644 index 0000000..f913f9f --- /dev/null +++ b/modding/ss-specs/archives/mapInformation.md @@ -0,0 +1,252 @@ +## Map Information + +### Map layout + +#### Level information + +```L04```, a compressed resource, contains the basic information about a level (a map). + +> In the original source, this structure is called ```FullMap```. + +**Level Information** (58 bytes) + + 0000 sint32 X Size - always 64 in the archives. + 0004 sint32 Y Size - always 64 in the archives. + 0008 sint32 X Shift - always 6 in the archives. + 000C sint32 Y Shift - always 6 in the archives. + 0010 sint32 Z Shift + 0014 byte[4] Placeholder - ignore + 0018 byte Cyberspace flag. 0: real life, 1: cyberspace + 0019 byte[12] Unused + 0025 byte[21] Scheduler Info + +> The engine doesn't support maps with more than 4096 tiles. By default, this is a map of 64x64 tiles. +> While other combinations of X/Y sizes are feasible, as long as the respective shift parameter matches, there is +> hardly a point to try something different than 64x64. + +The ```z shift``` determines the height of the level as well as the size of one height unit for tiles. +The level is one tile width high (perfect cube) if the shift value is 5. +A shift value of 0 gives 32 tiles of height, a value of 7 one fourth (1/4) of a tile. So it's 32, 16, 8, 4, 2, 1, 1/2, 1/4 for 0..7. +> Most levels have a shift value of 3, giving 4 tile widths of height. Security (Level 8) has a shift value of 1 - 16 tiles. + +One height unit is the 32th of the level height. The higher the level is, the larger are the visible jumps of any height value. + +Although any level can be marked as cyberspace - even the starting level - only levels 10, 14 and 15 properly work. +This is due to the engine considering 10 and everything above 13 to be cyberspace. +Cyberspace items in other levels don't work as expected, especially the exit port either crashes the game or puts the player at the start of the cyberspace level again. + +As a further hardcoded limitation, only cyberspace levels 14 and 15 have a time limit. Level 10 is without limit. + +The ```placeholder``` has no use in the archives. In the engine, this field is used as a placeholder for a pointer within the game logic. + +The ```schedule``` information is the primary entry for level-specific timers. + + +**Level Scheduler Information** (21 bytes) + + 0000 sint32 Size - always 64 in the archives + 0004 sint32 Schedule count + 0008 sint32 Element size - always 8 in the archives + 000C byte Grow - always 0 in the archives + 000D byte[4] Pointer1 + 0011 byte[4] Pointer2 + + +```Size``` and ```element size``` require to be set to the values ```0x40``` and ```0x08``` respectively. Otherwise the level timers (stored in ```L06```) will not work properly. + +The ```element size``` of 8 comes from the size of scheduler entries. + +```Grow```, a boolean, would indicate to the scheduler system to allow reallocating the timer list to contain more than ```size``` amount of entries. Yet, the engine always initializes this with 0 (= don't grow). + +The two pointer fields are used engine-internally. + + +#### Tile map + +The tiles are described in the compressed resource ```L05```, a table of tile map entries. The first entry describes the lower left tile and the next entries go east first, then north (first columns, then rows). All levels have a size of 64x64 tiles as the engine supports (and expects) only this layout. + +The outermost tiles of the level (the 'border') should always be solid tiles. If these tiles are non-solid, the graphics engine may have rendering issues (walls not shown, just black) and entering these tiles freezes the game. This gives an effective available space of 62x62 tiles. + +**Tile Map Entry** (16 bytes) + + 0000 byte Type + 0001 byte Floor Info + 0002 byte Ceiling Info + 0003 byte Slope height. Valid for any non-flat tile. + 0004 sint16 Index to first object in cross-reference table. + 0006 uint16 Texture Info + 0008 uint32 Flags + 000C byte Sub Clip -- always 0xFF + 000D byte Clear Solid -- unknown + 000E byte Unused + 000F byte Light delta + +**Type** (1 byte) + +The type is an enumeration with the following constants: + + 0x00 solid + 0x01 open + 0x02 diagonal open s/e (nw edge closed off) + 0x03 diagonal open s/w (ne edge closed off) + 0x04 diagonal open n/w (se edge closed off) + 0x05 diagonal open n/e (sw edge closed off) + 0x06 slope s->n + 0x07 slope w->e + 0x08 slope n->s + 0x09 slope e->w + 0x0A valley se->nw + 0x0B valley sw->ne + 0x0C valley nw->se + 0x0D valley ne->sw + 0x0E ridge nw->se + 0x0F ridge ne->sw + 0x10 ridge se->nw + 0x11 ridge sw->ne + +All non-open tiles are given as low -> high. Valleys have 3 vertexes higher and one lower (at floor level). Ridges have one vertex higher and the remaining three at floor level. The given direction is that between the one vertex and its opposite one. + + +**Floor/Ceiling Info** (1 byte) + + bits 0-4 Height (ceiling is units down from 0x20) + bits 5-6 Texture rotation. When viewed from above: 1: 90°, 2: 180°, 3: 270° - always clockwise + bit 7 Hazard flag. Floor has biohazard, ceiling has radiation hazard. + +The exact behaviour for the hazard flags is determined by the [level variables](levelVariables.md). + +**Texture Info** (2 bytes) + +The texture info field contains information for the tile textures of the level. +For real world, it contains index values into the texture map (see next resource below). +Cyberspace levels specify the palette color index for floor and ceiling of the South-Western corner. + + Real World: + bits 0-5 Wall texture + bits 6-10 Ceiling texture + bits 11-15 Floor texture + + Cyberspace: + bits 0-7 Floor palette index + bits 8-15 Ceiling palette index + +**Flags** (4 bytes) + + Real World: + 0x80000000 Tile visited (automapper) + 0x70000000 Unused + 0x0F000000 Ceiling shadow + 0x00F00000 Unused + 0x000F0000 Floor shadow + 0x0000E000 Music index + 0x00001000 Music "peril" + 0x00000C00 Slope control. See below. + 0x00000200 Deconstructed flag ("spooky" music) + 0x00000100 Use wall texture from adjacent tile + 0x00000080 Unused -- only set for computer room on level 7 + 0x00000040 Flip wall texture horizontally alternating + 0x00000020 Flip wall texture horizontally + 0x0000001F Offset for wall textures + + Cyberspace: + 0x01000000 Flight pull: Strong pull towards floor + 0x000F0000 Flight pull + 0x0000E000 Music index + 0x00001000 Music "peril" + 0x00000C00 Slope control. See below. + 0x00000060 Game-Of-Life flags (either needs to be set) + + +```Ceiling shadow``` and ```Floor shadow``` specify the darkness intensity for the south-western corner of the tile. +The ceiling value darkens all four surrounding ceilings. +The walls made by surrounding top-pillars are darkened starting from the ceiling. +The walls made by surrounding bottom-pillars are darkened starting from their floor. + +The floor value darkens all four surrounding floors. +The walls made by surrounding bottom-pillars are darkened starting from the floor. +The walls made by surrounding top-pillars are darkened starting from their ceiling. + +> The actions to modify the light values can only give tiles more light, or remove previously added light. +> Thus, with shadow values of 0, these tiles are as bright as possible, without any chance of making them darker. + + +```Slope control``` specifies for the floor and ceiling how slopes (and valleys and ridges) should look like. + + 0: Ceiling is inverted to floor; A slope height of 0x1F will have ceiling and floor seal of the tile. + 1: Ceiling is mirroring the floor; A slope height of 0x10 will have their most extent vertexes touch each other. + 2: Ceiling is flat. + 3: Floor is flat. + + +```Flip wall texture horizontally``` + +Wall textures can be flipped horizontally. + +In the ```alternating``` case, flipping is based on the parities of the tile's two coordinates (even/odd status of the X and Y values). +Furthermore, North/South wall textures are flipped separately to East/West wall textures; If the North/South walls are flipped for a certain tile, the corresponding East/West walls will not be flipped. If the North/South walls are not flipped, then the East/West walls will be flipped. +> This ensures mirrored patterns are kept across corners without the need to change the flags for every other tile. This is heavily used on level 9 for the infested walls. + +The North/South texture is to be flipped if the parities of the tile's two coordinates are different - East/West would be normal in this case. + +The North/Shouth texture is normal if the parities of the tile's two coordinates match - East/West would be flipped in this case. + +This alternation is inverted if additionally bit ```0x00000020```` is set. + + +```Flight pull``` specifies what default translation/rotation forces are in play + + 0: None + 1: Weak pull towards East + 2: Weak pull towards West + 3: Weak pull towards North + 4: Weak pull towards South + 5: Medium pull towards East + 6: Medium pull towards West + 7: Medium pull towards North + 8: Medium pull towards South + 9: Strong pull towards East + 10: Strong pull towards West + 11: Strong pull towards North + 12: Strong pull towards South + 13: Medium pull towards ceiling + 14: Medium pull towards floor + 15: Strong pull towards ceiling + 16: Strong pull towards floor (requires separate extra bit) + +> The engine applies the force to the 'nose' of the hacker, i.e. it will pull the heading until the hacker looks straight +> at the origin of the force. This causes the implicit rotation. Only if the heading is exactly opposite to the origin, +> then no rotation happens (i.e., pulled backwards). +> All but the 'Strong' pulls can be countered. + +**Light delta** (1 byte) + +The high nibble specifies the light delta to the ceiling shadow. The low nibble specifies the light delta to the floor shadow. +The [Change lighting action](../levelObjects/Actions.md#action-type-7-change-lightning) modifies these values. + + +### Texture map + +This resource is a list of int16 texture identifier in resource ```L07``` with a fixed length of 108 bytes. +The ```Texture Info``` fields in the tile map refer to these identifier. +Although the field for wall textures would allow for 64 textures, the resource can only hold up to 54 textures. +The first 32 textures can be used for floor/ceiling as well. + +> The engine caches these textures when reloading a savegame with the same level. +> In the original source this list is also referred to as ```loaded textures``` or ```loved_textures```. +> The 'texture' index for space is 0x00CD, the observation grating has 0x00B1 (ceiling on groves and bridge) + +See [Level Textures](../content/LevelTextures.md) for further information. + + +### Map notes + +#### Text Data + +Text for the map notes is stored in the level-specific resource ```L46```. The notes are stored sequentially, with the 0x00 character terminating a note. + +The maximum length of one note is 40 bytes (including the termination character). The resource itself is pre-initialized with 0x00 bytes to a total length of 0x800 bytes. + +Data in this resource is always defragmented. If a note is modified or removed, the notes are rearranged. + +#### Next available space +Resource ```L47``` is a single uint32 offset, pointing to the first available byte in the text data resource. diff --git a/modding/ss-specs/archives/surveillanceCameras.md b/modding/ss-specs/archives/surveillanceCameras.md new file mode 100644 index 0000000..bfdafb7 --- /dev/null +++ b/modding/ss-specs/archives/surveillanceCameras.md @@ -0,0 +1,28 @@ +## Surveillance Cameras + +The engine supports up to 8 surveillance cameras per level. +Such a camera can be any object in the level, and an additional surrogate can determine when a linked screen should show static although the actual source would still be available. + + +### Surveillance Sources + +The sources of surveillance screens are defined with ```L43```. This resource contains a list of up to 8 object table index values. +These referred objects provide the location and orientation for the video source of a screen. A screen then refers to one of these entries by using one of the corresponding [special values as 'picture source'](../levelObjects/07_BigStuff/levelBigStuffEntry.md). + +**Surveillance Sources Table** (16 bytes) + + 0000 [8]int16 Source object table index + +> For a source that is not an actual camera object, it is typical to find 'null' trigger objects to be the reference. +> Unused surveillance sources refer to the unused object at index 0. + + +### Surveillance Surrogates + +Screens may be bound to a destructible object. If this surrogate is destroyed, the corresponding screen shows static. These associations are stored in ```L44```, a resource with a list of up to 8 object table index values. + +**Surveillance Surrogates Table** (16 bytes) + + 0000 [8]int16 Deathwatch object table index + +> For camera sources, it is typical to refer to the actual camera in this table. diff --git a/modding/ss-specs/archives/textureAnimation.md b/modding/ss-specs/archives/textureAnimation.md new file mode 100644 index 0000000..c9c82b8 --- /dev/null +++ b/modding/ss-specs/archives/textureAnimation.md @@ -0,0 +1,30 @@ +## Texture Animation + +```L42``` is a table containing animation information for textures. 4 entries are stored in this table. + +The ```Animation Group``` field of the [Texture Properties](../fileFormat/PropertyFiles#properties-entry) specifies +the index into this table. A wall that has such a texture set will use further textures. According to the +```Number of frames``` field of this table entry, more textures are used from the [Texture Map](mapInformation.md#texture-map). + +Meaning, if the level uses texture X which has an animation group set, the corresponding animation entry specifies +how many further textures are used for this animation. These then have all to be next to each other in the texture map. + +The properties of the first texture determine how the remaining will behave (climbable, ...) + +> Since most textures have their animation group set to 0x00 and have no animation, the first entry in this +> table is unused and the bytes always 0. + +**Animation Entry** (7 bytes) + + 0000 int16 Frame time (in milliseconds) + 0002 int16 Current frame time + 0004 byte Current frame index + 0005 byte Number of frames for loop + 0006 byte Loop type/Loop state + +**Loop Type** (1 byte) + + 0x00 Loop forward + 0x01 Loop forth-and-back (forward state) + 0x81 Loop forth-and-back (backward state) + diff --git a/modding/ss-specs/content/AccessLevels.md b/modding/ss-specs/content/AccessLevels.md new file mode 100644 index 0000000..adce60a --- /dev/null +++ b/modding/ss-specs/content/AccessLevels.md @@ -0,0 +1,9 @@ +## Access Levels + +Doors can be restricted to certain access levels. The engine knows about 32 access levels (0..31). + +The names of the access levels are stored as [texts](../media/Texts.md) in resource ```0x0879```. Per access level there +are two text strings, so 64 in total. The first string is the short name, for example ```STD``` and the second the long +name, ```Standard```. + +The first level (0) with name ```None``` is not checked. Doors with level 0 immediately open. diff --git a/modding/ss-specs/content/ElectronicMessages.md b/modding/ss-specs/content/ElectronicMessages.md new file mode 100644 index 0000000..3b16660 --- /dev/null +++ b/modding/ss-specs/content/ElectronicMessages.md @@ -0,0 +1,57 @@ +## Electronic Messages + +This chapter documents the electronic messages that the hacker receives either through triggers or collecting them. +This means e-mails (including video mails), logs and data fragments - everything the "Multimedia data reader" can show. + +### Text Format + +The [text resources](../media/Texts.md#electronic-messages) for the messages contain both the content, as well as the +meta information, encoded in text form. Any such message resource has the following string blocks: + + 1 line Meta info + 1 line Title + 1 line Sender + 1 line Subject + N lines Verbose text + 1 line Empty + M lines Terse text + 1 line Empty + +The ```Meta info``` line describes what to show additionally. This text has the following format: + + meta-info := [event " "] [color " "] [left-mfd] ["," [" "] right-mfd] + event := ("i" hex2) | "t" + color := "c" hex2 + left-mfd := dec + right-mfd := dec + + Examples: + "13, 18" + "i17 8" + "t cD1 23,23" + "cD1 23,6" + + +For most of the messages, at least ```left-mfd``` is specified. ```left-mfd``` and ```right-mfd``` are decimal offset +numbers that refer to the image to show in the respective MFD. The offset is based on resource ```0x0028```. +If ```left-mfd``` is greater or equal 256, then the message refers to a video mail. Video mails don't have an MFD image. + +If specified, the ```color``` field identifies a hexadecimal colour palette index. This colour is used to draw the sender and subject lines. + +The ```event``` field chains messages together. "i" events identify the message that must immediately follow the current one; +The hexadecimal number is the offset for the next message (based on resource ```0x0989```). Such interrupt messages then have "t" as event. + +> The vanilla resource files contain a few messages with errors in them: +> There are some "stub email" messages with empty header lines, the text "stub email" twice and only one terminating empty line. +> Furthermore, there is one message in ```gerstrng.res``` which misses the terminating empty line in the terse text. + +### Text Content + +Within the texts, the substring ```$N``` is the placeholder for the name of the hacker. + +### Audio + +Corresponding audio resources are based on ID ```0x0AB5```. If an electronic message with ID X has audio, the audio will have resource ID ```0x0AB5``` + X. (Also: resource ID of text resource + 300) + +These audio resources are [MOVI resources](../media/moviResources.md) with only the audio component. The audio recordings are stored in the files +```citalog.res``` (English), ```geralog.res``` (German) and ```frnalog.res``` (French). diff --git a/modding/ss-specs/content/LevelTextures.md b/modding/ss-specs/content/LevelTextures.md new file mode 100644 index 0000000..f96693c --- /dev/null +++ b/modding/ss-specs/content/LevelTextures.md @@ -0,0 +1,29 @@ +## Level Textures + +A level texture is a square bitmap used for level paneling - floors, ceiling and walls. The resources contain 273 textures, +for which there are four different resolutions available. + +### Bitmaps + +The bitmaps are stored uncompressed in ```texture.res``` in the following resources: + + 0x004C 16x16 bitmaps, one per block (a compound resource) + 0x004D 32x32 bitmaps, one per block (a compound resource) + 0x02C3 - 0x03D3 64x64 bitmaps, one per resource + 0x03E8 - 0x04F8 128x128 bitmaps, one per resource + +### Texts + +Each texture comes with a description (name) and a "can't be used" message. [These texts](../media/Texts.md) +are stored in resources ```0x086A``` and ```0x086B``` respectively. + +> Note: The text resources reserve 512 blocks, but only 273 are used. + +### Properties + +The [texture properties](../fileFormat/PropertyFiles#texture-Properties-textprop.dat) give further information on textures. + +### Animation + +Some textures, according to their properties, are in animation groups. These groups are controlled [per level](../archives/textureAnimation.md). + diff --git a/modding/ss-specs/conventions.md b/modding/ss-specs/conventions.md new file mode 100644 index 0000000..cea16fc --- /dev/null +++ b/modding/ss-specs/conventions.md @@ -0,0 +1,72 @@ +## Conventions + +### Content +Information in these documents MUST be verifiable, or have been verified (e.g. through testing or cross-reference to original code) to be included. Assumptions, contradictions, discussions and the like MUST be handled through the [issue](https://github.com/inkyblackness/ss-specs/issues) system. + +In general, changes to the documents SHOULD be introduced by linking commits to corresponding issues. This way, issues can be used as reference material for verification. + +### Language +The human language of this documentation is English. Names of types/fields SHOULD follow that of the source (which is presumably en-us), while prose text SHOULD follow en-uk . + +This is primarily to uphold the legacy of this documentation. The original ss-specs was maintained within the context of TSSHP, which came from Britain. + +When wording actions, consider differenciating between the character in the game-world ("the hacker"), and the player. ("The hacker has a rotation angle of xyz, so the player sees..."). + +Sentences referring to individuals SHALL be phrased with inclusive, gender neutral language. The use of [singular they](https://en.wikipedia.org/wiki/Singular_they) is to be used if no other option is available. + +> While the hacker is represented with a male body and voice in the original game, a fan-mod could change this. (see [here](https://www.moddb.com/mods/system-shock-female-hacker)) +> This documentation states raw engine data, which doesn't care about humans, even if considered to be insects. + +More generally, consider online guides about inclusive language, such as [this one from Google](https://developers.google.com/style/inclusive-documentation). + +### Formatting + +Vanilla [Markdown](http://daringfireball.net/projects/markdown/syntax) SHOULD be used for all pages. + +The biggest header available is the H2 header (""## Title"). H1 header are reserved for the main title. Each page SHOULD start with an H2 header, which is also the link in the [index](index.md). + + +### Data +In general, this documentation describes (serialized) data structures. Such data structures SHOULD be documented using code blocks and follow the following format: + + offset type description + + +#### Example + + 0000 int32 some value + 0004 [14]byte unknown, not used (0x00) + 0012 int16 other value + +Offset values MUST start at 0 and be given in hexadecimal (or placeholder). They SHOULD have a width of 4 (or more) digits. + +Types are field specific. + +Description is a short human-readable text describing the field. + +#### Integers + +For integer types, they MUST be given in the following format: + + (s|u)?int(8|16|24|32) + +'s' and 'u' prefixes specify 'signed' and 'unsigned integer' respectively. If no sign-prefix is given, it has not been verified whether this field allows negative values or allows greater positive values. + +Implementors MAY treat fields without known sign to be unsigned - this can be the case for length fields for instance. It is unknown whether the original executable would handle them correctly. + +Unless otherwise noted, integer values are stored in [little-endian](http://en.wikipedia.org/wiki/Endianness#Little-endian) format. + +#### Fixed point numbers + +Due hardware limitations and slow perfomance of float point operations, System Shock uses fixed point number represenation of real data type. +Usually engine uses [Q15.16](https://en.wikipedia.org/wiki/Q_(number_format)) format (1 bit for sign, 15 bits for integer part, 16 bits for fractional): + + 0x 0000 0000 + | | + | fractional part + integer part with sign (first bit, 0 means positive, 1 - negative) + +This format is refered as `fix` (int32). For example, (2 * Pi) number is `0x0006487f` (6 + 18559/65536 = 6.28319) + +Internally engine supports Q23.8 numbers (`fix24`) or even Q2.28 (for some 2D- and 3D-computations). + diff --git a/modding/ss-specs/fileFormat/PropertyFiles.md b/modding/ss-specs/fileFormat/PropertyFiles.md new file mode 100644 index 0000000..aeb5bf0 --- /dev/null +++ b/modding/ss-specs/fileFormat/PropertyFiles.md @@ -0,0 +1,196 @@ +## Property Files +A few files don't have an archive format, and instead are flat files consisting directly serialized tables. + +### Texture Properties (textprop.dat) +This file contains a single table of texture property entries, after a header. + +> The table appears to be truncated. Since the game only uses 273 textures, this may have never been detected. +> The file has 4004 bytes on disk, meaning 4000 bytes are available for the table. +> In a test, the original engine appeared to be fine to read a smaller file with exactly 273 entries (3007 bytes) - +> which had the unknown fields set to zero. + +> The original source explains the difference: The texture property system would support up to 400 textures. +> It also **assumed** a property structure size of 10 bytes (hardcoded value), while the actual structure is 11 bytes long. +> In other words, one could feed the engine more than 273 textures, yet no more than 363. More than 363 would simply be ignored and the extra textures would just have their default properties. +> This also explains why the Mac port reads exactly 363 textures, as reading more would exhaust the file. + + +#### Header +The file starts with a 4-byte header. + +**Texture Properties File Header** (4 bytes) + + 0000 int32 file version number, value 0x09 + + +The engine rejects a properties file with a different version number. + + +#### Properties Entry + +**Texture Property Entry** (11 bytes) + + 0000 byte family (unused) + 0001 byte target (unused) + 0002 sint16 resilience (unused); Default: 10 + 0004 sint16 distance modifier; Default: 0 + 0006 byte Climbable. 0x00: not climbable; != 0x00: wall can be climbed (original name: friction) + 0007 byte friction walk (unused); Always 0x00 + 0008 byte Transparency control (original name: force_dir) + 0009 byte Animation group + 000A byte Animation index - if texture is animated + +The two bytes at the beginning (`family` and `target`) almost always are each set to the low-byte of the index. A few exceptions have both bytes 0x00. Since they are unused, it is unclear what they mean. + +The `resilience` is nearly always `0x0A`; The one existing exception has it 0. Since it is unused, it is unclear what it means. + +The `distance modifier` always zero. It is used in the renderer to determine which texture resolution to use, based on distance from the camera. A higher modifier causes the smaller resolutions to be chosen at larger distances; A negative value causes an earlier switch. + +See [Texture Animation](../archives/textureAnimation.md) for details on animated textures. + +**Transparency Control** + + 0x00 Regular texture; transparent pixel (palette index 0x00) are drawn black + 0x01 Space texture (pixel data of image is ignored) + 0x02 Texture with space background for transparent pixels + + +### Object Properties (objprop.dat) +The object properties file contains several tables about properties of [level objects](../levelObjects/index.md). It has the following format: + + | Header | Class 0 Tables | Class 1 Tables | ... | Common Table | + +#### Header +The file starts with a 4-byte header containing the version number. + +**Object Properties File Header** (4 bytes) + + 0000 int32 file version number, value 0x2D + + +The engine rejects a properties file with a different version number. + +#### Class Tables +For each object class, one entry exists with a table of class-generic properties and a further table, subclass-specific: + + | Class N Tables | + | Generic | Subclass 0 | Subclass 1 | ... | Subclass N | + +The sizes of the generic and specific tables are dependent on the object class, subclass and type amount. +The generic table contains one entry per object type of this class. The specific tables contain one entry per object type of the subclass. + +See [Level Objects](../levelObjects/index.md) for an overview of the amount of types and their entry sizes. + + +#### Common Table +At the end of the file is the table with common properties. For each object type one entry. + +**Common Object Properties** (27 bytes) + + 0000 int32 Mass + 0004 int16 Default hitpoints + 0006 uint8 Armour + 0007 byte Render type + 0008 byte Physics type; 0x00: none (insubstantial), 0x01: regular, 0x02: special + 0009 byte Hardness; 0..253 + 000A byte Unused; Internally called "pep", this value appears to not be used by the engine. + 000B byte Physics XR; 0..253 + 000C byte Unused; Internally called "physics_y". These values are set (almost) identical to 000B and have no effect. + 000D byte Physics Z; Set to 0x30 for all barriers (10/x/x). + 000E byte Vulnerabilities + 000F byte Special vulnerabilities + 0010 [2]byte Unused + 0012 byte Defence value + 0013 byte Toughness; 0x03: Invincible + 0014 int16 Flags + 0016 int16 MFD/Mesh ID + 0018 [2]byte Bitmap 3D field model index + 001A byte Destruction effect + + +`Physics type` governs how collisions shall be handled. `0x00` makes the object insubstantial, ignoring forces applied to it. For instance, most scenery stays where it was put. `0x01` is for regular objects meant to react "normal" in a physical world. `0x02` behaves strange is set for exactly one object (`14/0/6`, "SONIC THE HEDGEHOG", an unused critter). Objects with a physics type of `0x02` aren't affected by gravity for instance, but are repelled if hacker runs into them. +The `hardness` acts as repulsion force on the object if it collides with the ground or walls - or on the hacker if hacker runs into it. +Hardness depends on the `mass`; Objects with more mass bounce around less. + +The `physics xr` value is applied to vertically center (animated) sprites on their position. If `0x00`, the sprites are rendered with their bottom anchored at the object's position. Higher values will move the sprites downwards. + +Related, the `physics z` value is inconsistently used as a height value. Only if this value is `0x00` (most of the time), then `physics xr` is used for height calculation. Still, the XR value is used far more often than the Z value. + +The `vulnerabilities` and `special vulnerabilities` match the corresponding damage type fields from [Generic Weapon Info](../levelObjects/GenericWeaponInfo.md). +> The engine, internally, calls this field `resistances`, yet the code does the opposite. Furthermore, this `resistances` field covers the four bytes from `000E`. + +The field of `special vulnerabilities` is split up into `0x0F` primary vulnerabilities (double damage) and `0xF0` super vulnerabilities (quad damage). Damage is multiplied only if the originating special force equals the vulnerabilities in these fields. +> Quad damage is reduced to triple damage on difficulty level 3 (either for real-world, or cyber, wherever it is calculated). + +The `defence value` is compared to the `offence value` of a weapon or projectile to determine a "critical hit" (or critical reduction). Higher defence values will decrease the damage, higher offence values will increase the damage. If it is `0xFF`, then the target is invulnerable to critical hits. + +The `toughness` determines how much damage, after most of the calculation, goes through - this still requires the object to be solid to register damage. The value of `0x03` reduces the damage to zero. Otherwise, this value is considered to be a shift factor, so a value of `0x01` halves damage, `0x02` quarters it. +> This value is set to `0x04` only for the non-used SHODAN critter `14/3/5`. This one would have been affected by only the strongest weapons. +> Probably this value was intended to be in range of 0..3 only. + +`MFD/Mesh ID` identifies the MFD to open for this type of object, if selected from inventory. For 3D objects in the world, this value identifies the 3D mesh to render, based on resource `0x08FC`. + + +**Render Type Enumeration** (1 byte) + + 0x01 3D object + 0x02 Sprite + 0x03 Screen + 0x04 Critter + 0x06 Fragments (e.g. the Cyberdog) + 0x07 Not drawn + 0x08 Oriented surface (door, wall decoration) + 0x0B Special + 0x0C Force door + + +**Common Object Flags** (2 byte) + + 0x0001 Useful inventory object (green font) + 0x0002 Solid - can't be passed through + 0x000C Use mode (see below) + 0x0010 Usable inventory object - object is triggered on double-click in inventory list + 0x0020 Blocks 3D rendering if closed (set for opaque doors) + 0x00C0 Light type (see below) + 0x0100 Solid if closed (set for barriers and repulsor object) + 0x0200 Flat solid (objects can be placed on it) + 0x0400 Doubled bitmap size (set for various explosion animations) + 0x0800 Destroy on contact + 0x7000 Class-specific flags + 0x8000 Useless object. Unless specifically marked useful otherwise, object can be removed to make room + +**Use Mode Enumeration** (2 bit) + + 0x00 Pickup - items can be taken + 0x01 Use - items are used, not taken + 0x02 undefined + 0x03 "Null" - can't be used + +**Light Type Enumeration** (2 bit) + + 0x00 Normal, simple lighting + 0x01 Complex lighting + 0x02 Ignore darkness + 0x03 Deferred light type (instance-specific) + + +**Bitmap 3D Bitfield** (2 byte) + + 0x03FF Bitmap number low mask + 0x8000 Bitmap number high mask + 0x0400 Animation + 0x0800 Repeat + 0x7000 Frame number mask + + +**Object Destruction Effect** (1 byte) + +This byte is a bitmask field with the following layout: + + 0x1F Animation frame index -- refers to different types of animations + 0x20 Play destruction sound + 0x40 Play destruction sound (not used in main game) + 0x80 Actual explosion (with damage) -- requires Animation frame index to be 0x0F + +> Some objects have their "private" destruction sounds and have none of the sound bits set. +> The `actual explosion`, set for larger electrical appliances, works only if the full value is `0x8F`. diff --git a/modding/ss-specs/fileFormat/ResourceFiles.md b/modding/ss-specs/fileFormat/ResourceFiles.md new file mode 100644 index 0000000..93720f5 --- /dev/null +++ b/modding/ss-specs/fileFormat/ResourceFiles.md @@ -0,0 +1,126 @@ +## Resource Files +Resource files are structured archives. These files contain one or more "resource" entries. Resources are identified and contain one or more data blocks. A resource with more than one data block is called a "compound resource". + +### Identifier + +A resource identifier is a ```uint16``` value, and a compound resource uses a ```uint16``` value as index. + +Resource identifier are "valid" from value ```0x0003``` and above. +```0x0000``` is the NULL identifier (this was quick approach to erase resources from file for developers), and ```0x0001``` and ```0x0002``` are used engine-internally (first used to point to head ptr in +[LRU](https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)) cache, second - to tail ptr in LRU). + +### General Format +A resource file consists of a header, resources and a file directory. The header points to the file offset of the file directory and the file directory points to the file offset of the first resource. + + | /--------------------------------------v | + | Header.dirOffset | Res0 | Res1 | ... | Res N | Directory.dataOffset | + | ^----------------------------------------/ | + +All resources, as well as the file directory, start at a 4-byte boundary. + +To calculate the file offset of a resource, the file directory has to be read first to determine the offsets (and lengths) of the previous resources. + +> Although the format would allow for a different order, all resource files have the directory at the end of the file. + +### File Header +The file header has a length of 128 bytes and has the following format: + +**File Header** (`ResFileHeader` struct, 128 bytes) + + 0000 [16]byte signature signature "LG Res File v2\r\n" + 0010 [96]byte comment comment, terminated with "\x1A" (remainder 0x00) + 0070 [12]byte reserved reserved, set to 0x00 + 007C sint32 dirOffset file offset to directory + +> No resource file has been found containing a comment. It always only contains the terminator byte 0x1A. +> The comment is terminated with 0x1A as this represents the ```Ctrl+Z``` code. This was a little trick to enable people to ```type``` the file on the command line and not be spammed with binary garbage. + +### File Directory +The file directory consists of a 6 byte header followed directly by its directory entries: + +**Resource file directory header** (`ResDirHeader` struct, 6 bytes) + + 0000 uint16 numEntries number of resources in the file + 0002 sint32 dataOffset file offset to beginning of first resource + +**Resource file directory entry** (`ResDirEntry` struct, 10 bytes) + + 0000 uint16 id resource ID + 0002 sint24 size resource length (unpacked) + 0005 sint8 flags resource flags (see below) + 0006 sint24 csize resource length (packed in file) + 0009 sint8 type resource type (see below) + +### Resource Flags + +The resource flag marks extra information about the resource. + +**Resource Flag** (1 byte) + + 0x01 RDF_LZW compressed + 0x02 RDF_COMPOUND compound + 0x04 RDF_RESERVED reserved (unused) + 0x08 RDF_LOADONOPEN load on open (unused) + +> The original source lists several more resource flags, which are all unused in System Shock. +> Presumably this is the result of re-used code. + +"Flat" resources contain only one data block, which is the complete resource data itself. Compound resources contain 0, one, or more blocks. + +### Resource Types + +The following resource types appears in System Shock: + + 0x00 UNKNOWN Palette (although originally called "unknown", it is used for palettes exclusively) + 0x01 STRING String + 0x02 IMAGE Image + 0x03 FONT Font + 0x04 ANIM Animation Script (Video clip) + 0x07 VOC Sound effects and voices (.voc) + 0x0F OBJ3D 3D model geometry + 0x11 MOVIE Movie + 0x30 APP1 Application specific resource (used in archive.dat for storing maps) + +> The original source lists several more resource types, which are all unused in System Shock. +> Presumably this is the result of re-used code. +> +> There exists 0x05 (PALL) resource type specially for palettes, but never used in System Shock. + +### Compound Resources +If a resource is flagged to be compound, the resource data starts with a directory. This directory contains relative offsets to the first bytes of the contained blocks, following the directory. + +**Resource directory** (2 bytes + 4 bytes per block + 4 bytes) + + 0000 uint16 number of blocks in resource + 0002 sint32 resource offset to first block + .... + nnnn sint32 resource offset to last block + nnnn+4 sint32 total length of resource + +To calculate the length of a block N, take the start offset of N+1 and subtract the start offset of N. This is why there is one extra offset entry in the directory. + +If a compound resource is compressed, the directory itself is not compressed. Resource offsets always point into the uncompressed data. + +> Some compound resources have two zero bytes between the directory and the first block. + +### Resource Compression +Resource compression is based on the [LZW algorithm](http://en.wikipedia.org/wiki/Lempel%E2%80%93Ziv%E2%80%93Welch). It uses a fixed word-size of 14 bits. Two words are reserved for control. + +The words are serialized sequentially big-endian as a continuous stream. +For example, if the first 7 bytes in the resource are + + aaaaaaaa bbbbbbbb cccccccc dddddddd eeeeeeee ffffffff gggggggg + +then the corresponding words are + + aaaaaaaabbbbbb bbccccccccdddd ddddeeeeeeeeff ffffffgggggggg + +with the high bit to the left in each case. + +The first 256 words of the dictionary are initialized with length 1 for the corresponding byte values. The reserved words are: +* 0x3FFE Reset dictionary to initial state +* 0x3FFF End-Of-Stream + +The End-Of-Stream marker is always issued. The serialized byte stream always contains a trailing 0x00 byte after the End-Of-Stream word. + +The compressor issues a dictionary reset when it is exhausting the dictionary and can't add a new word to it for the 1001st time. diff --git a/modding/ss-specs/fileFormat/index.md b/modding/ss-specs/fileFormat/index.md new file mode 100644 index 0000000..403f475 --- /dev/null +++ b/modding/ss-specs/fileFormat/index.md @@ -0,0 +1,188 @@ +## File Overview +### Variants +Not considering any re-releases, System Shock 1 was available in the following variants: +* For DOS + * Demo: HD and CD + * Release: HD and CD +* For MAC + * Release: CD + +> Unless otherwise noted, this documentation considers only the DOS CD release. + +The main difference between HD and CD releases is the CD releases contain audio files for the voice logs and provide a high resolution option. + +### Locations +Data files described in this documentation are available at the following locations: +* CD-ROM (System Shock 1 CD) + * /cdrom/data + * /hd/data (will be copied to HD) +* HD (System Shock installation directory) + * /data + +### File Types +Data files come in the following variants: +* [Resource files](ResourceFiles.md), which are structured archives +* [Property files](PropertyFiles.md), containing one or more serialized tables + +Resource files are the majority. All files with filename suffix `.res` are resource files. Savegame files have the same format, including the "starter" savegame file `archive.dat`. These files contain one or more "resource" entries. Resources are identified and contain one or more data blocks. A resource with more than one data block is called a "compound resource". + +The property files are "flat" files containing one or more serialized tables. + +### List of Files + +The following table shows an overview of the known files, across the different releases. + +For resource files, resources may share the same identifier. In this case they represent the alternatives available through configuration. The options are: +* Language. Available are: "English", "French" and "German" +* Cutscene resolution (MOVI format). Available are: "low" (320x150) and "svga" (600x300) + +> The "bitmap" based cutscenes have a resolution of 320x200 and have their own identifier. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ContentLanguagesLocation HD ReleaseLocation CD Release
ENGFRNGERDemoReleaseDemoRelease
Startup and Cutscenes
Splash screens```splash.res```HDHDCDCD
Splash screen palette```splshpal.res```--HD--CD
Palette for low-res bitmap cutscenes```cutspal.res```--HD--CD
Low-Res bitmap intro cutscene```start1.res```--HD--CD
Low-Res MOVI intro cutscene```lowintr.res``````lofrintr.res``````logeintr.res```------CD
High-Res MOVI intro cutscene```svgaintr.res``````svfrintr.res``````svgeintr.res```------CD
Low-Res bitmap end cutscene```win1.res```--HD--CD
Low-Res MOVI end cutscene```lowend.res```------CD
High-Res MOVI end cutscene```svgaend.res```------CD
Low-Res bitmap death cutscene```death.res```--HD--CD
Low-Res MOVI death cutscene```lowdeth.res```------CD
High-Res MOVI death cutscene```svgadeth.res```------CD
Main menu screens```intro.res```HDHDCDHD & CD
In-game Resources
New Game Template```archive.dat```HDHDCDCD
In-game palette```gamepal.res```HDHDCDCD
Texts```cybstrng.res``````frnstrng.res``````gerstrng.res```HDHDHDHD
HUD resources```mfdart.res``````mfdfrn.res``````mfdger.res```HD 1)HDHD 1)HD
Trigger audio```citbark.res``````frnbark.res``````gerbark.res```----CD 1)CD
Log Audio```citalog.res``````frnalog.res``````geralog.res```----CD 1)CD
Video Mails```vidmail.res```--HD--CD
???```objart.res```HDHDCDCD
???```objart2.res```HDHDHDHD
???```sideart.res```HDHDHDHD
Level textures```texture.res```HDHDHDHD
???```citmat.res```HDHDHDHD
Sound Effects```digifx.res```HDHDHDHD
???```gamescr.res```HDHDHDHD
???```handart.res```HDHDHDHD
???```obj3d.res```HDHDHDHD
???```objart3.res```HDHDCDHD
Property Files
Object properties```objprop.dat```HDHDCDHD
Texture properties```textprop.dat```HDHDCDCD
+1) Only the English files available diff --git a/modding/ss-specs/index.md b/modding/ss-specs/index.md new file mode 100644 index 0000000..12424a5 --- /dev/null +++ b/modding/ss-specs/index.md @@ -0,0 +1,29 @@ +## Index + +* [Conventions](conventions.md) + +* General File Format + * [File Overview](fileFormat/index.md) + * [Resource Files](fileFormat/ResourceFiles.md) + * [Property Files](fileFormat/PropertyFiles.md) + +* Archives + * [Archive Overview](archives/index.md) + +* Raw Media + * [Bitmaps](media/Bitmaps.md) + * [Palettes](media/Palettes.md) + * [Sound Effects](media/SoundEffects.md) + * [MOVI Resources](media/moviResources.md) + * [Video Clips](media/VideoClips.md) + * [Texts](media/Texts.md) + * [Geometry](media/Geometry.md) + +* Content + * [Access Levels](content/AccessLevels.md) + * [Electronic Messages](content/ElectronicMessages.md) + * [Level Objects](levelObjects/index.md) + * [Level Textures](content/LevelTextures.md) + +* Other Files + * [Music](other/Music.md) diff --git a/modding/ss-specs/levelObjects/00_Gun/gunProperties.md b/modding/ss-specs/levelObjects/00_Gun/gunProperties.md new file mode 100644 index 0000000..7db4bbd --- /dev/null +++ b/modding/ss-specs/levelObjects/00_Gun/gunProperties.md @@ -0,0 +1,79 @@ +### Gun Properties + +#### Generic Gun Properties + +**Generic Gun Propertes** (`GunProp` struct) (2 bytes) + + 0000 uint8 Fire rate + 0001 byte Ammo type + +For non-automatic guns, the `fire rate` specifies when the next bullet can be shot. For melee weapons it is the time until the next swing can be made. +It is measured in game time, with a unit for this value of about 36 milliseconds. A value of ```0x00``` means 'as fast as you can press fire'. +> Changing this value does not have any detectable effect for automatic guns (```0/1/x```). + + +**Ammo type** (1 byte) + + 0xF0 Ammo subclass identifier + 0x0F Bitmask, which ammo types within the ammo subclass are compatible (0..3) + +> Ammo type is ignored for beam and melee weapons. + + +#### Specific 2 Properties + +This subclass describes special projectile weapons. + +**Specific 2 Properties** (`SpecialGunProp` struct) (16 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 uint8 Projectile travel speed + 0009 int32 Projectile triple (0x00CCSSTT) + 000D uint8 Attack mass + 000E sint16 Attack speed (kickback) + + +#### Specific 3 Properties + +This subclass describes melee weapons. + +**Specific 3 Properties** (`HandtohandGunProp` struct) (13 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 uint8 Energy usage + 0009 uint8 Attack mass (impact force - pushback on hit) + 000A uint8 Range + 000B sint16 Attack speed (kickback; only applied if hitting something) + + +#### Specific 4 Properties + +This subclass describes energy beam weapons. + +**Specific 4 Properties** (`BeamGunProp` struct) (13 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 uint8 Max charge + 0009 uint8 Attack mass (impact force - pushback on hit) + 000A uint8 Range + 000B sint16 Attack speed (kickback) + + +#### Specific 5 Properties + +This subclass describes energy projectile weapons. + +**Specific 5 Properties** (`BeamprojGunProp` struct) (18 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 uint8 Max charge + 0009 byte Attack mass + 000A sint16 Attack speed (kickback) + 000C uint8 Projectile travel speed + 000D int32 Projectile triple (0x00CCSSTT) + 0011 byte Flags + + +**Energy Projectile Weapon Flags** (1 byte) + + 0x02 Stun effect diff --git a/modding/ss-specs/levelObjects/00_Gun/levelGunEntry.md b/modding/ss-specs/levelObjects/00_Gun/levelGunEntry.md new file mode 100644 index 0000000..84c8062 --- /dev/null +++ b/modding/ss-specs/levelObjects/00_Gun/levelGunEntry.md @@ -0,0 +1,9 @@ +### Level Gun Table (Class 0) + +```L10``` is a table describing weapons and entries extend [the prefix](../../archives/levelObjects.md#class-specific-information) with their current ammo or charge. + +**Level Gun Entry** (8 bytes) + + 0000 [6]byte Level object prefix + 0006 byte Ammo type / Charge + 0007 byte Ammo count / Temperature diff --git a/modding/ss-specs/levelObjects/01_Ammo/ammoProperties.md b/modding/ss-specs/levelObjects/01_Ammo/ammoProperties.md new file mode 100644 index 0000000..7ac3bbb --- /dev/null +++ b/modding/ss-specs/levelObjects/01_Ammo/ammoProperties.md @@ -0,0 +1,12 @@ +### Ammo Properties + +#### Generic Ammo Properties + +**Generic Ammo Propertes** (`AmmoProp` struct) (14 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 uint8 Cartridge size + 0009 uint8 Bullet mass (impact force - pushback on hit) + 000A sint16 Bullet speed (kickback) + 000C uint8 Range + 000D uint8 Recoil force diff --git a/modding/ss-specs/levelObjects/01_Ammo/levelAmmoEntry.md b/modding/ss-specs/levelObjects/01_Ammo/levelAmmoEntry.md new file mode 100644 index 0000000..5ca63a1 --- /dev/null +++ b/modding/ss-specs/levelObjects/01_Ammo/levelAmmoEntry.md @@ -0,0 +1,7 @@ +### Level Ammo Table (Class 1) + +```L11``` is a table for ammo clips. Since clips are always full, entries have no further fields. + +**Level Ammo Entry** (6 bytes) + + 0000 [6]byte Level object prefix diff --git a/modding/ss-specs/levelObjects/02_Physics/physicsProperties.md b/modding/ss-specs/levelObjects/02_Physics/physicsProperties.md new file mode 100644 index 0000000..f488cff --- /dev/null +++ b/modding/ss-specs/levelObjects/02_Physics/physicsProperties.md @@ -0,0 +1,42 @@ +### Physics Properties + +#### Generic Properties + +**Physics Generic Properties** (`PhysicsProp` struct) (1 byte) + + 0000 byte Physics flags + + +**Physics Flags** (1 byte) + + 0x01 Emit light + 0x02 Bounce off walls + 0x04 Bounce off objects (real world), Pass through objects (cyberspace) + 0x08 Bounce off projectiles; set for all cyberspace projectiles + + +> Cyberspace projectiles can't emit light, though they can bounce off walls. + + +#### Specific 0 Properties + +"Tracer" objects. + +> These are not used in the engine. + +**Physics Specific 0 Properties** (`TracerPhysicsProp` struct) (20 bytes) + + 0000 [4]int16 X coordinates + 0008 [4]int16 Y coordinates + 0010 [4]uint8 Z coordinates + + +#### Specific 1 Properties + +These properties are set for some `2/1/x` objects. More specifically: For cyberspace projectiles, also called "slow physics". The other projectiles of this subclass have their specific bytes zero. + +**Physics Specific 1 Properties** (`SlowPhysicsProp` struct) (6 bytes) + + 0000 [6]byte Colour scheme + +`Colour scheme` contains 6 palette indices from the game palette. The index into this list comes from the geometry properties, specifically from the [Set Colour and Shade](../../media/Geometry#set-colour-and-shade) command. diff --git a/modding/ss-specs/levelObjects/03_Grenade/grenadeProperties.md b/modding/ss-specs/levelObjects/03_Grenade/grenadeProperties.md new file mode 100644 index 0000000..567667e --- /dev/null +++ b/modding/ss-specs/levelObjects/03_Grenade/grenadeProperties.md @@ -0,0 +1,50 @@ +### Grenade Properties + +#### Generic Grenade Properties + +**Generic Grenade Propertes** (`GrenadeProp` struct) (15 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 byte Touchiness + 0009 byte Blast radius + 000A byte Blast core range + 000B byte Blast damage + 000C uint8 Attack mass (impact force - pushback on hit) + 000D int16 Grenade Flags + +`Touchiness` defines how likely a grenade is going to chain-explode if another was exploding nearby. +> This value influences a calculation, based on a random value, as well as damage percentage. If the resulting number is higher than 7, then a chain-reaction is caused. +> In the main game, this value is always `0x00`. + +`Blast radius` specifies in tiles how far the explosion reaches, including visual effects (EMP scroll, explosion flash). + +The main damage (from the `Generic Weapon Info`) is applied within the `Blast core range` of the explosion. This damage falls off from the center to the edge of the core range. + +The `blast damage` is fully applied within the `blast core range`, additionally to the main damage. This damage falls off from the edge of the core range to the edge of the blast radius. + + +**Grenade Flags** (2 bytes) + + 0x01 Explode on contact + 0x02 unused + 0x04 Explode by timer + 0x08 Explode by vicinity (mine) + +> The behaviour of grenades is not exclusively governed by the flags field. Some seems to be hardcoded. +> For instance, regular grenades marked with "timer" don't offer a time setting in the HUD and explode right after being thrown. +> Regular timed grenades marked as "contact" ignore their timer setting, explode on object-contact, but not on floor/wall contact. + + +#### Specific 1 Properties + +Properties for timed grenades. + +**Specific 1 Properties** (`TimedGrenadeProp` struct) (3 bytes) + + 0000 uint8 Minimum time + 0001 uint8 Maximum time + 0002 uint8 Timing deviation -- always 0x00 + +`Minimum time` and `maximum time` specify the range, in seconds, timed grenades can be set to. + +> `Timing deviation` is always set to 0x00. Since it may also let the grenade go off earlier than expected, this may happen while still in the hand - which is presumably why it is unused in the main game. diff --git a/modding/ss-specs/levelObjects/03_Grenade/levelGrenadeEntry.md b/modding/ss-specs/levelObjects/03_Grenade/levelGrenadeEntry.md new file mode 100644 index 0000000..c86c80e --- /dev/null +++ b/modding/ss-specs/levelObjects/03_Grenade/levelGrenadeEntry.md @@ -0,0 +1,44 @@ +## Level Explosive Table (Class 3) + +```L13``` is a table describing explosives. + +**Level Explosive Entry** (12 bytes) + + 0000 [6]byte Level object prefix + 0006 [6]byte Explosive data + +**Explosive Data** (6 bytes) + + 0000 int16 Unknown; Only set for live explosives + 0002 int16 State + 0004 int16 Timer time + + +**State** (2 bytes) + + 0x0000 Inert + 0x0001 Thrown live grenade + 0x0005 Landed live grenade (land mines only, can't be picked up) + + +The timer settings of explosives are stored in the [game state](../../archives/gameState.md). + +The ```Timer time``` field is set to the same value as in the [level schedules](../../archives/levelSchedules.md), though modifying this value doesn't have an effect. The level timer is the authorative entry when to trigger the explosive. + + +### Contact explosives 3/0/x + +Contact explosives start a timer, though its expiry does nothing with the grenade. +Land mines can be catched mid-air and re-thrown, others explode. Land mines enter state ```0x0005``` when touching the ground, after which they can't be picked up safely anymore. + +> Although the game state also keeps time settings for these explosives, they can't be changed. Their timer don't have an effect anyway. + + +### Timed explosives 3/1/0, 3/1/1 + +These explosives keep the state at ```0x0001```, even when landed. Live explosives can be picked up and re-thrown, until their timer sets them off. + + +### Object explosion 3/1/2 + +This item has not been encountered. diff --git a/modding/ss-specs/levelObjects/04_Drug/levelDrugEntry.md b/modding/ss-specs/levelObjects/04_Drug/levelDrugEntry.md new file mode 100644 index 0000000..6c0fdcf --- /dev/null +++ b/modding/ss-specs/levelObjects/04_Drug/levelDrugEntry.md @@ -0,0 +1,9 @@ +### Level Drug Table (Class 4) + +```L14``` is a table describing Drugs. + +**Level Drug Entry** (6 bytes) + + 0000 [6]byte Level object prefix + +This entry does not contain any more information. diff --git a/modding/ss-specs/levelObjects/05_Hardware/levelHardwareEntry.md b/modding/ss-specs/levelObjects/05_Hardware/levelHardwareEntry.md new file mode 100644 index 0000000..409864e --- /dev/null +++ b/modding/ss-specs/levelObjects/05_Hardware/levelHardwareEntry.md @@ -0,0 +1,9 @@ +### Level Hardware Table (Class 5) + +```L15``` is a table describing hardware. + +**Level Hardware Entry** (7 bytes) + + 0000 [6]byte Level object prefix + 0006 byte Hardware version + diff --git a/modding/ss-specs/levelObjects/06_Software/levelSoftwareEntry.md b/modding/ss-specs/levelObjects/06_Software/levelSoftwareEntry.md new file mode 100644 index 0000000..069798c --- /dev/null +++ b/modding/ss-specs/levelObjects/06_Software/levelSoftwareEntry.md @@ -0,0 +1,92 @@ +## Level Software Table (Class 6) + +```L16``` is a table describing software - programs, logs, data... + +**Level Software Entry** (9 bytes) + + 0000 [6]byte Level object prefix + 0006 [3]byte Software data + + +### Cyberspace programs 6/0/x, 6/1/x, 6/2/x + +**Cyberspace program data** (3 bytes) + + 0000 byte Version + 0001 [2]byte Unused + + +> The three subclasses can be considered "aggressive programs", "defensive programs" and "boosts". Boosts ignore the version. +> Although several programs are defined, only a few are used. +> Different version numbers also change the color of the cube. + + +### Fun pack modules 6/3/0 + +**Fun pack module data** (3 bytes) + + 0000 byte Game mask + 0001 [2]byte Unused + +**Game mask** (1 byte) + + 0x01 Ping + 0x02 Eel Zapper + 0x04 Road + 0x08 Botbounce + 0x10 15 + 0x20 TriopToe + 0x40 Gameb (Not working, gives "not installed" message) + 0x80 Wing 0 + + +### Multimedia data 6/4/0, 6/4/1 + +**Multimedia software data** (3 bytes) + + 0000 byte Unused + 0001 byte Index + 0002 byte Type + +**Multimedia type** (1 byte) + + 0x00 E-Mail + 0x01 Log + 0x02 Data + +> In the game, e-mails are received through triggers, logs are only in real-world levels, and data only in cyberspace levels. The engine supports placing any of them in any level type. + + +#### Logs + +For logs, the ```Log resource offset``` is based on the resource ID ```0x09B8```. The high nibble is used as the level identifier - into +which category the log should be put. + +See [Electronic Messages](../../content/ElectronicMessages.md) for details on messages. + +> 6/4/0 are called "TEXT", behave like 6/4/1 "EMAIL", and are only used as logs. + + + +## Cyberspace "Scenery" Software (Class 7, in cyberspace) + +In cyberspace levels, ```L17``` is a table for additional software. It acts as an overflow for software if the first table is full. + +**Scenery Software Entry** (16 bytes) + + 0000 [6]byte Level object prefix + 0006 [10]byte Scenery software data + + +### Scenery software 7/3/2 ("GLOW BULB"), 7/2/0 ("SIGN") + +**Scenery software data** (10 bytes) + + 0000 int16 Parameter + 0002 int32 Subclass + 0006 int32 Type + + +For entries that specify a program, ```Parameter``` specifies the version. For fun packs, it is the game mask. + +> This type is only used for software with a single parameter; Found only for programs and fun packs. diff --git a/modding/ss-specs/levelObjects/07_BigStuff/levelBigStuffEntry.md b/modding/ss-specs/levelObjects/07_BigStuff/levelBigStuffEntry.md new file mode 100644 index 0000000..bf9cee1 --- /dev/null +++ b/modding/ss-specs/levelObjects/07_BigStuff/levelBigStuffEntry.md @@ -0,0 +1,220 @@ +## Level BigStuff Table (Class 7) + +```L17``` is a table describing big items in real-world levels. + +Cyberspace levels may re-use "big stuff" entries for further software entries. These entries need to be +interpreted differently. See [Software](../06_Software/levelSoftwareEntry.md). + + +**Level BigStuff Entry** (16 bytes) + + 0000 [6]byte Level object prefix + 0006 [10]byte BigStuff Info + +### Displays + +There are several big items that are capable of displaying (animated) content. They reuse the following common types: + +**Display Info** (10 bytes) + + 0000 int16 Frame count + 0002 int16 Loop type + 0004 int16 Alternation type + 0006 int16 Picture source + 0008 int16 Alternate picture source + +Animated screens are also controlled by the [Loop Configuration](../../archives/loopConfiguration.md). If not, they use their own loop type. + +**Loop Type** (1 byte) + + 0x00 Forward + 0x01 Forward/Backward + 0x02 Backward + 0x03 Forward/Backward + +> Other values have been found as well, without effect; The engine defaults to forward looping. + + +** Alternation Type** (1 byte) + + 0x00 Don't alternate + 0x03 Alternate randomly + +> Not all alternate source combinations work properly. Commonly it is used to alternate to a text. + + +**Picture Source** (2 bytes) + +This field specifies what shall be shown. It has the following cases: + + 0x0000 .. 0x00F5 Image frames, offset from resource 0x0141 + 0x00F6 Static fading into SHODAN's face + 0x00F7 Unknown (only found to display static) + 0x00F8 .. 0x00FF Surveillance screens 0 .. 7 + 0x0100 .. 0x017E Text message from resource 0x0877 + 0x017F Random number for CPU rooms, level 1-6 before destroying nodes. + 0x0180 .. 0x01FF Text message, scrolling vertically + +For surveillance screens, the sources of screens 0-7 are specified in the [Surveillance Sources Table](../../archives/surveillanceCameras.md). + + +The following big items support displays: +7/0/6 (TV), 7/0/7 (COMPUTER MONITOR), +7/2/6 (SCREEN, small), 7/2/8 (SCREEN, large), 7/2/9 (SCREEN, medium), +with variation: 7/5/5 (CONTROL PANEL), 7/5/6 (CONTROL PEDESTAL) (see below) + +### Electronics 7/0/x + +> Apart from some displays, nothing special + + +### Furniture 7/1/x + +#### Cabinets 7/1/2 + +**Cabinet Data** (10 bytes) + + 0000 [2]byte Unused + 0002 [2]int16 Object index + 0006 [4]byte Unused + +Cabinets can contain up to two objects. + + +#### Texturable furniture 7/1/5, 7/1/7, 7/1/8 (chairs and couches) + +**Texturable Furniture Data** (10 bytes) + + 0000 [6]byte Unused + 0006 int16 Texture index + 0008 [2]byte Unused + +If ```Texture index``` is not zero, it specifies the alternate texture to use for the "main" faces of the furniture. + + +### Surfaces 7/2/x + +These are big objects that are flat and should be placed at another surface. + +#### Words 7/2/3 + +**Words BigStuff Data** (10 bytes) + + 0000 int16 Text index (within resource 0x0868) + 0002 int16 Font and size (bits 0-3 are font, 4-7 are size) + 0004 int16 Colour (palette index, 0 defaults to red) + 0006 [4]byte Unused + + +#### Texture Maps 7/2/7 + +**Texture map big stuff** (10 bytes) + + 0000 [6]byte Unused + 0006 int16 Texture index + 0008 [2]byte Unused + +Texture maps allow to place one tile from the level texture list at any arbitrary position. + +### Lighting 7/3/x + +### Medical equipment 7/4/x + +#### Control Pedestals (buttons only) 7/4/0 + +These can trigger an object if used. + +**Button Control Pedestal Info** (10 bytes) + + 0000 [2]byte Unused + 0002 int16 Trigger object index + 0004 [6]byte Unused + + +#### Surgery Machines 7/4/3 + +**Surgery Machine Info** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Broken state; 0x00 = OK, 0xE7 = broken + 0003 [2]byte Unused + 0005 byte Broken message index; Broken default: 0x61 + 0006 [4]byte Unused + +With all bytes zero, the surgery machine works normally. + +When the ```Broken state``` field is set to a certain value (the game has ```0xE7``` for the one on level 2, though some other work, too), then the machine wouldn't work. When not working, the message identified with ```Broken message index``` is displayed in the HUD. (```0x61``` = "This machine is broken"). + + +#### Textureable Furniture 7/4/5 (chairs) + +These have the same layout as the general textureable furniture (see above). + + +### Science and security equipment 7/5/x + +#### Security Cameras 7/5/4 + +**Security Camera Info** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Panning switch; 0x00: stationary, 0x01: panning + 0003 [7]byte Unused + + +#### Control Pedestals (with display) 7/5/5, 7/5/6 + +Control pedestals are similar to displays regarding the source of the display. +Instead of having different loop options, they trigger an object if used. + +**Display Control Pedestal Info** (10 bytes) + + 0000 int16 Frame count + 0002 int16 Trigger object index + 0004 int16 Alternation type + 0006 int16 Picture source + 0008 int16 Alternate picture source + + +### Garden big stuff 7/6/x + +### Bridges 7/7/x + +#### Solid Bridges 7/7/0, 7/7/1 + +**Solid Bridge Info** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Size. Bits 0-3: X, bits 4-7: Y + 0003 byte Height. 0 is default, level height units otherwise. + 0004 byte Top/Bottom texture + 0005 byte Side texture + 0006 [4]byte Unused + +The size fields are defined as 0: 3D model default, 4: tile width. +The texture fields have bit 7 set to indicate texture from level texture list. If 0, the other 7 bits index +into ```citmat.res```. + +> If either textures are to be taken from the level list, the object description will be that of the textures. +> If a texture is to be taken from citmat.res, the texture index must be > 0, otherwise it will still be the first +> level texture, even if bit 7 is 0. + + +#### Force Bridges 7/7/7, 7/7/8, 7/7/9 + +**Force Bridge Info** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Size. Bits 0-3: X, bits 4-7: Y -- see above + 0003 byte Height. 0 is default, level height units otherwise. + 0004 [2]byte Unused + 0006 byte Colour + 0007 [3]byte Unused + +Object 7/7/8 is a placeholder for force bridges that have not been activated yet. These objects are invisible and are made to force bridges with the use of trigger action 24. + +Force bridges of type 7/7/9 differ from the typical ones (7/7/7) in having a default height of zero and no animation when switched from type 8. They furthermore need a size explicitly set. + +The engine supports several different values for ```Colour```. Used values are ```0x00``` for red and ```0xFE``` for green. + +> Not all 256 colour possibilities have been tested, many resulted in dark red tones or solid colours. Usable variants were found from ```0xF7``` and above. diff --git a/modding/ss-specs/levelObjects/08_SmallStuff/levelSmallStuffEntry.md b/modding/ss-specs/levelObjects/08_SmallStuff/levelSmallStuffEntry.md new file mode 100644 index 0000000..8626e97 --- /dev/null +++ b/modding/ss-specs/levelObjects/08_SmallStuff/levelSmallStuffEntry.md @@ -0,0 +1,186 @@ +## Level SmallStuff Table (Class 8) + +```L18``` is a table describing general small stuff. + +**Level SmallStuff Entry** (16 bytes) + + 0000 [6]byte Level object prefix + 0006 [10]byte SmallStuff data + +### Junk 8/0/x + +#### Papers 8/0/2 + +**Paper** (10 bytes) + + 0000 [2]byte Unused + 0002 int16 Paper ID + 0004 [6]byte Unused + +The ```Paper ID``` refers to a paper text entry and is the offset from resource ID ```0x003C```. +The referred text entries have multiple blocks and the engine simply concatenates all these strings to one text. + + +#### Briefcases 8/0/7 + +**Briefcase** (10 bytes) + + 0000 [2]byte Unused + 0002 [4]int16 Object index + + +### Broken Stuff 8/1/x + +Nothing special. + + +### Dead Bodies 8/2/x + +#### Corpses 8/2/0 - 8/2/7 + +**Corpse** (10 bytes) + + 0000 byte Treasure type + 0001 byte Unused + 0002 [4]int16 Object index + +The four ```Object index``` values determine the corpse's loot. More random loot can be added to empty slots based on the ```Treasure type```. + + +**Treasure Type Enumeration** (1 byte) + + 0xF5 No treasure + 0xF6 Humanoid + 0xF7 Drone + 0xF8 Assassin + 0xF9 Warrior cyborg + 0xFA Flier bot + 0xFB Security 1 bot + 0xFC Exec bot + 0xFD Cyborg enforcer + 0xFE Security 2 bot + 0xFF Elite cyborg + 0x00 Standard corpse + 0x01 Loot-oriented corpse + 0x02 Repairbot + 0x03 Serv-bot + + +> The treasure type values for corpses are the same as for [critter properties](../14_Critter/critterProperties.md), except offset by 11. + +#### Severed Heads 8/2/13, 8/2/14 + +**Severed Head** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Image index + 0003 [7]byte Unused + +If ```Image index``` is zero, the generic mutilated head is shown. Otherwise, it is the index for a picture of a person, using that of e-mails. + + +### Consumables 8/3/x + +Nothing special. + + +### Access Cards 8/4/x + +**Access Card** (10 bytes) + + 0000 int16 Unknown + 0002 sint32 Access mask + 0006 [4]byte Unused + +All access cards work the same way: When consumed, they add the given ```Access mask``` to the current priviledges and give a message whether and which new access was gained. + +**Access mask** (sint32) + + 0x00000001 "None" - This priviledge is none and will not be added. Should not be used. + 0x000000FE Various generic priviledges + 0x00FFFF00 Used for "group" priviledges; Typically used with 8/4/10 (Group access card) + 0x7F000000 Personal priviledges; 0x01: D'Arcy, 0x10: Diego + +8/4/11 items (Personal access cards) have a special handling if personal priviledges are set; When identifying the object, the name of the person is appended to the title. + +> 8/4/0 (Current access levels) works like any other access card, though it doesn't have a proper icon (only an 'X'). In the game it is used only on level 1 for a few cases, purpose unknown. + + +### Cyberspace Items 8/5/x + +#### Integrity Restorative 8/5/1 + +**Integrity Restorative** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Restoration amount + 0003 [7]byte Unused + +Per default (```Restoration amount``` = 0), the item restores about 3/4 of integrity. + +#### Security Defense Mine 8/5/2 + +**Security Defense Mine** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Damage amount + 0003 [7]byte Unused + +Per default (```Damage amount``` = 0), the item removes little amount (about one chevron). + + +#### Security ID Module 8/5/3 + +**Security ID Module** (10 bytes) + + 0000 int16 Unknown + 0002 sint32 Access mask + 0006 [4]byte Unused + +Works like access cards - see above. + +#### Cyber Info Nodes 8/5/6, 8/5/8 + +**Cyber Info Node** (10 bytes) + + 0000 [2]byte Unused + 0002 int16 Text index + 0004 [6]byte Unused + +For 8/5/6 (information nodes), the text index is for text resource ```0x0878```. +For 8/5/8 (data fragments), the text index is for text resource ```0x087A```. + + +#### Cyberspace Barricades 8/5/9 + +**Cyberspace Barricade** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Size. Bits 0-3: X, bits 4-7: Y + 0003 byte Height. 0 is default, level height units otherwise. + 0004 [2]byte Unused + 0006 byte Colour + 0007 [3]byte Unused + +Layout and properties are the same as for force bridges (7/7/7). + + +#### Cyberspace Decoys 8/5/10 + +**Cyberspace Decoy** (10 bytes) + + 0000 [2]byte Unused + 0002 int16 Destroy object index + 0004 [6]byte Unused + +Normally, these cannot be destroyed in cyberspace. However, if placed in a map and made vulnerable to damage, decoys can be made to destroy another object when destroyed. + + +### Wear and Tear Stuff 8/6/x + +Nothing special. + + +### Quest Items 8/7/x + +Nothing special. diff --git a/modding/ss-specs/levelObjects/08_SmallStuff/smallStuffProperties.md b/modding/ss-specs/levelObjects/08_SmallStuff/smallStuffProperties.md new file mode 100644 index 0000000..8a1d540 --- /dev/null +++ b/modding/ss-specs/levelObjects/08_SmallStuff/smallStuffProperties.md @@ -0,0 +1,11 @@ +### Small Stuff Properties + +#### Specific 5 Properties + +These items (```8/5/x```) are cyberspace items. + +**Cyberspace Items Specific Properties** (`CyberSmallstuffProp` struct) (6 bytes) + + 0000 [6]byte Colour scheme + +```Colour scheme``` contains 6 palette indices from the game palette. The index into this list comes from the geometry properties, specifically from the [Set Colour and Shade](../../media/Geometry#set-colour-and-shade) command. diff --git a/modding/ss-specs/levelObjects/09_Fixture/levelFixtureEntry.md b/modding/ss-specs/levelObjects/09_Fixture/levelFixtureEntry.md new file mode 100644 index 0000000..9937a74 --- /dev/null +++ b/modding/ss-specs/levelObjects/09_Fixture/levelFixtureEntry.md @@ -0,0 +1,266 @@ +## Level Fixture Table (Class 9) + +```L19``` is a table describing fixtures and electrical stuff. + +**Level Fixture Entry** (30 bytes) + + 0000 [6]byte Level object prefix + 0008 [24]byte Fixture Specific Info + + +### Buttons 9/0/x + +Buttons come in various shapes and forms. All of them are triggers for actions and the entry data that of [actions](../Actions.md). + +**Button Fixture Specific Info** (24 bytes) + + 0000 [22]byte Action data + 0016 uint16 Access mask + + +The conditions for buttons are based on [game variables](../Conditions.md#game-variable-conditions). + +```Access mask``` is an additional condition for the button. If not zero, the lower five bits can specify one required access level the hacker must have. The remaining 7 bits don't work properly or resolve to the "None" level. + + +### Receptacles 9/1/x, 9/4/x + +Most receptacles work in conjunction with the quest items (8/7/x). With the exception of antenna relay panels, they contain regular actions as data. +Unless noted otherwise below, the conditions for receptacles are based on object types, with an optional trap message if the wrong item is used. + +**Receptacle Fixture Specific Info** (24 bytes) + + 0000 [22]byte Action data + 0016 [2]byte Unused + +**Receptacle Fixture Condition** (4 bytes) + + 0000 byte Object type + 0001 byte Object subclass + 0002 byte Object class + 0003 byte Trap message (optional) + + +#### Antenna Relay Panel 9/1/3 + +**Antenna Relay Panel Specific Info** (24 bytes) + + 0000 [6]byte Unused + 0006 int16 Trigger object index 1 + 000A int16 Trigger object index 2 + 000E int16 Destroy object index + +The two ```trigger object index``` values work only with trigger objects, they can't trigger arbitrary objects. + +> Behaviour of relays is hardcoded: After accepting the plastique, their type changes to 9/1/4 and a level timer is started. When the timer expires, the panel explodes, the type changes again to 9/1/5, the two objects are triggered and the object-to-destroy is removed. + + +#### Retinal ID Scanner 9/1/6 + +When using a head item on the retinal scanner, the condition field is compared against the head's ```Current Frame``` value (for object 8/12/13), or ```Current Frame + 11``` (for object 8/12/14). Note that this is different from the ```Image index``` value which determines how the head appears in the MFD. + +### Stations 9/2/x + +The conditions for stations are based on [game variables](../Conditions.md#game-variable-conditions). + +#### Cyberspace Terminals 9/2/0 + +**Cyberspace Terminal Specific Info** (24 bytes) + + 0000 byte State + 0001 byte Unused + 0002 [4]byte Condition + 0006 int32 Target X + 000A int32 Target Y + 000E int32 Target Z + 0012 int32 Target Level + 0016 [2]byte Unused + +The orientation of the player in the target cyberspace level can not be specified. The orientation will be taken over from the real world. + +**State** (1 byte enumeration) + + 0: Off + 1: Active (Normal state) + 2: Locked. Trying to activate it gives an electrical shock. + + +#### Energy Charge Stations 9/2/1 + +**Energy Charge Station Specific Info** (24 bytes) + + 0000 [2]byte Unused + 0002 [4]byte Condition + 0006 int32 Power amount + 000A int32 Recharge time in seconds + 000E int32 Object index + 0012 int32 Recharged timestamp + 0016 [2]byte Unused + +The object identified by ```Object index``` is triggered every time when energy is drawn from the station. + +The ```Recharged timestamp``` will be set to the game time when the station is usable again. There is no dedicated timer running. + + +### Input Panels 9/3/x + +The conditions for input panels are based on [game variables](../Conditions.md#game-variable-conditions). + +**Input Panel Info** (24 bytes) + + 0000 [2]byte Unknown + 0002 [4]byte Condition + 0006 [18]byte Input panel info + + +#### Access Panels (Puzzles) 9/3/0 to 9/3/3, 9/3/9, 9/3/10 + +Puzzles are either "wire" or "block" puzzles. The type of the puzzle is determined by a bit within the info structure. + +##### Wire Puzzles + +**Wire Puzzle Input Panel Info** (18 bytes) + + 0000 int32 Target object index (object to toggle on success) + 0004 byte Puzzle layout + 0005 uint8 Target power level out of 0xFF + 0006 uint8 Wire properties mask + 0007 int8 Puzzle type - must be 0x00 for wire puzzle + 0008 uint32 Target state of wires + 000C uint32 Current state of wires + 0010 [2]byte Unused + +**Puzzle Layout** (1 byte bitmask) + + 0x0F Wire count; 0 default to 4 wires + 0xF0 Connector count per size; 0 defaults to 6 connectors + +**Wire properties mask** (1 byte bitmask) + + 0x0F Color wires; All pink if not set + 0xF0 Solved state + +> For the properties mask, any of the respective four bits needs to be set to count for the whole nibble. +> ```Solved state``` is always ```0xF0```, whereas ```Color wires``` is only set as ```0x01```. + +Wire states are stored as three-bit pairs, ordered from LSB to MSB (right to left). The first pair describes the first +wire and the lower triple specifies the left connector. + +This allows for puzzles up to 5 wires and 8 connectors, although only up to 6 connectors are visible. + + +##### Block Puzzles + +**Block Puzzle Input Panel Info** (18 bytes) + + 0000 int32 Target object index (object to toggle on success) + 0004 int16 State store object index (refers to a null trigger) + 0006 byte Unused - 0x00 + 0007 int8 Puzzle type - must be 0x10 for block puzzle + 0008 int32 Puzzle layout + 000C [6]byte Unused + +**Puzzle Layout** (4 byte bitmask) + + 0x00000001 Puzzle solved flag ("Circuit Activated") + 0x0000000E Unknown + 0x00000070 Coordinate of source connector + 0x00000180 Source location + 0x00000E00 Unknown + 0x00007000 Coordinate of destination connector + 0x00018000 Destination location + 0x00700000 Width + 0x07000000 Height + 0x70000000 Side effect type + +The location values are 0: top, 1: bottom, 2: left, 3: right + +Side effects are: + + 0: Toggle all blocks horizontally/vertically from the changed one (across the board) + 1: Toggle all surrounding 8 blocks + 2: Toggle all blocks diagonally from the changed one (across the board) + 3: Toggle all blocks horizontally, vertically and diagonally across the board + 4: None + +The state of the puzzle is stored in the referred ```State store object```, a trigger with no action. + +The trigger info block is repurposed and at its offset ```0006``` there are 4 uint32 fields, concatenated to a 128 bit array. +Starting with the LSB from this bit array, 3 bits are repeatedly taken to fill the blocks from left to right, top to bottom. +This allows a maximum puzzle size of 42 blocks (6x7 or 7x6). + +The blocks are: + + 0: Nothing (empty) + 1: Inactive (x) + 2: Active (+) + 3: Active (+) - not used + 4: Full node + 5: Full node - not used + 6: Hollow node + 7: Hollow node - not used + +```Full nodes``` require only one incoming connection to forward power to the other three neighbours. +```Hollow nodes``` require two incoming connections to forward power to the other two neighbours. + +#### Elevator Panels 9/3/4, 9/3/5, 9/3/6 + +**Elevator Input Panel Info** (18 bytes) + + 0000 int16 Destination object index 2 + 0002 int16 Destination object index 1 + 0004 int16 Destination object index 4 + 0006 int16 Destination object index 3 + 0008 int16 Destination object index 6 + 000A int16 Destination object index 5 + 000C int16 Accessible bitmask (which floors are accessible) + 000E int16 Elevator shaft bitmask (which floors does the shaft connect) + 0010 [2]byte Unused + +If a floor bit is set in the ```Elevator shaft bitmask``` field but not in the ```Accessible bitmask``` field, using +that button gives the "Shaft damage" message. The MFD is capable of displaying up to 12 floors. + +The ```Destination object index``` values refer to the object index of the elevator panels in the other levels. Note the swapped order of the index values. Presumably the six values are loaded as three int32 values first, and then the index values are extracted. The Nth bit from the accessible bitmask, counted from LSB, references the Nth destination object index. + +For an elevator panel to work properly (and not crash the game), the following conditions must be met: +* The panel must be in a tile which has the elevator music set. (Music index ```0xF``` in tile flags.) +* The panels envolved in a level transition must reference each other. In other words, all panels of one connected elevator must have the same configuration. + +> Referencing an object from another level requires an editor to keep track of inter-level references. Or burden the user. +> The game uses only up to three active floors. + +> The elevator panel on level 1 has the first byte of the input panel structure set to ```0x01```. Purpose and effect unknown. + + +#### Number Pads 9/3/7, 9/3/8 + +**Number Pad Input Panel Info** (18 bytes) + + 0000 int16 3-digit combination 1 + 0002 int16 Trigger object index 1 + 0004 int16 3-digit combination 2 + 0006 int16 Trigger object index 2 + 0008 int16 3-digit combination 3 + 000A int16 Trigger object index 3 + 000C int16 Fail object index + 000E [4]byte Unknown + +The 3-digit combinations are stored in [Binary-Coded-Decimal](https://en.wikipedia.org/wiki/Binary-coded_decimal) right-bound, +so the combination ```451``` is stored as ```0x0451```. + +A combination must be something else than "000" to be active ("000" is always wrong). +Combinations must not be entered in sequence, any of the three combinations is valid at any time. + +If the ```Fail object index``` refers to a valid object, this one is triggered when a wrong combination was entered. + +> One number pad on level 0 has the first byte of the input panel structure set to ```0x06```. Purpose and effect unknown. + + +### Cyberspace Switches 9/5/x + +Cyberspace switches are triggers for actions and the entry data that of [actions](../Actions.md). + + 0000 [22]byte Action data + 0016 [2]byte Unused + +The conditions for cyberspace switches are based on [game variables](../Conditions.md#game-variable-conditions). diff --git a/modding/ss-specs/levelObjects/10_Door/levelDoorEntry.md b/modding/ss-specs/levelObjects/10_Door/levelDoorEntry.md new file mode 100644 index 0000000..da01d7d --- /dev/null +++ b/modding/ss-specs/levelObjects/10_Door/levelDoorEntry.md @@ -0,0 +1,29 @@ +## Level Door Table (Class 10) + +```L20``` is a table describing doors and barriers. + +**Level Door Entry** (14 bytes) + + 0000 [6]byte Level object prefix + 0006 [8]bytes Door Data + + +**Door Data** (8 bytes) + + 0000 int16 Lock variable index + 0002 byte Lock message + 0003 byte Force door colour index + 0004 byte Required access level + 0005 byte Auto-close time; in units of approximately 0.5 seconds - common default is 0x0C + 0006 int16 Second door object index + + +The ```Lock variable index``` determines whether a door is locked. If the referenced boolean variable is 1, then the door won't open when used. If the door is triggered by other means, the door is unlocked and the index is reset to zero. +Using the door while being locked, the message according to ```Lock message``` is shown. The message index is into the text resource ```0x0871```, with a block offset of ```7```. (```0x00``` gives the default "The door is locked" at index ```7```). + +The ```Required access level``` indicates which [access level](../content/AccessLevels.md) the hacker needs to activate +(open) the door. If zero, no special access is required. This values is ```0xFF``` for doors "locked by SHODAN level security". +Such doors have a separate trigger referring these doors. + +The ```Second door object index``` refers to a second door to trigger when activating this one. +Bulkhead doors for instance reference each other this way. diff --git a/modding/ss-specs/levelObjects/11_Animating/animatingProperties.md b/modding/ss-specs/levelObjects/11_Animating/animatingProperties.md new file mode 100644 index 0000000..3e05e74 --- /dev/null +++ b/modding/ss-specs/levelObjects/11_Animating/animatingProperties.md @@ -0,0 +1,27 @@ +### Animating Properties + +#### Generic Animating Properties + +**Generic Animating Propertes** (`AnimatingProp` struct) (2 bytes) + + 0000 byte Frame time + 0001 byte Flags + +The `frame time` specifies the time of a single frame of the animation. A value of `0xFF` means a time of approximately 0.9 seconds. All animations have this value set to `0x1E` (30). + + +**Animating Flags** (1 byte) + + 0x01 Emit light; The animation lights up the area with one tile radius for its duration. + + +> Light is only emitted by objects placed by the engine. This flag is only set for such temporary animations, such as explosions (`11/2/x`), but works for impacts as well (`11/1/x`). + + +#### Specific 2 Properties + +"Explosion" animations. + +**Specific 2 Properties** (1 byte) + + 0000 byte Frame explode diff --git a/modding/ss-specs/levelObjects/12_Trap/levelTrapEntry.md b/modding/ss-specs/levelObjects/12_Trap/levelTrapEntry.md new file mode 100644 index 0000000..b1cd7c5 --- /dev/null +++ b/modding/ss-specs/levelObjects/12_Trap/levelTrapEntry.md @@ -0,0 +1,136 @@ +## Level Trap Table (Class 12) + +```L22``` is a table describing trap items and general marker. + +**Level Trap Entry** (28 bytes) + + 0000 [6]byte Level object prefix + 0006 [22]byte Trap data + +### Infrastructure 12/0/x + +#### Critter AI Hints 12/0/7 + +**Critter AI Hint Data** (22 byte) + + 0000 [6]byte Unused; A few have the first byte 0x06 + 0006 int16 Next object index + 0008 [10]byte Unused + 0012 int16 Trigger object flag -- 0: off, 1: on + 0014 int16 Trigger object index + +AI hint marker are arranged to form a loop with their ```next object index``` fields. They describe a patrolling path for critters. +If the ```trigger object flag``` is set to ```0x01```, the object identified by ```trigger object index``` will be triggered approximately 1.5 seconds after a critter entered the tile this marker is in. + +> These patrolling paths appear to work only under special circumstances. They are rarely used in the main game and so far have been found to properly work only on level 9 for a mutated cyborg. +> The main reason for these hints not properly working is that roaming critters only walk when the hacker is within their vicinity - which often already makes them hostile. The cyborg on level 9 initially is docile, thus not concerned about a (passive) hacker. Its 4 state bytes are ```00 00 04 00``` and requires the second loot object to point to the first hint. +> Critters take these hints as such - they first walk into their direction, and continue to walk after passing them. Without a new hint, they will wander off in random directions. The cyborg on level 9 properly walks back and forth because its linear back-and-forth path has a hint for ever tile, and the two edges of the path are in coves, within which the cyborg bumps against the walls until properly turned around. + +#### Repulsors 12/0/10 + +**Repulsor Data** (22 byte) + + 0000 [10]byte Unused; A few have the first byte 0x06 without effect + 000A int32 Start height + 000E int32 End height + 0012 int32 Repulsion flags; 0x00000001: disabled (float down); 0x00000008: strong repulsor + +The ```start height``` and ```end height``` values have a unit of ```0x00010000``` for one tile height. An ```end height``` of zero means endless. +Both values are measured from the floor. Repulsion only happens if the hacker is within these limits. + + +#### Triggers 12/0/x (with exceptions) + +The type of the trigger object determines its cause. The trap data is then that of [actions](../Actions.md). + +Trigger Types: + + 0: Entry; Hacker enters tile (mid-air or per foot) + 1: Null; Must be set off externally. Also used as data storage. + 2: Floor; Hacker touches floor of this tile (per foot exclusively) + 3: Player Death; Used to resurrect Hacker + 4: Death Watch; When certain objects (or types of objects) are destroyed. Common for CPU nodes. + 5: Area Enter; Hacker enters (or leaves) a given radius + 6: Area Continuous; Hacker enters any tile inside (or outside) a given radius + 7: This is not a trigger! See AI hints above. + 8: Level Entry; Used for instance to initialize starting health + 9: Continuous; Unused + 10: This is not a trigger! See repulsors above. + 11: Ecology; Certain objects go extinct + 12: SHODAN; Triggered when level security changes + +##### Conditions + +Nearly all triggers have conditions based on [game variables](../Conditions.md#game-variable-conditions). + +The following exceptions exist: +* 4 (Death Watch Trigger): A union of [object type][obj-type-cond] and [object index conditions][obj-index-cond] +* 5, 6 (Area Trigger): A radius around the trigger. See below. +* 11 (Ecology Trigger): A combination of [object type][obj-type-cond] and a limit value. See below. + +[obj-type-cond]: ./Conditions.md#object-type-conditions +[obj-index-cond]: ./Conditions.md#object-index-conditions + +##### Area Triggers + +These triggers activate when the hacker enters or leaves a circular area around the trigger. The radius of this area is determined by the condition. + +**Area Trigger Condition** (4 bytes) + + 0000 sint32 Radius (in tiles) + +If the ```radius``` is between 1 and 4095, the trigger activates when the hacker is within a circular area of that size around the trigger's tile. A negative radius will activate the trigger when the hacker is outside of the circle instead (e.g. if the radius is -5, the trigger activates when the hacker is at least 5 tiles away from the trigger). + +Trigger type 5 ("Area Enter") activates once the hacker either enters (positive radius) or leaves (negative radius) the area. Type 6 ("Area Continuous") activates when the hacker enters any tile that is either inside (positive radius) or outside (negative radius) of the area. + +##### Ecology Triggers + +These triggers watch a certain type of objects as their condition, together with a limit value. Whenever the current count of these objects is changed to a number below the limit value, the action is executed. + +**Ecology Trigger Condition** (4 bytes) + + 0000 byte Type + 0001 byte Subclass + 0002 byte Class + 0003 byte Limit + +Note: The action is executed more than once. How often the action is executed is dependent on the delta. If the new object count equals to (limit-1), then the action is executed twice. If the new object count is less than that, the action is executed three times. + +> The layout for the C/S/T is the common one in an int32 value, with the high-byte representing the limit value. + +> The game watches only critters, typically to spawn new ones. The trigger also works with other classes. + + +### Trip Beams 12/1/x + +These are not used in the game. + + +### Map Marker 12/2/x + +Only two types are used in the game. + + +#### Map Notes 12/2/3 + +**Map Note Marker Data** (22 bytes) + + 0000 [18]byte Unused + 0012 int32 Entry offset + +The ```Entry offset``` points into ```L46``` ([map notes](mapInformation.md#map-notes)) for the text entry. When a map note is removed and the whole table is defragmented, all the entry offsets are changed accordingly. + + +#### Music Voodoo Marker 12/2/4 + +**Music Voodoo Marker Data** (22 byte) + + 0000 [6]byte Unused + 0006 byte Music flavour + 0007 [15]byte Unused + +The ```Music flavour``` is an index, with a range of ```0x00```..```0x04```, referencing patches of "music" resembling industrial noise. +They have a rectangular area of effect which covers 14x14 tiles, centered on the tile of the trap. +The music is only played if the current tile has a music index of zero. + +> This is used extensively on maintenance level, the 4 flight decks, as well as for the maintenance tunnels on the groves. diff --git a/modding/ss-specs/levelObjects/13_Container/levelContainerEntry.md b/modding/ss-specs/levelObjects/13_Container/levelContainerEntry.md new file mode 100644 index 0000000..9b51005 --- /dev/null +++ b/modding/ss-specs/levelObjects/13_Container/levelContainerEntry.md @@ -0,0 +1,25 @@ +## Level Container Table (Class 13) + +```L23``` is a table describing containers. + +**Level Container Entry** (21 bytes) + + 0000 [6]byte Level object prefix + 0006 [4]int16 Contained object indices + + for crates (13/0/x): + 000E byte Width + 000F byte Depth + 0010 byte Height + 0011 byte Top/Bottom texture number + 0012 byte Side texture number + +For the crate-specific values (dimensions and textures), if a value is 0, the default value from the 3D model is used. +The textures are taken from the resources in ```citmat.res```, based on ```0x0884```. + +> Texture numbers from ```0x80``` have been found to take the textures from the level texture list. +> It is unclear if this is intentional or a side-effect of how data is arranged in the engine. + +> Barrels (13/1/x) can not be inspected and may still contain items. Destroying a barrel drops its contents. +> There is one radioactive waste barrel (13/1/0) on level 4 which "contains" a hornet clip, which is lying right next to it. +> This is most likely the result of a mistake. diff --git a/modding/ss-specs/levelObjects/14_Critter/critterProperties.md b/modding/ss-specs/levelObjects/14_Critter/critterProperties.md new file mode 100644 index 0000000..d060eb8 --- /dev/null +++ b/modding/ss-specs/levelObjects/14_Critter/critterProperties.md @@ -0,0 +1,113 @@ +### Critter Properties + +#### Generic Critter Properties + +**Generic Critter Propertes** (`CritterProp` struct) (75 bytes) + + 0000 byte Intelligence; unused + 0001 [21]byte Primary attack info + 0016 [21]byte Secondary attack info + 002B uint8 Perception + 002C uint8 Defence + 002D sint8 Projectile source height offset + 002E int32 Flags + 0032 byte Mirrored views; unused + 0033 [8]uint8 Frame numbers; Amount for each posture + 003B uint8 Animation speed + 003C byte Attack sound index + 003D byte Near sound index + 003E byte Hurt sound index + 003F byte Death sound index + 0040 byte Notice sound index + 0041 int32 Corpse triple - 0x00CCSSTT; zero for "none" + 0045 byte View count; for multi-view postures + 0046 byte Secondary attack probability + 0047 byte Disrupt probability + 0048 byte Treasure type + 0049 byte Hit effect -- 0x00: meat, 0x01: plant, 0x02: robot, 0x03: cyborg, 0x04: generic + 004A byte Attack key frame + +`Perception` is a probability whether an AI detects hacker when visible during an AI interval. + +The `projectile source height offset` is a vertical offset specifying from where projectiles should be launched. +> This is set to some positive values for the Elite Guard and Mutated Cyborg, so that their projectils appear from their heads. + +The `animation speed` specifies the time of a single frame of an animation. A value of `0xFF` means a time of approximately 0.9 seconds. + +For all `sound indices`, a value of `0xFF` indicates "no sound". +> No enemy has a sound set for being hurt in the main game. + +`View count` determines how many frames are available per viewing direction. Cyberspace entities, as well as unused critters, have this set to `0x01`, all others have `0x08`. Changing these values messes up rendering of the critter, and/or all subsequent critters. The sequence of the corresponding images will have to be adjusted accordingly. + +The `secondary attack probability` determines how likely the critter will use its secondary attack when close to the hacker. `0xFF` means always, `0x00` will have the critter use the secondary attack only when farther away. + +The `disrupt probability` determines how likely the critter will be interrupted when being hit. `0xFF` means always, `0x00` will have the critter execute its attacks and movement without interruptions. + +`Treasure type` is an enumeration value (ranging from `0x00` to `0x0E`), pointing into a hardcoded pool of possible loot. See below for the possible constants. The concrete loot is randomized accordingly. + +The `attack key frame` value is an index into the attack animation, when the corresponding attack should take effect. + + +**Critter Attack Info** (`CritterAttack` struct) (21 bytes) + + 0000 uint8 Damage type + 0001 uint8 Special damage type + 0002 [2]byte Unused + 0004 int16 Damage modifier + 0006 uint8 Offence value + 0007 uint8 Penetration + 0008 uint8 Attack mass + 0009 int16 Attack velocity + 000B uint8 Accuracy + 000C uint8 Attack range + 000D int32 Reload time, with 0x100 being approximately 0.9 seconds + 0011 int32 Projectile triple -- 0x00CCSSTT; zero for "none" + +The `damage type` seems to be compatible with the damage type of [generic weapon info](../GenericWeaponInfo.md). Yet all damage types that affect hacker are handled equally; The two damage types `0x04` and `0x08` have no effect at all. +> The main game only uses damage types `0x01` and `0x02` (and some combinations). `0x04` is also set for Diego as well as SHODAN, though this has no further effect. + +The `offence value` is compared to the defence value of the target object. See [common properties](../../fileFormat/PropertyFiles.md#common-table) for details. + +An `accuracy` of `0x00` will still have the critter hit, though not that often and with less damage. + +The critter will want to get within the `attack range` to perform its attack. + + +**Critter Properties Flags** (4 bytes) + + 0x00000001 Flying + +> There are further constants defined, though not in use by the engine. +> The properties file has some critters with flag `0x00000004` set, which indicated "small" critters. + + +**Treasure Type Enumeration** (1 byte) + + 0x00 No treasure + 0x01 Humanoid + 0x02 Drone + 0x03 Assassin + 0x04 Warrior cyborg + 0x05 Flier bot + 0x06 Security 1 bot + 0x07 Exec bot + 0x08 Cyborg enforcer + 0x09 Security 2 bot + 0x0A Elite cyborg + 0x0B Standard corpse + 0x0C Loot-oriented corpse + 0x0D Repairbot + 0x0E Serv-bot + + +#### Specific 3 Properties + +These critters (`14/3/x`) are for cyberspace. + +**Cyberspace Critters Specific Properties** (`CyberCritterProp` struct) (6 bytes) + + 0000 [3]byte Colour scheme + 0000 [3]byte Alternative colour scheme + +The `colour scheme`s contain 3 palette indices from the game palette. The 'alternative' colours are used for critters attacking or being hostile. +The index into this list comes from the geometry properties, specifically from the [Set Colour and Shade](../../media/Geometry#set-colour-and-shade) command. diff --git a/modding/ss-specs/levelObjects/14_Critter/levelCritterEntry.md b/modding/ss-specs/levelObjects/14_Critter/levelCritterEntry.md new file mode 100644 index 0000000..29b3b24 --- /dev/null +++ b/modding/ss-specs/levelObjects/14_Critter/levelCritterEntry.md @@ -0,0 +1,89 @@ +## Level Critter Table (Class 14) + +```L24``` is a table describing critters. + +**Level Critter Entry** (46 bytes) + + 0000 [6]byte Level object prefix + 0006 [40]byte Level critter data + + +**Level Critter Data** (40 bytes) + + 0000 int32 Pending Z-rotation. Values up to 0x608000 seen. + 0004 int32 Fixed forward velocity. Rare values of 0x0D000 and 0x090000 seen. + 0008 [2]byte Unknown, Unused + 000A int16 Hastiness. Values of 0x0050 and 0x0030 seen. + 000C int16 State timeout + 000E int16 Unknown - set to 0x0002 for a few critters. + 0010 int16 Unknown - set to arbitrary values. + 0012 [2]byte Unused + 0014 byte Roaming critter state + 0015 byte Primary critter state + 0016 byte Secondary critter state + 0017 byte Tertiary critter state + 0018 [6]byte AI coordinates A + 001E byte Always 0xFF + 001F byte AI coordinates B + 0020 [2]int16 Loot object indices + 0024 [2]byte Unknown, Unused + 0026 [2]byte Unknown, set only for a few roaming critters. + + +```Pending Z-rotation``` is a rotation value that the critter wants to perform next. Only when this value is zero, it continues with its regular state. + +```Fixed forward veloctiy``` is used for a few critters to give them a fixed forward speed. This speed can be different than their normal speed, and even make them slower than regular. + +```Hastiness``` makes a few critter appear as if they were under the effect of a speedup-stimulant. Their actions, including their animations, are quicker. + +```State timeout``` is an indicator on how long the critter will stay in this state. Observed only in "cautious" state. +This time is reduced as long as the hacker is in the vicinity. When the time reaches zero, the critter becomes hostile. +The time value is typically quite low. A few high values like 0x0062 have been observed. + +```AI coordinates A``` and ```B``` appear to be some sort of coordinates. Changing them has no visible effect, so they may be scratch values for the AI, determining where to go next. They are used only for roaming critters. + +```Always 0xFF``` must always be set to ```0xFF```, otherwise the critter is immediately hostile, regardless of where it is on the map. All critters in the main game have it set to ```0xFF```. + +### Critter states + +The state a critter is in is goverend by four bytes, which have meanings on their own, as well as combined effects. For simple cases, only the ```Primary critter state``` is used. This value is also the one modified by the "Set critter state" action. + +A roaming critter has the ```Roaming critter state``` value set, and also needs other states to be set. Working combinations found so far are: +* ```03 01 01 00``` +* ```02 01 01 00``` (not found in archive.dat) +A critter only roams on its own if the hacker is within a radius of about 6 tiles. + +A patrolling critter follows a path laid out by ```Critter AI Hint``` marker (See [traps](../12_Trap/levelTrapEntry.md#critter-ai-hints-1207)). The first marker of such a path must be referenced in the second ```loot object index```. +> The only working example of such a critter is initially docile, and has a state combination of ```00 00 04 00```. + +The ```Primary critter state``` holds the state the critter is currently behaving in. This state is also reported by the targeting tool. + +**Primary Critter State** (1 byte) + + 0x00 docile + 0x01 cautious + 0x02 hostile + 0x03 cautious (?) + 0x04 attacking + 0x05 sleeping + 0x06 tranquilized + 0x07 confused + +> Various combinations of the state values can be found in the main game. While they have different effects, their pattern is not clear. + +**Critter Mood Enumeration** + + 0 Friendly + 1 Neutral + 2 Hostile + 3 Isolation + 4 Attacking + +**Critter Orders Enumeration** + + 0 Guard + 1 Roam + 2 Sleep + 3 Patrol between two points + 4 Highway - Follow path + 5 No move (guarding without moving) diff --git a/modding/ss-specs/levelObjects/Actions.md b/modding/ss-specs/levelObjects/Actions.md new file mode 100644 index 0000000..e59a76b --- /dev/null +++ b/modding/ss-specs/levelObjects/Actions.md @@ -0,0 +1,786 @@ +## Actions + +Level traps and various fixtures (buttons and the like) make use of actions that perform some state change and/or give some indication to the player. They are essentially what make a level (and the game in total) come to life. + +Actions can be triggered by various sources. They may have conditions that first have to be met before the action is actually performed. The type of the action determines how to interpret the details (parameters) of the action. + + +### Quest Value Key + +Some actions make use of quest value keys, which are used to either resolve a raw value, or a value from the game variables ("quest values"). + +* If bit ```0x1000``` is set in the key, the current integer game variable value of index ```key & 0x0FFF``` is referenced. +* If bit ```0x2000``` is set in the key, the current boolean game variable value of index ```key & 0x0FFF``` is referenced. +* For resolving values, if the key is less than 0x1000, then the resulting value is the value key itself. + + +### Action Data + +**Action Data** (22 bytes) + + 0000 byte Action type + 0001 byte Use quota + 0002 [4]byte Condition + 0006 [16]byte Action details + +The ```Use quota``` indicates how often more this action can be executed. ```0``` means endless. +The value is only decremented if it is not zero and the condition was met. +If the value is decremented to zero after the action was executed, the object where the action is in is deleted. + +Internally the ```action details``` are processed as four sint32 values. This documentation dissects them further where appropriate. + +### Action Type 1: Transport hacker + +**Transport Hacker Action Details** (16 byte) + + 0000 int32 Target X + 0004 int32 Target Y + 0008 int32 Target Z + 000C int32 Target Level + +For all three target axis values, if they are ```0x4000``` or greater, they are ignored and the respective axis is not changed in the transport. +The ```X``` and ```Y``` axes specify tile numbers, the ```Z``` axis is interpreted in level units. + +> In the main game, this axis-preservation is used only on level 2 for the Z axis on the experimental transporter, +> which incidentally has both platforms at the same height. + +If to be considered, the target values are then used as quest value keys to resolve the actual location for the respective axis. + +> The main game does not make use of this quest value lookup feature. All encounterd values are direct values. + +The ```target level``` specifies a cross-level transfer to the identified level if the value is smaller than ```0x1000```. +If it is ```0x1000``` or greater, transportation is only done within the same level, regardless of the value. + +> Cross-level transport is used only for a same-level transfer on level 4 (restoration chamber), and twice for two groves, to +> transport the hacker to the restoration chamber on level 6. Other instances are set to ```0x2222```, which are ignored. + + +### Action Type 2: Change Health + +This action is also called "damage", probably based on the first parameter. + +**Change Health Action Details** (16 byte) + + 0000 int32 Damage delta + 0004 int16 Health delta + 0006 byte Health change flag; 0: remove delta, 1: add delta + 0007 byte Damage type -- used if removing health + 0008 int16 Power delta + 000A int16 Power change flag; 0: remove delta, 1: add delta + 000C int16 Fatigue delta + 000E int16 Fatigue change flag; 0: add delta, 1: remove delta + +All four ```delta``` values are used as quest value keys to resolve the actual delta value. + +> The ```damage delta``` is not used in the main game. +> Although intended to cause explosion damage, due to mixed up parameters in the code, a fixed amount of damage is done, +> with the delta value being interpreted as damage type. + +> Reducing health to/below 0 causes death. Reducing power below 0 causes an underflow and hacker is fully powered again. + + +### Action Type 3: Clone/Move Object + +> This action is internally called "Create Object". Name not kept to describe actual functionality better. + +**Clone/Move Action Details** (16 byte) + + 0000 int16 Source object index -- quest value key + 0002 int16 Move flag + 0004 int32 Target X tile + 0008 int32 Target Y tile + 000C int32 Target Z + +The ```move flag``` has a dual purpose. First, it is used to determine whether the object shall be moved or cloned. +If it is zero, then the object is cloned; If it has bit ```0x1000``` set, then the object is cloned as well; All other values cause the source object to be moved. +If the object is cloned, then the ```move flag``` parameter will be used as quest value key to store the object index of the newly created object. + +> The game archive has various values for the ```move flag```. + +For all three target axis values, if they are ```0x4000``` or greater, they are ignored and the respective axis is not changed. +The ```X``` and ```Y``` axes specify tile numbers, the ```Z``` axis is interpreted in level units. + +> In the main game, this axis-preservation is used with various values, including ```0x4400```. + +If to be considered, the target values are then used as quest value keys to resolve the actual location for the respective axis. + +The orientation and in-tile position of the source object will be kept. + + +### Action Type 4: Set Game Variable + +> This action is internally called "Quest Bit". Name not kept to describe actual functionality better. + +**Set Game Variable Action Details** (16 byte) + + 0000 int32 Quest value key + 0004 int16 Value + 0006 int16 Operation + 0008 int32 Message 1 (within resource 0x0867) + 000C int32 Message 2 (within resource 0x0867) + +Depending on the ```quest value key```, different sets of variables are modified. +The variables are stored in the [Game State Structure](../../archives/gameState.md). +If no bit in the nibble of ```0xF000``` is set, the quest value key defaults to boolean variables. + +> For the most part in the main game, the boolean variable selector ```0x2000``` is not set and thus relies on this default handling. +> There are a few cases where this boolean variable selector is set. + +For the int16 variables, ```value``` must be in the range ```0..0x0FFF```. ```Operation``` can be one of the following: + + 0: Set value + 1: Add value + 2: Subtract value + 3: Multiply with value + 4: Divide by value (Resulting fractions are cut, divide by 0 is ignored) + 5: Modulo by value + +For the boolean variables, ```operation``` is ignored and always ```0```. +The values are either ```0``` or ```1``` to set that value, or ```2``` to toggle the current value. + +If the resulting value is not zero, the (optional) ```message 1``` is played/shown. If the resulting value is zero, the (optional) ```message 2``` is played/shown. Both message parameters are quest value keys. + +> Operations other than Set or Add are not used in the game for the int16 values. + + +### Action Type 5: Cutscene + +**Cutscene Action Details** (16 byte) + + 0000 int32 Cutscene index; 0: Death, 1: Intro, 2: Ending + 0004 int32 End game flag + 0008 [8]byte Unused + +```End game flag``` must be ```1```. Setting it to ```0``` leaves the game in an unplayable state after the cutscene. + +What happens after the cutscene is dependent on the cutscene itself: The death and intro cutscenes return to the main menu, +the ending cutscene ends the game (showing score and credits). + +> In the regular game, this action is used only once when defeating SHODAN. + + +### Action Type 6: Trigger other objects + +**Trigger Objects Action Details** (16 byte) + + 0000 int16 Object 1 index + 0002 int16 Object 1 delay + ... + 000C int16 Object 4 index + 000E int16 Object 4 delay + +The delays are all relative to the execution start of the action. Their unit is 0.1 of a second. + +All four object indices are quest value keys. They are resolved when this action is executed, not after the delay. + + +### Action Type 7: Change lighting + +**Change Lighting Action Details** (16 byte) + + 0000 int16 Light type extent - quest value key + 0002 int16 Reference object index - quest value key + 0004 int16 Transition type. 0x0000: immediate, 0x0001: fade, 0x0100: flicker + 0006 int16 Transition state + 0008 byte Light type + 0009 byte Unused + 000A int16 Light surface. 0x0000: floor, 0x0001: ceiling, 0x0002: floor and ceiling + 000C [4]byte Light type parameter + +These actions modify the light deltas of the affected tiles. Such actions can not change the base darkness +values of a tile. If a tile is fully bright by default, nothing can change that. +The configured light parameters are absolute values. + +The ```transition state``` is a bitmask storing the state for a change. + +**Transition state** (2 bytes) + + 0x0FFF Step counter + 0x1000 0: light on, 1: light off + +**Light type** (1 byte) + + 0x00 Rectangular + 0x01 Linear gradient (East-West smooth) + 0x02 Linear gradient (North-South smooth) + 0x03 Radial gradient + +**Gradient light type parameter** (4 bytes) + + 0000 byte Off light begin intensity + 0001 byte Off light end intensity + 0002 byte On light begin intensity + 0003 byte On light end intensity + +#### Rectangular light (type 0x00) + +For this light type, the ```light type extent``` specifies a second object index. All tiles within this rectangle are affected. + +**Light type parameter** (4 bytes) + + 0000 byte Off light value 0x00..0x0F + 0001 byte On light value 0x00..0x0F + 0002 [2]byte Unused + +#### Linear gradient light (types 0x01, 0x02) + +For this light type, the ```light type extent``` specifies a second object index to define the affected area. + +For these types, the ```gradient light type parameter``` intensity values have a range of ```0x00```..```0x0F```. + +> These light types are not used in the regular game. + + +#### Radial gradient light (type 0x03) + +For this light type, the ```light type extent``` specifies a radius, in tiles, of affected tiles. The begin of the gradient is always the +tile of the triggering object (```reference object index``` is ignored). + +The intensity values of the ```gradient light type parameter``` have a range of ```0x00```..```0x7F```. +A value of around ```0x14``` lights a tile as if it were fully lit. +Values above will spill over to the surrounding tiles. + + +### Action Type 8: Effect + +**Effect Action Details** (16 byte) + + 0000 int16 Sound index, based on 0x00C9; 0: Play nothing + 0002 int16 Sound play count; 0: endless + 0004 int16 Visual effect + 0006 [2]byte Unused + 0008 int16 Additional visual effect + 000A [2]byte Unused + 000C int16 Additional visual effect time; 0: endless + 000E [2]byte Unused + +All parameters are used as quest value keys. + +Visual Effects: + + 0: None + 1: Power on: Dim level lights and fade back to normal; Plays generator sound as well + 2: Quake: Shake cam and play rumble sound + 3: Escape pod: Shows launch sequence, cam shake and abort; Plays SHODAN message afterwards + 4: Red static (fullscreen) + 5: Red static (interference) + +Additional Visual Effects: + + 0: None + 1: White flash + 2: Pink flash + 3: Gray static (fullscreen) + 4: Vertical panning (including HUD) + + +### Action Type 9: Change tile heights + + 0000 int32 Tile X + 0004 int32 Tile Y + 0008 int16 Target floor height; 0..31, measured from floor absolute zero + 000A int16 Target ceiling height; 1..31, measured from floor absolute zero + 000C [2]byte Unused + 000E int16 Silent flag; 0: play sound, 1: no sound + +Tile coordinates, as well as the heights, are used as quest value keys. + +For the height fields a value equal to, or greater than ```0x0100``` indicates "don't move". + +> The ceiling height has also been encountered to be ```0x0020```, also not moving. + +The ```silent flag``` only works for floor changes. If the ceiling changes height, it makes sound regardless of this parameter. + + +### Action Type 10: Change Terrain + + 0000 int32 Tile X + 0004 int32 Tile Y + 0008 int32 Tile type + 000C int32 Terrain parameter (slope height) + +All four parameters are used as quest value keys. + +If ```tile type``` or ```terrain parameter``` has the bit ```0x4000``` set, the respective property will not be changed. + +> This action is not used in the main game. + + +### Action Type 11: Scheduled Trap + +**Scheduled Trap Action Details** (16 byte) + + 0000 int32 Object index + 0004 int32 Time interval + 0008 int32 Activation value + 000C int16 Variance + 000E [2]byte Unused + +Parameters ```object index```, ```time interval```, and ```variance``` (if set != 0) are used as quest value keys. + +The ```time interval``` specifies in 0.1 seconds units within which time span the identified object shall be triggered regularly. +The ```variance``` adds some randomness to the interval. Usually low numbers are encountered in the main game. + +> The unit of the variance is that of the engine-internal ticks. + +The ```activation value``` has three different uses: +* greater than or equal to ```0xFFFF```: endless scheduling. +* bit ```0x1000``` set AND boolean game variable identified by this value key is true: schedule trigger +* value between ```0x0000``` (exclusive) and ```0x1000``` (exclusive): remaining schedule amount, down to zero + +> The game has the ```Activation value``` typically set to ```0xFFFF```, ```0x10000``` or ```0x11111``` in the main game. + + +### Action Type 12: Cycle Objects + +> Internally, this action is called "Alternating splitter" + +**Cycle Objects Action Details** (16 byte) + + 0000 [3]int32 Object indices + 000C int32 Next object + +All three ```object indices``` are interpreted as quest value keys. + +This action triggers objects from the list of ```object indices```. The ```next object``` field indicates which entry to trigger next. +Afterwards, the ```next object``` field is incremented. It is reset to ```0``` if it is either greater than ```2```, or it is ```2``` and the third object index is ```0```. + + +### Action Type 13: Destroy Object + +**Destroy Object Action Details** (16 byte) + + 0000 int16 Object 1 index + 0002 int16 Unused + 0004 int16 Object 2 index + 0006 int16 Unused + 0008 int16 Object 3 index + 000A int16 Unused + 000C int32 Message index (within resource 0x0867); 0: no message + +All three ```object indices``` are interpreted as quest value keys. + + +### Action Type 14: Unused + +> This action is not used and does nothing. + + +### Action Type 15: Receive EMail + +**Receive EMail Action Details** (16 byte) + + 0000 int16 EMail index, based on 0x0989 + 0002 [2]byte Unused + 0004 int16 Delay + 0008 [10]byte Unused + +The ```Delay``` is roughly counted in seconds. Tests showed their actual unit seems to be somewhere at 0.9 seconds. + + +### Action Type 16: Expose + +**Change Effect Action Details** (16 byte) + + 0000 int16 Delta value + 0002 int16 Effect change flag; 0: add delta, 1: remove delta + 0004 int32 Effect type + 0008 [8]byte Unused + +This action exposes the hacker to some typed hazard or treatment. + +Effect types are: + + 4: Radiation poisoning + 8: Bio contamination + +> The main game uses this only once to remove the radiation in level R - treatment area. + + +### Action Type 17: Set Object Parameter + +**Set Object Parameter Action Details** (16 byte) + + 0000 int16 Object index 0 + 0002 int16 Object index 2 + 0004 int32 Value 1 + 0008 int32 Value 2 + 000C int32 Value 3 + +Both object indices are treated as quest value keys. + +This action sets parameters of target objects. Which value is applied to which parameter is dependent on the target. + +The following table lists the mappings. + +| Target Class | Value 1 | Value 2 | Value 3 | +|:-------------:|-----------------|----------------------------------|-----------------| +| Big Stuff | Cosmetic value | Data1 | Data2 | +| Door | Locked | ```0xFF00```: Message index, ```0x00FF```: Cosmetic value | ```0xFF00```: Access level, ```0x00FF```: Auto-close time | +| Fixture, Trap | Parameter Number (1..4) | Parameter Value | Unused | + +For ```big stuff``` and ```door``` classes, if a value is ```-1``` it doesn't modify the respective target parameter. + +> Which parameters are specifially affected for fixtures and traps depends on their respective interpretation of the 16 bytes. + + +### Action Type 18: Set Screen Picture + +> Internally, this action is called "animate". + +**Set Screen Picture Action Details** (16 byte) + + 0000 int16 Screen 1 object index + 0002 int16 Screen 2 object index + 0004 int32 Single sequence source + 0008 int32 Loop sequence source + 000C int32 Unused + +This action sets the picture source for the given screen object(s). + +If provided, the ```Single sequence``` source is played first. If provided, the ```Loop sequence``` is played +next and looped. If no loop is provided, the last frame of the single sequence will be kept. + + +### Action Type 19: Hack + +This action is a more generic one with several different interpretations, depending on the first field. + +**Hack Action Details** (16 byte) + + 0000 int32 Hack type + 0004 [12]byte Hack parameter + + +#### Hack Action Type 1: Toggle repulsor + +**Toggle Repulsor Details** (12 byte) + + 0000 int32 Repulsor object index + 0004 byte "Off" texture index (into level texture list) + 0005 byte "On" texture index (into level texture list) + 0006 [2]byte Unused + 0008 byte Toggle type + 0009 [3]byte Unused + +**Toggle type** (1 byte) + + 0x00 Toggle On/Off + 0x01 Toggle On, stay on + 0x02 Toggle Off, stay off + + +#### Hack Action Type 2: Show Game Code Digit + +**Show Game Code Digit Details** (12 byte) + + 0000 int32 Screen object index + 0004 int32 Digit number 1..6 (= level number) + 0008 [4]byte Unused + + +#### Hack Action Type 3: Set Fixture Parameter from Variable + +**Set Fixture Parameter From Variable Details** (12 byte) + + 0000 int32 Fixture object index + 0004 int32 Parameter number (1..4) + 0008 int32 Game integer variable index + +> This hack is used only twice, exclusively to set the codes for the reactor in the base game. +> Similar to the "Set Object Parameter" action, the interpretation of the modified parameter is depending on the action in the target fixture. + + +#### Hack Action Type 4: Set Frame State + +> Although this hack is called "fixture frame", it doesn't check for the type of the target object and allows to modify any object's frame state. + +**Set Frame State Details** (12 byte) + + 0000 int32 Button object index + 0004 int32 New frame state; 0: off, 1: on + 0008 int32 MFD update flag; 0: ignored, != 0: update MFD + + +#### Hack Action Type 5: Door Control + +**Door Control State Details** (12 byte) + + 0000 int32 Door object index + 0004 int32 Control value + 0008 int32 Unused + +Control values: + + 1: open door + 2: close door + 3: toggle door + 4: disable auto-close + + +#### Hack Action Type 6: Return to main menu + +This hack has no parameters (all 12 bytes 0x00) and directly returns to the main menu. + + +#### Hack Action Type 7: Rotate Object + +**Rotate Object State Details** (12 byte) + + 0000 int32 Object index + 0004 byte Amount + 0005 byte Type -- 0x00: endless, != 0x00: back/forth + 0006 byte Direction -- 0x00: forward, 0x01 when running backwards + 0007 byte Axis -- 0x00: Z (yaw), 0x01: X (pitch), 0x02: Y (roll) + 0008 byte Forward limit + 0009 byte Backward limit + 000A [2]byte Unused + +This hack rotates an object along one axis. This rotation can be endless in one direction, or a back and forth rotation, +depending on the ```Type``` field. + +If the rotation type is back and forth, then the object is rotated between the two limits. Should the object start outside of these +limits, then rotation continues normally until it is within the limits. + +If the rotation type is endless, whenever one limit is reached, the object immediately jumps to an orientation as per the other limit. + + +#### Hack Action Type 8: Remove Critters + +> This hack is called "armageddon" internally, most likely in a reference to a similar action in the game Lemmings. + +**Remove Critters State Details** (12 byte) + + 0000 int32 Object type - 0x00CCSSTT + 0004 int32 Radius + 0008 int32 Unused + +This hack removes critters of the specified object type from the level. +The ```radius``` specifies how many shall be removed around the tile of the action's object, in tiles. + +> Avoid using high amounts. The engine can remove only a certain amount of objects in one tick (100) and has no guard if more are removed. +> A memory overrun occurs in this case. + + +#### Hack Action Type 9: SHODAN pixelation + +This hack has no parameters (all 12 bytes 0x00) and fills the screen with an image. The used image is hardcoded. +The game returns to the main menu after the screen is filled. +It should be used only in cyberspace; Using it in real world messes up tile textures. + + +#### Hack Action Type 10: Set Condition + +**Set Condition Details** (12 byte) + + 0000 int32 Object index + 0004 [4]byte Condition + 0008 [4]byte Unused + +Sets the condition values of specified object (fixture or trap). + + +#### Hack Action Type 11: Show System Analyzer + + 0000 int32 Page index (0..2) + 0004 [8]byte Unused + + +#### Hack Action Type 12: Make Area Radioactive + +> Internally this hack is called "spew". Yet it only supports spillage of radiation. + +**Make Area Radioactive Details** (12 byte) + + 0000 int32 Reference object index + 0004 int16 Watched object index + 0006 int16 Watched object trigger state + 0008 int32 Spew type. Only value of 0 supported (= radiation). + +The rectangular area defined by ```reference object``` and the object of the action emits radiation while ```watched object``` is in given state (1 = active/open). + +> This is used only once, on level 2 for the small room in Beta quadrant. The door is both the reference as well as the watched object. + + +#### Hack Action Type 13: Oriented Trigger Object + +> This hack is exclusively used on level 8 to trigger three different taunts from Diego. +> Hence it is also called "Diego Taunt" in the source. + +**Oriented Trigger Object Details** (12 byte) + + 0000 uint16 Horizontal direction + 0002 [2]byte Unused + 0004 int32 Object index + 0008 [4]byte Unused + +This hack triggers the given object only if the player looks to the given ```Horizontal direction``` (+/- 45°). +Value ```0x0000``` specifies north, going clockwise with increasing numbers, meaning: + + 0x0000 North (matches between NW and NE) + 0x4000 East (matches between NE and SE) + 0x8000 South (matches between SE and SW) + 0xC000 West (matches between SW and NW) + + +#### Hack Action Type 14: Close Data MFD + +**Close Data MFD Details** (12 byte) + + 0000 int32 Displayed object index + 0004 [8]byte Unused + +If the specified object is currently displayed in the "Data" MFD, close it and return to previous display. + +> This hack is used only once to close the keypad panel from the reactor if an invalid combination is entered. + + +#### Hack Action Type 15: Earth destruction by laser + +This hack has no parameters (all 12 bytes 0x00) and lets the player receive the message about the fired laser. +The game continues after the message has played. Images and text are hardcoded. + + +#### Hack Action Type 16: Change Critter Type Global + +**Change Critter Type Global Action Details** (16 byte) + + 0000 int32 Critter type - 0x00CCSSTT + 0004 byte New type + 0005 [7]byte Unused + +This hack morphs all critters of given object type in the level and changes their type to the given one. +Class and subclass stay the same. Stats, such as healthpoints, are maintained as well. + +If ```new type``` specifies a value greater than ```0x0F```, the object will be destroyed. + + +### Action Type 20: Set Tile Textures + +> This action is not used in the main game. + +**Set Tile Textures Action Details** (16 byte) + + 0000 int32 Area + 0004 int32 Floor Operation + 0008 int32 Wall Operation + 000C int32 Ceiling Operation + +The ```area``` defines a rectangular area of affected tiles. +If ```area``` is less than ```0x10000```, then only one tile is affected, identified by ```0xXXYY```. +Otherwise, ```area``` is interpreted as two object indices (interpreted as quest value keys), that define the area by their respective location. + +The three ```operation``` fields identify, per texture, what to do. + +**Texture Operation** (4 byte) + + 0000 int16 Source filter + 0002 int16 Destination texture + +If ```source filter``` is either equal to or greater than ```0x1000```, or the tile uses the texture identified by this filter, then set the texture according to ```destination texture``` if this value is less than ```0x1000```. + +Said differently, try to set ```destination texture``` only if the value is less than ```0x1000```, and consider only tiles if set as a flood-fill, or if the source matches. + + +### Action Type 21: Set Critter State + +**Set Critter State Action Details** (16 byte) + + 0000 int16 Filter parameter + 0002 int16 Filter type + 0004 int32 Area + 0008 int32 New critter mood -- ignored if value >= 0x1000 + 000C int32 New critter orders -- ignored if value >= 0x1000 + +Sets the state of critters. +See [Critters](14_Critter/levelCritterEntry.md) for the enumeration values of critter states. + +The ```filter type``` parameter defines via bits which critters should be considered and how the ```filter parameter``` is interpreted. + +**Filter Type Bitfield** (2 byte) + + 0x0001 Consider loner critters only. If not set, only consider pack critters. + 0x0002 Ignore loner/pack property of critter. + 0x0004 If set, ```filter parameter``` identifies one specific critter to affect (regardless of remaining bits). Use area otherwise. + 0x0008 Affect every critter in the area, regardless of subtype/type. (Note: only works for small areas) + +If neither bits ```0x0004```, nor ```0x0008``` are set, then ```filter parameter``` specifies the subclass and type of affected critters (```0xSSTT```). + +The ```area``` parameter defines the affected tiles in the following ways: +* All zero: affect entire level +* Lower 16 bits zero: upper 16 bits define half of the side-length of a square, with the tile of the action object at its center. +* Two 16 bit values identifying reference objects that define a rectangular area. + +Unless the ```new critter mood``` is ```friendly```, mood is ignored on combat level 0. + + +### Action Type 22: Trap Message + +> Internally, such messages are called "barks". + +**Trap Message Action Details** (16 byte) + + 0000 sint32 Background image + 0004 int32 Message index (within resource 0x0867) + 0008 int32 Text colour + 000C int32 MFD suppression flag; 0: Show in MFD, 1: Show only in HUD + +The ```Background image``` and ```Text colour``` are only considered for display in MFD. The user needs to have messages set to "text" or "both" to see them. + +For the ```Background image```, the following values are possible: + + -1 SHODAN (black and white) + -2 Diego + +> The game uses other image references as well, yet they don't work. This is due to the code being modified to ignore positive background image values. + + +### Action Type 23: Spawn Objects + +**Spawn Objects Action Details** (16 byte) + + 0000 int32 Object type; Format 0x00CCSSTT + 0004 int32 Area + 0008 int32 Quantity - quest value key + 000C int32 Flags + +This action spawns a random number of objects, randomly distributed within an area. +The spawned objects are placed at the floor in the center of the tile. + +The ```area``` field specifies a rectangular area, either based on two objects, or a square. +If ```area``` is less than ```0x10000```, then it specifies half of the final side-length of the resulting square, and the trap object's location is at the lower-right corner of this square. Otherwise, ```area``` specifies two object indices that define the rectangle with their locations. + +Objects are only spawned if the ```Combat``` value is 1 or higher. If it is 3, then one more object than requested is spawned. +Not all classes can be spawned. +> Although the game uses enemies exclusively, ammo, explosives, and patches can be spawned as well. + +**Spawn Objects Flags** (4 byte) + + 0x00000001 Avoid spawn if tile is within a distance of 7 based on the sum of X and Y deltas. + 0x00000002 Avoid spawn if hacker looks at the tile within a distance of 14 based on the sum of X and Y deltas. + 0x00000004 Allow spawn if tile is an elevator tile (identified by elevator music). + 0x00000008 Force spawn, even if the ceiling is too low, or the tile contains already a critter. + + +### Action Type 24: Change Object Type + +> Internally, this action is called "transmogrify". + +**Change Object Type Action Details** (16 byte) + + 0000 int32 Object index + 0004 int16 New type + 0006 int16 Reset mask + 0008 [8]byte Unused + +This action sets, or toggles, the type of an object. It is primarily used to extend and retract force bridges. +> For bridges in their retracted state, the game uses "Elephant, Jorp" (```7/7/8```), an invisible placeholder type. + +The ```Reset mask``` is used when the specified object is already at given type. If the specified object is already at given type, +then the new type is calculated as ```New type``` XOR ```Reset mask```. +> So, to toggle between ```0x08``` and ```0x09```, ```New type``` should be ```0x08``` and ```Reset mask``` must be set to ```0x01```. +> To toggle between ```0x08``` and ```0x07```, ```New type``` should be ```0x07``` and ```Reset mask``` must be set to ```0x0F```. + +> The first byte of unused field at ```0008``` is set to ```1``` for only one force bridge in the game (on level 2) and is ignored. +> It appears that an earlier version of the code supported suppressing of animations. + diff --git a/modding/ss-specs/levelObjects/Conditions.md b/modding/ss-specs/levelObjects/Conditions.md new file mode 100644 index 0000000..202a44b --- /dev/null +++ b/modding/ss-specs/levelObjects/Conditions.md @@ -0,0 +1,66 @@ +## Conditions + +Conditions are filters for [actions](Actions.md) and for some [fixtures](09_Fixture/levelFixtureEntry.md) that don't use actions. + +Conditions can examine different aspects of the state of the game or (current) level. + + +### Game Variable Conditions + +These conditions check the value of a game variable (also called "quest value"). + +**Game Variable Condition** (4 bytes) + + 0000 uint16 Variable key + 0002 byte Value + 0003 byte Failure message + +The ```Failure message``` is a message (or indication) the player shall receive if the condition was not met. +```0x00``` means no indication, ```0xFF``` is used for security warnings (blocked by SHODAN security); +All other values are interpreted as text/audio message index. + + +**Variable key** (2 bytes) + + 0x03FF Variable index + 0x0C00 Unused + 0x1000 Variable type; 0: boolean variable, 1: int16 variable + 0xE000 Comparison + +**Comparison** + + 0: Variable must be equal to value + 1: Variable must be less than value + 2: Variable must be less than, or equal to, value + 3: Variable must be greater than value + 4: Variable must be greater than, or equal to, value + 5: Variable must be not equal to value + 6: Value must be greater than a random value of range 0..254 (variable value is ignored) + + +### Object Type Conditions + +These conditions check for a specific object type. + +**Object Type Condition** (4 bytes) + + 0000 int32 Class/Subclass/Type encoded as 0x00CCSSTT + +Note that the most significant byte needs to be ```0x00```. + +> These conditions are used for the quest items and for death watch trigger. + + +### Object Index Conditions + +These conditions are level-specific and only used for death watch trigger. + +**Object Index Condition** (4 bytes) + + 0000 int16 Object index + 0002 byte Unused + 0003 byte Condition marker = 0x01 + + +As these conditions are used for the same type of triggers as the object type conditions, the ```condition marker``` identifies the class of condition. + diff --git a/modding/ss-specs/levelObjects/GenericWeaponInfo.md b/modding/ss-specs/levelObjects/GenericWeaponInfo.md new file mode 100644 index 0000000..3437ede --- /dev/null +++ b/modding/ss-specs/levelObjects/GenericWeaponInfo.md @@ -0,0 +1,33 @@ +### Generic Weapon Info + +This structure is reused for various offensive items. + +**Generic Weapon Info** (`COMBAT_DATA_FIELDS` macro) (8 bytes) + + 0000 sint16 Damage + 0002 byte Offence value + 0003 byte Damage type + 0004 byte Special damage type + 0005 [2]byte Unused + 0007 uint8 Armour penetration + +The `offence value` is compared to the defence value of the target object. See [Common Object Properties](../fileFormat/PropertyFiles.md#common-table) for details. + +If `Armour penetration` is equal or higher than the `Armour` value of the object being hit, the full `Damage` is applied. Otherwise, the difference is subtracted from `Damage`. If the resulting value reaches 0 or lower, "No Damage" is displayed. + +`Special damage type` matches the `Special vulnerabilities` sub-fields from [Common Object Properties](../fileFormat/PropertyFiles.md#common-table); +It is split up into `0x0F` primary damage (double damage) and `0xF0` super damage (quad damage). + +> Similar to the vulnerabilities in the common object properties, the damage type is a single 32bit value from `0003` in the source. + + +**Damage Type Bitfield** (1 byte) + + 0x01 Explosion (impact) + 0x02 Energy + 0x04 Magnetic (EMP) + 0x08 Radiation + 0x10 Gas + 0x20 Tranquilizer + 0x40 Needle + 0x80 Bio diff --git a/modding/ss-specs/levelObjects/index.md b/modding/ss-specs/levelObjects/index.md new file mode 100644 index 0000000..f7a4012 --- /dev/null +++ b/modding/ss-specs/levelObjects/index.md @@ -0,0 +1,99 @@ +## Level Objects + +A level object is anything that is not part of the basic level layout (the tile map). The game knows about 476 different objects. + +Objects are identified using three values. They are divided by class, subclass and type. In this documentation such an identifier will be written as ```c/s/t```. For example, the energy charge station is ```9/2/1```. + +> In the original source, such an identifier is called a ```TRIPLE```. + +Information about level objects exists in two places: +* Static properties, describing an object in general +* Instance data, describing the state of a specific object instance in the game + +The static properties are stored in a separate file. See [Property Files](../fileFormat/PropertyFiles.md) for basic information. +Instance data is stored as part of an archive. See [Level Objects](../archives/levelObjects.md) for basic information. + +### Known Classes, Subclasses and Types + +The following table lists the object classes together with the available types per subclass: + +| Class | Name | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +|:-----:|------------------------------------|----|----|----|----|----|----|----|----| +| 0 | Gun | 5 | 2 | 2 | 2 | 3 | 2 | - | - | +| 1 | Ammo | 2 | 2 | 3 | 2 | 2 | 2 | 2 | - | +| 2 | Physics | 6 | 16 | 2 | - | - | - | - | - | +| 3 | Grenade | 5 | 3 | - | - | - | - | - | - | +| 4 | Drug | 7 | - | - | - | - | - | - | - | +| 5 | Hardware | 5 | 10 | - | - | - | - | - | - | +| 6 | Software | 7 | 3 | 4 | 5 | 3 | - | - | - | +| 7 | Big Stuff | 9 | 10 | 11 | 4 | 9 | 8 | 16 | 10 | +| 8 | Small Stuff | 8 | 10 | 15 | 6 | 12 | 12 | 9 | 8 | +| 9 | Fixtures | 9 | 7 | 3 | 11 | 2 | 3 | - | - | +| 10 | Door | 10 | 9 | 7 | 5 | 10 | - | - | - | +| 11 | Animating | 9 | 11 | 14 | - | - | - | - | - | +| 12 | Trap | 13 | 1 | 5 | - | - | - | - | - | +| 13 | Container | 3 | 3 | 4 | 8 | 13 | 7 | 8 | - | +| 14 | Critter | 9 | 12 | 7 | 7 | 2 | - | - | - | + +> This information is not stored within the resource files, it is hardcoded in the engine. + + +### Static Properties + +The following table lists the byte sizes of the generic and specific property structures per class. +Many of these bytes are all zero in ```objprop.dat```; In these cases no detailed data definition will be linked. + +| Class | Generic | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +|:-----:|---------------------------------------------|-----------|-----------|-----------|-----------|-----------|-----------|----|----| +| 0 | [2](00_Gun/gunProperties.md) | 1 | 1 | [16][0/2] | [13][0/3] | [13][0/4] | [18][0/5] | - | - | +| 1 | [14](01_Ammo/ammoProperties.md) | 1 | 1 | 1 | 1 | 1 | 1 | 1 | - | +| 2 | [1](02_Physics/physicsProperties.md) | [20][2/0] | [6][2/1] | 1 | - | - | - | - | - | +| 3 | [15](03_Grenade/grenadeProperties.md) | 1 | [3][3/1] | - | - | - | - | - | - | +| 4 | 17 | 7 | - | - | - | - | - | - | - | +| 5 | 2 | 1 | 2 | - | - | - | - | - | - | +| 6 | 2 | 1 | 1 | 1 | 1 | 1 | - | - | - | +| 7 | 4 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | +| 8 | 2 | 1 | 1 | 1 | 1 | 1 | [6][8/5] | 1 | 2 | +| 9 | 1 | 1 | 1 | 1 | 1 | 0 | 1 | - | - | +| 10 | 1 | 1 | 1 | 1 | 1 | 1 | - | - | - | +| 11 | [2](11_Animating/animatingProperties.md) | 1 | 1 | [1][11/2] | - | - | - | - | - | +| 12 | 1 | 1 | 1 | 1 | - | - | - | - | - | +| 13 | 3 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | - | +| 14 | [75](14_Critter/critterProperties.md) | 1 | 2 | 2 | [6][14/3] | 1 | - | - | - | + + +> There is also the case of objects with a specific entry length of 0. This is the case for object types that didn't make it into the game (vending machines). +> Though, this is only a guess. Yet, the serialization code of the source-port (based on the mac-port) shows a hack by shifting the offset back by 2 bytes after serializing the doors. So, something must have been done in the original engine. + +[0/2]: 00_Gun/gunProperties.md#specific-2-properties +[0/3]: 00_Gun/gunProperties.md#specific-3-properties +[0/4]: 00_Gun/gunProperties.md#specific-4-properties +[0/5]: 00_Gun/gunProperties.md#specific-5-properties +[2/0]: 02_Physics/physicsProperties.md#specific-0-properties +[2/1]: 02_Physics/physicsProperties.md#specific-1-properties +[3/1]: 03_Grenade/grenadeProperties.md#specific-1-properties +[8/5]: 08_SmallStuff/smallStuffProperties.md#specific-5-properties +[11/2]: 11_Animating/animatingProperties.md#specific-2-properties +[14/3]: 14_Critter/critterProperties.md#specific-3-properties + + +### Object Names + +The names are localized and [stored as texts](../media/Texts.md). The short names are stored in resource ```0x086D```, the long +names are in resource ```0x0024```. + + +### Object Bitmaps + +Every object has bitmaps associated with it. The bitmaps are all stored in resource ```0x0546``` in ```objart.res```, starting with block 1 for the first object. Each object has at least 3 bitmaps associated, with additional in-world bitmaps specified as per ```Extra``` field of the [Common Object Properties](../fileFormat/PropertyFiles.md#common-table). + +As a result, to determine the proper block number for a given object, the total number of previous (extra) bitmaps needs to be known. + +The first image is the standard image, the second (and additional) bitmaps are in-world bitmaps, and the last one is a micro-image. + +> The micro-images are sometimes fully transparent. This has been found for marker objects for instance. +> Maybe a (future) mapping unit would have been able to show objects, except those that weren't meant to be seen by the player. +> For such transparent bitmaps, the first bitmap shows some abstract presentation. For example, the "continuous trigger" has the infinity symbol as first image. +> On the contrary, using the first bitmap exclusively for editors might not be usable either as they sometimes have the same image for different types (software instance). + +Objects of render type ```Fragment``` have one extra bitmap. The first one contains the colour information and the extra bitmap the depth information; It is a "gray-scale" bitmap with ```0xD0``` as the center. diff --git a/modding/ss-specs/media/Bitmaps.md b/modding/ss-specs/media/Bitmaps.md new file mode 100644 index 0000000..07b878a --- /dev/null +++ b/modding/ss-specs/media/Bitmaps.md @@ -0,0 +1,97 @@ +## Bitmaps + +Bitmaps are images using a palette of colours. System Shock uses bitmaps exclusively for its graphics and uses one byte +as index per pixel - thus 256 colours possible. The resources store such bitmaps with content type ```0x02``` and they are always compound, even if only one bitmap is stored. + +Bitmaps are stored with a header, the pixel data (sometimes compressed), and an optional private palette. + +### Header + +**Bitmap Header** (`FrameDesc` struct) (28 bytes) + + 0000 [4]byte Unused. Always zero. (used as pointer in the engine) + 0004 byte Type -- 0x02: Uncompressed; 0x04 Compressed + 0005 byte Align -- Unused + 0006 uint16 Flags + 0008 sint16 Width + 000A sint16 Height + 000C uint16 Stride + 000E byte WidthFactor + 000F byte HeightFactor + 0010 [4]int16 HotspotBox: Left, Top, Right, Bottom + 0018 sint32 Private palette offset + +The ```Flags``` is set to value ```0x0001``` then palette index ```0x00``` should be treated as a transparent pixel instead of painting it black. +> Compressed images appear to default to transparency if the resolution of the game is 320x200. Higher resolutions require the flag set to behave identical. +> +> Other factors may also govern whether the bitmap should be treated with transparency - videos and level textures have their special handling in this regard. + +The ```WidthFactor``` and ```HeightFactor``` specify the index of the highest set bit of ```Width``` and ```Height``` +respectively. For example, if Width is 320 (0x140), then the WidthFactor is 8 (1<<8 == 0x100). + +`HotspotBox` is a placeholder for different applications. It can define a rectangle, or it only defines a point (using the first two values) for anchoring. + +> For the existing images in the original resource files, all images have a ```Stride``` equal to ```Width```. + +### Pixel Data + +After the header, the pixel data is serialized. If the image is uncompressed (Type ```0x02```), +```Height``` x ```Stride``` bytes make up the image. Compressed images (Type ```0x04```) use a simple run-length-encoding (RLE). + +#### Pixel Compression + +The compressed byte-stream contains the following byte sequences: + + 00 nn zz write nn bytes of value zz + nn .. 0 < nn < 0x80: copy the following nn bytes + 80 lo hi lo hi are a little-endian uint16 nnnn, to be handled as follows: + 80 00 00 End of compressed data (Skip to end) + 80 lo hi 0 < hi < 0x80: skip (nnnn & 0x7FFF) output pixel + 80 lo hi .. 0x80 <= hi < 0xC0: copy the following (nnnn & 0x3FFF) bytes + 80 lo C0 hi == 0xC0: undefined (not encountered) + 80 lo hi zz 0xC0 < hi: write (nnnn & 0x3FFF) bytes of value zz + nn 0x80 < nn: skip (nn & 0x7F) output pixel + +Three cases exist to compress pixel data: skip a list of pixel, compress a list of a constant +value and copy an arbitrary array of bytes. For each of the three, two encoding variants are used: a short and a long form. +If a count is to be serialized that is higher than the long-form can store, the encoder uses the long-form to reduce this number to finalize with the short-form. + +> Skipping output pixel is relevant for compressing video clips. Skipped pixel will retain the colour value from the previous frame. +> For still images, initialize the output buffer with 0x00. +> +> To implement a compressor that creates output closest to the original content, use the following sequence. The term "reference" means either a previous frame bitmap, or a fictual frame with all bytes 0x00. +> * From an array of bytes with length X, calculate the count Z of bytes at the end that are identical to reference. Compress only N bytes, where N = X - (Z % 0x7FFF). +> The final sequence ```80 00 00``` implies to skip to the end. The encoder +> seems to have had a limit of counting only up to 0x7FFF, so there may be further (long) skip entries before. +> * Iterate through the pixel data from start to N: +> * Count the extent of static value bytes +> * Count the number of bytes identical to reference +> * Determine encoding method +> * If the pixel value shall be retained from the reference, skip the count of bytes +> * Otherwise, if the count is more than three, encode the count and the constant value +> * Otherwise, count the extent of an arbitrary array; Abort when more than three static or identical values are found in sequence; Encode this array. +> * Skip the input bytes that have been serialized and continue with the loop +> * Encode end of compressed data sequence. + + +### Private Palette + +The format also allows for an optional palette, which is specific for the bitmap. If an image has no private palette, then this +entry is not present and the resource data ends directly after the pixel data. +Without a private palette, the colours of the bitmap are context specific. See [Palettes](Palettes.md) for more information. + +Private palettes are used for the splash screens at the beginning of the game, where nothing else is shown. +The ```Private palette offset``` in the bitmap header specifies the absolute offset *from the beginning* of the resource to the private palette entry. This means in case of a compound resource, this offset measures across the previous resource blocks as well. + +**Private Palette** (772 bytes) + + 0000 [4]byte Unknown. Always 0x00, 0x00, 0x00, 0x01 + 0004 [256*3]byte RGB values + +> The only working examples for this can be found in ```splash.res```. +> +> The resource file ```gamescr.res``` also contains three bitmaps with a private palette, but these palettes are not used and are ignored by the engine. +> The three bitmaps in question are the help overlay (```ALT+o```), and are shown with the game world in the background. Since in these +> cases the world can't change its colour, the screen can't use a private palette. By extension, the side-effects of the "Berserk" patch +> also affect the colours of the help overlay. +> Furthermore, for these three bitmaps, the values of offset fields are wrong as well. diff --git a/modding/ss-specs/media/Fonts.md b/modding/ss-specs/media/Fonts.md new file mode 100644 index 0000000..cf7681d --- /dev/null +++ b/modding/ss-specs/media/Fonts.md @@ -0,0 +1,33 @@ +## Fonts + +This chapter documents the data format of the fonts. The game uses [bitmap fonts](https://en.wikipedia.org/wiki/Computer_font#Bitmap_fonts) which describe the characters. + +The fonts are stored in single blocks within resources of type ```0x03```. They can be found in the file ```gamescr.res```. + +All glyphs in the bitmap are lined up in a single line. There are no Y offsets per glyph, and the height is always equal to the height of the bitmap. An array of ```X-Offsets``` points to the start of a glyph and the width is equal the difference to the next offset. + +### Binary Format + +**Font Header** (84 bytes) + + 0000 uint16 Font Type -- 0x0000: Monochrome, 0xCCCC: Color + 0002 [34]byte Unknown -- always 0x00 + 0024 int16 First character index (inclusive) + 0026 int16 Last character index (inclusive) + 0028 [32]byte Unknown -- always 0x00 + 0048 int32 Offset to X-Offsets, from beginning of data + 004C int32 Offset to bitmap, from beginning of data + 0050 int16 Width of bitmap in bytes + 0052 int16 Height of bitmap in bytes + +**X-Offsets** (2*N bytes) + +The X-Offsets is an array of ```int16``` values. The length of the array is equal to the number of characters the font covers, plus one. The width of one glyph is always equal the difference to the next offset, even for the last one. + +For a monochrome font, the offset is counted in bits, always beginning with the MSB. + +**Bitmap** (W*H bytes) + +Monochrome fonts have the glyphs packed as bits within the bytes, color fonts use the bytes as palette indices. + +> Fonts that have an outline, primarily seen with the HUD fonts, don't store the outline information in the font bitmaps. The outlines are rendered at runtime. diff --git a/modding/ss-specs/media/Geometry.md b/modding/ss-specs/media/Geometry.md new file mode 100644 index 0000000..4492627 --- /dev/null +++ b/modding/ss-specs/media/Geometry.md @@ -0,0 +1,182 @@ +## 3D Object Geometry + +This chapter documents the data format of the three dimensional objects encountered in the game (tables, ICE in cyberspace, crates...). + +All the geometry is stored in the file ```obj3d.res``` with content type ```0x0F```. Each resource in this file is compound with one data block. There is one object per resource (and data block). + +Each three dimensional ```model``` consists of a list vertex definitions and a structure of ```nodes```, with the model itself being the root node. A node contains a list of ```anchors```, with each one having a normal vector and a reference point. +Up to exactly one anchor per node may branch out to two further nodes (creating a binary tree). Further anchors contain lists of rendered ```faces```. Faces are either texture mapped or filled with a single colour. + +### Binary Format + +The data starts with 8 bytes of header information, followed by a list of "commands" which describe the structure. + +**Geometry Header** (8 bytes) + + 0000 int16 Unknown -- always 0x0027 + 0002 int16 Unknown -- always 0x0008 + 0004 int16 Unknown -- always 0x0002 + 0006 int16 Total number of Faces + +> The ```Total number of Faces``` value does not seem to be authorative. There are a few entries in the resouce file which state a higher count than actually in the model. + +**Model Command** (2+N bytes) + + 0000 int16 Model Command Identifier + 0002 N Command specific content + +> There is no framing structure which would allow skipping command entries, so an implementation has to know of all commands beforehand. + + +**fixed32/16** (4 bytes) + +In the following, coordinates (and their offsets) are serialized as fixed floating point values in a 32/16 format. + + 0000 sint16 Integer value + 0002 uint16 Fraction (in 1/65536 units) + +> For a simple implementation, it is sufficient to read the value as a ```sint32```, convert it to floating point and divide by 65536.0 . + + +**Vector** (12 bytes) + +A vector is serialized with three coordinates: + + 0000 fixed32/16 X + 0004 fixed32/16 Y + 0008 fixed32/16 Z + + +### Geometry Commands + +#### Vertex Definitions +Vertices are always (and only) defined at the beginning of the model (the root node). Three ways exist: define a single vertex, multiple vertices or references to previous ones with offsets. + +##### Define Single Vertex + + 0000 int16 Model Command Identifier -- 0x0015 + 0002 int16 Unknown -- always 0x0000 + 0004 Vector Vertex coordinates + +##### Define Multiple Vertices + + 0000 int16 Model Command Identifier -- 0x0003 + 0002 int16 Number of vertices + 0004 int16 Unknown -- always 0x0000 + 0006 [N]Vector Coordinates of vertices + +##### Define Vertex (One Offset) + + 0000 int16 Model Command Identifier -- X: 0x000A, Y: 0x000B, Z: 0x000C + 0002 int16 New vertex index + 0004 int16 Reference vertex index + 0006 fixed32/16 Offset value to coordinate as per command identifier + +> In the existing files, the ```New vertex index``` has always been found to equal the current amount of known vertices, appending at the end of the list. + +##### Define Vertex (Two Offsets) + + 0000 int16 Model Command Identifier -- XY: 0x000D, XZ: 0x000E, YZ: 0x000F + 0002 int16 New vertex index + 0004 int16 Reference vertex index + 0006 fixed32/16 Offset value to first coordinate as per command identifier + 0010 fixed32/16 Offset value to second coordinate as per command identifier + +> In the existing files, the ```New vertex index``` has always been found to equal the current amount of known vertices, appending at the end of the list. + +> To generate a list of vertex definitions as close as possible to the original files, follow these steps: +> * Determine count of dissimilar vertices: +> * From the list of vertices, iterate reverse from the tail. +> * For each vertex, search the list below the current index if there is another vertex which has at least one identical coordinate. +> * Abort this loop if the current vertex has no similar other. Vertices from beginning to the current index are all dissimilar. +> * Depending on the count of dissimilar vertices, define them with either the single definition or the multiple definition. +> * Now iterate forward through the list (until end), beginning with the first vertex after the dissimilar ones. +> * For each vertex, again find another most similar from those coming before it (including those already handled in this loop). +> * Compare the coordinates again. +> * If two are identical, write a single offset definition (for the one coordinate not equal). +> * If one is identical, write a double offset definition (for the two coordinates not equal). +> +> This list assumes the vertices are all unique and they were sorted to have the dissimilar ones come first. This must be done in +> combination with modifying the face definitions as they refer to the vertices via indices. +> Following this sequence will create a vertex definition with equal byte length as in the original. The differences to the original files are in which +> reference vertices were taken. + +#### Node Control + +##### End Of Node + + 0000 int16 Model Command Identifier -- 0x0000 + +Every node is finished with this command. + +##### Define Node Anchor + + 0000 int16 Model Command Identifier -- 0x0006 + 0002 Vector Normal vector + 000E Vector Reference point + 001A int16 Byte offset to 'left' branch + 001C int16 Byte offset to 'right' branch + +The offset values are based on the start of the command. Branch data comes only after the current node has ben finished; There is always only one ```End Of Node``` command pending. Branches are serialized depth-first. + +> In the existing files always the 'right' branch has been found to be serialized first. Although only a few cases, several layers can be found within one branch. If a branch is not needed, it contains only the ```End Of Node``` command. + + +#### Face Control + +##### Define Face Anchor + + 0000 int16 Model Command Identifier -- 0x0001 + 0002 int16 Anchor byte length (measured from start of this command) + 0004 Vector Normal vector + 0010 Vector Reference point + 001C N Face drawing commands + +Faces within one ```Define Face Anchor``` command are either painted in a single colour (with an optional shade) or texture mapped. + +##### Coloured Face + + 0000 int16 Model Command Identifier -- 0x0004 + 0002 int16 Vertex count + 0004 [N]int16 Vertex indices + +This command always has either a ```Set Colour``` or a ```Set Colour And Shade``` command before. It may be that several ```Coloured Face``` commands refer to the same setting. + +##### Set Colour + + 0000 int16 Model Command Identifier -- 0x0005 + 0002 int16 Colour palette index + +##### Set Colour And Shade + + 0000 int16 Model Command Identifier -- 0x001C + 0002 int16 Colour palette cross index + 0004 int16 Shade value (0..3) + +The ```Colour palette cross index``` refers to a small lookup table from the corresponding static object properties. This lookup table is a list of always 6 palette indices. + +> The data files contain cross indices of 0..6 . TSSHP skips those with value 0 and subtracts 1 from the remaining 6 values. + +##### Texture Mapping + + 0000 int16 Model Command Identifier -- 0x0025 + 0002 int16 Number of entries + 0004 [N]Entry Mapping entries + +**Mapping Entry (10 bytes)** + + 0000 int16 Vertex index + 0002 fixed32/16 texture u coordinate + 0006 fixed32/16 texture v coordinate + +##### Textured Face + + 0000 int16 Model Command Identifier -- 0x0026 + 0002 int16 Texture identifier + 0004 int16 Vertex count + 0006 [N]int16 Vertex indices + +This command always has a ```Texture Mapping``` command before. + +The ```Texture identifier``` references a texture with a resource identifier base of ```0x01DB```, found in ```citmat.res```. +The value of 0 specifies special, object specific handling. diff --git a/modding/ss-specs/media/Palettes.md b/modding/ss-specs/media/Palettes.md new file mode 100644 index 0000000..e9e26f7 --- /dev/null +++ b/modding/ss-specs/media/Palettes.md @@ -0,0 +1,40 @@ +## Palettes + +The engine uses a graphic mode with 256 colors per pixel. The graphic resources (images and videos) use bitmaps together +with palette information to resolve a pixel value (1 byte) to the corresponding RGB value (3x1 byte). + +Depending on the game-state, different palettes are in use. The splash screens at the beginning are created using a +```Private Palette``` (see the [bitmap](Bitmaps.md) format). Cutscenes have their own palette(s) and during the game, +one specific palette is used throughout. This game palette is stored in ```gamepal.res```. + +### Format + +Palettes always have a size of 768 bytes, for 256 colors with 1 byte for each of the three colour channels. + +**Palette** (768 bytes) + + 0000 [256*3]byte RGB values + +### Transparency + +Palette index 0 is used as a marker for fully transparent pixel. For instance, various decoration or critter sprites need +to have transparent areas in order to lose the rectangular shape. + +### Pixel Animation / Palette Looping + +During the game various textures can be observed with small animations. Most typically seen with LED-like blinking effects. +Instead of implementing this with multiple frames, the engine simply loops through the colors within a few groups. + +The following groups (palette index values) are looped: + + 0x03 - 0x07: Changed every ~1200 msec + 0x0B - 0x0F: Changed every ~700 msec + 0x10 - 0x14: Changed every ~360 msec + 0x15 - 0x17: Changed every ~1800 msec + 0x18 - 0x1A: Changed every ~1430 msec + 0x1B - 0x1F: Changed every ~1080 msec + +> These groups are looped even while the game is paused. +> +> Above switching times have been determined by counting frames of a video recording from dosbox, running within a VM. +> Exact values may be different. diff --git a/modding/ss-specs/media/SoundEffects.md b/modding/ss-specs/media/SoundEffects.md new file mode 100644 index 0000000..7a0e34a --- /dev/null +++ b/modding/ss-specs/media/SoundEffects.md @@ -0,0 +1,45 @@ +## Sound Effects + +Sound effects are stored in ```digifx.res```, which contains resources of data type ```0x07```. +The resources have IDs in the range from ```0x00C9``` to ```0x013B```, are uncompressed and have one effect per resource. + +Each resource contains one sound effect, which is stored in the +[Creative Voice File](http://wiki.multimedia.cx/index.php?title=Creative_Voice) format. + +Although the format would allow for different sample formats, only unsigned 8-bit PCM audio (MIME type ```audio/L8```) +is supported and expected. The sound effects don't use any special block types of the file format, +only one ```Sound data``` block is present, followed by a ```Terminator``` block identifier. All effects have one channel (mono). + +### Format +The following is an extract of the above linked page, specialized to the use by the engine. + +***Sound Effect*** + + 0000 [19]byte Identifier text "Creative Voice File" + 0013 byte Identifier terminator: 0x1A + 0014 int16 Length of header (commonly 0x1A) + 0016 uint16 Version number (0x010A) + 0018 uint16 Version number validation + + 001A byte Sound data block type: 0x01 + 001B [3]byte Length of block data (sample count + 2) + + 001E byte Frequency divisor + 001F byte Codec ID (audio/L8: 0x00) + 0020 []byte Samples + + NNNN byte Terminator block type: 0x00 + +***Version Number*** + +The version number is encoded as a major and minor number, in the high and low bytes respectively. + +The validation value is the result of building the binary complement of the version number and adding the constant ```0x1234```. + +***Frequency Divisor*** + +The frequency divisor is used to calculate the sample rate with the following formula: + +``` +sampleRate := 1000000 / (256 - frequencyDivisor) +``` diff --git a/modding/ss-specs/media/Texts.md b/modding/ss-specs/media/Texts.md new file mode 100644 index 0000000..611d604 --- /dev/null +++ b/modding/ss-specs/media/Texts.md @@ -0,0 +1,67 @@ +## Texts + +All texts are stored in the language specific files ```cybstrng.res```, ```frnstrng.res``` and ```gerstrng.res```. +These files contain 188 resources of type ```0x01```. They are compound with several blocks. Each block contains +one string, which is always 0x00 terminated. + +While the English strings may be readable according to standard ASCII, with the French and German options, texts +need to be interpreted according to the DOS [code page 437](http://en.wikipedia.org/wiki/Code_page_437). + +> Since texts are always presented through the game fonts, any one-byte codepage can be used - As long as the +> corresponding font bitmaps match for the requested characters (bytes). + +### Control Characters + +For texts with multiple lines, ```0x0A``` acts as a newline marker. + +```0x02``` is a soft-hyphen between syllables. Not all texts contain this character. + +### Resource Overview + +* 0x0867 Trap messages, one per block +* 0x0868 Wall texts +* 0x0869 Switch/Panel descriptions +* 0x086A Texture descriptions (Note: From block 273 onwards of the 512 blocks, the text is always "bug") +* 0x086B "Can't use X" texts for textures (Note: bug texts are simply "can't be used") +* 0x086C HUD texts/messages +* 0x086D Object short names +* 0x086E Messages of the targeting unit +* 0x086F HUD messages (top) +* 0x0870 Log category titles ("Level 1 Logs"...) +* 0x0871 Action messages +* 0x0872 Intro cutscene subtitles +* 0x0873 Death cutscene subtitles +* 0x0874 Win cutscene subtitles +* 0x0875 System scanner label texts +* 0x0876 Sign/Logo names +* 0x0877 Monitor/Screen texts +* 0x0878 Cyberspace toggle texts +* 0x0879 Access card names +* 0x087A Datalet messages (cyberspace nodes) +* 0x087B Menu texts +* 0x087C Credits +* 0x087D Keyboard help +* 0x087E Various HUD messages +* 0x087F Tooltip texts +* 0x0880 Main menu texts +* 0x0881 Endgame statistics texts +* 0x0882 Inventory description/version texts +* 0x0883 Texts for MFD games + +* 0x0024 Object names (readable) + +* 0x003C - 0x0046 Paper text messages + +#### [Electronic Messages](../content/ElectronicMessages.md) + +* 0x0989 - 0x09B6 E-Mails +* 0x09B8 - 0x09BD Level R logs +* 0x09C8 - 0x09D7 Level 1 Logs +* 0x09D8 - 0x09E4 ... +* 0x09E8 - 0x09F0 +* 0x09F8 - 0x09FE +* 0x0A08 - 0x0A13 +* 0x0A18 - 0x0A21 +* 0x0A28 - 0x0A30 +* 0x0A38 - 0x0A3D Level 8 logs +* 0x0A98 - 0x0AA4 Data fragments diff --git a/modding/ss-specs/media/VideoClips.md b/modding/ss-specs/media/VideoClips.md new file mode 100644 index 0000000..7eb8242 --- /dev/null +++ b/modding/ss-specs/media/VideoClips.md @@ -0,0 +1,57 @@ +## Video Clips + +Video clips are made out of a sequence of standard [bitmaps](Bitmaps.md). The meta-information about a video clip +is stored in resources of type ```0x04```. This information block refers to the resource that contains the frames, which +is stored in the same resource file. + +Contrary to [MOVI Resources](moviResources.md), video clips contain only the frame information - nothing else. + +### Format + +**Video Clip Sequence Description** (16+5*N bytes) + + 0000 int16 Width of video. + 0002 int16 Height of video. + 0004 int16 Resource ID of frames, referring to a compound resource + 0006 [6]byte Unknown. Always 0x00 + 000C int16 Unknown. TriOp intro: 0x0001, 0x0000 otherwise + 000E N*[5]byte Sub-table of N sequence entries + MMMM int16 Unknown. Always 0x010C + +**Video Clip Sequence Entry** (5 bytes) + + 0000 byte Unknown. Always 0x04. + 0001 byte First frame + 0002 byte Last frame + 0003 int16 Frame time (in milliseconds) + + +### Video Mails + +Video mails are small animated movies sent as mail during the game, all stored in ```vidmail.res```. + +A single video mail is split up in several sequences. Most notably, the TriOp intro is a reused sequence for all mails. +The resource file contains 12 such sequences: Per sequence there is one resource of type ```0x04``` +and a corresponding bitmap resource (containing the frames in blocks). + +Video mails have a size of 200x100 pixel and are the same for the HD and CD releases. They share the palette of the +game (```gamepal.res```). + +#### Hardcoded constants + +The classic engine has the following hardcoded constants: + +* There are 6 video mails, ordered as: "shield", "grove", "bridge", "laser", "explode", and "status" +* "laser" has two sequences, "explode" has five sequences. The twelth sequence is the intro, having index 10, right before the status. +* The first ID for the sequence descriptions is `0x0A4C` +* The first ID for the sequence bitmaps is `0x0A40` + +> The engine always plays the intro first, then the requested video mail. The bundling of sequences (like for "explode") is hardcoded, +> so if another video mail could either split itself up, or simply have only one frame (or none at all) at the end. + +### Cutscenes + +The files ```start1.res```, ```death.res``` and ```win1.res``` contain low-res variants of the corresponding +cutscenes. + +The palettes for these cutscenes are stored in ```cutspal.res```. diff --git a/modding/ss-specs/media/moviResources.md b/modding/ss-specs/media/moviResources.md new file mode 100644 index 0000000..486044c --- /dev/null +++ b/modding/ss-specs/media/moviResources.md @@ -0,0 +1,258 @@ +## MOVI Resources + +Resources with content type `0x11` are a media container themselves, similar to audio/video container formats. +These resources are called "MOVI Resources" since the initial tag is the four letter code `MOVI`. + +### Format + +Such a resource contains the following components: +* a header, describing meta information about the media, +* a palette, with initial colours, +* an index, describing the following data entries, and +* the data entries. + +**MOVI Header** (`MovieHeader` struct) (1024 bytes) + + 0000 uint32 magicId Container tag 0x49564F4D ("MOVI") + 0004 int32 numChunks Number of chunk entries in file + 0008 int32 sizeChunks Size of index table + 000C int32 sizeData Size of content (all data entries) + 0010 fix totalTime Length of media (in seconds) + 0014 fix frameRate Frame rate (informational only) + 0018 int16 frameWidht Width of video + 001A int16 frameHeight Height of video + 001C int16 gfxNumBits For videos may be 0x0008, 0x000F or 0x0018, 0x0000 otherwise + 001E int16 isPalette Palette presence in file. For videos 0x0001, 0x0000 otherwise + 0020 int16 audioNumChans Numbers of audiochannels 0x0000 - no audio, 0x0001 - mono, 0x0002 stereo + 0022 int16 audioSampleSize Audio sample size, 0x0001 - 8-bit, 0x0002 - 16-bit + 0026 fix audioSampleRate Sample rate of audio, in Hz (0x2B770000 - 11127 Hz, 0x56EE0000 - 22254 Hz) + 0030 uint8[216] reserved Reserved + 0108 uint8[768] palette Palette + +If the index table size is greater than the total of index entry number times one entry size, the remaining bytes are zero. + +> The index table sizes come in multiples of 0x0400. The rough rule for recreating the existing index sizes is: +> Required size less than 0x0400 -> Use 0x0400. Otherwise: Round up to next 0x0400 border and add another 0x0400. + +**Palette** (256 * 3 bytes) + +The palette is always present, even if the MOVI contains only audio. In such cases all the colour channels are 0x00. + +**Movie chunk Entry** (`MovieChunk` struct) (8 bytes) + + 0000 uint32 Chunk info (bitfield) + 0004 uint32 offset Offset to data (from beginning) + +First 4 bytes are uint32 bit field which follow structure: + + bits 00-23 24 time Fixed point timestamp from movie start (in seconds) + bits 24-24 1 played Has this chunk been clocked out? (Used by engine internally) + bits 25-28 4 flags Chunk type specific flags + bits 39-31 3 chunkType Chunk type (see below) + +**Chunk type specific flags** may be: + +For Video chunk type (0x01 MOVIE_CHUNK_VIDEO) + + 0x0F MOVIE_FVIDEO_BMTMASK 4 bits of flags is bmtype + 0x0F MOVIE_FVIDEO_BMF_4X4 4x4 movie format + +> Not clear, why those flags uses same code, this may be bug or leftovers from reusing library component + +For Palette chunk type (0x04 MOVIE_CHUNK_PALETTE) + + 0x00 MOVIE_FPAL_SET Set palette from data + 0x01 MOVIE_FPAL_BLACK Set palette to black + 0x07 MOVIE_FPAL_EFFECTMASK 4 bits of flags is effect + 0x08 MOVIE_FPAL_CLEAR If bit set, clear screen + +For Table chunk type (0x05 MOVIE_CHUNK_TABLE) + + 0x01 MOVIE_FTABLE_COLORSET Table is color set + 0x02 MOVIE_FTABLE_HUFFTAB Huffman table (compressed) + +### Chunk Types + +#### 0 End of chunk (MOVIE_CHUNK_END) + +This type is always present as the last entry in the index. It is used to calculate the length of the previous entry as +the table does not contain a length field for entries. + +The timestamp of this entry should match the media length from the header. + +#### 1 Video (MOVIE_CHUNK_VIDEO) + +Video frames are compressed in two variants, for low and high resolution. + +Frames are either "full" frames, which set all, or nearly all, pixels in the buffer, typically at a scene change. Other frames are "delta" frames, which re-use (most of) the previous pixel in the buffer. This re-use is done by writing/using the palete index 0x00, which is meant to be transparent. + +##### Low-resolution Video + +Low-resolution video is using type `0x21`. Data contains a bounding box and then the compressed frame using the [compression algorithm](./Bitmaps.md#pixel-compression) for standard bitmaps. + +**Low Resolution Video Data** (8+N bytes) + + 0000 [4]int16 Bounding Box + 0008 []byte Compressed pixel data + +> In the existing videos, the bounding box is always `0, 0, 320, 150`, specifying the same size as the video itself. + +##### High-resolution Video + +High-resolution video is using type `0x79` with a more complex compression algorithm. This algorithm works in conjunction of the `Table` types. See [below](#high-resolution-video-compression) for details on the algorithm. + +**High Resolution Video Data** (2+N bytes) + + 0000 int16 Offset to Mask-stream in bytes, from beginning (XXXX) + 0002 []byte Bitstream + XXXX []byte Mask-stream + +The `Bitstream` is a stream of serialized big-endian integers. The `Mask-stream` is a stream of serialized little-endian integers of different lengths (2, 4, 6, and 8 bytes). + +The order and layout of the values in the streams is determined by the algorithm. The decoder will want to read past the end of the streams in a few cases. For these cases the missing bits/bytes are to be set to zero. The encoder removed any trailing 0x00 bytes from these streams in order to save further space. + +#### 2 Audio (MOVIE_CHUNK_AUDIO) + +The data entry contains unsigned 8-bit PCM samples. + +> Audio is chunked into entries of 8192 bytes, even if the MOVI contains only audio. + +#### 3 Text (MOVIE_CHUNK_TEXT) + +**Subtitle Entry** (`MovieTextItem` struct) (16+N bytes) + + 0000 uint32 tag Subtitle Control + 0004 uint32 offset Offset to text string, always 0x10 + 0008 [8]byte Unused -- always 0x00, as they are skipped + 0010 []byte Null terminated string + +Subtitle text will be shown with the color from palette index 255 (last entry). + +##### Subtitle Controls + +`'AREA'` (uint32 `0x41455241`) is for setting up the subtitle space. It defines the rectangle, in pixel (left, top, right, bottom), where the text is to be displayed. Example: + +``` +20 365 620 395 CLR +``` + +`'STD '` (uint32 `0x20445453`), `'FRN '` (uint32 `0x204E5246`) and `'GER '` (uint32 `0x20524547`) specify the actual subtitle text in the respective language, with `'STD '` being the default language (English). + +#### 4 Palette (MOVIE_CHUNK_PALETTE) + +There are two palette entry types: `0x4C` and `0x04`. For any change in the palette, two entries are in the container. Both entries always come in sequence (first `0x4C`, then `0x04`) and both have identical timestamp values. + +The entry with type `0x4C` will have a length of 0 and the same data offset as the next entry, `0x04`. +The entry with type `0x04` points to data of 0x300 bytes (= 256*3) which describes the new palette for upcoming frames. + +> At least for high-resolution videos, the next frame after a palette change does not always entirely set the complete pixel buffer for the next frame. +> In the intro video for instance, SHODAN's gaining of conscience scene has a few pixel left over from the previous scene. +> +> As a recommendation, a decoder should wipe the current frame buffer with 0x00 before applying a new palette/drawing further frames. +> An alternative could be to remap all pixel values with palette indices as close as possible to the new palette, though this might alter the resulting frame too much. + +#### 5 Table (MOVIE_CHUNK_TABLE) + +Table entries are used in combination with video type `0x79` entries for high-resolution videos. There are two table types: +* `0x05`: Palette lookup list +* `0x0D`: Control table + +See [below](#high-resolution-video-compression) for details on the algorithm. + +Such entries are common to several frames. For any change, two entries are in the container. Both entries always come in sequence (first `0x05`, then `0x0D`). + +> In the existing files, a change of tables is always followed by a change of palette. The exception is the first scene, which uses the initial palette from the MOVI header. + +##### Palette lookup list + +This entry, with type `0x05` is a simple lookup list. Every entry of type byte is a palette index. +This list is truncated as well. The decoder will want to read past the end of this stream, in which case zero bytes must be returned. + +##### Packed Control Words + +Table type `0x0D` contains a runlength encoded list of control words. + +**Packed Control Words** (4+(N*4) bytes) + + 0000 int32 Unpacked size + 0004 []Packed Packed entries + +The `Unpacked size` field equals the number of control words, times three (specifying the size in bytes). +The remainder of the entry is then the list of packed control words, from which the actual list of control words is unpacked. + +**Packed Entry** (4 bytes) + + 0000 byte Word count + 0001 uint24 Control Word + +The field `Word count` specifies how often the `Control Word` shall be extracted. A value of 0 is never encountered. + +### High-resolution Video Compression + +High-resolution video compression uses scene-specific tables shared amongst several frames and frame-specific bit- and mask-streams. Frames in this compression are made up of tiles with a size of 4x4 pixel each. For the high-resolution videos of 600x300 pixel this means 150x75 tiles. Tiles are coloured according to different types of control words, from left to right, top to bottom. + +**Control Word** (3 bytes, masked) + + 0xF00000 Count value + 0x0E0000 Type + 0x01FFFF Parameter + + +As a rough guideline, the frame-specific bit-stream provides index values into the control word table. According to the type of the control word, the current tile is coloured either directly, or with the additional help of the mask-stream and palette lookup list. + +A note on the bit-stream: Reading from this stream does not automatically advance the current position. Advancing the bit-stream is a separate operation, which may advance with a different number of bits than were previously read. This may cause re-use of some bits to increase compression. + +As noted above, the bit- and mask-stream, as well as the palette lookup list, have possible trailing zero values removed in the files. When the decoder wants to read beyond their end, they must provide values set to 0. + + +#### Decoder sequence + +A possible decoder is implemented using the following sequence: + +* Iterate over the tiles, outer loop is the rows, inner loop the columns. Finish the loops if either all tiles are covered, or the bit-stream has been exhausted. The stream is exhausted if the current position is at the end of or beyond the available range. +* Read a 12-bit value from the bit-stream. Use this value as an index into the control word table. +* If the `Count value` of the control word is zero + * A `Count value` of zero indicates a `Long Offset`. In this case, the `Type` and `Parameter` fields are taken together to form a 20-bit `Long Offset` value. + * Advance the bit-stream by 8 bits + * Enter a loop as long as the current control word indicates a `Long Offset`. + * Advance the bit-stream by 4 bits + * Read a 4-bit value from the bit-stream. Add this value to the `Long Offset` of the current control word and use the result as another index into the control word table. The retrieved entry becomes the new current control word. +* Advance the bit-stream by the `Count` value of the current control word. This may indicate a different amount of bits than previously read, even less. +* If the type of the current control word is `6`, use the previously processed control word as the current one for further processing. +* If the type of the current control word is `5`, tiles shall be skipped + * Read a 5-bit value from the bit-stream and advance it by 5 bits. This is the `Skip Count` value. + * A `Skip Count` of `0x1F` skips the remainder of the current tile row + * Any other value skips the given amount of columns, additionally to the current one. A value of 0 skips only the current tile. +* Any type in the range of `0` to `4` colours the current tile. See below. +* Next loop iteration + +> Type `7` is never encountered. According to the documentation of TSSHP, this has the same meaning as `6`: use previous control word. + +##### Encoder hints + +* Using the previous control word (type `6`) is not allowed for the first tile of a row. It can't repeat the last operation from the previous row. +* Usage of "transparency" byte `0x00` is only processed if at the first index of a palette lookup. See below. + +##### Colouring a tile + +To colour a tile, the `Parameter` value of the current control word is used, as well as the `mask-stream` and the `palette lookup list`. The 16 pixel of a tile are coloured from left to right, top to bottom. + +The palette value for a pixel is determined by an index into a small `lookup array` of palette indices. Depending on the type of the control word, this lookup array is either a made up one, or a segment of the `palette lookup list`. The length of a `lookup array` is always a power of 2 and has possible lengths of 2, 4, 8 or 16. + +The corresponding index values into such lookup arrays have bit sizes of 1, 2, 3 or 4 bits. The 16 index values for a tile are packed into a `mask integer` of 2, 4, 6 or 8 bytes. Depending on the type of the control word, this `mask integer` is made up or read from the `mask-stream`. + +> For example, a lookup array of 4 palette values requires an index size of 2 bits, resulting in a mask integer of 4 byte. +> +> For pixel coordinates given in x,y: Mask integer bits 0-1 colour pixel 0,0; mask integer bits 2-3 colour pixel 1,0; ... +> mask integer bits 28-29 colour pixel 2,3 and mask integer bits 30-31 colour pixel 3,3. + + +| Type | Lookup Array | Mask Integer | +|:----:|--------------------------------------------------------|-------------------------| +| 0 | `[ Parameter >> 0 & 0xFF, Parameter >> 8 & 0xFF ]` | Static `0xAAAA` | +| 1 | `[ Parameter >> 0 & 0xFF, Parameter >> 8 & 0xFF ]` | 2 byte from mask-stream | +| 2 | Parameter is start index into list, 4 entries | 4 byte from mask-stream | +| 3 | Parameter is start index into list, 8 entries | 6 byte from mask-stream | +| 4 | Parameter is start index into list, 16 entries | 8 byte from mask-stream | + +If the lookup array index is `0`, and the entry ix `0x00`, use the color from the previous frame. diff --git a/modding/ss-specs/other/Music.md b/modding/ss-specs/other/Music.md new file mode 100644 index 0000000..be61bcd --- /dev/null +++ b/modding/ss-specs/other/Music.md @@ -0,0 +1,7 @@ +## Music Files + +> For the time being, music files are not covered. For further information, follow these resources: +> +> SSPLAYER: The random song generator. Available from these links (including source): +> * http://www.vgmpf.com/Wiki/index.php?title=System_Shock_Random_Song_Generator +> * http://wenchy.net/old/ssplayer.html diff --git a/registry.env.example b/registry.env.example index 1610b64..7a4dbfc 100644 --- a/registry.env.example +++ b/registry.env.example @@ -7,3 +7,8 @@ REGISTRY=cr.ladkau.de REGISTRY_IMAGE=cr.ladkau.de/questshock/builder REGISTRY_USER= REGISTRY_PASSWORD= + +# Optional: modding/hacked-build-image.sh's own image name, read from this +# same file. REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD above are shared with +# it for pushing. +HACKED_REGISTRY_IMAGE=cr.ladkau.de/questshock/hacked-builder