Compare commits

..

No commits in common. "master" and "dtmm-v0.1.0" have entirely different histories.

113 changed files with 3026 additions and 11724 deletions

View file

@ -1,7 +0,0 @@
FROM dtmt-ci-base-linux
COPY . /src/dtmt
COPY --from=dtmt-ci-base-linux /src/*.lib /src/*.so /src/dtmt/lib/oodle/
RUN --mount=type=cache,id=cargo-registry,target=/cargo/registry \
--mount=type=cache,id=cargo-target,target=/src/dtmt/target \
cargo build --release --locked

View file

@ -1,35 +0,0 @@
FROM dtmt-ci-base-msvc
# Create dummy crates and copy their Cargo.toml, so that dependencies can be cached
RUN set -e; \
cargo new --bin crates/dtmt; \
cargo new --bin crates/dtmm; \
cargo new --lib lib/dtmt-shared; \
cargo new --lib lib/nexusmods; \
cargo new --lib lib/sdk; \
cargo new --lib lib/serde_sjson; \
cargo new --lib lib/ansi-parser
COPY Cargo.toml Cargo.lock /src/dtmt/
COPY crates/dtmt/Cargo.toml /src/dtmt/crates/dtmt/
COPY crates/dtmm/Cargo.toml /src/dtmt/crates/dtmm/
COPY lib/dtmt-shared/Cargo.toml /src/dtmt/lib/dtmt-shared/
COPY lib/nexusmods/Cargo.toml /src/dtmt/lib/nexusmods/
COPY lib/sdk/Cargo.toml /src/dtmt/lib/sdk/
COPY lib/serde_sjson/Cargo.toml /src/dtmt/lib/serde_sjson/
COPY lib/ansi-parser/Cargo.toml /src/dtmt/lib/ansi-parser/
# Crates with build scripts cannot be split that way, but they shouldn't change too often
COPY lib/luajit2-sys /src/dtmt/lib/luajit2-sys
COPY lib/oodle /src/dtmt/lib/oodle
# color-eyre needs to be copied, too, then, as it's used by `oodle`
COPY lib/color-eyre /src/dtmt/lib/color-eyre
COPY --from=dtmt-ci-base-msvc /src/*.lib /src/dtmt/lib/oodle/
RUN cargo build --release --target x86_64-pc-windows-msvc --locked -Zbuild-std
RUN rm -r crates lib
COPY . /src/dtmt
COPY --from=dtmt-ci-base-msvc /src/*.lib /src/dtmt/lib/oodle/
RUN cargo build --release --target x86_64-pc-windows-msvc --frozen -Zbuild-std

View file

@ -1,138 +0,0 @@
# https://jake-shadle.github.io/xwin/
FROM debian:bullseye-slim as xwin
ARG XWIN_VERSION=0.5.2
ARG XWIN_PREFIX="xwin-$XWIN_VERSION-x86_64-unknown-linux-musl"
ADD https://github.com/Jake-Shadle/xwin/releases/download/$XWIN_VERSION/$XWIN_PREFIX.tar.gz /root/$XWIN_PREFIX.tar.gz
RUN set -eux; \
apt-get update; \
apt-get install --no-install-recommends -y \
tar \
; \
# Install xwin to cargo/bin via github release. Note you could also just use `cargo install xwin`.
tar -xzv -f /root/$XWIN_PREFIX.tar.gz -C /usr/bin --strip-components=1 $XWIN_PREFIX/xwin; \
apt-get remove -y --auto-remove; \
rm -rf \
/var/lib/apt/lists/* \
/root/$XWIN_PREFIX.tar.gz;
RUN set -eux; \
# Splat the CRT and SDK files to /xwin/crt and /xwin/sdk respectively
xwin \
--log-level debug \
--cache-dir /root/.xwin-cache \
--manifest-version 16 \
--accept-license \
splat \
--output /xwin; \
# Even though this build step only exists temporary, to copy the
# final data out of, it still generates a cache entry on the Docker host.
# And to keep that to a minimum, we still delete the stuff we don't need.
rm -rf /root/.xwin-cache;
FROM rust:slim-bullseye as linux
RUN set -eux; \
apt-get update; \
apt-get install --no-install-recommends -y \
build-essential \
cmake \
curl \
git \
gpg \
jq \
libatk1.0-dev \
libclang-13-dev \
libglib2.0-dev \
libgtk-3-dev \
libpango1.0-dev \
libssl-dev \
libzstd-dev \
pkg-config; \
apt-get remove -y --auto-remove; \
rm -rf /var/lib/apt/lists/*; \
rustup default nightly
WORKDIR /src/dtmt
COPY lib/oodle/*.so lib/oodle/*.a /src/
FROM linux as msvc
ARG LLVM_VERSION=18
ENV KEYRINGS /usr/local/share/keyrings
ADD https://apt.llvm.org/llvm-snapshot.gpg.key /root/llvm-snapshot.gpg.key
ADD https://dl.winehq.org/wine-builds/winehq.key /root/winehq.key
RUN set -eux; \
mkdir -p $KEYRINGS; \
# clang/lld/llvm
gpg --dearmor > $KEYRINGS/llvm.gpg < /root/llvm-snapshot.gpg.key; \
# wine
gpg --dearmor > $KEYRINGS/winehq.gpg < /root/winehq.key; \
echo "deb [signed-by=$KEYRINGS/llvm.gpg] http://apt.llvm.org/bullseye/ llvm-toolchain-bullseye-${LLVM_VERSION} main" > /etc/apt/sources.list.d/llvm.list; \
echo "deb [signed-by=$KEYRINGS/winehq.gpg] https://dl.winehq.org/wine-builds/debian/ bullseye main" > /etc/apt/sources.list.d/winehq.list; \
dpkg --add-architecture i386; \
apt-get update; \
apt-get install --no-install-recommends -y \
libclang-${LLVM_VERSION}-dev \
gcc-mingw-w64-x86-64 \
clang-${LLVM_VERSION} \
llvm-${LLVM_VERSION} \
lld-${LLVM_VERSION} \
winehq-staging \
; \
# ensure that clang/clang++ are callable directly
ln -s clang-${LLVM_VERSION} /usr/bin/clang && ln -s clang /usr/bin/clang++ && ln -s lld-${LLVM_VERSION} /usr/bin/ld.lld; \
# We also need to setup symlinks ourselves for the MSVC shims because they aren't in the debian packages
ln -s clang-${LLVM_VERSION} /usr/bin/clang-cl && ln -s llvm-ar-${LLVM_VERSION} /usr/bin/llvm-lib && ln -s lld-link-${LLVM_VERSION} /usr/bin/lld-link; \
# Verify the symlinks are correct
clang++ -v; \
ld.lld -v; \
# Doesn't have an actual -v/--version flag, but it still exits with 0
llvm-lib -v; \
clang-cl -v; \
lld-link --version; \
# Use clang instead of gcc when compiling and linking binaries targeting the host (eg proc macros, build files)
update-alternatives --install /usr/bin/cc cc /usr/bin/clang 100; \
update-alternatives --install /usr/bin/c++ c++ /usr/bin/clang++ 100; \
update-alternatives --install /usr/bin/ld ld /usr/bin/ld.lld 100; \
rustup target add x86_64-pc-windows-msvc; \
rustup component add rust-src; \
# Remove unneeded files to reduce image size
apt-get remove -y --auto-remove; \
rm -rf \
/var/lib/apt/lists/* \
/root/*.key;
COPY lib/oodle/*.lib /src
COPY --from=xwin /xwin /xwin
# Note that we're using the full target triple for each variable instead of the
# simple CC/CXX/AR shorthands to avoid issues when compiling any C/C++ code for
# build dependencies that need to compile and execute in the host environment
ENV CC_x86_64_pc_windows_msvc="clang-cl" \
CXX_x86_64_pc_windows_msvc="clang-cl" \
AR_x86_64_pc_windows_msvc="llvm-lib" \
# wine can be quite spammy with log messages and they're generally uninteresting
WINEDEBUG="-all" \
# Use wine to run test executables
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUNNER="wine" \
# Note that we only disable unused-command-line-argument here since clang-cl
# doesn't implement all of the options supported by cl, but the ones it doesn't
# are _generally_ not interesting.
CL_FLAGS="-Wno-unused-command-line-argument -fuse-ld=lld-link /imsvc/xwin/crt/include /imsvc/xwin/sdk/include/ucrt /imsvc/xwin/sdk/include/um /imsvc/xwin/sdk/include/shared" \
# Let cargo know what linker to invoke if you haven't already specified it
# in a .cargo/config.toml file
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER="lld-link" \
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS="-Lnative=/xwin/crt/lib/x86_64 -Lnative=/xwin/sdk/lib/um/x86_64 -Lnative=/xwin/sdk/lib/ucrt/x86_64"
# These are separate since docker/podman won't transform environment variables defined in the same ENV block
ENV CFLAGS_x86_64_pc_windows_msvc="$CL_FLAGS" \
CXXFLAGS_x86_64_pc_windows_msvc="$CL_FLAGS"
# Run wineboot just to setup the default WINEPREFIX so we don't do it every
# container run
RUN wine wineboot --init

View file

@ -1,230 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/cappyzawa/concourse-pipeline-jsonschema/master/concourse_jsonschema.json#/definitions/Config
---
# The actual CI pipeline that is run per branch
resource_types:
- name: gitea-package
type: registry-image
source:
repository: registry.local:5000/gitea-package
- name: gitea-status
type: registry-image
source:
repository: registry.local:5000/gitea-status
- name: gitea-pr
type: registry-image
source:
repository: registry.local:5000/gitea-pr
resources:
- name: repo
type: git
source:
uri: http://forgejo:3000/bitsquid_dt/dtmt
branch: master
- name: repo-pr
type: gitea-pr
source:
access_token: ((gitea_api_key))
owner: ((owner))
repo: ((repo))
url: https://git.sclu1034.dev
- name: gitea-package
type: gitea-package
source:
access_token: ((gitea_api_key))
url: http://forgejo:3000
owner: bitsquid_dt
type: generic
name: dtmt
- name: status-build-msvc
type: gitea-status
source:
access_token: ((gitea_api_key))
url: http://forgejo:3000
owner: bitsquid_dt
repo: dtmt
context: build/msvc
description: "Build for the target platform: msvc"
- name: status-build-linux
type: gitea-status
source:
access_token: ((gitea_api_key))
url: http://forgejo:3000
owner: bitsquid_dt
repo: dtmt
context: build/linux
description: "Build for the target platform: linux"
jobs:
- name: set-pipelines
plan:
- in_parallel:
- get: repo-pr
trigger: true
- get: repo
- load_var: prs
file: repo-pr/prs.json
- across:
- var: pr
values: ((.:prs))
set_pipeline: dtmt-pr
file: repo/.ci/pipelines/pr.yml
vars:
pr: ((.:pr))
gitea_api_key: ((gitea_api_key))
instance_vars:
number: ((.:pr.number))
- name: build-msvc
on_success:
put: state-success
resource: status-build-msvc
no_get: true
params:
state: success
sha: ((.:git_sha))
on_failure:
put: state-failure
resource: status-build-msvc
no_get: true
params:
state: failure
sha: ((.:git_sha))
plan:
- get: repo
trigger: true
- load_var: git_sha
file: repo/.git/ref
- put: state-pending
resource: status-build-msvc
no_get: true
params:
state: pending
sha: ((.:git_sha))
- task: build
file: repo/.ci/tasks/build.yml
vars:
pr: ""
target: msvc
- load_var: version_number
reveal: true
file: artifact/version
- put: package
resource: gitea-package
no_get: true
inputs:
- artifact
params:
version: ((.:version_number))
fail_fast: true
override: true
globs:
- artifact/*.exe
- artifact/*.exe.sha256
- put: package
resource: gitea-package
no_get: true
inputs:
- artifact
params:
version: master
fail_fast: true
override: true
globs:
- artifact/*.exe
- artifact/*.exe.sha256
- name: build-linux
on_success:
put: state-success
resource: status-build-linux
no_get: true
params:
state: success
sha: ((.:git_sha))
on_failure:
put: state-failure
resource: status-build-linux
no_get: true
params:
state: failure
sha: ((.:git_sha))
plan:
- get: repo
trigger: true
- load_var: git_sha
file: repo/.git/ref
- put: state-pending
resource: status-build-linux
no_get: true
params:
state: pending
sha: ((.:git_sha))
- task: build
file: repo/.ci/tasks/build.yml
vars:
pr: ""
target: linux
gitea_url: http://forgejo:3000
gitea_api_key: ((gitea_api_key))
- load_var: version_number
reveal: true
file: artifact/version
- put: package
resource: gitea-package
no_get: true
inputs:
- artifact
params:
version: ((.:version_number))
fail_fast: true
override: true
globs:
- artifact/dtmt
- artifact/dtmm
- artifact/dtmm.sha256
- artifact/dtmt.sha256
- put: package
resource: gitea-package
no_get: true
inputs:
- artifact
params:
version: master
fail_fast: true
override: true
globs:
- artifact/dtmt
- artifact/dtmm
- artifact/dtmm.sha256
- artifact/dtmt.sha256

View file

@ -1,28 +0,0 @@
---
# The actual CI pipeline that is run per branch
resources:
- name: repo
type: git
source:
uri: https://git.sclu1034.dev/bitsquid_dt/dtmt
branch: ((branch))
jobs:
- name: build-msvc
plan:
- get: repo
trigger: true
- task: build
file: repo/.ci/tasks/build.yml
vars:
target: msvc
- name: build-linux
plan:
- get: repo
trigger: true
- task: build
file: repo/.ci/tasks/build.yml
vars:
target: linux

View file

@ -1,217 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/cappyzawa/concourse-pipeline-jsonschema/master/concourse_jsonschema.json#/definitions/Config
---
# The actual CI pipeline that is run per branch
resource_types:
- name: gitea-package
type: registry-image
source:
repository: registry.local:5000/gitea-package
- name: gitea-status
type: registry-image
source:
repository: registry.local:5000/gitea-status
resources:
- name: repo
type: git
source:
uri: http://forgejo:3000/bitsquid_dt/dtmt
branch: ((pr.head.ref))
- name: gitea-package
type: gitea-package
source:
access_token: ((gitea_api_key))
url: http://forgejo:3000
owner: bitsquid_dt
type: generic
name: dtmt
- name: pr-status-lint-clippy
type: gitea-status
source:
access_token: ((gitea_api_key))
url: http://forgejo:3000
owner: bitsquid_dt
repo: dtmt
context: lint/clippy
description: Checking for common mistakes and opportunities for code improvement
- name: pr-status-build-msvc
type: gitea-status
source:
access_token: ((gitea_api_key))
url: http://forgejo:3000
owner: bitsquid_dt
repo: dtmt
context: build/msvc
description: "Build for the target platform: msvc"
- name: pr-status-build-linux
type: gitea-status
source:
access_token: ((gitea_api_key))
url: http://forgejo:3000
owner: bitsquid_dt
repo: dtmt
context: build/linux
description: "Build for the target platform: linux"
jobs:
- name: clippy
on_success:
put: state-success
resource: pr-status-lint-clippy
no_get: true
params:
state: success
sha: ((.:git_sha))
on_failure:
put: state-failure
resource: pr-status-lint-clippy
no_get: true
params:
state: failure
sha: ((.:git_sha))
plan:
- get: repo
trigger: true
- load_var: git_sha
file: repo/.git/ref
- put: state-pending
resource: pr-status-lint-clippy
no_get: true
params:
state: pending
sha: ((.:git_sha))
- task: check
file: repo/.ci/tasks/clippy.yml
vars:
gitea_api_key: ((gitea_api_key))
- name: build-msvc
on_success:
put: state-success
resource: pr-status-build-msvc
no_get: true
params:
state: success
sha: ((.:git_sha))
on_failure:
put: state-failure
resource: pr-status-build-msvc
no_get: true
params:
state: failure
sha: ((.:git_sha))
plan:
- get: repo
trigger: true
- load_var: git_sha
file: repo/.git/ref
- put: state-pending
resource: pr-status-build-msvc
no_get: true
params:
state: pending
sha: ((.:git_sha))
- task: build
file: repo/.ci/tasks/build.yml
vars:
target: msvc
pr: ((pr))
gitea_url: http://forgejo:3000
gitea_api_key: ((gitea_api_key))
- load_var: version_number
reveal: true
file: artifact/version
- put: package
resource: gitea-package
no_get: true
inputs:
- artifact
params:
version: ((.:version_number))
fail_fast: true
override: true
globs:
- artifact/dtmt
- artifact/dtmm
- artifact/*.exe
- artifact/*.sha256
- name: build-linux
on_success:
put: state-success
resource: pr-status-build-linux
no_get: true
params:
state: success
sha: ((.:git_sha))
on_failure:
put: state-failure
resource: pr-status-build-linux
no_get: true
params:
state: failure
sha: ((.:git_sha))
plan:
- get: repo
trigger: true
- load_var: git_sha
file: repo/.git/ref
- put: state-pending
resource: pr-status-build-linux
no_get: true
params:
state: pending
sha: ((.:git_sha))
- task: build
file: repo/.ci/tasks/build.yml
vars:
target: linux
pr: ((pr))
gitea_url: http://forgejo:3000
gitea_api_key: ((gitea_api_key))
- load_var: version_number
reveal: true
file: artifact/version
- put: package
resource: gitea-package
no_get: true
inputs:
- artifact
params:
version: ((.:version_number))
fail_fast: true
override: true
globs:
- artifact/dtmt
- artifact/dtmm
- artifact/*.exe
- artifact/*.sha256

View file

@ -1,62 +0,0 @@
#!/bin/bash
set -eu
if [ -n "$OUTPUT" ]; then
OUTPUT="$PWD/$OUTPUT"
else
OUTPUT=$(mktemp -d)
fi
title() {
printf "\033[1m%s\033[0m\n" "$1"
}
install_artifact() {
install -v -t "$OUTPUT/" "$1"
sha256sum "$1" | cut -d' ' -f1 > "$OUTPUT/$(basename "$1").sha256"
}
cd "repo"
PR=${PR:-}
if [ -n "$PR" ]; then
title "PR: $(echo "$PR" | jq '.number') - $(echo "$PR" | jq '.title')"
ref="pr-$(echo "$PR" | jq '.number')-$(git rev-parse --short "$(cat .git/ref || echo "HEAD")" 2>/dev/null || echo 'manual')"
elif [ -f ".git/branch"]; then
ref=$(cat .git/branch)-$(git rev-parse --short $ref)
else
ref=$(git rev-parse --short "$(cat .git/ref || echo "HEAD")")
fi
title "Version: '$ref'"
echo "$ref" > "$OUTPUT/version"
case "$TARGET" in
msvc)
cp /src/*.lib ./lib/oodle/
title "Building project for target $TARGET"
cargo build --color always --locked --release --target x86_64-pc-windows-msvc -Zbuild-std
title "Install artifacts"
install_artifact target/x86_64-pc-windows-msvc/release/dtmt.exe
install_artifact target/x86_64-pc-windows-msvc/release/dtmm.exe
;;
linux)
cp /src/*.a ./lib/oodle/
title "Building project for target $TARGET"
cargo build --color always --locked --profile release-lto
title "Installing artifacts"
install_artifact target/release-lto/dtmt
install_artifact target/release-lto/dtmm
;;
*)
echo -e "\033[31;1mEnv var 'TARGET' must either be 'msvc' or 'linux'. Got '$TARGET'.\033[0m" >&2
exit 1
esac
title "Done"

View file

@ -1,29 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/cappyzawa/concourse-pipeline-jsonschema/master/concourse_jsonschema.json#/definitions/TaskConfig
---
platform: linux
image_resource:
name: ctmt-bi-base-((target))
type: registry-image
source:
repository: registry.local:5000/dtmt-ci-base-((target))
tag: latest
inputs:
- name: repo
outputs:
- name: artifact
caches:
- path: repo/target
- path: /usr/local/cargo/registry
params:
CI: "true"
TARGET: ((target))
PR: ((pr))
OUTPUT: artifact
run:
path: repo/.ci/tasks/build.sh

View file

@ -1,15 +0,0 @@
#!/bin/sh
set -eu
title() {
printf "\033[1m%s\033[0m\n" "$1"
}
title "Install clippy"
rustup component add clippy
title "Run clippy"
cargo clippy --color always --no-deps
title "Done"

View file

@ -1,26 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/cappyzawa/concourse-pipeline-jsonschema/master/concourse_jsonschema.json#/definitions/TaskConfig
---
platform: linux
image_resource:
name: dtmt-ci-base-linux
type: registry-image
source:
repository: registry.local:5000/dtmt-ci-base-linux
tag: latest
inputs:
- name: repo
caches:
- path: repo/target
- path: /usr/local/cargo/registry
params:
CI: "true"
GITEA_API_KEY: ((gitea_api_key))
run:
path: .ci/tasks/clippy.sh
dir: repo

View file

@ -1,51 +0,0 @@
#!/bin/sh
set -ux
script="$1"
context="$2"
desc="$3"
if [ -z "$script" ]; then
echo "No script to run" >&2
exit 1
fi
if [ -z "$context" ]; then
echo "Missing 'context' for CI status report" >&2
exit 1
fi
if [ -z "$REF" ]; then
echo "Environment variable 'REF' must be set to a valid Git ref." >&2
exit 1
fi
if [ -z "$GITEA_API_KEY" ]; then
echo "Environment variable 'GITEA_API_KEY' must be set." >&2
exit 1
fi
notify() {
curl -X 'POST' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H "Authorization: token $GITEA_API_KEY" \
"https://git.sclu1034.dev/api/v1/repos/bitsquid_dt/dtmt/statuses/$REF" \
--data @- <<EOF
{
"context": "$2",
"description": "$3",
"state": "$1"
}
EOF
}
notify 'pending' "$context" "$desc"
if sh "$script"; then
notify 'success' "$context" "$desc"
else
notify 'failure' "$context" "$desc"
exit 1
fi

View file

@ -1,9 +0,0 @@
target/
docs/
data/
.git/
README.adoc
CHANGELOG.adoc
LICENSE
dictionary.csv
Justfile

View file

@ -1,5 +1,3 @@
/target
*.a
*.so
*.dll
*.lib
liboo2corelinux64.so
oo2core_8_win64.dll

6
.gitattributes vendored
View file

@ -1,6 +0,0 @@
* text=auto
*.xcf filter=lfs diff=lfs merge=lfs -text
*.ico filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text

6
.gitignore vendored
View file

@ -1,8 +1,6 @@
/target
/data
.envrc
*.a
*.so
*.dll
*.lib
liboo2corelinux64.so
oo2core_8_win64.dll
dictionary.csv

13
.gitmodules vendored
View file

@ -1,14 +1,3 @@
[submodule "lib/serde_sjson"]
path = lib/serde_sjson
url = https://git.sclu1034.dev/lucas/serde_sjson.git
[submodule "lib/luajit2-sys"]
path = lib/luajit2-sys
url = https://github.com/sclu1034/luajit2-sys.git
[submodule "lib/color-eyre"]
path = lib/color-eyre
url = https://github.com/sclu1034/color-eyre.git
branch = "fork"
[submodule "lib/ansi-parser"]
path = lib/ansi-parser
url = https://gitlab.com/lschwiderski/ansi-parser.git
branch = "issue/outdated-nom"
url = git@git.sclu1034.dev:lucas/serde_sjson.git

View file

@ -1,15 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":combinePatchMinorReleases",
":enableVulnerabilityAlerts",
":rebaseStalePrs"
],
"prConcurrentLimit": 10,
"branchPrefix": "renovate/",
"baseBranches": [
"$default",
"/^release\\/.*/"
]
}

View file

@ -2,33 +2,6 @@
== [Unreleased]
=== Added
- dtmt: split `build` into `build` and `package`
- dtmt: implement deploying built bundles
- dtmm: indicate when a deployment is necessary
- dtmm: check for Steam game update before deployment
- dtmm: remove unused bundles from previous deployment
- dtmm: show dialog for critical errors
- dtmm: check mod order before deployment
- dtmt: add mod dependencies to config
- dtmm: match mods to Nexus and check for updates
- dtmt: add utility to migrate mod projects
- dtmm: reset dtkit-patch installations
- sdk: implement decompiling Lua files
- dtmm: fetch cover image for Nexus mods
- dtmm: fetch file version for Nexus mods
- dtmm: handle `nxm://` URIs via IPC and import the corresponding mod
- dtmm: Add button to open mod on nexusmods.com
- dtmt: Implement commands to list bundles and contents
- dtmt: Implement command to search for files
=== Fixed
- all: force unix path separators for engine values
- dtmt: fix extracing files with non-flattened file names
- oodle: fix static linking
== 2023-03-01
=== Added

2962
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,79 +1,7 @@
[workspace]
resolver = "2"
members = [
"crates/dtmt",
"crates/dtmm",
"lib/dtmt-shared",
"lib/oodle",
"lib/sdk",
"lib/serde_sjson",
"lib/luajit2-sys",
"lib/color-eyre",
]
exclude = ["lib/color-eyre"]
[workspace.dependencies]
ansi-parser = "0.9.1"
ansi_term = "0.12.1"
async-recursion = "1.0.5"
bincode = "1.3.3"
bitflags = "2.5.0"
byteorder = "1.4.3"
clap = { version = "4.0.15", features = ["color", "derive", "std", "cargo", "string", "unicode"] }
cli-table = { version = "0.4.7", default-features = false, features = ["derive"] }
color-eyre = { path = "lib/color-eyre" }
colors-transform = "0.2.11"
confy = "0.6.1"
csv-async = { version = "1.2.4", features = ["tokio", "serde"] }
druid = { version = "0.8", features = ["im", "serde", "image", "png", "jpeg", "bmp", "webp", "svg"] }
druid-widget-nursery = "0.1"
dtmt-shared = { path = "lib/dtmt-shared" }
fastrand = "2.1.0"
futures = "0.3.25"
futures-util = "0.3.24"
glob = "0.3.0"
interprocess = "2.1.0"
lazy_static = "1.4.0"
luajit2-sys = { path = "lib/luajit2-sys" }
minijinja = { version = "2.0.1", default-features = false }
nanorand = "0.7.0"
nexusmods = { path = "lib/nexusmods" }
notify = "8.0.0"
oodle = { path = "lib/oodle" }
open = "5.0.1"
path-clean = "1.0.1"
path-slash = "0.2.1"
pin-project-lite = "0.2.9"
promptly = "0.3.1"
sdk = { path = "lib/sdk" }
serde = { version = "1.0.152", features = ["derive", "rc"] }
serde_sjson = { path = "lib/serde_sjson" }
steamlocate = "2.0.0-beta.2"
strip-ansi-escapes = "0.2.0"
time = { version = "0.3.20", features = ["serde", "serde-well-known", "local-offset", "formatting", "macros"] }
tokio = { version = "1.23.0", features = ["rt-multi-thread", "fs", "process", "macros", "tracing", "io-util", "io-std"] }
tokio-stream = { version = "0.1.12", features = ["fs", "io-util"] }
tracing = { version = "0.1.37", features = ["async-await"] }
tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
usvg = "0.25.0"
zip = { version = "2.1.3", default-features = false, features = ["deflate", "bzip2", "zstd", "time"] }
[profile.dev.package.backtrace]
opt-level = 3
members = ["crates/*", "lib/*"]
[profile.release]
strip = "debuginfo"
# The MSVC toolchain cannot handle LTO properly. Some symbol related to
# panic unwind would always be missing.
# So we use a separate profile for when we can compile with LTO.
[profile.release-lto]
inherits = "release"
lto = true
[profile.perf]
inherits = "release"
strip = false
lto = true
debug = "line-tables-only"

View file

@ -1,59 +0,0 @@
set positional-arguments
fly_target := "main"
build-perf-dtmt:
cargo build --profile perf --bin dtmt
perf-dtmt *args='': build-perf-dtmt
perf record --call-graph dwarf ./target/perf/dtmt "$@"
ci-build: ci-build-msvc ci-build-linux
ci-build-msvc:
docker run --rm -ti --user $(id -u) -v ./:/src/dtmt dtmt-ci-base-msvc cargo --color always build --release --target x86_64-pc-windows-msvc --locked -Zbuild-std
ci-build-linux:
docker run --rm -ti --user $(id -u) -v ./:/src/dtmt dtmt-ci-base-linux cargo --color always build --profile release-lto --locked
build-image: build-image-msvc build-image-linux
build-image-msvc:
docker build -f .ci/Dockerfile.msvc .
build-image-linux:
docker build -f .ci/Dockerfile.linux .
ci-image:
# The MSVC image depends on the Linux image. So by building that first,
# we actually build both, and cache them, so that "building" the
# Linux image afterwards merely needs to pull the cache.
docker build --target msvc -t dtmt-ci-base-msvc -f .ci/image/Dockerfile .
docker build --target linux -t dtmt-ci-base-linux -f .ci/image/Dockerfile .
docker tag dtmt-ci-base-msvc registry.sclu1034.dev/dtmt-ci-base-msvc
docker tag dtmt-ci-base-linux registry.sclu1034.dev/dtmt-ci-base-linux
docker push registry.sclu1034.dev/dtmt-ci-base-msvc
docker push registry.sclu1034.dev/dtmt-ci-base-linux
set-base-pipeline:
fly -t {{fly_target}} set-pipeline \
--pipeline dtmt \
--config .ci/pipelines/base.yml \
-v gitea_api_key=${GITEA_API_KEY} \
-v owner=bitsquid_dt \
-v repo=dtmt
set-pr-pipeline pr:
curl \
-H "Authorization: ${GITEA_API_KEY}" \
-H 'Accept: application/json' \
'https://git.sclu1034.dev/api/v1/repos/bitsquid_dt/dtmt/pulls/{{pr}}' \
| yq -y '.' - > 'pr-{{pr}}.yaml'
fly -t main set-pipeline \
--pipeline dtmt-pr \
--config .ci/pipelines/pr.yml \
-v gitea_api_key=${GITEA_API_KEY} \
-i number={{pr}} \
-y branch="$(yq -y '.head.ref' 'pr-{{pr}}.yaml')" \
-y pr="$(cat 'pr-{{pr}}.yaml')"

View file

@ -2,48 +2,24 @@
name = "dtmm"
version = "0.1.0"
edition = "2021"
authors = ["Lucas Schwiderski <lucas@lschwiderski.de>"]
description = "DTMM is a GUI application to install and manage mods for the game."
documentation = "https://git.sclu1034.dev/bitsquid_dt/dtmt/wiki"
repository = "https://git.sclu1034.dev/bitsquid_dt/dtmt"
homepage = "https://git.sclu1034.dev/bitsquid_dt/dtmt"
license-file = "LICENSE"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
ansi-parser = { workspace = true }
async-recursion = { workspace = true }
bincode = { workspace = true }
bitflags = { workspace = true }
clap = { workspace = true }
color-eyre = { workspace = true }
colors-transform = { workspace = true }
confy = { workspace = true }
druid = { workspace = true }
druid-widget-nursery = { workspace = true }
dtmt-shared = { workspace = true }
futures = { workspace = true }
interprocess = { workspace = true }
lazy_static = { workspace = true }
luajit2-sys = { workspace = true }
minijinja = { workspace = true }
nexusmods = { workspace = true }
oodle = { workspace = true }
open = { workspace = true }
path-slash = { workspace = true }
sdk = { workspace = true }
serde = { workspace = true }
serde_sjson = { workspace = true }
strip-ansi-escapes = { workspace = true }
time = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
tracing-error = { workspace = true }
tracing-subscriber = { workspace = true }
usvg = { workspace = true }
zip = { workspace = true }
[build-dependencies]
winres = "0.1.12"
bitflags = "1.3.2"
clap = { version = "4.0.15", features = ["color", "derive", "std", "cargo", "string", "unicode"] }
color-eyre = "0.6.2"
confy = "0.5.1"
druid = { git = "https://github.com/linebender/druid.git", features = ["im", "serde"] }
dtmt-shared = { path = "../../lib/dtmt-shared", version = "*" }
futures = "0.3.25"
oodle = { path = "../../lib/oodle", version = "*" }
sdk = { path = "../../lib/sdk", version = "*" }
serde_sjson = { path = "../../lib/serde_sjson", version = "*" }
serde = { version = "1.0.152", features = ["derive", "rc"] }
tokio = { version = "1.23.0", features = ["rt", "fs", "tracing", "sync"] }
tracing = "0.1.37"
tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
zip = "0.6.4"
tokio-stream = { version = "0.1.12", features = ["fs"] }

BIN
crates/dtmm/assets/DTMM_logo.xcf (Stored with Git LFS)

Binary file not shown.

BIN
crates/dtmm/assets/DTMM_logo_256.png (Stored with Git LFS)

Binary file not shown.

BIN
crates/dtmm/assets/DTMM_logo_48.png (Stored with Git LFS)

Binary file not shown.

BIN
crates/dtmm/assets/DTMM_logo_64.png (Stored with Git LFS)

Binary file not shown.

BIN
crates/dtmm/assets/DTMM_logo_border.png (Stored with Git LFS)

Binary file not shown.

Binary file not shown.

BIN
crates/dtmm/assets/DTMM_logo_small.png (Stored with Git LFS)

Binary file not shown.

View file

@ -1,11 +0,0 @@
[Desktop Entry]
Name=DTMM
GenericName=Mod Manager
Comment=A graphical mod manager for Warhammer 40,000: Darktide
Exec=dtmm %u
Type=Application
Keywords=Mod;
StartupNotify=true
Categories=Utility;
MimeType=x-scheme-handler/nxm;
Icon=dtmm

BIN
crates/dtmm/assets/dtmm.ico (Stored with Git LFS)

Binary file not shown.

View file

@ -1,70 +0,0 @@
local StateGame = require("scripts/game_states/state_game")
local StateSplash = require("scripts/game_states/game/state_splash")
local GameStateMachine = require("scripts/foundation/utilities/game_state_machine")
local function hook(obj, fn_name, cb)
local orig = obj[fn_name]
obj[fn_name] = function(...)
return cb(orig, ...)
end
end
function init(mod_data, boot_gui)
local ModLoader = require("scripts/mods/mod_loader")
local mod_loader = ModLoader:new(mod_data, boot_gui)
-- The mod loader needs to remain active during game play, to
-- enable reloads
hook(StateGame, "update", function(func, dt, ...)
mod_loader:update(dt)
return func(dt, ...)
end)
-- Skip splash view
hook(StateSplash, "on_enter", function(func, self, ...)
local result = func(self, ...)
self._should_skip = true
self._continue = true
return result
end)
-- Trigger state change events
hook(GameStateMachine, "_change_state", function(func, self, ...)
local old_state = self._state
local old_state_name = old_state and self:current_state_name()
if old_state_name then
mod_loader:on_game_state_changed("exit", old_state_name, old_state)
end
local result = func(self, ...)
local new_state = self._state
local new_state_name = new_state and self:current_state_name()
if new_state_name then
mod_loader:on_game_state_changed("enter", new_state_name, new_state)
end
return result
end)
-- Trigger ending state change event
hook(GameStateMachine, "destroy", function(func, self, ...)
local old_state = self._state
local old_state_name = old_state and self:current_state_name()
if old_state_name then
mod_loader:on_game_state_changed("exit", old_state_name)
end
return func(self, ...)
end)
return mod_loader
end
return init

View file

@ -1,28 +0,0 @@
return {
{% for mod in mods %}
{
id = "{{ mod.id }}",
name = "{{ mod.name }}",
bundled = {{ mod.bundled }},
version = {{ mod.version }},
packages = {
{% for pkg in mod.packages %}
"{{ pkg }}",
{% endfor %}
},
run = function()
{% if mod.data is none %}
return dofile("{{ mod.init }}")
{% else %}
new_mod("{{ mod.id }}", {
mod_script = "{{ mod.init }}",
mod_data = "{{ mod.data }}",
{% if not mod.localization is none %}
mod_localization = "{{ mod.localization }}",
{% endif %}
})
{% endif %}
end,
},
{% endfor %}
}

View file

@ -1,412 +0,0 @@
-- Copyright on this file is owned by Fatshark.
-- It is extracted, used and modified with permission only for
-- the purpose of loading mods within Warhammer 40,000: Darktide.
local ModLoader = class("ModLoader")
local table_unpack = table.unpack or unpack
local table_pack = table.pack or pack
local ScriptGui = require("scripts/foundation/utilities/script_gui")
local FONT_MATERIAL = "content/ui/fonts/arial"
local LOG_LEVELS = {
spew = 4,
info = 3,
warning = 2,
error = 1
}
local DEFAULT_SETTINGS = {
log_level = LOG_LEVELS.error,
developer_mode = false
}
local Keyboard = Keyboard
local BUTTON_INDEX_R = Keyboard.button_index("r")
local BUTTON_INDEX_LEFT_SHIFT = Keyboard.button_index("left shift")
local BUTTON_INDEX_LEFT_CTRL = Keyboard.button_index("left ctrl")
ModLoader.init = function(self, mod_data, boot_gui)
table.dump(mod_data, nil, 5, function(...) Log.info("ModLoader", ...) end)
self._mod_data = mod_data
self._gui = boot_gui
self._settings = Application.user_setting("mod_settings") or DEFAULT_SETTINGS
self._mods = {}
self._num_mods = nil
self._chat_print_buffer = {}
self._reload_data = {}
self._ui_time = 0
self._state = "scanning"
end
ModLoader.developer_mode_enabled = function(self)
return self._settings.developer_mode
end
ModLoader.set_developer_mode = function(self, enabled)
self._settings.developer_mode = enabled
end
ModLoader._draw_state_to_gui = function(self, gui, dt)
local state = self._state
local t = self._ui_time + dt
self._ui_time = t
local status_str = "Loading mods"
if state == "scanning" then
status_str = "Scanning for mods"
elseif state == "loading" or state == "initializing" then
local mod = self._mods[self._mod_load_index]
status_str = string.format("Loading mod %q", mod.name)
end
local msg = status_str .. string.rep(".", (2 * t) % 4)
ScriptGui.text(gui, msg, FONT_MATERIAL, 25, Vector3(20, 30, 1), Color.white())
end
ModLoader.remove_gui = function(self)
self._gui = nil
end
ModLoader.mod_data = function(self, id)
-- Since this primarily exists for DMF,
-- we can optimize the search for its use case of looking for the
-- mod currently being loaded
local mod_data = self._mods[self._mod_load_index]
if mod_data.id ~= id then
mod_data = nil
for _, v in ipairs(self._mods) do
if v.id == id then
mod_data = v
end
end
end
return mod_data
end
ModLoader._check_reload = function()
return Keyboard.pressed(BUTTON_INDEX_R) and
Keyboard.button(BUTTON_INDEX_LEFT_SHIFT) +
Keyboard.button(BUTTON_INDEX_LEFT_CTRL) == 2
end
ModLoader.update = function(self, dt)
local chat_print_buffer = self._chat_print_buffer
local num_delayed_prints = #chat_print_buffer
if num_delayed_prints > 0 and Managers.chat then
for i = 1, num_delayed_prints, 1 do
-- TODO: Use new chat system
-- Managers.chat:add_local_system_message(1, chat_print_buffer[i], true)
chat_print_buffer[i] = nil
end
end
local old_state = self._state
if self._settings.developer_mode and self:_check_reload() then
self._reload_requested = true
end
if self._reload_requested and old_state == "done" then
self:_reload_mods()
end
if old_state == "done" then
self:_run_callbacks("update", dt)
elseif old_state == "scanning" then
Log.info("ModLoader", "Scanning for mods")
self:_build_mod_table()
self._state = self:_load_mod(1)
self._ui_time = 0
elseif old_state == "loading" then
local handle = self._loading_resource_handle
if ResourcePackage.has_loaded(handle) then
ResourcePackage.flush(handle)
local mod = self._mods[self._mod_load_index]
local next_index = mod.package_index + 1
local mod_data = mod.data
if next_index <= #mod_data.packages then
self:_load_package(mod, next_index)
else
self._state = "initializing"
end
end
elseif old_state == "initializing" then
local mod = self._mods[self._mod_load_index]
local mod_data = mod.data
Log.info("ModLoader", "Initializing mod %q", mod.name)
mod.state = "running"
local ok, object = xpcall(mod_data.run, function(err)
if type(err) == "string" then
return err .. "\n" .. Script.callstack()
else
return err
end
end)
if not ok then
if object.error then
object = string.format(
"%s\n<<Lua Stack>>\n%s\n<</Lua Stack>>\n<<Lua Locals>>\n%s\n<</Lua Locals>>\n<<Lua Self>>\n%s\n<</Lua Self>>",
object.error, object.traceback, object.locals, object.self)
end
Log.error("ModLoader", "Failed 'run' for %q: %s", mod.name, object)
end
mod.object = object or {}
self:_run_callback(mod, "init", self._reload_data[mod.id])
Log.info("ModLoader", "Finished loading %q", mod.name)
self._state = self:_load_mod(self._mod_load_index + 1)
end
local gui = self._gui
if gui then
self:_draw_state_to_gui(gui, dt)
end
if old_state ~= self._state then
Log.info("ModLoader", "%s -> %s", old_state, self._state)
end
end
ModLoader.all_mods_loaded = function(self)
return self._state == "done"
end
ModLoader.destroy = function(self)
self:_run_callbacks("on_destroy")
self:unload_all_mods()
end
ModLoader._run_callbacks = function(self, callback_name, ...)
for i = 1, self._num_mods, 1 do
local mod = self._mods[i]
if mod and not mod.callbacks_disabled then
self:_run_callback(mod, callback_name, ...)
end
end
end
ModLoader._run_callback = function(self, mod, callback_name, ...)
local object = mod.object
local cb = object[callback_name]
if not cb then
return
end
local args = table_pack(...)
local success, val = xpcall(
function() return cb(object, table_unpack(args)) end,
function(err)
if type(err) == "string" then
return err .. "\n" .. Script.callstack()
else
return err
end
end
)
if success then
return val
else
Log.error("ModLoader", "Failed to run callback %q for mod %q with id %q. Disabling callbacks until reload.",
callback_name, mod.name, mod.id)
if val.error then
Log.error("ModLoader",
"Error: %s\n<<Lua Stack>>\n%s<</Lua Stack>>\n<<Lua Locals>>\n%s<</Lua Locals>>\n<<Lua Self>>\n%s<</Lua Self>>",
val.error, val.traceback, val.locals, val.self)
else
Log.error("ModLoader", "Error: %s", val or "[unknown error]")
end
mod.callbacks_disabled = true
end
end
ModLoader._start_scan = function(self)
Log.info("ModLoader", "Starting mod scan")
self._state = "scanning"
end
ModLoader._build_mod_table = function(self)
fassert(table.is_empty(self._mods), "Trying to add mods to non-empty mod table")
for i, mod_data in ipairs(self._mod_data) do
Log.info(
"ModLoader",
"mods[%d] = id=%q | name=%q | version=%q | bundled=%s",
i,
mod_data.id,
mod_data.name,
mod_data.version,
tostring(mod_data.bundled)
)
self._mods[i] = {
id = mod_data.id,
state = "not_loaded",
callbacks_disabled = false,
name = mod_data.name,
loaded_packages = {},
packages = mod_data.packages,
data = mod_data,
bundled = mod_data.bundled or false,
}
end
self._num_mods = #self._mods
Log.info("ModLoader", "Found %i mods", self._num_mods)
end
ModLoader._load_mod = function(self, index)
self._ui_time = 0
local mods = self._mods
local mod = mods[index]
if not mod then
table.clear(self._reload_data)
return "done"
end
Log.info("ModLoader", "Loading mod %q", mod.id)
mod.state = "loading"
Crashify.print_property(string.format("Mod:%s", mod.name), true)
self._mod_load_index = index
if mod.bundled and mod.packages[1] then
self:_load_package(mod, 1)
return "loading"
else
return "initializing"
end
end
ModLoader._load_package = function(self, mod, index)
mod.package_index = index
local package_name = mod.packages[index]
if not package_name then
return
end
Log.info("ModLoader", "Loading package %q", package_name)
local resource_handle = Application.resource_package(package_name)
self._loading_resource_handle = resource_handle
ResourcePackage.load(resource_handle)
table.insert(mod.loaded_packages, resource_handle)
end
ModLoader.unload_all_mods = function(self)
if self._state ~= "done" then
Log.error("ModLoader", "Mods can't be unloaded, mod state is not \"done\". current: %q", self._state)
return
end
Log.info("ModLoader", "Unload all mod packages")
for i = self._num_mods, 1, -1 do
local mod = self._mods[i]
if mod then
self:unload_mod(i)
end
self._mods[i] = nil
end
self._num_mods = nil
self._state = "unloaded"
end
ModLoader.unload_mod = function(self, index)
local mod = self._mods[index]
if mod then
Log.info("ModLoader", "Unloading %q.", mod.name)
for _, handle in ipairs(mod.loaded_packages) do
ResourcePackage.unload(handle)
Application.release_resource_package(handle)
end
mod.state = "not_loaded"
else
Log.error("ModLoader", "Mod index %i can't be unloaded, has not been loaded", index)
end
end
ModLoader._reload_mods = function(self)
Log.info("ModLoader", "reloading mods")
for i = 1, self._num_mods, 1 do
local mod = self._mods[i]
if mod and mod.state == "running" then
Log.info("ModLoader", "reloading %s", mod.name)
self._reload_data[mod.id] = self:_run_callback(mod, "on_reload")
else
Log.info("ModLoader", "not reloading mod, state: %s", mod.state)
end
end
self:unload_all_mods()
self:_start_scan()
self._reload_requested = false
end
ModLoader.on_game_state_changed = function(self, status, state_name, state_object)
if self._state == "done" then
self:_run_callbacks("on_game_state_changed", status, state_name, state_object)
else
Log.warning("ModLoader", "Ignored on_game_state_changed call due to being in state %q", self._state)
end
end
ModLoader.print = function(self, level, str, ...)
local f = Log[level]
if f then
f("ModLoader", str, ...)
else
local message = string.format("[ModLoader][" .. level .. "] " .. str, ...)
local log_level = LOG_LEVELS[level] or 99
if log_level <= 2 then
print(message)
end
end
end
return ModLoader

View file

@ -0,0 +1,192 @@
local _G = _G
local rawget = rawget
local rawset = rawset
local log = function(category, format, ...)
local Log = rawget(_G, "Log")
if Log then
Log.info(category, format, ...)
else
print(string.format("[%s] %s", category or "", string.format(format or "", ...)))
end
end
-- Patch `GameStateMachine.init` to add our own state for loading mods.
-- In the future, Fatshark might provide us with a dedicated way to do this.
local function patch_mod_loading_state()
local StateBootSubStateBase = require("scripts/game_states/boot/state_boot_sub_state_base")
-- A necessary override.
-- The original does not proxy `dt` to `_state_update`, but we need that.
StateBootSubStateBase.update = function (self, dt)
local done, error = self:_state_update(dt)
local params = self._params
if error then
return StateError, { error }
elseif done then
local next_index = params.sub_state_index + 1
params.sub_state_index = next_index
local next_state_data = params.states[next_index]
if next_state_data then
return next_state_data[1], self._params
else
self._parent:sub_states_done()
end
end
end
local StateBootLoadMods = class("StateBootLoadMods", "StateBootSubStateBase")
StateBootLoadMods.on_enter = function (self, parent, params)
log("StateBootLoadMods", "Entered")
StateBootLoadMods.super.on_enter(self, parent, params)
local state_params = self:_state_params()
local package_manager = state_params.package_manager
self._state = "load_package"
self._package_manager = package_manager
self._package_handles = {
["packages/mods"] = package_manager:load("packages/mods", "StateBootLoadMods", nil),
["packages/dml"] = package_manager:load("packages/dml", "StateBootLoadMods", nil),
}
end
StateBootLoadMods._state_update = function (self, dt)
local state = self._state
local package_manager = self._package_manager
if state == "load_package" and package_manager:update() then
log("StateBootLoadMods", "Packages loaded, loading mods")
self._state = "load_mods"
local mod_loader = require("scripts/mods/dml/init")
self._mod_loader = mod_loader
local mod_data = require("scripts/mods/mod_data")
mod_loader:init(mod_data, self._parent:gui())
elseif state == "load_mods" and self._mod_loader:update(dt) then
log("StateBootLoadMods", "Mods loaded, exiting")
return true, false
end
return false, false
end
local GameStateMachine = require("scripts/foundation/utilities/game_state_machine")
local patched = false
local GameStateMachine_init = GameStateMachine.init
GameStateMachine.init = function(self, parent, start_state, params, ...)
if not patched then
log("mod_main", "Injecting mod loading state")
patched = true
-- Hardcoded position after `StateRequireScripts`.
-- We do want to wait until then, so that most of the game's core
-- systems are at least loaded and can be hooked, even if they aren't
-- running, yet.
local pos = 4
table.insert(params.states, pos, {
StateBootLoadMods,
{
package_manager = params.package_manager,
},
})
end
GameStateMachine_init(self, parent, start_state, params, ...)
end
log("mod_main", "Mod patching complete")
end
log("mod_main", "Initializing mods...")
local require_store = {}
Mods = {
-- Keep a backup of certain system libraries before
-- Fatshark's code scrubs them.
-- The loader can then decide to pass them on to mods, or ignore them
lua = setmetatable({}, {
io = io,
debug = debug,
ffi = ffi,
os = os,
load = load,
loadfile = loadfile,
loadstring = loadstring,
}),
require_store = require_store
}
local can_insert = function(filepath, new_result)
local store = require_store[filepath]
if not store or #store then
return true
end
if store[#store] ~= new_result then
return true
end
end
local original_require = require
require = function(filepath, ...)
local result = original_require(filepath, ...)
if result and type(result) == "table" then
if can_insert(filepath, result) then
require_store[filepath] = require_store[filepath] or {}
local store = require_store[filepath]
table.insert(store, result)
if Mods.hook then
Mods.hook.enable_by_file(filepath, #store)
end
end
end
return result
end
require("scripts/boot_init")
require("scripts/foundation/utilities/class")
-- The `__index` metamethod maps a proper identifier `CLASS.MyClassName` to the
-- stringified version of the key: `"MyClassName"`.
-- This allows using LuaCheck for the stringified class names in hook parameters.
_G.CLASS = setmetatable({}, {
__index = function(_, key)
return key
end
})
local original_class = class
class = function(class_name, super_name, ...)
local result = original_class(class_name, super_name, ...)
if not rawget(_G, class_name) then
rawset(_G, class_name, result)
end
if not rawget(_G.CLASS, class_name) then
rawset(_G.CLASS, class_name, result)
end
return result
end
require("scripts/main")
log("mod_main", "'scripts/main' loaded")
-- Override `init` to run our injection
function init()
patch_mod_loading_state()
-- As requested by Fatshark
local StateRequireScripts = require("scripts/game_states/boot/state_require_scripts")
StateRequireScripts._get_is_modded = function() return true end
Main:init()
end

View file

@ -1,216 +0,0 @@
local _G = _G
local rawget = rawget
local rawset = rawset
local log = function(category, format, ...)
local Log = rawget(_G, "Log")
if Log then
Log.info(category, format, ...)
else
print(string.format("[%s] %s", category or "", string.format(format or "", ...)))
end
end
log("mod_main", "Initializing mods...")
log("mod_main", "[DTMM] Deployment data:\n{{ deployment_info }}")
local require_store = {}
-- This token is treated as a string template and filled by DTMM during deployment.
-- This allows hiding unsafe I/O functions behind a setting.
-- When not replaced, it's also a valid table definition, thereby degrading gracefully.
local is_io_enabled = {{ is_io_enabled }} -- luacheck: ignore 113
local lua_libs = {
debug = debug,
os = {
date = os.date,
time = os.time,
clock = os.clock,
getenv = os.getenv,
difftime = os.difftime,
},
load = load,
loadfile = loadfile,
loadstring = loadstring,
}
if is_io_enabled then
lua_libs.io = io
lua_libs.os = os
lua_libs.ffi = require("ffi")
end
Mods = {
-- Keep a backup of certain system libraries before
-- Fatshark's code scrubs them.
-- The loader can then decide to pass them on to mods, or ignore them
lua = setmetatable({}, { __index = lua_libs }),
require_store = require_store,
original_require = require,
}
local can_insert = function(filepath, new_result)
local store = require_store[filepath]
if not store or #store then
return true
end
if store[#store] ~= new_result then
return true
end
end
local original_require = require
require = function(filepath, ...)
local result = original_require(filepath, ...)
if result and type(result) == "table" then
if can_insert(filepath, result) then
require_store[filepath] = require_store[filepath] or {}
local store = require_store[filepath]
table.insert(store, result)
if Mods.hook then
Mods.hook.enable_by_file(filepath, #store)
end
end
end
return result
end
require("scripts/boot_init")
require("scripts/foundation/utilities/class")
-- The `__index` metamethod maps a proper identifier `CLASS.MyClassName` to the
-- stringified version of the key: `"MyClassName"`.
-- This allows using LuaCheck for the stringified class names in hook parameters.
_G.CLASS = setmetatable({}, {
__index = function(_, key)
return key
end
})
local original_class = class
class = function(class_name, super_name, ...)
local result = original_class(class_name, super_name, ...)
if not rawget(_G, class_name) then
rawset(_G, class_name, result)
end
if not rawget(_G.CLASS, class_name) then
rawset(_G.CLASS, class_name, result)
end
return result
end
require("scripts/main")
log("mod_main", "'scripts/main' loaded")
-- We need to inject two states into two different state machines:
-- First, we inject one into the `"Main"` state machine at a specific location, so that we're
-- still early in the process, but right after `StateRequireScripts` where most game files
-- are already available to `require` and hook.
-- This is where the `ModLoader` is created initially.
-- Then, we inject into the very first position of the `"Game"` state machine. This runs right
-- after `StateGame._init_managers`, at which point all the parts needed for DMF and other mods
-- have been initialized.
-- This is where `ModLoader` will finally start loading mods.
local function patch_mod_loading_state()
local StateBootLoadDML = class("StateBootLoadDML", "StateBootSubStateBase")
local StateGameLoadMods = class("StateGameLoadMods")
StateBootLoadDML.on_enter = function(self, parent, params)
log("StateBootLoadDML", "Entered")
StateBootLoadDML.super.on_enter(self, parent, params)
local state_params = self:_state_params()
local package_manager = state_params.package_manager
self._package_manager = package_manager
self._package_handles = {
["packages/mods"] = package_manager:load("packages/mods", "StateBootLoadDML", nil),
}
end
StateBootLoadDML._state_update = function(self, _)
local package_manager = self._package_manager
if package_manager:update() then
local mod_data = require("scripts/mods/mod_data")
local create_mod_loader = require("scripts/mods/init")
local mod_loader = create_mod_loader(mod_data)
Managers.mod = mod_loader
log("StateBootLoadDML", "DML loaded, exiting")
return true, false
end
return false, false
end
function StateGameLoadMods:on_enter(_, params)
log("StateGameLoadMods", "Entered")
self._next_state = require("scripts/game_states/game/state_splash")
self._next_state_params = params
end
function StateGameLoadMods:update(_)
-- We're relying on the fact that DML internally makes sure
-- that `Managers.mod:update()` is being called appropriately.
-- The implementation as of this writing is to hook `StateGame.update`.
if Managers.mod:all_mods_loaded() then
Log.info("StateGameLoadMods", "Mods loaded, exiting")
return self._next_state, self._next_state_params
end
end
local GameStateMachine = require("scripts/foundation/utilities/game_state_machine")
local GameStateMachine_init = GameStateMachine.init
GameStateMachine.init = function(self, parent, start_state, params, creation_context, state_change_callbacks, name)
if name == "Main" then
log("mod_main", "Injecting StateBootLoadDML")
-- Hardcoded position after `StateRequireScripts`.
-- We need to wait until then to even begin most of our stuff,
-- so that most of the game's core systems are at least loaded and can be hooked,
-- even if they aren't running, yet.
local pos = 4
table.insert(params.states, pos, {
StateBootLoadDML,
{
package_manager = params.package_manager,
},
})
GameStateMachine_init(self, parent, start_state, params, creation_context, state_change_callbacks, name)
elseif name == "Game" then
log("mod_main", "Injection StateGameLoadMods")
-- The second time around, we want to be the first, so we pass our own
-- 'start_state'.
-- We can't just have the state machine be initialized and then change its `_next_state`, as by the end of
-- `init`, a bunch of stuff will already be initialized.
GameStateMachine_init(self, parent, StateGameLoadMods, params, creation_context, state_change_callbacks, name)
-- And since we're done now, we can revert the function to its original
GameStateMachine.init = GameStateMachine_init
else
-- In all other cases, simply call the original
GameStateMachine_init(self, parent, start_state, params, creation_context, state_change_callbacks, name)
end
end
end
-- Override `init` to run our injection
function init()
patch_mod_loading_state()
-- As requested by Fatshark
local StateRequireScripts = require("scripts/game_states/boot/state_require_scripts")
StateRequireScripts._get_is_modded = function() return true end
Main:init()
end
-- vim: ft=lua

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020-2023 Paweł Kuna
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.

View file

@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-alert-circle" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>

Before

Width:  |  Height:  |  Size: 396 B

View file

@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-alert-triangle" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M10.24 3.957l-8.422 14.06a1.989 1.989 0 0 0 1.7 2.983h16.845a1.989 1.989 0 0 0 1.7 -2.983l-8.423 -14.06a1.989 1.989 0 0 0 -3.4 0z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

Before

Width:  |  Height:  |  Size: 491 B

View file

@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-cloud-download" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4" />
<path d="M12 13l0 9" />
<path d="M9 19l3 3l3 -3" />
</svg>

Before

Width:  |  Height:  |  Size: 439 B

View file

@ -1,7 +0,0 @@
fn main() {
if cfg!(target_os = "windows") {
let mut res = winres::WindowsResource::new();
res.set_icon("assets/dtmm.ico");
res.compile().unwrap();
}
}

View file

@ -1,303 +1,213 @@
use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::io::{Cursor, ErrorKind, Read};
use std::path::Path;
use color_eyre::eyre::{self, Context};
use color_eyre::{Help, Report, Result};
use color_eyre::{Help, Result};
use druid::im::Vector;
use druid::ImageBuf;
use druid::FileInfo;
use dtmt_shared::ModConfig;
use nexusmods::Api as NexusApi;
use tokio::fs::{self, DirEntry, File};
use serde::Deserialize;
use tokio::fs::{self, DirEntry};
use tokio::runtime::Runtime;
use tokio_stream::wrappers::ReadDirStream;
use tokio_stream::StreamExt;
use zip::ZipArchive;
use crate::state::{ActionState, InitialLoadResult, ModInfo, ModOrder, NexusInfo, PackageInfo};
use crate::util;
use crate::state::{ModInfo, PackageInfo, State};
use crate::util::config::{ConfigSerialize, LoadOrderEntry};
use super::read_sjson_file;
#[tracing::instrument(skip(state))]
pub(crate) async fn import_mod(state: State, info: FileInfo) -> Result<ModInfo> {
let data = fs::read(&info.path)
.await
.wrap_err_with(|| format!("failed to read file {}", info.path.display()))?;
let data = Cursor::new(data);
let mut archive = ZipArchive::new(data).wrap_err("failed to open ZIP archive")?;
if tracing::enabled!(tracing::Level::DEBUG) {
let names = archive.file_names().fold(String::new(), |mut s, name| {
s.push('\n');
s.push_str(name);
s
});
tracing::debug!("Archive contents:{}", names);
}
let dir_name = {
let f = archive.by_index(0).wrap_err("archive is empty")?;
if !f.is_dir() {
let err = eyre::eyre!("archive does not have a top-level directory");
return Err(err).with_suggestion(|| "Use 'dtmt build' to create the mod archive.");
}
let name = f.name();
// The directory name is returned with a trailing slash, which we don't want
name[..(name.len().saturating_sub(1))].to_string()
};
tracing::info!("Importing mod {}", dir_name);
let mod_cfg: ModConfig = {
let mut f = archive
.by_name(&format!("{}/{}", dir_name, "dtmt.cfg"))
.wrap_err("failed to read mod config from archive")?;
let mut buf = Vec::with_capacity(f.size() as usize);
f.read_to_end(&mut buf)
.wrap_err("failed to read mod config from archive")?;
let data = String::from_utf8(buf).wrap_err("mod config is not valid UTF-8")?;
serde_sjson::from_str(&data).wrap_err("failed to deserialize mod config")?
};
tracing::debug!(?mod_cfg);
let files: HashMap<String, Vec<String>> = {
let mut f = archive
.by_name(&format!("{}/{}", dir_name, "files.sjson"))
.wrap_err("failed to read file index from archive")?;
let mut buf = Vec::with_capacity(f.size() as usize);
f.read_to_end(&mut buf)
.wrap_err("failed to read file index from archive")?;
let data = String::from_utf8(buf).wrap_err("file index is not valid UTF-8")?;
serde_sjson::from_str(&data).wrap_err("failed to deserialize file index")?
};
tracing::trace!(?files);
let mod_dir = state.get_mod_dir();
tracing::trace!("Creating mods directory {}", mod_dir.display());
fs::create_dir_all(&mod_dir)
.await
.wrap_err_with(|| format!("failed to create data directory {}", mod_dir.display()))?;
tracing::trace!("Extracting mod archive to {}", mod_dir.display());
archive
.extract(&mod_dir)
.wrap_err_with(|| format!("failed to extract archive to {}", mod_dir.display()))?;
let packages = files
.into_iter()
.map(|(name, files)| PackageInfo::new(name, files.into_iter().collect()))
.collect();
let info = ModInfo::new(mod_cfg, packages);
Ok(info)
}
#[tracing::instrument(skip(state))]
pub(crate) async fn delete_mod(state: ActionState, info: &ModInfo) -> Result<()> {
let mod_dir = state.mod_dir.join(&info.id);
pub(crate) async fn delete_mod(state: State, info: &ModInfo) -> Result<()> {
let mod_dir = state.get_mod_dir().join(&info.id);
fs::remove_dir_all(&mod_dir)
.await
.wrap_err_with(|| format!("Failed to remove directory {}", mod_dir.display()))?;
.wrap_err_with(|| format!("failed to remove directory {}", mod_dir.display()))?;
Ok(())
}
#[tracing::instrument(skip(state))]
pub(crate) async fn save_settings(state: ActionState) -> Result<()> {
pub(crate) async fn save_settings(state: State) -> Result<()> {
let cfg = ConfigSerialize::from(&state);
tracing::info!("Saving settings to '{}'", state.config_path.display());
tracing::debug!(?cfg);
let data = serde_sjson::to_string(&cfg).wrap_err("Failed to serialize config")?;
let data = serde_sjson::to_string(&cfg).wrap_err("failed to serialize config")?;
fs::write(state.config_path.as_ref(), &data)
.await
.wrap_err_with(|| {
format!(
"Failed to write config to '{}'",
"failed to write config to '{}'",
state.config_path.display()
)
})
}
async fn read_sjson_file<P, T>(path: P) -> Result<T>
where
T: for<'a> Deserialize<'a>,
P: AsRef<Path> + std::fmt::Debug,
{
let buf = fs::read(path).await.wrap_err("failed to read file")?;
let data = String::from_utf8(buf).wrap_err("invalid UTF8")?;
serde_sjson::from_str(&data).wrap_err("failed to deserialize")
}
#[tracing::instrument(skip_all,fields(
name = ?res.as_ref().map(|entry| entry.file_name())
))]
async fn read_mod_dir_entry(res: Result<DirEntry>) -> Result<ModInfo> {
let entry = res?;
let config_path = entry.path().join("dtmt.cfg");
let nexus_path = entry.path().join("nexus.sjson");
let index_path = entry.path().join("files.sjson");
let cfg: ModConfig = read_sjson_file(&config_path)
.await
.wrap_err_with(|| format!("Failed to read mod config '{}'", config_path.display()))?;
.wrap_err_with(|| format!("failed to read mod config '{}'", config_path.display()))?;
let nexus: Option<NexusInfo> = match read_sjson_file(&nexus_path)
let files: HashMap<String, Vec<String>> = read_sjson_file(&index_path)
.await
.wrap_err_with(|| format!("Failed to read Nexus info '{}'", nexus_path.display()))
{
Ok(nexus) => Some(nexus),
Err(err) if err.is::<std::io::Error>() => match err.downcast_ref::<std::io::Error>() {
Some(err) if err.kind() == std::io::ErrorKind::NotFound => None,
_ => return Err(err),
},
Err(err) => return Err(err),
};
let files: HashMap<String, Vec<String>> = if cfg.bundled {
read_sjson_file(&index_path)
.await
.wrap_err_with(|| format!("Failed to read file index '{}'", index_path.display()))?
} else {
Default::default()
};
let image = if let Some(path) = &cfg.image {
let path = entry.path().join(path);
if let Ok(data) = fs::read(&path).await {
// Druid somehow doesn't return an error compatible with eyre, here.
// So we have to wrap through `Display` manually.
let img = match ImageBuf::from_data(&data) {
Ok(img) => img,
Err(err) => {
let err = Report::msg(err.to_string());
return Err(err)
.wrap_err_with(|| {
format!("Failed to import image file '{}'", path.display())
})
.with_suggestion(|| {
"Supported formats are: PNG, JPEG, Bitmap and WebP".to_string()
});
}
};
Some(img)
} else {
None
}
} else {
None
};
.wrap_err_with(|| format!("failed to read file index '{}'", index_path.display()))?;
let packages = files
.into_iter()
.map(|(name, files)| Arc::new(PackageInfo::new(name, files.into_iter().collect())))
.map(|(name, files)| PackageInfo::new(name, files.into_iter().collect()))
.collect();
let info = ModInfo::new(cfg, packages, image, nexus);
let info = ModInfo::new(cfg, packages);
Ok(info)
}
#[tracing::instrument(skip(mod_order))]
pub(crate) async fn load_mods<'a, P, S>(mod_dir: P, mod_order: S) -> Result<Vector<Arc<ModInfo>>>
pub(crate) fn load_mods<'a, P, S>(mod_dir: P, mod_order: S) -> Result<Vector<ModInfo>>
where
S: Iterator<Item = &'a LoadOrderEntry>,
P: AsRef<Path> + std::fmt::Debug,
{
let mod_dir = mod_dir.as_ref();
let read_dir = match fs::read_dir(mod_dir).await {
Ok(read_dir) => read_dir,
Err(err) if err.kind() == ErrorKind::NotFound => {
return Ok(Vector::new());
}
Err(err) => {
return Err(err)
.wrap_err_with(|| format!("Failed to open directory '{}'", mod_dir.display()));
}
};
let rt = Runtime::new()?;
let stream = ReadDirStream::new(read_dir)
.map(|res| res.wrap_err("Failed to read dir entry"))
.then(read_mod_dir_entry);
tokio::pin!(stream);
let mut mods: HashMap<String, ModInfo> = HashMap::new();
while let Some(res) = stream.next().await {
let info = res?;
mods.insert(info.id.clone(), info);
}
let mods = mod_order
.filter_map(|entry| {
if let Some(mut info) = mods.remove(&entry.id) {
info.enabled = entry.enabled;
Some(Arc::new(info))
} else {
None
rt.block_on(async move {
let mod_dir = mod_dir.as_ref();
let read_dir = match fs::read_dir(mod_dir).await {
Ok(read_dir) => read_dir,
Err(err) if err.kind() == ErrorKind::NotFound => {
return Ok(Vector::new());
}
})
.collect();
Ok(mods)
}
pub(crate) fn check_mod_order(state: &ActionState) -> Result<()> {
if tracing::enabled!(tracing::Level::DEBUG) {
let order = state
.mods
.iter()
.enumerate()
.filter(|(_, i)| i.enabled)
.fold(String::new(), |mut s, (i, info)| {
s.push_str(&format!("{}: {} - {}\n", i, info.id, info.name));
s
});
tracing::debug!("Mod order:\n{}", order);
}
for (i, mod_info) in state.mods.iter().enumerate().filter(|(_, i)| i.enabled) {
for dep in &mod_info.depends {
let dep_info = state.mods.iter().enumerate().find(|(_, m)| m.id == dep.id);
match dep_info {
Some((_, dep_info)) if !dep_info.enabled => {
eyre::bail!(
"Dependency '{}' ({}) must be enabled.",
dep_info.name,
dep.id
);
}
Some((j, dep_info)) if dep.order == ModOrder::Before && j >= i => {
eyre::bail!(
"Dependency '{}' ({}) must be loaded before '{}'",
dep_info.name,
dep.id,
mod_info.name
);
}
Some((j, dep_info)) if dep.order == ModOrder::After && j <= i => {
eyre::bail!(
"Dependency '{}' ({}) must be loaded after '{}'",
dep_info.name,
dep.id,
mod_info.name
);
}
None => {
eyre::bail!(
"Missing dependency '{}' for mod '{}'",
dep.id,
mod_info.name
);
}
Some(_) => {
// All good
}
}
}
}
Ok(())
}
#[tracing::instrument(skip(info, api), fields(id = info.id, name = info.name, version = info.version))]
async fn check_mod_update(info: Arc<ModInfo>, api: Arc<NexusApi>) -> Result<Option<ModInfo>> {
let Some(nexus) = &info.nexus else {
return Ok(None);
};
let updated_info = api
.mods_id(nexus.id)
.await
.wrap_err_with(|| format!("Failed to query mod {} from Nexus", nexus.id))?;
let mut info = Arc::unwrap_or_clone(info);
info.nexus = Some(NexusInfo::from(updated_info));
Ok(Some(info))
}
#[tracing::instrument(skip(state))]
pub(crate) async fn check_updates(state: ActionState) -> Result<Vec<ModInfo>> {
if state.nexus_api_key.is_empty() {
eyre::bail!("Nexus API key not set. Cannot check for updates.");
}
let api = NexusApi::new(state.nexus_api_key.to_string())
.wrap_err("Failed to initialize Nexus API")?;
let api = Arc::new(api);
let tasks = state
.mods
.iter()
.map(|info| check_mod_update(info.clone(), api.clone()));
let results = futures::future::join_all(tasks).await;
let updates = results
.into_iter()
.filter_map(|res| match res {
Ok(info) => info,
Err(err) => {
tracing::error!("{:?}", err);
None
return Err(err)
.wrap_err_with(|| format!("failed to open directory '{}'", mod_dir.display()));
}
})
.collect();
Ok(updates)
}
};
pub(crate) async fn load_initial(path: PathBuf, is_default: bool) -> Result<InitialLoadResult> {
let config = util::config::read_config(path, is_default)
.await
.wrap_err("Failed to read config file")?;
let stream = ReadDirStream::new(read_dir)
.map(|res| res.wrap_err("failed to read dir entry"))
.then(read_mod_dir_entry);
tokio::pin!(stream);
// Create or truncate the log file
let log_path = config.data_dir.join("dtmm.log");
tokio::spawn(async move {
let _ = File::create(&log_path).await;
tracing::debug!("Truncated log file");
});
let mut mods: HashMap<String, ModInfo> = HashMap::new();
let game_info = tokio::task::spawn_blocking(dtmt_shared::collect_game_info)
.await
.wrap_err("Failed to spawn task to collect Steam game info")?;
let game_info = match game_info {
Ok(game_info) => game_info,
Err(err) => {
tracing::error!("Failed to collect game info: {:?}", err);
None
while let Some(res) = stream.next().await {
let info = res?;
mods.insert(info.id.clone(), info);
}
};
if config.game_dir.is_none() && game_info.is_none() {
tracing::error!("No Game Directory set. Head to the 'Settings' tab to set it manually",);
}
let mods = mod_order
.filter_map(|entry| {
if let Some(mut info) = mods.remove(&entry.id) {
info.enabled = entry.enabled;
Some(info)
} else {
None
}
})
.collect();
let mod_dir = config.data_dir.join("mods");
let mods = load_mods(mod_dir, config.mod_order.iter())
.await
.wrap_err("Failed to load mods")?;
Ok((config, mods))
Ok::<_, color_eyre::Report>(mods)
})
}

View file

@ -1,816 +0,0 @@
use std::io::{Cursor, ErrorKind};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use color_eyre::eyre::Context;
use color_eyre::{eyre, Help, Report, Result};
use futures::StreamExt;
use futures::{stream, TryStreamExt};
use minijinja::Environment;
use sdk::filetype::lua;
use sdk::filetype::package::Package;
use sdk::murmur::Murmur64;
use sdk::{
Bundle, BundleDatabase, BundleFile, BundleFileType, BundleFileVariant, FromBinary, ToBinary,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use tokio::fs::{self, DirEntry};
use tokio::io::AsyncWriteExt;
use tracing::Instrument;
use super::read_sjson_file;
use crate::controller::app::check_mod_order;
use crate::state::{ActionState, PackageInfo};
pub const MOD_BUNDLE_NAME: &str = "packages/mods";
pub const BOOT_BUNDLE_NAME: &str = "packages/boot";
pub const BUNDLE_DATABASE_NAME: &str = "bundle_database.data";
pub const MOD_BOOT_SCRIPT: &str = "scripts/mod_main";
pub const MOD_DATA_SCRIPT: &str = "scripts/mods/mod_data";
pub const SETTINGS_FILE_PATH: &str = "application_settings/settings_common.ini";
pub const DEPLOYMENT_DATA_PATH: &str = "dtmm-deployment.sjson";
#[derive(Debug, Serialize, Deserialize)]
pub struct DeploymentData {
pub bundles: Vec<String>,
pub mod_folders: Vec<String>,
#[serde(with = "time::serde::iso8601")]
pub timestamp: OffsetDateTime,
}
#[tracing::instrument]
async fn read_file_with_backup<P>(path: P) -> Result<Vec<u8>>
where
P: AsRef<Path> + std::fmt::Debug,
{
let path = path.as_ref();
let backup_path = {
let mut p = PathBuf::from(path);
let ext = if let Some(ext) = p.extension() {
ext.to_string_lossy().to_string() + ".bak"
} else {
String::from("bak")
};
p.set_extension(ext);
p
};
let file_name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| String::from("file"));
let bin = match fs::read(&backup_path).await {
Ok(bin) => bin,
Err(err) if err.kind() == ErrorKind::NotFound => {
// TODO: This doesn't need to be awaited here, yet.
// I only need to make sure it has finished before writing the changed bundle.
tracing::debug!(
"Backup does not exist. Backing up original {} to '{}'",
file_name,
backup_path.display()
);
fs::copy(path, &backup_path).await.wrap_err_with(|| {
format!(
"Failed to back up {} '{}' to '{}'",
file_name,
path.display(),
backup_path.display()
)
})?;
tracing::debug!("Reading {} from original '{}'", file_name, path.display());
fs::read(path).await.wrap_err_with(|| {
format!("Failed to read {} file: {}", file_name, path.display())
})?
}
Err(err) => {
return Err(err).wrap_err_with(|| {
format!(
"Failed to read {} from backup '{}'",
file_name,
backup_path.display()
)
});
}
};
Ok(bin)
}
#[tracing::instrument(skip_all)]
async fn patch_game_settings(state: Arc<ActionState>) -> Result<()> {
let settings_path = state.game_dir.join("bundle").join(SETTINGS_FILE_PATH);
let settings = read_file_with_backup(&settings_path)
.await
.wrap_err("Failed to read settings.ini")?;
let settings = String::from_utf8(settings).wrap_err("Settings.ini is not valid UTF-8")?;
let mut f = fs::File::create(&settings_path)
.await
.wrap_err_with(|| format!("Failed to open {}", settings_path.display()))?;
let Some(i) = settings.find("boot_script =") else {
eyre::bail!("couldn't find 'boot_script' field");
};
f.write_all(settings[0..i].as_bytes()).await?;
f.write_all(b"boot_script = \"scripts/mod_main\"").await?;
let Some(j) = settings[i..].find('\n') else {
eyre::bail!("couldn't find end of 'boot_script' field");
};
f.write_all(settings[(i + j)..].as_bytes()).await?;
Ok(())
}
#[tracing::instrument(skip_all, fields(package = info.name))]
fn make_package(info: &PackageInfo) -> Result<Package> {
let mut pkg = Package::new(info.name.clone(), PathBuf::new());
for f in &info.files {
let mut it = f.rsplit('.');
let file_type = it
.next()
.ok_or_else(|| eyre::eyre!("missing file extension"))
.and_then(BundleFileType::from_str)
.wrap_err("Invalid file name in package info")?;
let name: String = it.collect();
pkg.add_file(file_type, name);
}
Ok(pkg)
}
#[tracing::instrument]
async fn copy_recursive(
from: impl Into<PathBuf> + std::fmt::Debug,
to: impl AsRef<Path> + std::fmt::Debug,
) -> Result<()> {
let to = to.as_ref();
#[tracing::instrument]
async fn handle_dir(from: PathBuf) -> Result<Vec<(bool, DirEntry)>> {
let mut dir = fs::read_dir(&from)
.await
.wrap_err("Failed to read directory")?;
let mut entries = Vec::new();
while let Some(entry) = dir.next_entry().await? {
let meta = entry.metadata().await.wrap_err_with(|| {
format!("Failed to get metadata for '{}'", entry.path().display())
})?;
entries.push((meta.is_dir(), entry));
}
Ok(entries)
}
let base = from.into();
stream::unfold(vec![base.clone()], |mut state| async {
let from = state.pop()?;
let inner = match handle_dir(from).await {
Ok(entries) => {
for (is_dir, entry) in &entries {
if *is_dir {
state.push(entry.path());
}
}
stream::iter(entries).map(Ok).left_stream()
}
Err(e) => stream::once(async { Err(e) }).right_stream(),
};
Some((inner, state))
})
.flatten()
.try_for_each(|(is_dir, entry)| {
let path = entry.path();
let dest = path
.strip_prefix(&base)
.map(|suffix| to.join(suffix))
.expect("all entries are relative to the directory we are walking");
async move {
if is_dir {
tracing::trace!("Creating directory '{}'", dest.display());
// Instead of trying to filter "already exists" errors out explicitly,
// we just ignore all. It'll fail eventually with the next copy operation.
let _ = fs::create_dir(&dest).await;
Ok(())
} else {
tracing::trace!("Copying file '{}' -> '{}'", path.display(), dest.display());
fs::copy(&path, &dest).await.map(|_| ()).wrap_err_with(|| {
format!(
"Failed to copy file '{}' -> '{}'",
path.display(),
dest.display()
)
})
}
}
})
.await
.map(|_| ())
}
#[tracing::instrument(skip(state))]
async fn copy_mod_folders(state: Arc<ActionState>) -> Result<Vec<String>> {
let game_dir = Arc::clone(&state.game_dir);
let mut tasks = Vec::new();
for mod_info in state.mods.iter().filter(|m| m.enabled && !m.bundled) {
let span = tracing::trace_span!("copying legacy mod", name = mod_info.name);
let _enter = span.enter();
let mod_id = mod_info.id.clone();
let mod_dir = Arc::clone(&state.mod_dir);
let game_dir = Arc::clone(&game_dir);
let task = async move {
let from = mod_dir.join(&mod_id);
let to = game_dir.join("mods").join(&mod_id);
tracing::debug!(from = %from.display(), to = %to.display(), "Copying legacy mod '{}'", mod_id);
let _ = fs::create_dir_all(&to).await;
copy_recursive(&from, &to).await.wrap_err_with(|| {
format!(
"Failed to copy legacy mod from '{}' to '{}'",
from.display(),
to.display()
)
})?;
Ok::<_, Report>(mod_id)
};
tasks.push(task);
}
let ids = futures::future::try_join_all(tasks).await?;
Ok(ids)
}
fn build_mod_data_lua(state: Arc<ActionState>) -> Result<String> {
#[derive(Serialize)]
struct TemplateDataMod {
id: String,
name: String,
bundled: bool,
version: String,
init: String,
data: Option<String>,
localization: Option<String>,
packages: Vec<String>,
}
let mut env = Environment::new();
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
env.add_template("mod_data.lua", include_str!("../../assets/mod_data.lua.j2"))
.wrap_err("Failed to compile template for `mod_data.lua`")?;
let tmpl = env
.get_template("mod_data.lua")
.wrap_err("Failed to get template `mod_data.lua`")?;
let data: Vec<TemplateDataMod> = state
.mods
.iter()
.filter_map(|m| {
if !m.enabled {
return None;
}
Some(TemplateDataMod {
id: m.id.clone(),
name: m.name.clone(),
bundled: m.bundled,
version: m.version.clone(),
init: m.resources.init.to_string_lossy().to_string(),
data: m
.resources
.data
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
localization: m
.resources
.localization
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
packages: m.packages.iter().map(|p| p.name.clone()).collect(),
})
})
.collect();
let lua = tmpl
.render(minijinja::context!(mods => data))
.wrap_err("Failed to render template `mod_data.lua`")?;
tracing::debug!("mod_data.lua:\n{}", lua);
Ok(lua)
}
#[tracing::instrument(skip_all)]
async fn build_bundles(state: Arc<ActionState>) -> Result<Vec<Bundle>> {
let mut mod_bundle = Bundle::new(MOD_BUNDLE_NAME.to_string());
let mut tasks = Vec::new();
let bundle_dir = Arc::new(state.game_dir.join("bundle"));
let mut bundles = Vec::new();
let mut add_lua_asset = |name: &str, data: &str| {
let span = tracing::info_span!("Compiling Lua", name, data_len = data.len());
let _enter = span.enter();
let file = lua::compile(name.to_string(), data).wrap_err("Failed to compile Lua")?;
mod_bundle.add_file(file);
Ok::<_, Report>(())
};
build_mod_data_lua(state.clone())
.wrap_err("Failed to build 'mod_data.lua'")
.and_then(|data| add_lua_asset(MOD_DATA_SCRIPT, &data))?;
add_lua_asset("scripts/mods/init", include_str!("../../assets/init.lua"))?;
add_lua_asset(
"scripts/mods/mod_loader",
include_str!("../../assets/mod_loader.lua"),
)?;
tracing::trace!("Preparing tasks to deploy bundle files");
for mod_info in state.mods.iter().filter(|m| m.enabled && m.bundled) {
let span = tracing::trace_span!("building mod packages", name = mod_info.name);
let _enter = span.enter();
let mod_dir = state.mod_dir.join(&mod_info.id);
for pkg_info in &mod_info.packages {
let span = tracing::trace_span!("building package", name = pkg_info.name);
let _enter = span.enter();
tracing::trace!(
"Building package {} for mod {}",
pkg_info.name,
mod_info.name
);
let pkg = make_package(pkg_info).wrap_err("Failed to make package")?;
let mut variant = BundleFileVariant::new();
let bin = pkg
.to_binary()
.wrap_err("Failed to serialize package to binary")?;
variant.set_data(bin);
let mut file = BundleFile::new(pkg_info.name.clone(), BundleFileType::Package);
file.add_variant(variant);
tracing::trace!(
"Compiled package {} for mod {}",
pkg_info.name,
mod_info.name
);
mod_bundle.add_file(file);
let bundle_name = format!("{:016x}", Murmur64::hash(&pkg_info.name));
let src = mod_dir.join(&bundle_name);
let dest = bundle_dir.join(&bundle_name);
let pkg_name = pkg_info.name.clone();
let mod_name = mod_info.name.clone();
// Explicitely drop the guard, so that we can move the span
// into the async operation
drop(_enter);
let ctx = state.ctx.clone();
let task = async move {
let bundle = {
let bin = fs::read(&src).await.wrap_err_with(|| {
format!("Failed to read bundle file '{}'", src.display())
})?;
let name = Bundle::get_name_from_path(&ctx, &src);
Bundle::from_binary(&ctx, name, bin)
.wrap_err_with(|| format!("Failed to parse bundle '{}'", src.display()))?
};
tracing::debug!(
src = %src.display(),
dest = %dest.display(),
"Copying bundle '{}' for mod '{}'",
pkg_name,
mod_name,
);
// We attempt to remove any previous file, so that the hard link can be created.
// We can reasonably ignore errors here, as a 'NotFound' is actually fine, the copy
// may be possible despite an error here, or the error will be reported by it anyways.
// TODO: There is a chance that we delete an actual game bundle, but with 64bit
// hashes, it's low enough for now, and the setup required to detect
// "game bundle vs mod bundle" is non-trivial.
let _ = fs::remove_file(&dest).await;
fs::copy(&src, &dest).await.wrap_err_with(|| {
format!(
"Failed to copy bundle {pkg_name} for mod {mod_name}. Src: {}, dest: {}",
src.display(),
dest.display()
)
})?;
Ok::<Bundle, color_eyre::Report>(bundle)
}
.instrument(span);
tasks.push(task);
}
}
tracing::debug!("Copying {} mod bundles", tasks.len());
let mut tasks = stream::iter(tasks).buffer_unordered(10);
while let Some(res) = tasks.next().await {
let bundle = res?;
bundles.push(bundle);
}
{
let path = bundle_dir.join(format!("{:x}", mod_bundle.name().to_murmur64()));
tracing::trace!("Writing mod bundle to '{}'", path.display());
fs::write(&path, mod_bundle.to_binary()?)
.await
.wrap_err_with(|| format!("Failed to write bundle to '{}'", path.display()))?;
}
bundles.push(mod_bundle);
Ok(bundles)
}
#[tracing::instrument(skip_all)]
async fn patch_boot_bundle(
state: Arc<ActionState>,
deployment_info: &String,
) -> Result<Vec<Bundle>> {
let bundle_dir = Arc::new(state.game_dir.join("bundle"));
let bundle_path = bundle_dir.join(format!("{:x}", Murmur64::hash(BOOT_BUNDLE_NAME.as_bytes())));
let mut bundles = Vec::with_capacity(2);
let mut boot_bundle = async {
let bin = read_file_with_backup(&bundle_path)
.await
.wrap_err("Failed to read boot bundle")?;
Bundle::from_binary(&state.ctx, BOOT_BUNDLE_NAME.to_string(), bin)
.wrap_err("Failed to parse boot bundle")
}
.instrument(tracing::trace_span!("read boot bundle"))
.await
.wrap_err_with(|| format!("Failed to read bundle '{}'", BOOT_BUNDLE_NAME))?;
{
tracing::trace!("Adding mod package file to boot bundle");
let span = tracing::trace_span!("create mod package file");
let _enter = span.enter();
let mut pkg = Package::new(MOD_BUNDLE_NAME.to_string(), PathBuf::new());
for mod_info in &state.mods {
for pkg_info in &mod_info.packages {
pkg.add_file(BundleFileType::Package, &pkg_info.name);
}
}
pkg.add_file(BundleFileType::Lua, MOD_DATA_SCRIPT);
let mut variant = BundleFileVariant::new();
variant.set_data(pkg.to_binary()?);
let mut f = BundleFile::new(MOD_BUNDLE_NAME.to_string(), BundleFileType::Package);
f.add_variant(variant);
boot_bundle.add_file(f);
}
{
let span = tracing::debug_span!("Importing mod main script");
let _enter = span.enter();
let mut env = Environment::new();
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
env.add_template("mod_main.lua", include_str!("../../assets/mod_main.lua.j2"))
.wrap_err("Failed to compile template for `mod_main.lua`")?;
let tmpl = env
.get_template("mod_main.lua")
.wrap_err("Failed to get template `mod_main.lua`")?;
let is_io_enabled = if state.is_io_enabled { "true" } else { "false" };
let deployment_info = deployment_info.replace("\"", "\\\"").replace("\n", "\\n");
let lua = tmpl
.render(minijinja::context!(is_io_enabled => is_io_enabled, deployment_info => deployment_info))
.wrap_err("Failed to render template `mod_main.lua`")?;
tracing::trace!("Main script rendered:\n===========\n{}\n=============", lua);
let file = lua::compile(MOD_BOOT_SCRIPT.to_string(), lua)
.wrap_err("Failed to compile mod main Lua file")?;
boot_bundle.add_file(file);
}
async {
let bin = boot_bundle
.to_binary()
.wrap_err("Failed to serialize boot bundle")?;
fs::write(&bundle_path, bin)
.await
.wrap_err_with(|| format!("Failed to write main bundle: {}", bundle_path.display()))
}
.instrument(tracing::trace_span!("write boot bundle"))
.await?;
bundles.push(boot_bundle);
Ok(bundles)
}
#[tracing::instrument(skip_all, fields(bundles = bundles.as_ref().len()))]
async fn patch_bundle_database<B>(state: Arc<ActionState>, bundles: B) -> Result<()>
where
B: AsRef<[Bundle]>,
{
let bundle_dir = Arc::new(state.game_dir.join("bundle"));
let database_path = bundle_dir.join(BUNDLE_DATABASE_NAME);
let mut db = {
let bin = read_file_with_backup(&database_path)
.await
.wrap_err("Failed to read bundle database")?;
let mut r = Cursor::new(bin);
let db = BundleDatabase::from_binary(&mut r).wrap_err("Failed to parse bundle database")?;
tracing::trace!("Finished parsing bundle database");
db
};
for bundle in bundles.as_ref() {
tracing::trace!("Adding '{}' to bundle database", bundle.name().display());
db.add_bundle(bundle);
}
{
let bin = db
.to_binary()
.wrap_err("Failed to serialize bundle database")?;
fs::write(&database_path, bin).await.wrap_err_with(|| {
format!(
"failed to write bundle database to '{}'",
database_path.display()
)
})?;
}
Ok(())
}
#[tracing::instrument(skip_all, fields(bundles = bundles.as_ref().len()))]
fn build_deployment_data(
bundles: impl AsRef<[Bundle]>,
mod_folders: impl AsRef<[String]>,
) -> Result<String> {
let info = DeploymentData {
timestamp: OffsetDateTime::now_utc(),
bundles: bundles
.as_ref()
.iter()
.map(|bundle| format!("{:x}", bundle.name().to_murmur64()))
.collect(),
// TODO:
mod_folders: mod_folders
.as_ref()
.iter()
.map(|folder| folder.clone())
.collect(),
};
serde_sjson::to_string(&info).wrap_err("Failed to serizalize deployment data")
}
#[tracing::instrument(skip_all, fields(
game_dir = %state.game_dir.display(),
mods = state.mods.len()
))]
pub(crate) async fn deploy_mods(state: ActionState) -> Result<()> {
let state = Arc::new(state);
let bundle_dir = state.game_dir.join("bundle");
let boot_bundle_path = format!("{:016x}", Murmur64::hash(BOOT_BUNDLE_NAME.as_bytes()));
if fs::metadata(bundle_dir.join(format!("{boot_bundle_path}.patch_999")))
.await
.is_ok()
{
let err = eyre::eyre!("Found dtkit-patch-based mod installation.");
return Err(err)
.with_suggestion(|| {
"If you're a mod author and saved projects directly in 'mods/', \
use DTMT to migrate them to the new project structure."
.to_string()
})
.with_suggestion(|| {
"Click 'Reset Game' to remove the previous mod installation.".to_string()
});
}
let (_, game_info, deployment_info) = tokio::try_join!(
async {
fs::metadata(&bundle_dir)
.await
.wrap_err("Failed to open game bundle directory")
.with_suggestion(|| "Double-check 'Game Directory' in the Settings tab.")
},
async {
tokio::task::spawn_blocking(dtmt_shared::collect_game_info)
.await
.map_err(Report::new)
},
async {
let path = state.game_dir.join(DEPLOYMENT_DATA_PATH);
match read_sjson_file::<_, DeploymentData>(&path).await {
Ok(data) => Ok(Some(data)),
Err(err) => {
if let Some(err) = err.downcast_ref::<std::io::Error>()
&& err.kind() == ErrorKind::NotFound
{
Ok(None)
} else {
Err(err).wrap_err(format!(
"Failed to read deployment data from: {}",
path.display()
))
}
}
}
}
)
.wrap_err("Failed to gather deployment information")?;
let game_info = match game_info {
Ok(game_info) => game_info,
Err(err) => {
tracing::error!("Failed to collect game info: {:#?}", err);
None
}
};
tracing::debug!(?game_info, ?deployment_info);
if let Some(game_info) = game_info {
if deployment_info
.as_ref()
.map(|i| game_info.last_updated > i.timestamp)
.unwrap_or(false)
{
tracing::warn!(
"Game was updated since last mod deployment. \
Attempting to reconcile game files."
);
tokio::try_join!(
async {
let path = bundle_dir.join(BUNDLE_DATABASE_NAME);
let backup_path = path.with_extension("data.bak");
fs::copy(&path, &backup_path)
.await
.wrap_err("Failed to re-create backup for bundle database.")
},
async {
let path = bundle_dir.join(boot_bundle_path);
let backup_path = path.with_extension("bak");
fs::copy(&path, &backup_path)
.await
.wrap_err("Failed to re-create backup for boot bundle")
}
)
.with_suggestion(|| {
"Reset the game using 'Reset Game', then verify game files.".to_string()
})?;
tracing::info!(
"Successfully re-created game file backups. \
Continuing mod deployment."
);
}
}
check_mod_order(&state)?;
tracing::info!(
"Deploying {} mods to '{}'.",
state.mods.iter().filter(|i| i.enabled).count(),
bundle_dir.display()
);
tracing::info!("Copy legacy mod folders");
let mod_folders = copy_mod_folders(state.clone())
.await
.wrap_err("Failed to copy mod folders")?;
tracing::info!("Build mod bundles");
let mut bundles = build_bundles(state.clone())
.await
.wrap_err("Failed to build mod bundles")?;
let new_deployment_info = build_deployment_data(&bundles, &mod_folders)
.wrap_err("Failed to build new deployment data")?;
tracing::info!("Patch boot bundle");
let mut boot_bundles = patch_boot_bundle(state.clone(), &new_deployment_info)
.await
.wrap_err("Failed to patch boot bundle")?;
bundles.append(&mut boot_bundles);
if let Some(info) = &deployment_info {
let bundle_dir = Arc::new(bundle_dir);
// Remove bundles from the previous deployment that don't match the current one.
// I.e. mods that used to be installed/enabled but aren't anymore.
{
let tasks = info.bundles.iter().cloned().filter_map(|file_name| {
let is_being_deployed = bundles.iter().any(|b2| {
let name = format!("{:016x}", b2.name());
file_name == name
});
if !is_being_deployed {
let bundle_dir = bundle_dir.clone();
let task = async move {
let path = bundle_dir.join(&file_name);
tracing::debug!("Removing unused bundle '{}'", file_name);
if let Err(err) = fs::remove_file(&path).await.wrap_err_with(|| {
format!("Failed to remove unused bundle '{}'", path.display())
}) {
tracing::error!("{:?}", err);
}
};
Some(task)
} else {
None
}
});
futures::future::join_all(tasks).await;
}
// Do the same thing for mod folders
{
let tasks = info.mod_folders.iter().filter_map(|mod_id| {
let is_being_deployed = mod_folders.iter().any(|id| id == mod_id);
if !is_being_deployed {
let path = bundle_dir.join("mods").join(mod_id);
tracing::debug!("Removing unused mod folder '{}'", path.display());
let task = async move {
if let Err(err) = fs::remove_dir_all(&path).await.wrap_err_with(|| {
format!("Failed to remove unused legacy mod '{}'", path.display())
}) {
tracing::error!("{:?}", err);
}
};
Some(task)
} else {
None
}
});
futures::future::join_all(tasks).await;
}
}
tracing::info!("Patch game settings");
patch_game_settings(state.clone())
.await
.wrap_err("Failed to patch game settings")?;
tracing::info!("Patching bundle database");
patch_bundle_database(state.clone(), &bundles)
.await
.wrap_err("Failed to patch bundle database")?;
tracing::info!("Writing deployment data");
{
let path = state.game_dir.join(DEPLOYMENT_DATA_PATH);
fs::write(&path, &new_deployment_info)
.await
.wrap_err_with(|| format!("Failed to write deployment data to '{}'", path.display()))?;
}
tracing::info!("Finished deploying mods");
Ok(())
}

View file

@ -1,19 +1,32 @@
use std::io::{self, ErrorKind};
use std::ffi::CString;
use std::io::{Cursor, ErrorKind};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use color_eyre::eyre::Context;
use color_eyre::{eyre, Result};
use color_eyre::{eyre, Help, Result};
use futures::stream;
use futures::StreamExt;
use sdk::filetype::lua;
use sdk::filetype::package::Package;
use sdk::murmur::Murmur64;
use tokio::fs::{self};
use tokio::io::AsyncWriteExt;
use crate::controller::deploy::{
DeploymentData, BOOT_BUNDLE_NAME, BUNDLE_DATABASE_NAME, DEPLOYMENT_DATA_PATH,
use sdk::{
Bundle, BundleDatabase, BundleFile, BundleFileType, BundleFileVariant, FromBinary, ToBinary,
};
use crate::state::ActionState;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tracing::Instrument;
use super::deploy::SETTINGS_FILE_PATH;
use crate::state::{PackageInfo, State};
const MOD_BUNDLE_NAME: &str = "packages/mods";
const BOOT_BUNDLE_NAME: &str = "packages/boot";
const DML_BUNDLE_NAME: &str = "packages/dml";
const BUNDLE_DATABASE_NAME: &str = "bundle_database.data";
const MOD_BOOT_SCRIPT: &str = "scripts/mod_main";
const MOD_DATA_SCRIPT: &str = "scripts/mods/mod_data";
const SETTINGS_FILE_PATH: &str = "application_settings/settings_common.ini";
#[tracing::instrument]
async fn read_file_with_backup<P>(path: P) -> Result<Vec<u8>>
@ -49,7 +62,7 @@ where
);
fs::copy(path, &backup_path).await.wrap_err_with(|| {
format!(
"Failed to back up {} '{}' to '{}'",
"failed to back up {} '{}' to '{}'",
file_name,
path.display(),
backup_path.display()
@ -58,13 +71,13 @@ where
tracing::debug!("Reading {} from original '{}'", file_name, path.display());
fs::read(path).await.wrap_err_with(|| {
format!("Failed to read {} file: {}", file_name, path.display())
format!("failed to read {} file: {}", file_name, path.display())
})?
}
Err(err) => {
return Err(err).wrap_err_with(|| {
format!(
"Failed to read {} from backup '{}'",
"failed to read {} from backup '{}'",
file_name,
backup_path.display()
)
@ -75,17 +88,17 @@ where
}
#[tracing::instrument(skip_all)]
async fn patch_game_settings(state: Arc<ActionState>) -> Result<()> {
async fn patch_game_settings(state: Arc<State>) -> Result<()> {
let settings_path = state.game_dir.join("bundle").join(SETTINGS_FILE_PATH);
let settings = read_file_with_backup(&settings_path)
.await
.wrap_err("Failed to read settings.ini")?;
let settings = String::from_utf8(settings).wrap_err("Settings.ini is not valid UTF-8")?;
.wrap_err("failed to read settings.ini")?;
let settings = String::from_utf8(settings).wrap_err("settings.ini is not valid UTF-8")?;
let mut f = fs::File::create(&settings_path)
.await
.wrap_err_with(|| format!("Failed to open {}", settings_path.display()))?;
.wrap_err_with(|| format!("failed to open {}", settings_path.display()))?;
let Some(i) = settings.find("boot_script =") else {
eyre::bail!("couldn't find 'boot_script' field");
@ -103,109 +116,427 @@ async fn patch_game_settings(state: Arc<ActionState>) -> Result<()> {
Ok(())
}
#[tracing::instrument(skip_all, fields(package = info.name))]
fn make_package(info: &PackageInfo) -> Result<Package> {
let mut pkg = Package::new(info.name.clone(), PathBuf::new());
for f in &info.files {
let mut it = f.rsplit('.');
let file_type = it
.next()
.ok_or_else(|| eyre::eyre!("missing file extension"))
.and_then(BundleFileType::from_str)
.wrap_err("invalid file name in package info")?;
let name: String = it.collect();
pkg.add_file(file_type, name);
}
Ok(pkg)
}
fn build_mod_data_lua(state: Arc<State>) -> String {
let mut lua = String::from("return {\n");
// DMF is handled explicitely by the loading procedures, as it actually drives most of that
// and should therefore not show up in the load order.
for mod_info in state.mods.iter().filter(|m| m.id != "dml" && m.enabled) {
lua.push_str(" {\n name = \"");
lua.push_str(&mod_info.name);
lua.push_str("\",\n id = \"");
lua.push_str(&mod_info.id);
lua.push_str("\",\n run = function()\n");
let resources = &mod_info.resources;
if resources.data.is_some() || resources.localization.is_some() {
lua.push_str(" new_mod(\"");
lua.push_str(&mod_info.id);
lua.push_str("\", {\n mod_script = \"");
lua.push_str(&resources.init.to_string_lossy());
if let Some(data) = resources.data.as_ref() {
lua.push_str("\",\n mod_data = \"");
lua.push_str(&data.to_string_lossy());
}
if let Some(localization) = &resources.localization {
lua.push_str("\",\n mod_localization = \"");
lua.push_str(&localization.to_string_lossy());
}
lua.push_str("\",\n })\n");
} else {
lua.push_str(" return dofile(\"");
lua.push_str(&resources.init.to_string_lossy());
lua.push_str("\")\n");
}
lua.push_str(" end,\n packages = {\n");
for pkg_info in &mod_info.packages {
lua.push_str(" \"");
lua.push_str(&pkg_info.name);
lua.push_str("\",\n");
}
lua.push_str(" },\n },\n");
}
lua.push('}');
tracing::debug!("mod_data_lua:\n{}", lua);
lua
}
#[tracing::instrument(skip_all)]
async fn reset_dtkit_patch(state: ActionState) -> Result<()> {
let bundle_dir = state.game_dir.join("bundle");
async fn build_bundles(state: Arc<State>) -> Result<Vec<Bundle>> {
let mut mod_bundle = Bundle::new(MOD_BUNDLE_NAME.to_string());
let mut tasks = Vec::new();
let bundle_dir = Arc::new(state.game_dir.join("bundle"));
let mut bundles = Vec::new();
{
let path = bundle_dir.join(BUNDLE_DATABASE_NAME);
let backup_path = path.with_extension("data.bak");
fs::rename(&backup_path, &path).await.wrap_err_with(|| {
let span = tracing::debug_span!("Building mod data script");
let _enter = span.enter();
let lua = build_mod_data_lua(state.clone());
let lua = CString::new(lua).wrap_err("failed to build CString from mod data Lua string")?;
let file =
lua::compile(MOD_DATA_SCRIPT, &lua).wrap_err("failed to compile mod data Lua file")?;
mod_bundle.add_file(file);
}
for mod_info in state.mods.iter().filter(|m| m.id != "dml" && m.enabled) {
let span = tracing::trace_span!("building mod packages", name = mod_info.name);
let _enter = span.enter();
let mod_dir = state.get_mod_dir().join(&mod_info.id);
for pkg_info in &mod_info.packages {
let span = tracing::trace_span!("building package", name = pkg_info.name);
let _enter = span.enter();
let pkg = make_package(pkg_info).wrap_err("failed to make package")?;
let mut variant = BundleFileVariant::new();
let bin = pkg
.to_binary()
.wrap_err("failed to serialize package to binary")?;
variant.set_data(bin);
let mut file = BundleFile::new(pkg_info.name.clone(), BundleFileType::Package);
file.add_variant(variant);
mod_bundle.add_file(file);
let bundle_name = Murmur64::hash(&pkg_info.name)
.to_string()
.to_ascii_lowercase();
let src = mod_dir.join(&bundle_name);
let dest = bundle_dir.join(&bundle_name);
let pkg_name = pkg_info.name.clone();
let mod_name = mod_info.name.clone();
// Explicitely drop the guard, so that we can move the span
// into the async operation
drop(_enter);
let ctx = state.ctx.clone();
let task = async move {
let bundle = {
let bin = fs::read(&src).await.wrap_err_with(|| {
format!("failed to read bundle file '{}'", src.display())
})?;
let name = Bundle::get_name_from_path(&ctx, &src);
Bundle::from_binary(&ctx, name, bin)
.wrap_err_with(|| format!("failed to parse bundle '{}'", src.display()))?
};
tracing::debug!(
src = %src.display(),
dest = %dest.display(),
"Copying bundle '{}' for mod '{}'",
pkg_name,
mod_name,
);
// We attempt to remove any previous file, so that the hard link can be created.
// We can reasonably ignore errors here, as a 'NotFound' is actually fine, the copy
// may be possible despite an error here, or the error will be reported by it anyways.
// TODO: There is a chance that we delete an actual game bundle, but with 64bit
// hashes, it's low enough for now, and the setup required to detect
// "game bundle vs mod bundle" is non-trivial.
let _ = fs::remove_file(&dest).await;
fs::copy(&src, &dest).await.wrap_err_with(|| {
format!(
"failed to copy bundle {pkg_name} for mod {mod_name}. src: {}, dest: {}",
src.display(),
dest.display()
)
})?;
Ok::<Bundle, color_eyre::Report>(bundle)
}
.instrument(span);
tasks.push(task);
}
}
tracing::debug!("Copying {} mod bundles", tasks.len());
let mut tasks = stream::iter(tasks).buffer_unordered(10);
while let Some(res) = tasks.next().await {
let bundle = res?;
bundles.push(bundle);
}
{
let path = bundle_dir.join(format!("{:x}", mod_bundle.name().to_murmur64()));
tracing::trace!("Writing mod bundle to '{}'", path.display());
fs::write(&path, mod_bundle.to_binary()?)
.await
.wrap_err_with(|| format!("failed to write bundle to '{}'", path.display()))?;
}
bundles.push(mod_bundle);
Ok(bundles)
}
#[tracing::instrument(skip_all)]
async fn patch_boot_bundle(state: Arc<State>) -> Result<Vec<Bundle>> {
let bundle_dir = Arc::new(state.game_dir.join("bundle"));
let bundle_path = bundle_dir.join(format!("{:x}", Murmur64::hash(BOOT_BUNDLE_NAME.as_bytes())));
let mut bundles = Vec::with_capacity(2);
let mut boot_bundle = async {
let bin = read_file_with_backup(&bundle_path)
.await
.wrap_err("failed to read boot bundle")?;
Bundle::from_binary(&state.ctx, BOOT_BUNDLE_NAME.to_string(), bin)
.wrap_err("failed to parse boot bundle")
}
.instrument(tracing::trace_span!("read boot bundle"))
.await
.wrap_err_with(|| format!("failed to read bundle '{}'", BOOT_BUNDLE_NAME))?;
{
tracing::trace!("Adding mod package file to boot bundle");
let span = tracing::trace_span!("create mod package file");
let _enter = span.enter();
let mut pkg = Package::new(MOD_BUNDLE_NAME.to_string(), PathBuf::new());
for mod_info in &state.mods {
for pkg_info in &mod_info.packages {
pkg.add_file(BundleFileType::Package, &pkg_info.name);
}
}
pkg.add_file(BundleFileType::Lua, MOD_DATA_SCRIPT);
let mut variant = BundleFileVariant::new();
variant.set_data(pkg.to_binary()?);
let mut f = BundleFile::new(MOD_BUNDLE_NAME.to_string(), BundleFileType::Package);
f.add_variant(variant);
boot_bundle.add_file(f);
}
{
tracing::trace!("Handling DML packages and bundle");
let span = tracing::trace_span!("handle DML");
let _enter = span.enter();
let mut variant = BundleFileVariant::new();
let mod_info = state
.mods
.iter()
.find(|m| m.id == "dml")
.ok_or_else(|| eyre::eyre!("DML not found in mod list"))?;
let pkg_info = mod_info
.packages
.get(0)
.ok_or_else(|| eyre::eyre!("invalid mod package for DML"))
.with_suggestion(|| "Re-download and import the newest version.".to_string())?;
let bundle_name = Murmur64::hash(&pkg_info.name)
.to_string()
.to_ascii_lowercase();
let src = state.get_mod_dir().join(&mod_info.id).join(&bundle_name);
{
let bin = fs::read(&src)
.await
.wrap_err_with(|| format!("failed to read bundle file '{}'", src.display()))?;
let name = Bundle::get_name_from_path(&state.ctx, &src);
let dml_bundle = Bundle::from_binary(&state.ctx, name, bin)
.wrap_err_with(|| format!("failed to parse bundle '{}'", src.display()))?;
bundles.push(dml_bundle);
};
{
let dest = bundle_dir.join(&bundle_name);
let pkg_name = pkg_info.name.clone();
let mod_name = mod_info.name.clone();
tracing::debug!(
"Copying bundle {} for mod {}: {} -> {}",
pkg_name,
mod_name,
src.display(),
dest.display()
);
// We attempt to remove any previous file, so that the hard link can be created.
// We can reasonably ignore errors here, as a 'NotFound' is actually fine, the copy
// may be possible despite an error here, or the error will be reported by it anyways.
// TODO: There is a chance that we delete an actual game bundle, but with 64bit
// hashes, it's low enough for now, and the setup required to detect
// "game bundle vs mod bundle" is non-trivial.
let _ = fs::remove_file(&dest).await;
fs::copy(&src, &dest).await.wrap_err_with(|| {
format!(
"failed to copy bundle {pkg_name} for mod {mod_name}. src: {}, dest: {}",
src.display(),
dest.display()
)
})?;
}
let pkg = make_package(pkg_info).wrap_err("failed to create package file for dml")?;
variant.set_data(pkg.to_binary()?);
let mut f = BundleFile::new(DML_BUNDLE_NAME.to_string(), BundleFileType::Package);
f.add_variant(variant);
boot_bundle.add_file(f);
}
{
let span = tracing::debug_span!("Importing mod main script");
let _enter = span.enter();
let lua = include_str!("../../assets/mod_main.lua");
let lua = CString::new(lua).wrap_err("failed to build CString from mod main Lua string")?;
let file =
lua::compile(MOD_BOOT_SCRIPT, &lua).wrap_err("failed to compile mod main Lua file")?;
boot_bundle.add_file(file);
}
async {
let bin = boot_bundle
.to_binary()
.wrap_err("failed to serialize boot bundle")?;
fs::write(&bundle_path, bin)
.await
.wrap_err_with(|| format!("failed to write main bundle: {}", bundle_path.display()))
}
.instrument(tracing::trace_span!("write boot bundle"))
.await?;
bundles.push(boot_bundle);
Ok(bundles)
}
#[tracing::instrument(skip_all, fields(bundles = bundles.len()))]
async fn patch_bundle_database(state: Arc<State>, bundles: Vec<Bundle>) -> Result<()> {
let bundle_dir = Arc::new(state.game_dir.join("bundle"));
let database_path = bundle_dir.join(BUNDLE_DATABASE_NAME);
let mut db = {
let bin = read_file_with_backup(&database_path)
.await
.wrap_err("failed to read bundle database")?;
let mut r = Cursor::new(bin);
let db = BundleDatabase::from_binary(&mut r).wrap_err("failed to parse bundle database")?;
tracing::trace!("Finished parsing bundle database");
db
};
for bundle in bundles {
tracing::trace!("Adding '{}' to bundle database", bundle.name().display());
db.add_bundle(&bundle);
}
{
let bin = db
.to_binary()
.wrap_err("failed to serialize bundle database")?;
fs::write(&database_path, bin).await.wrap_err_with(|| {
format!(
"Failed to move bundle database backup '{}' -> '{}'",
backup_path.display(),
path.display()
"failed to write bundle database to '{}'",
database_path.display()
)
})?;
tracing::trace!("Reverted bundle database from backup");
}
for path in [
bundle_dir.join(format!(
"{:016x}.patch_999",
Murmur64::hash(BOOT_BUNDLE_NAME.as_bytes())
)),
state.game_dir.join("binaries/mod_loader"),
state.game_dir.join("toggle_darktide_mods.bat"),
state.game_dir.join("README.md"),
] {
match fs::remove_file(&path).await {
Ok(_) => tracing::trace!("Removed file '{}'", path.display()),
Err(err) if err.kind() != io::ErrorKind::NotFound => {
tracing::error!("Failed to remove file '{}': {}", path.display(), err)
}
Err(_) => {}
Ok(())
}
#[tracing::instrument(skip_all, fields(
game_dir = %state.game_dir.display(),
mods = state.mods.len()
))]
pub(crate) async fn deploy_mods(state: State) -> Result<()> {
let state = Arc::new(state);
{
let first = state.mods.get(0);
if first.is_none() || !(first.unwrap().id == "dml" && first.unwrap().enabled) {
// TODO: Add a suggestion where to get it, once that's published
eyre::bail!("'Darktide Mod Loader' needs to be installed, enabled and at the top of the load order");
}
}
// We deliberately skip the `mods/` directory here.
// Many modders did their development right in there, and as people are prone to not read
// error messages and guides in full, there is bound to be someone who would have
// deleted all their source code if this removed the `mods/` folder.
for path in [state.game_dir.join("tools")] {
match fs::remove_dir_all(&path).await {
Ok(_) => tracing::trace!("Removed directory '{}'", path.display()),
Err(err) if err.kind() != io::ErrorKind::NotFound => {
tracing::error!("Failed to remove directory '{}': {}", path.display(), err)
}
Err(_) => {}
}
}
tracing::info!(
"Deploying {} mods to {}",
state.mods.len(),
state.game_dir.join("bundle").display()
);
tracing::info!("Removed dtkit-patch-based mod installation.");
tracing::info!("Build mod bundles");
let mut bundles = build_bundles(state.clone())
.await
.wrap_err("failed to build mod bundles")?;
tracing::info!("Patch boot bundle");
let mut more_bundles = patch_boot_bundle(state.clone())
.await
.wrap_err("failed to patch boot bundle")?;
bundles.append(&mut more_bundles);
tracing::info!("Patch game settings");
patch_game_settings(state.clone())
.await
.wrap_err("failed to patch game settings")?;
tracing::info!("Patching bundle database");
patch_bundle_database(state.clone(), bundles)
.await
.wrap_err("failed to patch bundle database")?;
tracing::info!("Finished deploying mods");
Ok(())
}
#[tracing::instrument(skip(state))]
pub(crate) async fn reset_mod_deployment(state: ActionState) -> Result<()> {
pub(crate) async fn reset_mod_deployment(state: State) -> Result<()> {
let boot_bundle_path = format!("{:016x}", Murmur64::hash(BOOT_BUNDLE_NAME.as_bytes()));
let paths = [BUNDLE_DATABASE_NAME, &boot_bundle_path, SETTINGS_FILE_PATH];
let bundle_dir = state.game_dir.join("bundle");
tracing::info!("Resetting mod deployment in {}", bundle_dir.display());
if fs::metadata(bundle_dir.join(format!("{boot_bundle_path}.patch_999")))
.await
.is_ok()
{
tracing::info!("Found dtkit-patch-based mod installation. Removing.");
return reset_dtkit_patch(state).await;
}
tracing::debug!("Reading mod deployment");
let info: DeploymentData = {
let path = state.game_dir.join(DEPLOYMENT_DATA_PATH);
let data = match fs::read(&path).await {
Ok(data) => data,
Err(err) if err.kind() == ErrorKind::NotFound => {
tracing::info!("No deployment to reset");
return Ok(());
}
Err(err) => {
return Err(err).wrap_err_with(|| {
format!("Failed to read deployment info at '{}'", path.display())
});
}
};
let data = String::from_utf8(data).wrap_err("Invalid UTF8 in deployment data")?;
serde_sjson::from_str(&data).wrap_err("Invalid SJSON in deployment data")?
};
for name in info.bundles {
let path = bundle_dir.join(name);
match fs::remove_file(&path).await {
Ok(_) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => {
tracing::error!("Failed to remove '{}': {:?}", path.display(), err);
}
};
}
for p in paths {
let path = bundle_dir.join(p);
let backup = bundle_dir.join(&format!("{}.bak", p));
@ -219,17 +550,13 @@ pub(crate) async fn reset_mod_deployment(state: ActionState) -> Result<()> {
fs::copy(&backup, &path)
.await
.wrap_err_with(|| format!("Failed to copy from '{}'", backup.display()))?;
.wrap_err_with(|| format!("failed to copy from '{}'", backup.display()))?;
tracing::debug!("Deleting backup: {}", backup.display());
match fs::remove_file(&backup).await {
Ok(_) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => {
Err(err).wrap_err_with(|| format!("Failed to remove '{}'", backup.display()))
}
}
fs::remove_file(&backup)
.await
.wrap_err_with(|| format!("failed to remove '{}'", backup.display()))
}
.await;
@ -242,17 +569,6 @@ pub(crate) async fn reset_mod_deployment(state: ActionState) -> Result<()> {
}
}
{
let path = state.game_dir.join(DEPLOYMENT_DATA_PATH);
if let Err(err) = fs::remove_file(&path).await {
tracing::error!(
"Failed to remove deployment data '{}': {:?}",
path.display(),
err
);
}
}
tracing::info!("Reset finished");
Ok(())

View file

@ -1,584 +0,0 @@
use std::collections::HashMap;
use std::ffi::CStr;
use std::io::{Cursor, Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use color_eyre::eyre::{self, Context};
use color_eyre::{Help, Report, Result};
use druid::im::Vector;
use druid::{FileInfo, ImageBuf};
use dtmt_shared::{ModConfig, ModConfigResources};
use luajit2_sys as lua;
use nexusmods::Api as NexusApi;
use tokio::fs;
use zip::ZipArchive;
use crate::state::{ActionState, ModInfo, NexusInfo, PackageInfo};
fn find_archive_file<R: Read + Seek>(
archive: &ZipArchive<R>,
name: impl AsRef<str>,
) -> Option<String> {
let path = archive
.file_names()
.find(|path| path.ends_with(name.as_ref()))
.map(|s| s.to_string());
path
}
fn image_data_to_buffer(data: impl AsRef<[u8]>) -> Result<ImageBuf> {
// Druid somehow doesn't return an error compatible with eyre, here.
// So we have to wrap through `Display` manually.
ImageBuf::from_data(data.as_ref()).map_err(|err| {
Report::msg(err.to_string())
.wrap_err("Invalid image data")
.suggestion("Supported formats are: PNG, JPEG, Bitmap and WebP")
})
}
// Runs the content of a `.mod` file to extract what data we can get
// from legacy mods.
// 1. Create a global function `new_mod` that stores
// the relevant bits in global variables.
// 2. Run the `.mod` file, which will return a table.
// 3. Run the `run` function from that table.
// 4. Access the global variables from #1.
#[tracing::instrument]
fn parse_mod_id_file(data: &str) -> Result<(String, ModConfigResources)> {
tracing::debug!("Parsing mod file:\n{}", data);
let ret = unsafe {
let state = lua::luaL_newstate();
lua::luaL_openlibs(state);
let run = b"
function fassert() end
function new_mod(id, resources)
_G.id = id
_G.script = resources.mod_script
_G.data = resources.mod_data
_G.localization = resources.mod_localization
end
\0";
match lua::luaL_loadstring(state, run.as_ptr() as _) as u32 {
lua::LUA_OK => {}
lua::LUA_ERRSYNTAX => {
let err = lua::lua_tostring(state, -1);
let err = CStr::from_ptr(err).to_string_lossy().to_string();
lua::lua_close(state);
eyre::bail!("Invalid syntax: {}", err);
}
lua::LUA_ERRMEM => {
lua::lua_close(state);
eyre::bail!("Failed to allocate sufficient memory to create `new_mod`")
}
_ => unreachable!(),
}
match lua::lua_pcall(state, 0, 0, 0) as u32 {
lua::LUA_OK => {}
lua::LUA_ERRRUN => {
let err = lua::lua_tostring(state, -1);
let err = CStr::from_ptr(err).to_string_lossy().to_string();
lua::lua_close(state);
eyre::bail!("Failed to run buffer: {}", err);
}
lua::LUA_ERRMEM => {
lua::lua_close(state);
eyre::bail!("Failed to allocate sufficient memory to run buffer")
}
// We don't use an error handler function, so this should be unreachable
lua::LUA_ERRERR => unreachable!(),
_ => unreachable!(),
}
let name = b".mod\0";
match lua::luaL_loadbuffer(
state,
data.as_ptr() as _,
data.len() as _,
name.as_ptr() as _,
) as u32
{
lua::LUA_OK => {}
lua::LUA_ERRSYNTAX => {
let err = lua::lua_tostring(state, -1);
let err = CStr::from_ptr(err).to_string_lossy().to_string();
lua::lua_close(state);
eyre::bail!("Invalid syntax: {}", err);
}
lua::LUA_ERRMEM => {
lua::lua_close(state);
eyre::bail!("Failed to allocate sufficient memory to load `.mod` file buffer")
}
_ => unreachable!(),
}
match lua::lua_pcall(state, 0, 1, 0) as u32 {
lua::LUA_OK => {}
lua::LUA_ERRRUN => {
let err = lua::lua_tostring(state, -1);
let err = CStr::from_ptr(err).to_string_lossy().to_string();
lua::lua_close(state);
eyre::bail!("Failed to run `.mod` file: {}", err);
}
lua::LUA_ERRMEM => {
lua::lua_close(state);
eyre::bail!("Failed to allocate sufficient memory to run `.mod` file")
}
// We don't use an error handler function, so this should be unreachable
lua::LUA_ERRERR => unreachable!(),
_ => unreachable!(),
}
let key = b"run\0";
lua::lua_pushstring(state, key.as_ptr() as _);
lua::lua_gettable(state, -2);
match lua::lua_pcall(state, 0, 0, 0) as u32 {
lua::LUA_OK => {}
lua::LUA_ERRRUN => {
let err = lua::lua_tostring(state, -1);
let err = CStr::from_ptr(err).to_string_lossy().to_string();
lua::lua_close(state);
eyre::bail!("Failed to run `.mod.run`: {}", err);
}
lua::LUA_ERRMEM => {
lua::lua_close(state);
eyre::bail!("Failed to allocate sufficient memory to run `.mod.run`")
}
// We don't use an error handler function, so this should be unreachable
lua::LUA_ERRERR => unreachable!(),
_ => unreachable!(),
}
let get_global = |state, key: &[u8]| {
lua::lua_getglobal(state, key.as_ptr() as _);
if lua::lua_isnil(state, -1) != 0 {
return Ok(None);
}
let s = lua::lua_tostring(state, -1);
if s.is_null() {
eyre::bail!("Expected string, got NULL");
}
let ret = CStr::from_ptr(s).to_string_lossy().to_string();
lua::lua_pop(state, 1);
Ok(Some(ret))
};
let mod_id = get_global(state, b"id\0")
.and_then(|s| s.ok_or_else(|| eyre::eyre!("Got `nil`")))
.wrap_err("Failed to get `id`")?;
let resources = ModConfigResources {
init: get_global(state, b"script\0")
.and_then(|s| s.map(PathBuf::from).ok_or_else(|| eyre::eyre!("Got `nil`")))
.wrap_err("Failed to get `script`.")?,
data: get_global(state, b"data\0")
.wrap_err("Failed to get `data`.")?
.map(PathBuf::from),
localization: get_global(state, b"localization\0")
.wrap_err("Failed to get `localization`")?
.map(PathBuf::from),
};
lua::lua_close(state);
(mod_id, resources)
};
Ok(ret)
}
// Extracts the mod configuration from the mod archive.
// This may either be a proper `dtmt.cfg`, or the legacy `<mod_name>.mod` ID file.
//
// It also returns the directory where this file was found, used as root path. This
// allows flexibility in what the directory structure is exactly, since many people
// still end up creating tarbombs and Nexus does its own re-packaging.
#[tracing::instrument(skip(archive))]
fn extract_mod_config<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Result<(ModConfig, String)> {
let legacy_mod_data = if let Some(name) = find_archive_file(archive, ".mod") {
let (mod_id, resources) = {
let mut f = archive
.by_name(&name)
.wrap_err("Failed to read `.mod` file from archive")?;
let mut buf = Vec::with_capacity(f.size() as usize);
f.read_to_end(&mut buf)
.wrap_err("Failed to read `.mod` file from archive")?;
let data = String::from_utf8(buf).wrap_err("`.mod` file is not valid UTF-8")?;
parse_mod_id_file(&data)
.wrap_err("Invalid `.mod` file")
.note(
"The `.mod` file's `run` function may not contain any additional logic \
besides the default.",
)
.suggestion("Contact the mod author to fix this.")?
};
let root = if let Some(index) = name.rfind('/') {
name[..index].to_string()
} else {
String::new()
};
Some((mod_id, resources, root))
} else {
None
};
tracing::debug!(?legacy_mod_data);
if let Some(name) = find_archive_file(archive, "dtmt.cfg") {
let mut f = archive
.by_name(&name)
.wrap_err("Failed to read mod config from archive")?;
let mut buf = Vec::with_capacity(f.size() as usize);
f.read_to_end(&mut buf)
.wrap_err("Failed to read mod config from archive")?;
let data = String::from_utf8(buf).wrap_err("Mod config is not valid UTF-8")?;
let mut cfg: ModConfig = serde_sjson::from_str(&data)
.wrap_err("Failed to deserialize mod config")
.suggestion("Contact the mod author to fix this.")?;
if let Some((mod_id, resources, root)) = legacy_mod_data {
if cfg.id != mod_id {
let err = eyre::eyre!("Mod ID in `dtmt.cfg` does not match mod ID in `.mod` file");
return Err(err).suggestion("Contact the mod author to fix this.");
}
cfg.resources = resources;
// Enforce that packages are skipped
cfg.bundled = false;
cfg.packages = vec![];
Ok((cfg, root))
} else {
let root = name
.strip_suffix("dtmt.cfg")
.expect("String must end with that suffix")
.to_string();
Ok((cfg, root))
}
} else if let Some((mod_id, resources, root)) = legacy_mod_data {
let cfg = ModConfig {
bundled: false,
dir: PathBuf::new(),
id: mod_id.clone(),
name: mod_id,
summary: "A mod for the game Warhammer 40,000: Darktide".into(),
version: "N/A".into(),
description: None,
author: None,
image: None,
categories: Vec::new(),
packages: Vec::new(),
resources,
depends: Vec::new(),
name_overrides: Default::default(),
};
Ok((cfg, root))
} else {
eyre::bail!(
"Mod needs a config file or `.mod` file. \
Please get in touch with the author to provide a properly packaged mod."
);
}
}
#[tracing::instrument(skip(archive))]
fn extract_bundled_mod<R: Read + Seek>(
archive: &mut ZipArchive<R>,
root: String,
dest: impl AsRef<Path> + std::fmt::Debug,
) -> Result<Vector<Arc<PackageInfo>>> {
let files: HashMap<String, Vec<String>> = {
let name = archive
.file_names()
.find(|name| name.ends_with("files.sjson"))
.map(|s| s.to_string())
.ok_or_else(|| eyre::eyre!("archive does not contain file index"))?;
let mut f = archive
.by_name(&name)
.wrap_err("Failed to read file index from archive")?;
let mut buf = Vec::with_capacity(f.size() as usize);
f.read_to_end(&mut buf)
.wrap_err("Failed to read file index from archive")?;
let data = String::from_utf8(buf).wrap_err("File index is not valid UTF-8")?;
serde_sjson::from_str(&data).wrap_err("Failed to deserialize file index")?
};
tracing::trace!(?files);
let dest = dest.as_ref();
tracing::trace!("Extracting mod archive to {}", dest.display());
archive
.extract(dest)
.wrap_err_with(|| format!("Failed to extract archive to {}", dest.display()))?;
let packages = files
.into_iter()
.map(|(name, files)| Arc::new(PackageInfo::new(name, files.into_iter().collect())))
.collect();
tracing::trace!(?packages);
Ok(packages)
}
#[tracing::instrument(skip(archive))]
fn extract_legacy_mod<R: Read + Seek>(
archive: &mut ZipArchive<R>,
root: String,
dest: impl Into<PathBuf> + std::fmt::Debug,
) -> Result<()> {
let dest = dest.into();
let file_count = archive.len();
for i in 0..file_count {
let mut f = archive
.by_index(i)
.wrap_err_with(|| format!("Failed to get file at index {}", i))?;
let Some(name) = f.enclosed_name().map(|p| p.to_path_buf()) else {
let err = eyre::eyre!("File name in archive is not a safe path value.").suggestion(
"Only use well-known applications to create the ZIP archive, \
and don't create paths that point outside the archive directory.",
);
return Err(err);
};
let Ok(suffix) = name.strip_prefix(&root) else {
tracing::warn!(
"Skipping file outside of the mod root directory: {}",
name.display()
);
continue;
};
let name = dest.join(suffix);
if f.is_dir() {
// The majority of errors will actually be "X already exists".
// But rather than filter them invidually, we just ignore all of them.
// If there is a legitimate error of "couldn't create X", it will eventually fail when
// we try to put a file in there.
tracing::trace!("Creating directory '{}'", name.display());
let _ = std::fs::create_dir_all(&name);
} else {
let mut buf = Vec::with_capacity(f.size() as usize);
f.read_to_end(&mut buf)
.wrap_err_with(|| format!("Failed to read file '{}'", name.display()))?;
tracing::trace!("Writing file '{}'", name.display());
let mut out = std::fs::OpenOptions::new()
.write(true)
.create(true)
.open(&name)
.wrap_err_with(|| format!("Failed to open file '{}'", name.display()))?;
out.write_all(&buf)
.wrap_err_with(|| format!("Failed to write to '{}'", name.display()))?;
}
}
Ok(())
}
#[tracing::instrument(skip(state))]
pub(crate) async fn import_from_file(state: ActionState, info: FileInfo) -> Result<ModInfo> {
let data = fs::read(&info.path)
.await
.wrap_err_with(|| format!("Failed to read file {}", info.path.display()))?;
let nexus = if let Some((_, id, version, timestamp)) = info
.path
.file_name()
.and_then(|s| s.to_str())
.and_then(NexusApi::parse_file_name)
{
if !state.nexus_api_key.is_empty() {
let api = NexusApi::new(state.nexus_api_key.to_string())?;
let mod_info = api
.mods_id(id)
.await
.wrap_err_with(|| format!("Failed to query mod {} from Nexus", id))?;
let version = match api.file_version(id, timestamp).await {
Ok(version) => version,
Err(err) => {
let err = Report::new(err);
tracing::warn!(
"Failed to fetch version for Nexus download. \
Falling back to file name:\n{:?}",
err
);
version
}
};
let info = NexusInfo::from(mod_info);
tracing::debug!(version, ?info);
Some((info, version))
} else {
None
}
} else {
None
};
tracing::trace!(?nexus);
import_mod(state, nexus, data).await
}
#[tracing::instrument(skip(state))]
pub(crate) async fn import_from_nxm(state: ActionState, uri: String) -> Result<ModInfo> {
let url = uri
.parse()
.wrap_err_with(|| format!("Invalid Uri '{}'", uri))?;
let api = NexusApi::new(state.nexus_api_key.to_string())?;
let (mod_info, file_info, data) = api
.handle_nxm(url)
.await
.wrap_err_with(|| format!("Failed to download mod from NXM uri '{}'", uri))?;
let nexus = NexusInfo::from(mod_info);
import_mod(state, Some((nexus, file_info.version)), data).await
}
#[tracing::instrument(skip(state, data), fields(data = data.len()))]
pub(crate) async fn import_mod(
state: ActionState,
nexus: Option<(NexusInfo, String)>,
data: Vec<u8>,
) -> Result<ModInfo> {
let data = Cursor::new(data);
let mut archive = ZipArchive::new(data).wrap_err("Failed to open ZIP archive")?;
if tracing::enabled!(tracing::Level::DEBUG) {
let names = archive.file_names().fold(String::new(), |mut s, name| {
s.push('\n');
s.push_str(name);
s
});
tracing::debug!("Archive contents:{}", names);
}
let (mut mod_cfg, root) =
extract_mod_config(&mut archive).wrap_err("Failed to extract mod configuration")?;
tracing::info!("Importing mod {} ({})", mod_cfg.name, mod_cfg.id);
let mod_dir = state.data_dir.join(state.mod_dir.as_ref());
let dest = mod_dir.join(&mod_cfg.id);
tracing::trace!("Creating mods directory {}", dest.display());
fs::create_dir_all(&dest)
.await
.wrap_err_with(|| format!("Failed to create data directory '{}'", dest.display()))?;
let image = if let Some(path) = &mod_cfg.image {
let name = archive
.file_names()
.find(|name| name.ends_with(&path.display().to_string()))
.map(|s| s.to_string())
.ok_or_else(|| eyre::eyre!("archive does not contain configured image file"))?;
let mut f = archive
.by_name(&name)
.wrap_err("Failed to read image file from archive")?;
let mut buf = Vec::with_capacity(f.size() as usize);
f.read_to_end(&mut buf)
.wrap_err("Failed to read file index from archive")?;
let img = image_data_to_buffer(buf)?;
Some(img)
} else if let Some((nexus, _)) = &nexus {
let api = NexusApi::new(state.nexus_api_key.to_string())?;
let url = nexus.picture_url.as_ref();
let data = api
.picture(url)
.await
.wrap_err_with(|| format!("Failed to download Nexus image from '{}'", url))?;
let img = image_data_to_buffer(&data)?;
let name = "image.bin";
let path = dest.join(name);
match fs::write(&path, &data).await {
Ok(_) => {
mod_cfg.image = Some(name.into());
Some(img)
}
Err(err) => {
let err = Report::new(err).wrap_err(format!(
"Failed to write Nexus picture to file '{}'",
path.display()
));
tracing::error!("{:?}", err);
None
}
}
} else {
None
};
tracing::trace!(?image);
tracing::debug!(root, ?mod_cfg);
let packages = if mod_cfg.bundled {
extract_bundled_mod(&mut archive, root, &mod_dir).wrap_err("Failed to extract mod")?
} else {
extract_legacy_mod(&mut archive, root, &dest).wrap_err("Failed to extract legacy mod")?;
if let Some((_, version)) = &nexus {
// We use the version number stored in the `ModInfo` to compare against the `NexusInfo`
// for version checks. So for this one, we can't actually rely on merely shadowing,
// like with the other fields.
mod_cfg.version = version.clone();
}
let data = serde_sjson::to_string(&mod_cfg).wrap_err("Failed to serialize mod config")?;
fs::write(dest.join("dtmt.cfg"), &data)
.await
.wrap_err("Failed to write mod config")?;
Default::default()
};
if let Some((nexus, _)) = &nexus {
let data = serde_sjson::to_string(nexus).wrap_err("Failed to serialize Nexus info")?;
let path = dest.join("nexus.sjson");
fs::write(&path, data.as_bytes())
.await
.wrap_err_with(|| format!("Failed to write Nexus info to '{}'", path.display()))?;
}
let info = ModInfo::new(mod_cfg, packages, image, nexus.map(|(info, _)| info));
Ok(info)
}

View file

@ -1,25 +0,0 @@
use std::path::Path;
use color_eyre::{eyre::Context, Result};
use serde::Deserialize;
use tokio::fs;
pub mod app;
pub mod deploy;
pub mod game;
pub mod import;
pub mod worker;
#[tracing::instrument]
async fn read_sjson_file<P, T>(path: P) -> Result<T>
where
T: for<'a> Deserialize<'a>,
P: AsRef<Path> + std::fmt::Debug,
{
let path = path.as_ref();
let buf = fs::read(path)
.await
.wrap_err_with(|| format!("Failed to read file '{}'", path.display()))?;
let data = String::from_utf8(buf).wrap_err("Invalid UTF8")?;
serde_sjson::from_str(&data).wrap_err("Failed to deserialize SJSON")
}

View file

@ -1,53 +1,30 @@
use std::sync::Arc;
use color_eyre::eyre::Context;
use color_eyre::Help;
use color_eyre::Report;
use color_eyre::Result;
use druid::{ExtEventSink, SingleUse, Target};
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use tokio::runtime::Runtime;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::RwLock;
use crate::controller::app::*;
use crate::controller::deploy::deploy_mods;
use crate::controller::game::*;
use crate::controller::import::*;
use crate::state::AsyncAction;
use crate::state::ACTION_FINISH_CHECK_UPDATE;
use crate::state::ACTION_FINISH_LOAD_INITIAL;
use crate::state::ACTION_FINISH_SAVE_SETTINGS;
use crate::state::ACTION_SHOW_ERROR_DIALOG;
use crate::state::{
ACTION_FINISH_ADD_MOD, ACTION_FINISH_DELETE_SELECTED_MOD, ACTION_FINISH_DEPLOY,
ACTION_FINISH_RESET_DEPLOYMENT, ACTION_LOG,
};
async fn send_error(sink: Arc<RwLock<ExtEventSink>>, err: Report) {
sink.write()
.await
.submit_command(ACTION_SHOW_ERROR_DIALOG, SingleUse::new(err), Target::Auto)
.expect("failed to send command");
}
async fn handle_action(
event_sink: Arc<RwLock<ExtEventSink>>,
action_queue: Arc<RwLock<UnboundedReceiver<AsyncAction>>>,
) {
while let Some(action) = action_queue.write().await.recv().await {
if cfg!(debug_assertions) && !matches!(action, AsyncAction::Log(_)) {
tracing::debug!(?action);
}
let event_sink = event_sink.clone();
match action {
AsyncAction::DeployMods(state) => tokio::spawn(async move {
if let Err(err) = deploy_mods(state).await.wrap_err("Failed to deploy mods") {
tracing::error!("{:?}", err);
send_error(event_sink.clone(), err).await;
if let Err(err) = deploy_mods(state).await {
tracing::error!("Failed to deploy mods: {:?}", err);
}
event_sink
@ -56,39 +33,32 @@ async fn handle_action(
.submit_command(ACTION_FINISH_DEPLOY, (), Target::Auto)
.expect("failed to send command");
}),
AsyncAction::AddMod(state, info) => tokio::spawn(async move {
match import_from_file(state, info)
.await
.wrap_err("Failed to import mod")
{
AsyncAction::AddMod((state, info)) => tokio::spawn(async move {
match import_mod(state, info).await {
Ok(mod_info) => {
event_sink
.write()
.await
.submit_command(
ACTION_FINISH_ADD_MOD,
SingleUse::new(Arc::new(mod_info)),
SingleUse::new(mod_info),
Target::Auto,
)
.expect("failed to send command");
}
Err(err) => {
tracing::error!("{:?}", err);
send_error(event_sink.clone(), err).await;
tracing::error!("Failed to import mod: {:?}", err);
}
}
}),
AsyncAction::DeleteMod(state, info) => tokio::spawn(async move {
let mod_dir = state.mod_dir.join(&info.id);
if let Err(err) = delete_mod(state, &info)
.await
.wrap_err("Failed to delete mod files")
.with_suggestion(|| {
format!("Clean the folder '{}' manually", mod_dir.display())
})
{
tracing::error!("{:?}", err);
send_error(event_sink.clone(), err).await;
AsyncAction::DeleteMod((state, info)) => tokio::spawn(async move {
if let Err(err) = delete_mod(state, &info).await {
tracing::error!(
"Failed to delete mod files. \
You might want to clean up the data directory manually. \
Reason: {:?}",
err
);
}
event_sink
@ -102,12 +72,8 @@ async fn handle_action(
.expect("failed to send command");
}),
AsyncAction::ResetDeployment(state) => tokio::spawn(async move {
if let Err(err) = reset_mod_deployment(state)
.await
.wrap_err("Failed to reset mod deployment")
{
tracing::error!("{:?}", err);
send_error(event_sink.clone(), err).await;
if let Err(err) = reset_mod_deployment(state).await {
tracing::error!("Failed to reset mod deployment: {:?}", err);
}
event_sink
@ -117,12 +83,8 @@ async fn handle_action(
.expect("failed to send command");
}),
AsyncAction::SaveSettings(state) => tokio::spawn(async move {
if let Err(err) = save_settings(state)
.await
.wrap_err("Failed to save settings")
{
tracing::error!("{:?}", err);
send_error(event_sink.clone(), err).await;
if let Err(err) = save_settings(state).await {
tracing::error!("Failed to save settings: {:?}", err);
}
event_sink
@ -131,90 +93,13 @@ async fn handle_action(
.submit_command(ACTION_FINISH_SAVE_SETTINGS, (), Target::Auto)
.expect("failed to send command");
}),
AsyncAction::CheckUpdates(state) => tokio::spawn(async move {
let updates = match check_updates(state)
.await
.wrap_err("Failed to check for updates")
{
Ok(updates) => updates,
Err(err) => {
tracing::error!("{:?}", err);
send_error(event_sink.clone(), err).await;
vec![]
}
};
event_sink
.write()
.await
.submit_command(
ACTION_FINISH_CHECK_UPDATE,
SingleUse::new(updates),
Target::Auto,
)
.expect("failed to send command");
}),
AsyncAction::LoadInitial((path, is_default)) => tokio::spawn(async move {
let data = match load_initial(path, is_default)
.await
.wrap_err("Failed to load initial application data")
{
Ok(data) => Some(data),
Err(err) => {
tracing::error!("{:?}", err);
send_error(event_sink.clone(), err).await;
None
}
};
event_sink
.write()
.await
.submit_command(
ACTION_FINISH_LOAD_INITIAL,
SingleUse::new(data),
Target::Auto,
)
.expect("failed to send command");
}),
AsyncAction::Log((state, line)) => tokio::spawn(async move {
if let Ok(mut f) = OpenOptions::new()
.append(true)
.open(state.data_dir.join("dtmm.log"))
.await
{
let _ = f.write_all(&line).await;
}
}),
AsyncAction::NxmDownload(state, uri) => tokio::spawn(async move {
match import_from_nxm(state, uri)
.await
.wrap_err("Failed to handle NXM URI")
{
Ok(mod_info) => {
event_sink
.write()
.await
.submit_command(
ACTION_FINISH_ADD_MOD,
SingleUse::new(Arc::new(mod_info)),
Target::Auto,
)
.expect("failed to send command");
}
Err(err) => {
tracing::error!("{:?}", err);
send_error(event_sink.clone(), err).await;
}
}
}),
};
}
}
async fn handle_log(
event_sink: Arc<RwLock<ExtEventSink>>,
log_queue: Arc<RwLock<UnboundedReceiver<Vec<u8>>>>,
log_queue: Arc<RwLock<UnboundedReceiver<String>>>,
) {
while let Some(line) = log_queue.write().await.recv().await {
let event_sink = event_sink.clone();
@ -229,7 +114,7 @@ async fn handle_log(
pub(crate) fn work_thread(
event_sink: Arc<RwLock<ExtEventSink>>,
action_queue: Arc<RwLock<UnboundedReceiver<AsyncAction>>>,
log_queue: Arc<RwLock<UnboundedReceiver<Vec<u8>>>>,
log_queue: Arc<RwLock<UnboundedReceiver<String>>>,
) -> Result<()> {
let rt = Runtime::new()?;

View file

@ -1,69 +1,33 @@
#![recursion_limit = "256"]
#![feature(let_chains)]
#![feature(iterator_try_collect)]
#![windows_subsystem = "windows"]
use std::path::PathBuf;
use std::sync::Arc;
use clap::parser::ValueSource;
use clap::{command, value_parser, Arg};
use color_eyre::eyre::{self, Context};
use color_eyre::{Report, Result, Section};
use clap::command;
use clap::value_parser;
use clap::Arg;
use color_eyre::eyre::Context;
use color_eyre::{Report, Result};
use druid::AppLauncher;
use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions};
use tokio::sync::RwLock;
use crate::controller::app::load_mods;
use crate::controller::worker::work_thread;
use crate::state::{AsyncAction, ACTION_HANDLE_NXM};
use crate::state::{Delegate, State};
use crate::ui::theme;
use crate::util::log::LogLevel;
mod controller;
mod controller {
pub mod app;
pub mod game;
pub mod worker;
}
mod state;
mod util {
pub mod ansi;
pub mod config;
pub mod log;
}
mod ui;
// As explained in https://docs.rs/interprocess/2.1.0/interprocess/local_socket/struct.Name.html
// namespaces are supported on both platforms we care about: Windows and Linux.
const IPC_ADDRESS: &str = "dtmm.sock";
#[tracing::instrument]
fn notify_nxm_download(
uri: impl AsRef<str> + std::fmt::Debug,
level: Option<LogLevel>,
) -> Result<()> {
util::log::create_tracing_subscriber(level, None);
tracing::debug!("Received Uri '{}', sending to main process.", uri.as_ref());
let mut stream = LocalSocketStream::connect(
IPC_ADDRESS
.to_ns_name::<GenericNamespaced>()
.expect("Invalid socket name"),
)
.wrap_err_with(|| format!("Failed to connect to '{}'", IPC_ADDRESS))
.suggestion("Make sure the main window is open.")?;
tracing::debug!("Connected to main process at '{}'", IPC_ADDRESS);
bincode::serialize_into(&mut stream, uri.as_ref()).wrap_err("Failed to send URI")?;
// We don't really care what the message is, we just need an acknowledgement.
let _: String = bincode::deserialize_from(&mut stream).wrap_err("Failed to receive reply")?;
tracing::info!(
"Notified DTMM with uri '{}'. Check the main window.",
uri.as_ref()
);
Ok(())
}
#[tracing::instrument]
fn main() -> Result<()> {
color_eyre::install()?;
@ -73,6 +37,12 @@ fn main() -> Result<()> {
tracing::trace!(default_config_path = %default_config_path.display());
let matches = command!()
.arg(Arg::new("oodle").long("oodle").help(
"The oodle library to load. This may either be:\n\
- A library name that will be searched for in the system's default paths.\n\
- A file path relative to the current working directory.\n\
- An absolute file path.",
))
.arg(
Arg::new("config")
.long("config")
@ -81,127 +51,41 @@ fn main() -> Result<()> {
.value_parser(value_parser!(PathBuf))
.default_value(default_config_path.to_string_lossy().to_string()),
)
.arg(
Arg::new("log-level")
.long("log-level")
.help("The maximum level of log events to print")
.value_parser(value_parser!(LogLevel))
.default_value("info"),
)
.arg(
Arg::new("nxm")
.help("An `nxm://` URI to download")
.required(false),
)
.get_matches();
let level = if matches.value_source("log-level") == Some(ValueSource::DefaultValue) {
None
} else {
matches.get_one::<LogLevel>("log-level").cloned()
let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel();
util::log::create_tracing_subscriber(log_tx);
let config = util::config::read_config(&default_config_path, &matches)
.wrap_err("failed to read config file")?;
let initial_state = {
let mut state = State::new(
config.path,
config.game_dir.unwrap_or_default(),
config.data_dir.unwrap_or_default(),
);
state.mods = load_mods(state.get_mod_dir(), config.mod_order.iter())
.wrap_err("failed to load mods")?;
state
};
if let Some(uri) = matches.get_one::<String>("nxm") {
return notify_nxm_download(uri, level).wrap_err("Failed to send NXM Uri to main window.");
}
let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel();
util::log::create_tracing_subscriber(level, Some(log_tx));
let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel();
let delegate = Delegate::new(action_tx);
let config_path = matches
.get_one::<PathBuf>("config")
.cloned()
.expect("argument has default value");
let is_config_default = matches.value_source("config") == Some(ValueSource::DefaultValue);
if action_tx
.send(AsyncAction::LoadInitial((config_path, is_config_default)))
.is_err()
{
let err = eyre::eyre!("Failed to send action");
return Err(err);
}
let launcher = AppLauncher::with_window(ui::window::main::new())
.delegate(Delegate::new(action_tx))
.configure_env(theme::set_theme_env);
let launcher = AppLauncher::with_window(ui::window::main::new()).delegate(delegate);
let event_sink = launcher.get_external_handle();
{
let span = tracing::info_span!(IPC_ADDRESS, "nxm-socket");
let _guard = span.enter();
let event_sink = event_sink.clone();
let server = ListenerOptions::new()
.name(
IPC_ADDRESS
.to_ns_name::<GenericNamespaced>()
.expect("Invalid socket name"),
)
.create_sync()
.wrap_err("Failed to create IPC listener")?;
tracing::debug!("IPC server listening on '{}'", IPC_ADDRESS);
// Drop the guard here, so that we can re-enter the same span in the thread.
drop(_guard);
std::thread::Builder::new()
.name("nxm-socket".into())
.spawn(move || {
let _guard = span.enter();
loop {
let res = server.accept().wrap_err_with(|| {
format!("IPC server failed to listen on '{}'", IPC_ADDRESS)
});
match res {
Ok(mut stream) => {
let res = bincode::deserialize_from(&mut stream)
.wrap_err("Failed to read message")
.and_then(|uri: String| {
tracing::trace!(uri, "Received NXM uri");
event_sink
.submit_command(ACTION_HANDLE_NXM, uri, druid::Target::Auto)
.wrap_err("Failed to start NXM download")
});
match res {
Ok(()) => {
let _ = bincode::serialize_into(&mut stream, "Ok");
}
Err(err) => {
tracing::error!("{:?}", err);
let _ = bincode::serialize_into(&mut stream, "Error");
}
}
}
Err(err) => {
tracing::error!("Failed to receive client connection: {:?}", err)
}
}
}
})
.wrap_err("Failed to create thread")?;
}
std::thread::Builder::new()
.name("work-thread".into())
.spawn(move || {
let event_sink = Arc::new(RwLock::new(event_sink));
let action_rx = Arc::new(RwLock::new(action_rx));
let log_rx = Arc::new(RwLock::new(log_rx));
loop {
if let Err(err) = work_thread(event_sink.clone(), action_rx.clone(), log_rx.clone())
{
tracing::error!("Work thread failed, restarting: {:?}", err);
}
std::thread::spawn(move || {
let event_sink = Arc::new(RwLock::new(event_sink));
let action_rx = Arc::new(RwLock::new(action_rx));
let log_rx = Arc::new(RwLock::new(log_rx));
loop {
if let Err(err) = work_thread(event_sink.clone(), action_rx.clone(), log_rx.clone()) {
tracing::error!("Work thread failed, restarting: {:?}", err);
}
})
.wrap_err("Failed to create thread")?;
}
});
launcher.launch(State::new()).map_err(Report::new)
launcher.launch(initial_state).map_err(Report::new)
}

View file

@ -1,11 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::{path::PathBuf, sync::Arc};
use druid::im::{HashMap, Vector};
use druid::text::RichText;
use druid::{Data, ImageBuf, Lens, WindowHandle, WindowId};
use druid::{im::Vector, Data, Lens};
use dtmt_shared::ModConfig;
use nexusmods::Mod as NexusMod;
use super::SelectedModLens;
@ -21,7 +17,7 @@ impl Default for View {
}
}
#[derive(Clone, Data, Debug, PartialEq)]
#[derive(Clone, Data, Debug)]
pub struct PackageInfo {
pub name: String,
pub files: Vector<String>,
@ -33,191 +29,68 @@ impl PackageInfo {
}
}
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug)]
pub(crate) struct ModResourceInfo {
pub init: PathBuf,
pub data: Option<PathBuf>,
pub localization: Option<PathBuf>,
}
#[derive(Clone, Data, Debug, PartialEq)]
pub(crate) enum ModOrder {
Before,
After,
}
#[derive(Clone, Data, Debug, PartialEq)]
pub(crate) struct ModDependency {
pub id: String,
pub order: ModOrder,
}
impl From<dtmt_shared::ModDependency> for ModDependency {
fn from(value: dtmt_shared::ModDependency) -> Self {
match value {
dtmt_shared::ModDependency::ID(id) => ModDependency {
id,
order: ModOrder::Before,
},
dtmt_shared::ModDependency::Config { id, order } => ModDependency {
id,
order: match order {
dtmt_shared::ModOrder::Before => ModOrder::Before,
dtmt_shared::ModOrder::After => ModOrder::After,
},
},
}
}
}
#[derive(Clone, Data, Debug, Lens, serde::Serialize, serde::Deserialize)]
pub(crate) struct NexusInfo {
pub author: String,
pub category_id: u64,
pub created_timestamp: i64,
pub description: Arc<String>,
pub id: u64,
pub name: String,
pub picture_url: Arc<String>,
pub summary: Arc<String>,
pub uid: u64,
pub updated_timestamp: i64,
pub uploaded_by: String,
pub version: String,
}
impl From<NexusMod> for NexusInfo {
fn from(value: NexusMod) -> Self {
Self {
author: value.author,
category_id: value.category_id,
created_timestamp: value.created_timestamp.unix_timestamp(),
description: Arc::new(value.description),
id: value.mod_id,
name: value.name,
picture_url: Arc::new(value.picture_url.into()),
summary: Arc::new(value.summary),
uid: value.uid,
updated_timestamp: value.updated_timestamp.unix_timestamp(),
uploaded_by: value.uploaded_by,
version: value.version,
}
}
}
#[derive(Clone, Data, Lens)]
#[derive(Clone, Data, Debug, Lens)]
pub(crate) struct ModInfo {
pub id: String,
pub name: String,
pub summary: Arc<String>,
pub description: Option<Arc<String>>,
pub categories: Vector<String>,
pub author: Option<String>,
pub image: Option<ImageBuf>,
pub version: String,
pub description: Arc<String>,
pub enabled: bool,
pub depends: Vector<ModDependency>,
pub bundled: bool,
#[lens(ignore)]
#[data(ignore)]
pub packages: Vector<Arc<PackageInfo>>,
pub packages: Vector<PackageInfo>,
#[lens(ignore)]
#[data(ignore)]
pub resources: ModResourceInfo,
#[data(ignore)]
pub nexus: Option<NexusInfo>,
}
impl std::fmt::Debug for ModInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ModInfo")
.field("id", &self.id)
.field("name", &self.name)
.field("summary", &self.summary)
.field(
"description",
&(match &self.description {
Some(desc) => format!("Some(String[0..{}])", desc.len()),
None => "None".to_string(),
}),
)
.field("categories", &self.categories)
.field("author", &self.author)
.field(
"image",
&(match &self.image {
Some(image) => format!("Some(ImageBuf[{}x{}])", image.width(), image.height()),
None => "None".to_string(),
}),
)
.field("version", &self.version)
.field("enabled", &self.enabled)
.field("packages", &format!("Vec[0..{}]", self.packages.len()))
.field("resources", &self.resources)
.field("depends", &self.depends)
.field("bundled", &self.bundled)
.field("nexus", &self.nexus)
.finish()
}
}
impl ModInfo {
pub fn new(
cfg: ModConfig,
packages: Vector<Arc<PackageInfo>>,
image: Option<ImageBuf>,
nexus: Option<NexusInfo>,
) -> Self {
pub fn new(cfg: ModConfig, packages: Vector<PackageInfo>) -> Self {
Self {
id: cfg.id,
name: cfg.name,
summary: Arc::new(cfg.summary),
description: cfg.description.map(Arc::new),
author: cfg.author,
version: cfg.version,
description: Arc::new(cfg.description),
enabled: false,
packages,
bundled: cfg.bundled,
image,
categories: cfg.categories.into_iter().collect(),
resources: ModResourceInfo {
init: cfg.resources.init,
data: cfg.resources.data,
localization: cfg.resources.localization,
},
depends: cfg.depends.into_iter().map(ModDependency::from).collect(),
nexus,
}
}
}
impl PartialEq for ModInfo {
fn eq(&self, other: &Self) -> bool {
self.name.eq(&other.name)
}
}
#[derive(Clone, Data, Lens)]
pub(crate) struct State {
pub current_view: View,
pub mods: Vector<Arc<ModInfo>>,
pub mods: Vector<ModInfo>,
pub selected_mod_index: Option<usize>,
pub dirty: bool,
pub is_deployment_in_progress: bool,
pub is_reset_in_progress: bool,
pub is_save_in_progress: bool,
pub is_next_save_pending: bool,
pub is_update_in_progress: bool,
pub is_io_enabled: bool,
pub game_dir: Arc<PathBuf>,
pub data_dir: Arc<PathBuf>,
pub nexus_api_key: Arc<String>,
pub log: Vector<RichText>,
// True, when the initial loading of configuration and mods is still in progress
pub loading: bool,
pub log: Arc<String>,
#[lens(ignore)]
#[data(ignore)]
pub config_path: Arc<PathBuf>,
#[lens(ignore)]
#[data(ignore)]
pub windows: HashMap<WindowId, WindowHandle>,
#[lens(ignore)]
#[data(ignore)]
pub ctx: Arc<sdk::Context>,
}
@ -225,7 +98,7 @@ impl State {
#[allow(non_upper_case_globals)]
pub const selected_mod: SelectedModLens = SelectedModLens;
pub fn new() -> Self {
pub fn new(config_path: PathBuf, game_dir: PathBuf, data_dir: PathBuf) -> Self {
let ctx = sdk::Context::new();
Self {
@ -233,20 +106,14 @@ impl State {
current_view: View::default(),
mods: Vector::new(),
selected_mod_index: None,
dirty: false,
is_deployment_in_progress: false,
is_reset_in_progress: false,
is_save_in_progress: false,
is_next_save_pending: false,
is_update_in_progress: false,
is_io_enabled: false,
config_path: Arc::new(PathBuf::new()),
game_dir: Arc::new(PathBuf::new()),
data_dir: Arc::new(PathBuf::new()),
nexus_api_key: Arc::new(String::new()),
log: Vector::new(),
windows: HashMap::new(),
loading: true,
config_path: Arc::new(config_path),
game_dir: Arc::new(game_dir),
data_dir: Arc::new(data_dir),
log: Arc::new(String::new()),
}
}
@ -254,8 +121,8 @@ impl State {
self.selected_mod_index = Some(index);
}
pub fn add_mod(&mut self, info: Arc<ModInfo>) {
if let Some(pos) = self.mods.iter().position(|i| i.id == info.id) {
pub fn add_mod(&mut self, info: ModInfo) {
if let Some(pos) = self.mods.index_of(&info) {
self.mods.set(pos, info);
self.selected_mod_index = Some(pos);
} else {
@ -273,4 +140,13 @@ impl State {
pub fn can_move_mod_up(&self) -> bool {
self.selected_mod_index.map(|i| i > 0).unwrap_or(false)
}
pub(crate) fn get_mod_dir(&self) -> PathBuf {
self.data_dir.join("mods")
}
pub(crate) fn add_log_line(&mut self, line: String) {
let log = Arc::make_mut(&mut self.log);
log.push_str(&line);
}
}

View file

@ -1,27 +1,17 @@
use std::path::PathBuf;
use std::sync::Arc;
use color_eyre::Report;
use druid::im::Vector;
use druid::{
AppDelegate, Command, DelegateCtx, Env, FileInfo, Handled, Selector, SingleUse, Target,
WindowHandle, WindowId,
};
use tokio::sync::mpsc::UnboundedSender;
use crate::ui::window;
use crate::util::ansi::ansi_to_rich_text;
use crate::util::config::Config;
use super::{ModInfo, State};
pub(crate) const ACTION_SELECT_MOD: Selector<usize> = Selector::new("dtmm.action.select-mod");
pub(crate) const ACTION_SELECTED_MOD_UP: Selector = Selector::new("dtmm.action.selected-mod-up");
pub(crate) const ACTION_SELECTED_MOD_DOWN: Selector =
Selector::new("dtmm.action.selected-mod-down");
pub(crate) const ACTION_START_DELETE_SELECTED_MOD: Selector<SingleUse<Arc<ModInfo>>> =
pub(crate) const ACTION_START_DELETE_SELECTED_MOD: Selector<SingleUse<ModInfo>> =
Selector::new("dtmm.action.srart-delete-selected-mod");
pub(crate) const ACTION_FINISH_DELETE_SELECTED_MOD: Selector<SingleUse<Arc<ModInfo>>> =
pub(crate) const ACTION_FINISH_DELETE_SELECTED_MOD: Selector<SingleUse<ModInfo>> =
Selector::new("dtmm.action.finish-delete-selected-mod");
pub(crate) const ACTION_START_DEPLOY: Selector = Selector::new("dtmm.action.start-deploy");
@ -32,99 +22,23 @@ pub(crate) const ACTION_START_RESET_DEPLOYMENT: Selector =
pub(crate) const ACTION_FINISH_RESET_DEPLOYMENT: Selector =
Selector::new("dtmm.action.finish-reset-deployment");
pub(crate) const ACTION_HANDLE_NXM: Selector<String> = Selector::new("dtmm.action.handle-nxm");
pub(crate) const ACTION_ADD_MOD: Selector<FileInfo> = Selector::new("dtmm.action.add-mod");
pub(crate) const ACTION_FINISH_ADD_MOD: Selector<SingleUse<Arc<ModInfo>>> =
pub(crate) const ACTION_FINISH_ADD_MOD: Selector<SingleUse<ModInfo>> =
Selector::new("dtmm.action.finish-add-mod");
pub(crate) const ACTION_LOG: Selector<SingleUse<Vec<u8>>> = Selector::new("dtmm.action.log");
pub(crate) const ACTION_LOG: Selector<SingleUse<String>> = Selector::new("dtmm.action.log");
pub(crate) const ACTION_START_SAVE_SETTINGS: Selector =
Selector::new("dtmm.action.start-save-settings");
pub(crate) const ACTION_FINISH_SAVE_SETTINGS: Selector =
Selector::new("dtmm.action.finish-save-settings");
pub(crate) const ACTION_START_CHECK_UPDATE: Selector =
Selector::new("dtmm.action.start-check-update");
pub(crate) const ACTION_FINISH_CHECK_UPDATE: Selector<SingleUse<Vec<ModInfo>>> =
Selector::new("dtmm.action.finish-check-update");
pub(crate) const ACTION_SET_DIRTY: Selector = Selector::new("dtmm.action.set-dirty");
pub(crate) const ACTION_SHOW_ERROR_DIALOG: Selector<SingleUse<Report>> =
Selector::new("dtmm.action.show-error-dialog");
pub(crate) const ACTION_SET_WINDOW_HANDLE: Selector<SingleUse<(WindowId, WindowHandle)>> =
Selector::new("dtmm.action.set-window-handle");
pub(crate) type InitialLoadResult = (Config, Vector<Arc<ModInfo>>);
pub(crate) const ACTION_FINISH_LOAD_INITIAL: Selector<SingleUse<Option<InitialLoadResult>>> =
Selector::new("dtmm.action.finish-load-initial");
pub(crate) const ACTION_OPEN_LINK: Selector<Arc<String>> = Selector::new("dtmm.action.open-link");
// A sub-selection of `State`'s fields that are required in `AsyncAction`s and that are
// `Send + Sync`
pub(crate) struct ActionState {
pub mods: Vector<Arc<ModInfo>>,
pub game_dir: Arc<PathBuf>,
pub data_dir: Arc<PathBuf>,
pub mod_dir: Arc<PathBuf>,
pub config_path: Arc<PathBuf>,
pub ctx: Arc<sdk::Context>,
pub nexus_api_key: Arc<String>,
pub is_io_enabled: bool,
}
impl From<State> for ActionState {
fn from(state: State) -> Self {
Self {
mods: state.mods,
game_dir: state.game_dir,
mod_dir: Arc::new(state.data_dir.join("mods")),
data_dir: state.data_dir,
config_path: state.config_path,
ctx: state.ctx,
nexus_api_key: state.nexus_api_key,
is_io_enabled: state.is_io_enabled,
}
}
}
pub(crate) enum AsyncAction {
DeployMods(ActionState),
ResetDeployment(ActionState),
AddMod(ActionState, FileInfo),
DeleteMod(ActionState, Arc<ModInfo>),
SaveSettings(ActionState),
CheckUpdates(ActionState),
LoadInitial((PathBuf, bool)),
Log((ActionState, Vec<u8>)),
NxmDownload(ActionState, String),
}
impl std::fmt::Debug for AsyncAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AsyncAction::DeployMods(_) => write!(f, "AsyncAction::DeployMods(_state)"),
AsyncAction::ResetDeployment(_) => write!(f, "AsyncAction::ResetDeployment(_state)"),
AsyncAction::AddMod(_, info) => write!(f, "AsyncAction::AddMod(_state, {:?})", info),
AsyncAction::DeleteMod(_, info) => {
write!(f, "AsyncAction::DeleteMod(_state, {:?})", info)
}
AsyncAction::SaveSettings(_) => write!(f, "AsyncAction::SaveSettings(_state)"),
AsyncAction::CheckUpdates(_) => write!(f, "AsyncAction::CheckUpdates(_state)"),
AsyncAction::LoadInitial((path, is_default)) => write!(
f,
"AsyncAction::LoadInitial(({:?}, {:?}))",
path, is_default
),
AsyncAction::Log(_) => write!(f, "AsyncAction::Log(_)"),
AsyncAction::NxmDownload(_, uri) => {
write!(f, "AsyncAction::NxmDownload(_state, {})", uri)
}
}
}
DeployMods(State),
ResetDeployment(State),
AddMod((State, FileInfo)),
DeleteMod((State, ModInfo)),
SaveSettings(State),
}
pub(crate) struct Delegate {
@ -155,7 +69,7 @@ impl AppDelegate<State> for Delegate {
cmd if cmd.is(ACTION_START_DEPLOY) => {
if self
.sender
.send(AsyncAction::DeployMods(state.clone().into()))
.send(AsyncAction::DeployMods(state.clone()))
.is_ok()
{
state.is_deployment_in_progress = true;
@ -167,13 +81,12 @@ impl AppDelegate<State> for Delegate {
}
cmd if cmd.is(ACTION_FINISH_DEPLOY) => {
state.is_deployment_in_progress = false;
state.dirty = false;
Handled::Yes
}
cmd if cmd.is(ACTION_START_RESET_DEPLOYMENT) => {
if self
.sender
.send(AsyncAction::ResetDeployment(state.clone().into()))
.send(AsyncAction::ResetDeployment(state.clone()))
.is_ok()
{
state.is_reset_in_progress = true;
@ -229,12 +142,11 @@ impl AppDelegate<State> for Delegate {
cmd if cmd.is(ACTION_START_DELETE_SELECTED_MOD) => {
let info = cmd
.get(ACTION_START_DELETE_SELECTED_MOD)
.and_then(SingleUse::take)
.and_then(|info| info.take())
.expect("command type matched but didn't contain the expected value");
if self
.sender
.send(AsyncAction::DeleteMod(state.clone().into(), info))
.send(AsyncAction::DeleteMod((state.clone(), info)))
.is_err()
{
tracing::error!("Failed to queue action to deploy mods");
@ -245,40 +157,25 @@ impl AppDelegate<State> for Delegate {
cmd if cmd.is(ACTION_FINISH_DELETE_SELECTED_MOD) => {
let info = cmd
.get(ACTION_FINISH_DELETE_SELECTED_MOD)
.and_then(SingleUse::take)
.and_then(|info| info.take())
.expect("command type matched but didn't contain the expected value");
let found = state.mods.iter().enumerate().find(|(_, i)| i.id == info.id);
let Some((index, _)) = found else {
return Handled::No;
};
state.mods.remove(index);
// ctx.submit_command(ACTION_START_SAVE_SETTINGS);
Handled::Yes
}
cmd if cmd.is(ACTION_HANDLE_NXM) => {
let uri = cmd
.get(ACTION_HANDLE_NXM)
.expect("command type match but didn't contain the expected value");
if self
.sender
.send(AsyncAction::NxmDownload(state.clone().into(), uri.clone()))
.is_err()
{
tracing::error!("Failed to queue action to download NXM mod");
}
Handled::Yes
}
cmd if cmd.is(ACTION_ADD_MOD) => {
let info = cmd
.get(ACTION_ADD_MOD)
.expect("command type matched but didn't contain the expected value");
if self
.sender
.send(AsyncAction::AddMod(state.clone().into(), info.clone()))
.send(AsyncAction::AddMod((state.clone(), info.clone())))
.is_err()
{
tracing::error!("Failed to queue action to add mod");
@ -289,33 +186,19 @@ impl AppDelegate<State> for Delegate {
let info = cmd
.get(ACTION_FINISH_ADD_MOD)
.expect("command type matched but didn't contain the expected value");
if let Some(info) = info.take() {
state.add_mod(info);
// ctx.submit_command(ACTION_START_SAVE_SETTINGS);
}
Handled::Yes
}
cmd if cmd.is(ACTION_LOG) => {
let line = cmd
.get(ACTION_LOG)
.expect("command type matched but didn't contain the expected value");
if let Some(line) = line.take() {
{
let line = String::from_utf8_lossy(&line);
state.log.push_back(ansi_to_rich_text(line.trim()));
}
if self
.sender
.send(AsyncAction::Log((state.clone().into(), line)))
.is_err()
{
tracing::error!("Failed to queue action to add mod");
}
state.add_log_line(line);
}
Handled::Yes
}
cmd if cmd.is(ACTION_START_SAVE_SETTINGS) => {
@ -323,7 +206,7 @@ impl AppDelegate<State> for Delegate {
state.is_next_save_pending = true;
} else if self
.sender
.send(AsyncAction::SaveSettings(state.clone().into()))
.send(AsyncAction::SaveSettings(state.clone()))
.is_ok()
{
state.is_save_in_progress = true;
@ -334,11 +217,6 @@ impl AppDelegate<State> for Delegate {
Handled::Yes
}
cmd if cmd.is(ACTION_FINISH_SAVE_SETTINGS) => {
tracing::trace!(
in_progress = state.is_save_in_progress,
next_pending = state.is_next_save_pending,
"Finished saving settings",
);
state.is_save_in_progress = false;
if state.is_next_save_pending {
@ -348,128 +226,12 @@ impl AppDelegate<State> for Delegate {
Handled::Yes
}
cmd if cmd.is(ACTION_SET_DIRTY) => {
state.dirty = true;
Handled::Yes
}
cmd if cmd.is(ACTION_SHOW_ERROR_DIALOG) => {
let err = cmd
.get(ACTION_SHOW_ERROR_DIALOG)
.and_then(SingleUse::take)
.expect("command type matched but didn't contain the expected value");
let window = state
.windows
.get(&window::main::WINDOW_ID)
.expect("root window does not exist");
let dialog = window::dialog::error::<State>(err, window.clone());
ctx.new_window(dialog);
Handled::Yes
}
cmd if cmd.is(ACTION_SET_WINDOW_HANDLE) => {
let (id, handle) = cmd
.get(ACTION_SET_WINDOW_HANDLE)
.and_then(SingleUse::take)
.expect("command type matched but didn't contain the expected value");
state.windows.insert(id, handle);
Handled::Yes
}
cmd if cmd.is(ACTION_START_CHECK_UPDATE) => {
if self
.sender
.send(AsyncAction::CheckUpdates(state.clone().into()))
.is_ok()
{
state.is_update_in_progress = true;
} else {
tracing::error!("Failed to queue action to check updates");
cmd => {
if cfg!(debug_assertions) {
tracing::warn!("Unknown command: {:?}", cmd);
}
Handled::Yes
Handled::No
}
cmd if cmd.is(ACTION_FINISH_CHECK_UPDATE) => {
let mut updates = cmd
.get(ACTION_FINISH_CHECK_UPDATE)
.and_then(SingleUse::take)
.expect("command type matched but didn't contain the expected value");
if tracing::enabled!(tracing::Level::DEBUG) {
let mods: Vec<_> = updates
.iter()
.map(|info| {
format!(
"{}: {} -> {:?}",
info.name,
info.version,
info.nexus.as_ref().map(|n| &n.version)
)
})
.collect();
tracing::info!("Mod updates:\n{}", mods.join("\n"));
}
for mod_info in state.mods.iter_mut() {
if let Some(index) = updates.iter().position(|i2| i2.id == mod_info.id) {
let update = updates.swap_remove(index);
*mod_info = Arc::new(update);
}
}
state.is_update_in_progress = false;
Handled::Yes
}
cmd if cmd.is(ACTION_FINISH_LOAD_INITIAL) => {
let data = cmd
.get(ACTION_FINISH_LOAD_INITIAL)
.and_then(SingleUse::take)
.expect("command type matched but didn't contain the expected value");
if let Some((config, mods)) = data {
state.mods = mods;
state.config_path = Arc::new(config.path);
state.data_dir = Arc::new(config.data_dir);
state.game_dir = Arc::new(config.game_dir.unwrap_or_default());
state.nexus_api_key = Arc::new(config.nexus_api_key.unwrap_or_default());
state.is_io_enabled = config.unsafe_io;
}
state.loading = false;
Handled::Yes
}
cmd if cmd.is(ACTION_OPEN_LINK) => {
let url = cmd
.get(ACTION_OPEN_LINK)
.expect("command type matched but didn't contain the expected value");
if let Err(err) = open::that_detached(Arc::as_ref(url)) {
tracing::error!(
"{:?}",
Report::new(err).wrap_err(format!("Failed to open url '{}'", url))
);
}
Handled::Yes
}
_ => Handled::No,
}
}
fn window_added(
&mut self,
id: WindowId,
handle: WindowHandle,
data: &mut State,
_: &Env,
_: &mut DelegateCtx,
) {
data.windows.insert(id, handle);
}
fn window_removed(&mut self, id: WindowId, data: &mut State, _: &Env, _: &mut DelegateCtx) {
data.windows.remove(&id);
}
}

View file

@ -1,15 +1,13 @@
use std::sync::Arc;
use druid::im::Vector;
use druid::{Data, Lens};
use super::{ModInfo, NexusInfo, State};
use super::{ModInfo, State};
pub(crate) struct SelectedModLens;
impl Lens<State, Option<Arc<ModInfo>>> for SelectedModLens {
impl Lens<State, Option<ModInfo>> for SelectedModLens {
#[tracing::instrument(name = "SelectedModLens::with", skip_all)]
fn with<V, F: FnOnce(&Option<Arc<ModInfo>>) -> V>(&self, data: &State, f: F) -> V {
fn with<V, F: FnOnce(&Option<ModInfo>) -> V>(&self, data: &State, f: F) -> V {
let info = data
.selected_mod_index
.and_then(|i| data.mods.get(i).cloned());
@ -18,16 +16,16 @@ impl Lens<State, Option<Arc<ModInfo>>> for SelectedModLens {
}
#[tracing::instrument(name = "SelectedModLens::with_mut", skip_all)]
fn with_mut<V, F: FnOnce(&mut Option<Arc<ModInfo>>) -> V>(&self, data: &mut State, f: F) -> V {
fn with_mut<V, F: FnOnce(&mut Option<ModInfo>) -> V>(&self, data: &mut State, f: F) -> V {
match data.selected_mod_index {
Some(i) => {
let mut info = data.mods.get_mut(i).cloned();
let ret = f(&mut info);
if let Some(new) = info {
if let Some(info) = info {
// TODO: Figure out a way to check for equality and
// only update when needed
data.mods.set(i, new);
data.mods.set(i, info);
} else {
data.selected_mod_index = None;
}
@ -42,7 +40,6 @@ impl Lens<State, Option<Arc<ModInfo>>> for SelectedModLens {
/// A Lens that maps an `im::Vector<T>` to `im::Vector<(usize, T)>`,
/// where each element in the destination vector includes its index in the
/// source vector.
#[allow(dead_code)]
pub(crate) struct IndexedVectorLens;
impl<T: Data> Lens<Vector<T>, Vector<(usize, T)>> for IndexedVectorLens {
@ -74,51 +71,3 @@ impl<T: Data> Lens<Vector<T>, Vector<(usize, T)>> for IndexedVectorLens {
ret
}
}
/// A Lens that first checks a key in a mod's `NexusInfo`, then falls back to
/// the regular one.
pub(crate) struct NexusInfoLens<T, L, R>
where
L: Lens<NexusInfo, T>,
R: Lens<ModInfo, T>,
{
value: L,
fallback: R,
_marker: std::marker::PhantomData<T>,
}
impl<T: Data, L, R> NexusInfoLens<T, L, R>
where
L: Lens<NexusInfo, T>,
R: Lens<ModInfo, T>,
{
pub fn new(value: L, fallback: R) -> Self {
Self {
value,
fallback,
_marker: std::marker::PhantomData,
}
}
}
impl<T: Data, L, R> Lens<ModInfo, T> for NexusInfoLens<T, L, R>
where
L: Lens<NexusInfo, T>,
R: Lens<ModInfo, T>,
{
fn with<V, F: FnOnce(&T) -> V>(&self, data: &ModInfo, f: F) -> V {
if let Some(nexus) = &data.nexus {
self.value.with(nexus, f)
} else {
self.fallback.with(data, f)
}
}
fn with_mut<V, F: FnOnce(&mut T) -> V>(&self, data: &mut ModInfo, f: F) -> V {
if let Some(nexus) = &mut data.nexus {
self.value.with_mut(nexus, f)
} else {
self.fallback.with_mut(data, f)
}
}
}

View file

@ -1,6 +1,5 @@
pub mod theme;
pub mod widget;
pub mod window {
pub mod dialog;
pub mod main;
}

View file

@ -0,0 +1,4 @@
use druid::{Color, Insets};
pub const TOP_BAR_BACKGROUND_COLOR: Color = Color::rgba8(255, 255, 255, 50);
pub const TOP_BAR_INSETS: Insets = Insets::uniform(5.0);

View file

@ -1,87 +0,0 @@
use colors_transform::Color as _;
use colors_transform::Rgb;
use druid::Color;
pub use gruvbox_dark::*;
macro_rules! make_color {
($name:ident, $r:literal, $g:literal, $b:literal, $a:literal) => {
pub const $name: Color = Color::rgba8($r, $g, $b, $a);
};
($name:ident, $r:literal, $g:literal, $b:literal) => {
pub const $name: Color = Color::rgb8($r, $g, $b);
};
($name:ident, $col:expr) => {
pub const $name: Color = $col;
};
}
make_color!(TOP_BAR_BACKGROUND_COLOR, COLOR_BG1);
make_color!(LINK_COLOR, COLOR_ACCENT);
#[allow(dead_code)]
pub mod gruvbox_dark {
use druid::Color;
make_color!(COLOR_BG0_H, 0x1d, 0x20, 0x21);
make_color!(COLOR_BG0_S, 0x32, 0x20, 0x2f);
make_color!(COLOR_BG0, 0x28, 0x28, 0x28);
make_color!(COLOR_BG1, 0x3c, 0x38, 0x36);
make_color!(COLOR_BG2, 0x50, 0x49, 0x45);
make_color!(COLOR_BG3, 0x66, 0x5c, 0x54);
make_color!(COLOR_BG4, 0x7c, 0x6f, 0x64);
make_color!(COLOR_FG0, 0xfb, 0xf1, 0xc7);
make_color!(COLOR_FG1, 0xeb, 0xdb, 0xb2);
make_color!(COLOR_FG2, 0xd5, 0xc4, 0xa1);
make_color!(COLOR_FG3, 0xbd, 0xae, 0x93);
make_color!(COLOR_FG4, 0xa8, 0x99, 0x84);
make_color!(COLOR_BG, COLOR_BG0);
make_color!(COLOR_GRAY_LIGHT, 0x92, 0x83, 0x74);
make_color!(COLOR_RED_DARK, 0xcc, 0x24, 0x1d);
make_color!(COLOR_RED_LIGHT, 0xfb, 0x49, 0x34);
make_color!(COLOR_GREEN_DARK, 0x98, 0x97, 0x1a);
make_color!(COLOR_GREEN_LIGHT, 0xb8, 0xbb, 0x26);
make_color!(COLOR_YELLOW_DARK, 0xd7, 0x99, 0x21);
make_color!(COLOR_YELLOW_LIGHT, 0xfa, 0xbd, 0x2f);
make_color!(COLOR_BLUE_DARK, 0x45, 0x85, 0x88);
make_color!(COLOR_BLUE_LIGHT, 0x83, 0xa5, 0x98);
make_color!(COLOR_PURPLE_DARK, 0xb1, 0x26, 0x86);
make_color!(COLOR_PURPLE_LIGHT, 0xd3, 0x86, 0x9b);
make_color!(COLOR_AQUA_DARK, 0x68, 0x9d, 0x6a);
make_color!(COLOR_AQUA_LIGHT, 0x8e, 0xc0, 0x7c);
make_color!(COLOR_GRAY_DARK, 0xa8, 0x99, 0x84);
make_color!(COLOR_FG, COLOR_FG1);
make_color!(COLOR_ORANGE_DARK, 0xd6, 0x5d, 0x0e);
make_color!(COLOR_ORANGE_LIGHT, 0xfe, 0x80, 0x19);
make_color!(COLOR_ACCENT, COLOR_BLUE_LIGHT);
make_color!(COLOR_ACCENT_FG, COLOR_BG0_H);
}
pub trait ColorExt {
fn darken(&self, fac: f32) -> Self;
}
impl ColorExt for Color {
fn darken(&self, fac: f32) -> Self {
let (r, g, b, a) = self.as_rgba();
let rgb = Rgb::from(r as f32, g as f32, b as f32);
let rgb = rgb.lighten(-1. * fac);
Self::rgba(
rgb.get_red() as f64,
rgb.get_green() as f64,
rgb.get_blue() as f64,
a,
)
}
}

View file

@ -1,41 +0,0 @@
use druid::Color;
use usvg::{
Error, Fill, LineCap, LineJoin, NodeKind, NonZeroPositiveF64, Options, Paint, Stroke, Tree,
};
pub static ALERT_CIRCLE: &str = include_str!("../../../assets/tabler-icons/alert-circle.svg");
pub static CLOUD_DOWNLOAD: &str = include_str!("../../../assets/tabler-icons/cloud-download.svg");
pub fn parse_svg(svg: &str) -> Result<Tree, Error> {
let opt = Options::default();
Tree::from_str(svg, &opt.to_ref())
}
pub fn recolor_icon(tree: Tree, stroke: bool, color: Color) -> Tree {
let (red, green, blue, _) = color.as_rgba8();
let mut children = tree.root.children();
// The first element is always some kind of background placeholder
children.next();
for node in children {
if let NodeKind::Path(ref mut path) = *node.borrow_mut() {
if stroke {
path.stroke = Some(Stroke {
paint: Paint::Color(usvg::Color { red, green, blue }),
width: NonZeroPositiveF64::new(2.).expect("the value is not zero"),
linecap: LineCap::Round,
linejoin: LineJoin::Round,
..Default::default()
});
} else {
path.fill = Some(Fill {
paint: Paint::Color(usvg::Color { red, green, blue }),
..Default::default()
});
}
}
}
tree
}

View file

@ -1,13 +0,0 @@
use druid::{Color, Insets, Key};
pub const KEY_BUTTON_BG: Key<Color> = Key::new("dtmm.button.bg");
pub const KEY_BUTTON_BG_HOT: Key<Color> = Key::new("dtmm.button.bg-hot");
pub const KEY_BUTTON_BG_ACTIVE: Key<Color> = Key::new("dtmm.button.bg-active");
pub const KEY_BUTTON_BG_DISABLED: Key<Color> = Key::new("dtmm.button.bg-disabled");
pub const KEY_BUTTON_FG: Key<Color> = Key::new("dtmm.button.fg");
pub const KEY_BUTTON_FG_DISABLED: Key<Color> = Key::new("dtmm.button.fg-disabled");
pub const KEY_BUTTON_PADDING: Key<Insets> = Key::new("dtmm.button.padding");
pub const KEY_MOD_LIST_ITEM_BG_COLOR: Key<Color> = Key::new("dtmm.mod-list.item.background-color");

View file

@ -1,33 +0,0 @@
use druid::{Env, Insets};
use crate::state::State;
mod colors;
pub mod icons;
pub mod keys;
pub use colors::*;
pub const TOP_BAR_INSETS: Insets = Insets::uniform(5.0);
pub const DISABLED_ALPHA: f64 = 0.65;
pub(crate) fn set_theme_env(env: &mut Env, _: &State) {
env.set(druid::theme::TEXT_COLOR, COLOR_FG);
env.set(druid::theme::SCROLLBAR_COLOR, COLOR_FG);
env.set(druid::theme::BORDER_LIGHT, COLOR_FG);
env.set(druid::theme::BUTTON_BORDER_RADIUS, 2.);
env.set(keys::KEY_BUTTON_BG, COLOR_ACCENT);
env.set(keys::KEY_BUTTON_BG_HOT, COLOR_ACCENT.darken(0.03));
env.set(keys::KEY_BUTTON_BG_ACTIVE, COLOR_ACCENT.darken(0.1));
env.set(
keys::KEY_BUTTON_BG_DISABLED,
COLOR_ACCENT.with_alpha(DISABLED_ALPHA),
);
env.set(keys::KEY_BUTTON_FG, COLOR_ACCENT_FG);
env.set(
keys::KEY_BUTTON_FG_DISABLED,
COLOR_ACCENT_FG.with_alpha(DISABLED_ALPHA),
);
env.set(keys::KEY_BUTTON_PADDING, Insets::uniform_xy(8., 2.));
}

View file

@ -1,197 +0,0 @@
use druid::kurbo::Line;
use druid::widget::prelude::*;
use druid::{Color, KeyOrValue, Point, WidgetPod};
pub struct Border<T> {
inner: WidgetPod<T, Box<dyn Widget<T>>>,
color: BorderColor,
width: BorderWidths,
// corner_radius: KeyOrValue<RoundedRectRadii>,
}
impl<T: Data> Border<T> {
pub fn new(inner: impl Widget<T> + 'static) -> Self {
let inner = WidgetPod::new(inner).boxed();
Self {
inner,
color: Color::TRANSPARENT.into(),
width: 0f64.into(),
}
}
pub fn set_color(&mut self, color: impl Into<KeyOrValue<Color>>) {
self.color = BorderColor::Uniform(color.into());
}
pub fn with_color(mut self, color: impl Into<KeyOrValue<Color>>) -> Self {
self.set_color(color);
self
}
pub fn set_bottom_border(&mut self, width: impl Into<KeyOrValue<f64>>) {
self.width.bottom = width.into();
}
pub fn with_bottom_border(mut self, width: impl Into<KeyOrValue<f64>>) -> Self {
self.set_bottom_border(width);
self
}
pub fn set_top_border(&mut self, width: impl Into<KeyOrValue<f64>>) {
self.width.top = width.into();
}
pub fn with_top_border(mut self, width: impl Into<KeyOrValue<f64>>) -> Self {
self.set_top_border(width);
self
}
}
impl<T: Data> Widget<T> for Border<T> {
fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {
self.inner.event(ctx, event, data, env)
}
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {
self.inner.lifecycle(ctx, event, data, env);
}
fn update(&mut self, ctx: &mut UpdateCtx, _: &T, data: &T, env: &Env) {
self.inner.update(ctx, data, env);
}
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {
bc.debug_check("Border");
let (left, top, right, bottom) = self.width.resolve(env);
let inner_bc = bc.shrink((left + right, top + bottom));
let inner_size = self.inner.layout(ctx, &inner_bc, data, env);
let origin = Point::new(left, top);
self.inner.set_origin(ctx, origin);
let size = Size::new(
inner_size.width + left + right,
inner_size.height + top + bottom,
);
let insets = self.inner.compute_parent_paint_insets(size);
ctx.set_paint_insets(insets);
let baseline_offset = self.inner.baseline_offset();
if baseline_offset > 0. {
ctx.set_baseline_offset(baseline_offset + bottom);
}
size
}
fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {
let size = ctx.size();
let (left, top, right, bottom) = self.width.resolve(env);
let (col_left, col_top, col_right, col_bottom) = self.color.resolve(env);
self.inner.paint(ctx, data, env);
// There's probably a more elegant way to create the various `Line`s, but this works for now.
// The important bit is to move each line inwards by half each side's border width. Otherwise
// it would draw hald of the border outside of the widget's boundary.
if left > 0. {
ctx.stroke(
Line::new((left / 2., top / 2.), (left / 2., size.height)),
&col_left,
left,
);
}
if top > 0. {
ctx.stroke(
Line::new((left / 2., top / 2.), (size.width - (right / 2.), top / 2.)),
&col_top,
top,
);
}
if right > 0. {
ctx.stroke(
Line::new(
(size.width - (right / 2.), top / 2.),
(size.width - (right / 2.), size.height - (bottom / 2.)),
),
&col_right,
right,
);
}
if bottom > 0. {
ctx.stroke(
Line::new(
(left / 2., size.height - (bottom / 2.)),
(size.width - (right / 2.), size.height - (bottom / 2.)),
),
&col_bottom,
bottom,
);
}
}
}
#[derive(Clone, Debug)]
pub enum BorderColor {
Uniform(KeyOrValue<Color>),
// Individual {
// left: KeyOrValue<Color>,
// top: KeyOrValue<Color>,
// right: KeyOrValue<Color>,
// bottom: KeyOrValue<Color>,
// },
}
impl BorderColor {
pub fn resolve(&self, env: &Env) -> (Color, Color, Color, Color) {
match self {
Self::Uniform(val) => {
let color = val.resolve(env);
(color, color, color, color)
}
}
}
}
impl From<Color> for BorderColor {
fn from(value: Color) -> Self {
Self::Uniform(value.into())
}
}
#[derive(Clone, Debug)]
pub struct BorderWidths {
pub left: KeyOrValue<f64>,
pub top: KeyOrValue<f64>,
pub right: KeyOrValue<f64>,
pub bottom: KeyOrValue<f64>,
}
impl From<f64> for BorderWidths {
fn from(value: f64) -> Self {
Self {
left: value.into(),
top: value.into(),
right: value.into(),
bottom: value.into(),
}
}
}
impl BorderWidths {
pub fn resolve(&self, env: &Env) -> (f64, f64, f64, f64) {
(
self.left.resolve(env),
self.top.resolve(env),
self.right.resolve(env),
self.bottom.resolve(env),
)
}
}

View file

@ -1,113 +0,0 @@
use druid::widget::prelude::*;
use druid::widget::{Click, ControllerHost, Label, LabelText};
use druid::WidgetPod;
use druid::{Affine, WidgetExt};
use crate::ui::theme;
pub struct Button<T> {
inner: WidgetPod<T, Box<dyn Widget<T>>>,
inner_size: Size,
}
impl<T: Data> Button<T> {
pub fn new(inner: impl Widget<T> + 'static) -> Self {
let inner = inner.env_scope(|env, _| {
env.set(
druid::theme::TEXT_COLOR,
env.get(theme::keys::KEY_BUTTON_FG),
);
env.set(
druid::theme::DISABLED_TEXT_COLOR,
env.get(theme::keys::KEY_BUTTON_FG_DISABLED),
);
});
let inner = WidgetPod::new(inner).boxed();
Self {
inner,
inner_size: Size::ZERO,
}
}
pub fn with_label(text: impl Into<LabelText<T>>) -> Self {
let inner = Label::new(text);
Self::new(inner)
}
pub fn on_click(
self,
f: impl Fn(&mut EventCtx, &mut T, &Env) + 'static,
) -> ControllerHost<Self, Click<T>> {
ControllerHost::new(self, Click::new(f))
}
}
impl<T: Data> Widget<T> for Button<T> {
fn event(&mut self, ctx: &mut EventCtx, event: &Event, _: &mut T, _: &Env) {
match event {
Event::MouseDown(_) if !ctx.is_disabled() => {
ctx.set_active(true);
ctx.request_paint();
}
Event::MouseUp(_) => {
if ctx.is_active() && !ctx.is_disabled() {
ctx.request_paint();
}
ctx.set_active(false);
}
_ => {}
}
}
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {
if let LifeCycle::HotChanged(_) | LifeCycle::DisabledChanged(_) = event {
ctx.request_paint();
}
self.inner.lifecycle(ctx, event, data, env);
}
fn update(&mut self, ctx: &mut UpdateCtx, _: &T, data: &T, env: &Env) {
self.inner.update(ctx, data, env);
}
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {
bc.debug_check("Button");
let padding = env.get(theme::keys::KEY_BUTTON_PADDING).size();
let inner_bc = bc.shrink(padding).loosen();
self.inner_size = self.inner.layout(ctx, &inner_bc, data, env);
bc.constrain(Size::new(
self.inner_size.width + padding.width,
self.inner_size.height + padding.height,
))
}
fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {
let size = ctx.size();
let bg_color = if ctx.is_disabled() {
env.get(theme::keys::KEY_BUTTON_BG_DISABLED)
} else if ctx.is_hot() {
env.get(theme::keys::KEY_BUTTON_BG_HOT)
} else if ctx.is_active() {
env.get(theme::keys::KEY_BUTTON_BG_ACTIVE)
} else {
env.get(theme::keys::KEY_BUTTON_BG)
};
ctx.fill(
size.to_rect()
.to_rounded_rect(env.get(druid::theme::BUTTON_BORDER_RADIUS)),
&bg_color,
);
let inner_pos = (size.to_vec2() - self.inner_size.to_vec2()) / 2.;
ctx.with_save(|ctx| {
ctx.transform(Affine::translate(inner_pos));
self.inner.paint(ctx, data, env);
});
}
}

View file

@ -1,9 +1,7 @@
use druid::widget::{Button, Controller, Image, Scroll};
use druid::{
Data, Env, Event, EventCtx, ImageBuf, LifeCycle, LifeCycleCtx, Rect, UpdateCtx, Widget,
};
use druid::widget::{Button, Controller, Scroll};
use druid::{Data, Env, Event, EventCtx, Rect, UpdateCtx, Widget};
use crate::state::{State, ACTION_SET_DIRTY, ACTION_START_SAVE_SETTINGS};
use crate::state::{State, ACTION_START_SAVE_SETTINGS};
pub struct DisabledButtonController;
@ -50,26 +48,20 @@ impl<T: Data, W: Widget<T>> Controller<T, Scroll<T, W>> for AutoScrollController
data: &T,
env: &Env,
) {
child.update(ctx, old_data, data, env);
if !ctx.is_disabled() {
let size = child.child_size();
let end_region = Rect::new(size.width - 1., size.height - 1., size.width, size.height);
child.scroll_to(ctx, end_region);
}
child.update(ctx, old_data, data, env)
}
}
macro_rules! compare_state_fields {
($old:ident, $new:ident, $($field:ident),+) => {
$(!Data::same(&$old.$field, &$new.$field)) || +
}
}
/// A controller that submits the command to save settings every time its widget's
/// data changes.
pub struct SaveSettingsController;
/// A controller that tracks state changes for certain fields and submits commands to handle them.
pub struct DirtyStateController;
impl<W: Widget<State>> Controller<State, W> for DirtyStateController {
impl<W: Widget<State>> Controller<State, W> for SaveSettingsController {
fn update(
&mut self,
child: &mut W,
@ -78,59 +70,13 @@ impl<W: Widget<State>> Controller<State, W> for DirtyStateController {
data: &State,
env: &Env,
) {
// Only start tracking changes after the initial load has finished
if old_data.loading == data.loading {
if compare_state_fields!(
old_data,
data,
mods,
game_dir,
data_dir,
nexus_api_key,
is_io_enabled
) {
ctx.submit_command(ACTION_START_SAVE_SETTINGS);
}
if compare_state_fields!(old_data, data, mods, game_dir, is_io_enabled) {
ctx.submit_command(ACTION_SET_DIRTY);
}
// Only filter for the values that actually go into the settings file.
if old_data.mods != data.mods
|| old_data.game_dir != data.game_dir
|| old_data.data_dir != data.data_dir
{
ctx.submit_command(ACTION_START_SAVE_SETTINGS);
}
child.update(ctx, old_data, data, env)
}
}
pub struct ImageLensController;
impl Controller<ImageBuf, Image> for ImageLensController {
fn lifecycle(
&mut self,
widget: &mut Image,
ctx: &mut LifeCycleCtx,
event: &LifeCycle,
data: &ImageBuf,
env: &Env,
) {
if let LifeCycle::WidgetAdded = event {
widget.set_image_data(data.clone());
}
widget.lifecycle(ctx, event, data, env);
}
fn update(
&mut self,
widget: &mut Image,
ctx: &mut UpdateCtx,
old_data: &ImageBuf,
data: &ImageBuf,
env: &Env,
) {
if !Data::same(old_data, data) {
widget.set_image_data(data.clone());
}
widget.update(ctx, old_data, data, env);
}
}

View file

@ -2,11 +2,14 @@ use std::path::PathBuf;
use std::sync::Arc;
use druid::text::Formatter;
use druid::{Data, Widget};
pub mod border;
pub mod button;
pub mod controller;
pub trait ExtraWidgetExt<T: Data>: Widget<T> + Sized + 'static {}
impl<T: Data, W: Widget<T> + 'static> ExtraWidgetExt<T> for W {}
pub(crate) struct PathBufFormatter;
impl PathBufFormatter {

View file

@ -1,91 +0,0 @@
use color_eyre::{Handler, HelpInfo, Report};
use druid::widget::{CrossAxisAlignment, Flex, Label, LineBreaking};
use druid::{Data, WidgetExt, WindowDesc, WindowHandle};
use crate::ui::theme;
use crate::ui::widget::button::Button;
const WINDOW_SIZE: (f64, f64) = (600., 250.);
/// Show an error dialog.
/// The title and message are extracted from the error chain in the given `Report`.
pub fn error<T: Data>(err: Report, _parent: WindowHandle) -> WindowDesc<T> {
let (title, msg) = {
let count = err.chain().count();
if count == 1 {
// If there is only one error, that's all we can show.
(
String::from("An error occurred!"),
err.root_cause().to_string(),
)
} else {
let first = err.chain().next().unwrap();
let root = err.root_cause();
// If there is more than one error in the chain we want to show
// - The first one: This will describe the overall operation that failed
// - The root cause: The actual thing that failed (e.g. 'No such file or directory')
// - The one before the root cause: With diligent `wrap_err` usage, this will provide
// context to the root cause (e.g. the file name we failed to access)
//
// If there are only two errors, the first one is also the context to the root cause.
if count > 2 {
// The second to last one, the context to the root cause
let context = err.chain().nth(count - 2).unwrap();
(format!("{first}!"), format!("{}: {}", context, root))
} else {
("An error occurred!".to_string(), format!("{}: {}", first, root))
}
}
};
let title = Label::new(title)
.with_text_size(24.)
.with_text_color(theme::COLOR_RED_LIGHT);
let text = Label::new(msg).with_line_break_mode(LineBreaking::WordWrap);
let button = Button::with_label("Ok")
.on_click(|ctx, _, _| {
ctx.window().close();
})
.align_right();
let mut widget = Flex::column()
.cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(title)
.with_default_spacer()
.with_child(text);
if let Some(handler) = err.handler().downcast_ref::<Handler>() {
let mut first = true;
for section in handler.sections() {
if let HelpInfo::Suggestion(data, _) = section {
if first {
widget.add_default_spacer();
first = false;
}
let w = Flex::row()
.cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Label::new("Suggestion:").with_text_color(theme::COLOR_GREEN_LIGHT))
.with_spacer(2.)
.with_child(
Label::new(data.to_string()).with_line_break_mode(LineBreaking::WordWrap),
);
widget.add_child(w);
}
}
}
let widget = widget.with_flex_spacer(1.).with_child(button).padding(10.);
WindowDesc::new(widget)
.title("Critical Error")
.show_titlebar(true)
.with_min_size(WINDOW_SIZE)
.set_always_on_top(true)
.resizable(false)
}

View file

@ -1,43 +1,29 @@
use std::str::FromStr;
use std::sync::Arc;
use druid::im::Vector;
use druid::text::RichTextBuilder;
use druid::lens;
use druid::widget::{
Checkbox, CrossAxisAlignment, Either, Flex, Image, Label, LineBreaking, List,
MainAxisAlignment, Maybe, Scroll, SizedBox, Split, Svg, SvgData, TextBox, ViewSwitcher,
Button, Checkbox, CrossAxisAlignment, Flex, Label, LineBreaking, List, MainAxisAlignment,
Maybe, Scroll, SizedBox, Split, TextBox, ViewSwitcher,
};
use druid::{lens, Env};
use druid::{
Color, FileDialogOptions, FileSpec, FontDescriptor, FontFamily, LensExt, SingleUse, Widget,
WidgetExt, WindowDesc, WindowId,
Color, FileDialogOptions, FileSpec, FontDescriptor, FontFamily, Key, LensExt, SingleUse,
TextAlignment, Widget, WidgetExt, WindowDesc,
};
use druid::{Data, ImageBuf, LifeCycleCtx};
use druid_widget_nursery::WidgetExt as _;
use lazy_static::lazy_static;
use crate::state::{
ModInfo, NexusInfo, NexusInfoLens, State, View, ACTION_ADD_MOD, ACTION_OPEN_LINK,
ACTION_SELECTED_MOD_DOWN, ACTION_SELECTED_MOD_UP, ACTION_SELECT_MOD, ACTION_SET_WINDOW_HANDLE,
ACTION_START_CHECK_UPDATE, ACTION_START_DELETE_SELECTED_MOD, ACTION_START_DEPLOY,
ModInfo, State, View, ACTION_ADD_MOD, ACTION_SELECTED_MOD_DOWN, ACTION_SELECTED_MOD_UP,
ACTION_SELECT_MOD, ACTION_START_DELETE_SELECTED_MOD, ACTION_START_DEPLOY,
ACTION_START_RESET_DEPLOYMENT,
};
use crate::ui::theme::{self, ColorExt, COLOR_GREEN_LIGHT};
use crate::ui::widget::border::Border;
use crate::ui::widget::button::Button;
use crate::ui::widget::controller::{
AutoScrollController, DirtyStateController, ImageLensController,
};
use crate::ui::theme;
use crate::ui::widget::controller::{AutoScrollController, SaveSettingsController};
use crate::ui::widget::PathBufFormatter;
lazy_static! {
pub static ref WINDOW_ID: WindowId = WindowId::next();
}
const TITLE: &str = "Darktide Mod Manager";
const WINDOW_SIZE: (f64, f64) = (1080., 720.);
const MOD_DETAILS_MIN_WIDTH: f64 = 325.;
const KEY_MOD_LIST_ITEM_BG_COLOR: Key<Color> = Key::new("dtmm.mod-list.item.background-color");
pub(crate) fn new() -> WindowDesc<State> {
WindowDesc::new(build_window())
.title(TITLE)
@ -45,160 +31,70 @@ pub(crate) fn new() -> WindowDesc<State> {
}
fn build_top_bar() -> impl Widget<State> {
let mods_button = Button::with_label("Mods")
.on_click(|_ctx, state: &mut State, _env| state.current_view = View::Mods);
let settings_button =
Button::with_label("Settings").on_click(|_ctx, state: &mut State, _env| {
state.current_view = View::Settings;
});
let check_update_button = {
let make_button = || {
Button::with_label("Check for updates").on_click(|ctx, _: &mut State, _| {
ctx.submit_command(ACTION_START_CHECK_UPDATE);
})
};
Either::new(
|data, _| data.nexus_api_key.is_empty(),
make_button()
.tooltip(|_: &State, _: &Env| "A Nexus API key is required")
.disabled_if(|_, _| true),
make_button().disabled_if(|data, _| data.is_update_in_progress),
)
};
let deploy_button = {
let icon = Svg::new(SvgData::from_str(theme::icons::ALERT_CIRCLE).expect("invalid SVG"))
.fix_height(druid::theme::TEXT_SIZE_NORMAL);
let inner = Either::new(
|state: &State, _| state.dirty,
Flex::row()
.with_child(icon)
.with_spacer(3.)
.with_child(Label::new("Deploy Mods")),
Label::new("Deploy Mods"),
);
Button::new(inner)
.on_click(|ctx, _state: &mut State, _env| {
ctx.submit_command(ACTION_START_DEPLOY);
})
.disabled_if(|data, _| data.is_deployment_in_progress || data.is_reset_in_progress)
};
let reset_button = Button::with_label("Reset Game")
.on_click(|ctx, _state: &mut State, _env| {
ctx.submit_command(ACTION_START_RESET_DEPLOYMENT);
})
.disabled_if(|data, _| data.is_deployment_in_progress || data.is_reset_in_progress);
let bar = Flex::row()
Flex::row()
.must_fill_main_axis(true)
.main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(
Flex::row()
.with_child(mods_button)
.with_child(
Button::new("Mods")
.on_click(|_ctx, state: &mut State, _env| state.current_view = View::Mods),
)
.with_default_spacer()
.with_child(settings_button),
.with_child(
Button::new("Settings").on_click(|_ctx, state: &mut State, _env| {
state.current_view = View::Settings;
}),
),
)
.with_child(
Flex::row()
.with_child(check_update_button)
.with_child(
Button::new("Deploy Mods")
.on_click(|ctx, _state: &mut State, _env| {
ctx.submit_command(ACTION_START_DEPLOY);
})
.disabled_if(|data, _| {
data.is_deployment_in_progress || data.is_reset_in_progress
}),
)
.with_default_spacer()
.with_child(deploy_button)
.with_default_spacer()
.with_child(reset_button),
.with_child(
Button::new("Reset Game")
.on_click(|ctx, _state: &mut State, _env| {
ctx.submit_command(ACTION_START_RESET_DEPLOYMENT);
})
.disabled_if(|data, _| {
data.is_deployment_in_progress || data.is_reset_in_progress
}),
),
)
.padding(theme::TOP_BAR_INSETS)
.background(theme::TOP_BAR_BACKGROUND_COLOR);
Border::new(bar)
.with_color(theme::COLOR_FG2)
.with_bottom_border(1.)
.background(theme::TOP_BAR_BACKGROUND_COLOR)
// TODO: Add bottom border. Need a custom widget for that, as the built-in only provides
// uniform borders on all sides
}
fn build_mod_list() -> impl Widget<State> {
let list = List::new(|| {
let checkbox = Checkbox::new("")
.env_scope(|env, selected| {
env.set(druid::theme::BORDER_DARK, theme::COLOR_BG3);
env.set(druid::theme::BORDER_LIGHT, theme::COLOR_BG3);
env.set(druid::theme::TEXT_COLOR, theme::COLOR_ACCENT_FG);
if *selected {
env.set(druid::theme::BACKGROUND_DARK, theme::COLOR_ACCENT);
env.set(druid::theme::BACKGROUND_LIGHT, theme::COLOR_ACCENT);
} else {
env.set(druid::theme::BACKGROUND_DARK, Color::TRANSPARENT);
env.set(druid::theme::BACKGROUND_LIGHT, Color::TRANSPARENT);
}
})
.lens(lens!((usize, Arc<ModInfo>, bool), 1).then(ModInfo::enabled.in_arc()));
let name = Label::dynamic(|info: &Arc<ModInfo>, _| {
info.nexus
.as_ref()
.map(|n| n.name.clone())
.unwrap_or_else(|| info.name.clone())
})
.lens(lens!((usize, Arc<ModInfo>, bool), 1));
let version = {
let icon = {
let tree =
theme::icons::parse_svg(theme::icons::CLOUD_DOWNLOAD).expect("invalid SVG");
let tree = theme::icons::recolor_icon(tree, true, COLOR_GREEN_LIGHT);
Svg::new(tree).fix_height(druid::theme::TEXT_SIZE_NORMAL)
};
Either::new(
|info, _| {
info.nexus
.as_ref()
.map(|n| info.version != n.version)
.unwrap_or(false)
},
Flex::row()
.with_child(icon)
.with_spacer(3.)
.with_child(Label::raw().lens(ModInfo::version.in_arc())),
Label::raw().lens(ModInfo::version.in_arc()),
)
.lens(lens!((usize, Arc<ModInfo>, bool), 1))
};
let fields = Flex::row()
.must_fill_main_axis(true)
.main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(name)
.with_child(version);
let checkbox =
Checkbox::new("").lens(lens!((usize, ModInfo, bool), 1).then(ModInfo::enabled));
let name = Label::raw().lens(lens!((usize, ModInfo, bool), 1).then(ModInfo::name));
Flex::row()
.must_fill_main_axis(true)
.with_child(checkbox)
.with_flex_child(fields, 1.)
.with_child(name)
.padding((5.0, 4.0))
.background(theme::keys::KEY_MOD_LIST_ITEM_BG_COLOR)
.background(KEY_MOD_LIST_ITEM_BG_COLOR)
.on_click(|ctx, (i, _, _), _env| ctx.submit_command(ACTION_SELECT_MOD.with(*i)))
.env_scope(|env, (i, _, selected)| {
if *selected {
env.set(theme::keys::KEY_MOD_LIST_ITEM_BG_COLOR, theme::COLOR_ACCENT);
env.set(
druid::theme::TEXT_COLOR,
theme::COLOR_ACCENT_FG.darken(0.05),
);
env.set(KEY_MOD_LIST_ITEM_BG_COLOR, Color::NAVY);
} else if (i % 2) == 1 {
env.set(KEY_MOD_LIST_ITEM_BG_COLOR, Color::WHITE.with_alpha(0.05));
} else {
env.set(druid::theme::TEXT_COLOR, theme::COLOR_FG);
if (i % 2) == 1 {
env.set(theme::keys::KEY_MOD_LIST_ITEM_BG_COLOR, theme::COLOR_BG1);
} else {
env.set(theme::keys::KEY_MOD_LIST_ITEM_BG_COLOR, theme::COLOR_BG);
}
env.set(KEY_MOD_LIST_ITEM_BG_COLOR, Color::TRANSPARENT);
}
})
});
@ -213,10 +109,8 @@ fn build_mod_list() -> impl Widget<State> {
.collect::<Vector<_>>()
},
|state, infos| {
infos.into_iter().for_each(|(i, new, _)| {
if !Data::same(&state.mods.get(i).cloned(), &Some(new.clone())) {
state.mods.set(i, new);
}
infos.into_iter().for_each(|(i, info, _)| {
state.mods.set(i, info);
});
},
));
@ -228,36 +122,35 @@ fn build_mod_list() -> impl Widget<State> {
}
fn build_mod_details_buttons() -> impl Widget<State> {
let button_move_up = Button::with_label("Move Up")
let button_move_up = Button::new("Move Up")
.on_click(|ctx, _state, _env| ctx.submit_command(ACTION_SELECTED_MOD_UP))
.disabled_if(|state: &State, _env: &druid::Env| !state.can_move_mod_up());
let button_move_down = Button::with_label("Move Down")
let button_move_down = Button::new("Move Down")
.on_click(|ctx, _state, _env| ctx.submit_command(ACTION_SELECTED_MOD_DOWN))
.disabled_if(|state: &State, _env: &druid::Env| !state.can_move_mod_down());
let button_toggle_mod = Maybe::new(
|| {
let inner = Label::dynamic(|enabled, _env| {
Button::dynamic(|enabled, _env| {
if *enabled {
"Disable Mod".into()
} else {
"Enable Mod".into()
}
});
Button::new(inner)
.on_click(|_ctx, enabled: &mut bool, _env| {
*enabled = !(*enabled);
})
.lens(ModInfo::enabled.in_arc())
})
.on_click(|_ctx, enabled: &mut bool, _env| {
*enabled = !(*enabled);
})
.lens(ModInfo::enabled)
},
// TODO: Gray out
|| Button::with_label("Enable Mod"),
|| Button::new("Enable Mod"),
)
.disabled_if(|info: &Option<Arc<ModInfo>>, _env: &druid::Env| info.is_none())
.disabled_if(|info: &Option<ModInfo>, _env: &druid::Env| info.is_none())
.lens(State::selected_mod);
let button_add_mod = Button::with_label("Add Mod").on_click(|ctx, _state: &mut State, _env| {
let button_add_mod = Button::new("Add Mod").on_click(|ctx, _state: &mut State, _env| {
let zip = FileSpec::new("Zip file", &["zip"]);
let opts = FileDialogOptions::new()
.allowed_types(vec![zip])
@ -268,15 +161,15 @@ fn build_mod_details_buttons() -> impl Widget<State> {
ctx.submit_command(druid::commands::SHOW_OPEN_PANEL.with(opts))
});
let button_delete_mod = Button::with_label("Delete Mod")
.on_click(|ctx, data: &mut Option<Arc<ModInfo>>, _env| {
let button_delete_mod = Button::new("Delete Mod")
.on_click(|ctx, data: &mut Option<ModInfo>, _env| {
if let Some(info) = data {
ctx.submit_command(
ACTION_START_DELETE_SELECTED_MOD.with(SingleUse::new(info.clone())),
);
}
})
.disabled_if(|info: &Option<Arc<ModInfo>>, _env: &druid::Env| info.is_none())
.disabled_if(|info: &Option<ModInfo>, _env: &druid::Env| info.is_none())
.lens(State::selected_mod);
Flex::column()
@ -305,94 +198,26 @@ fn build_mod_details_info() -> impl Widget<State> {
Maybe::new(
|| {
let name = Label::raw()
.with_text_alignment(TextAlignment::Center)
.with_text_size(24.)
// Force the label to take up the entire details' pane width,
// so that we can center-align it.
.expand_width()
.lens(NexusInfoLens::new(NexusInfo::name, ModInfo::name).in_arc());
let summary = Label::raw()
.lens(ModInfo::name);
let description = Label::raw()
.with_line_break_mode(LineBreaking::WordWrap)
.lens(NexusInfoLens::new(NexusInfo::summary, ModInfo::summary).in_arc());
.lens(ModInfo::description);
let version_line = Label::dynamic(|info: &Arc<ModInfo>, _| {
let author = info
.nexus
.as_ref()
.map(|n| &n.author)
.or(info.author.as_ref());
if let Some(author) = &author {
format!("Version: {}, by {author}", info.version)
} else {
format!("Version: {}", info.version)
}
});
let categories = Label::dynamic(|info: &Arc<ModInfo>, _| {
if info.categories.is_empty() {
String::from("Uncategorized")
} else {
info.categories.iter().enumerate().fold(
String::from("Category: "),
|mut s, (i, category)| {
if i > 0 {
s.push_str(", ");
}
s.push_str(category);
s
},
)
}
});
let nexus_link = Maybe::or_empty(|| {
let link = Label::raw().lens(NexusInfo::id.map(
|id| {
let url = format!("https://nexusmods.com/warhammer40kdarktide/mods/{}", id);
let mut builder = RichTextBuilder::new();
builder
.push("Open on Nexusmods")
.underline(true)
.text_color(theme::LINK_COLOR)
.link(ACTION_OPEN_LINK.with(Arc::new(url)));
builder.build()
},
|_, _| {},
));
Flex::column()
.cross_axis_alignment(CrossAxisAlignment::Start)
.main_axis_alignment(MainAxisAlignment::Start)
.with_child(link)
.with_spacer(4.)
})
.lens(ModInfo::nexus.in_arc());
let details = Flex::column()
Flex::column()
.cross_axis_alignment(CrossAxisAlignment::Start)
.main_axis_alignment(MainAxisAlignment::Start)
.with_child(name)
.with_spacer(4.)
.with_child(summary)
.with_spacer(4.)
.with_child(nexus_link)
.with_child(version_line)
.with_spacer(4.)
.with_child(categories)
.padding((4., 4.));
let image =
Maybe::or_empty(|| Image::new(ImageBuf::empty()).controller(ImageLensController))
.lens(ModInfo::image.in_arc());
Flex::column()
.main_axis_alignment(MainAxisAlignment::Start)
.must_fill_main_axis(true)
.cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(image)
.with_child(details)
.with_child(description)
},
Flex::column,
)
.padding((4., 4.))
.lens(State::selected_mod)
}
@ -402,7 +227,7 @@ fn build_mod_details() -> impl Widget<State> {
.cross_axis_alignment(CrossAxisAlignment::Start)
.main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_flex_child(build_mod_details_info(), 1.0)
.with_child(build_mod_details_buttons().padding((4., 4., 4., 8.)))
.with_child(build_mod_details_buttons().padding(4.))
}
fn build_view_mods() -> impl Widget<State> {
@ -443,47 +268,12 @@ fn build_view_settings() -> impl Widget<State> {
)
.expand_width();
let nexus_apy_key_setting = Flex::row()
.must_fill_main_axis(true)
.main_axis_alignment(MainAxisAlignment::Start)
.with_child(Label::new("Nexus API Key:"))
.with_default_spacer()
.with_flex_child(TextBox::new().expand_width().lens(State::nexus_api_key), 1.)
.expand_width();
let io_setting = Flex::row()
.must_fill_main_axis(true)
.main_axis_alignment(MainAxisAlignment::Start)
.with_child(Label::new("Enable unsafe I/O:"))
.with_default_spacer()
.with_child(Checkbox::from_label(Label::dynamic(
|enabled: &bool, _: &Env| {
if *enabled {
"Enabled".into()
} else {
"Disabled".into()
}
},
)))
.lens(State::is_io_enabled)
.tooltip(|_: &State, _: &Env| {
"Enabling this gives ANY mod full access to your files \
and the ability to load arbitrary software libraries.\n\
Only enable this if it is crucial for a mod's functionality, \
and you are sure none of the ones you have installed are malicious."
})
.expand_width();
let content = Flex::column()
.must_fill_main_axis(true)
.cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(data_dir_setting)
.with_default_spacer()
.with_child(game_dir_setting)
.with_default_spacer()
.with_child(io_setting)
.with_default_spacer()
.with_child(nexus_apy_key_setting);
.with_child(game_dir_setting);
SizedBox::new(content)
.width(800.)
@ -502,22 +292,17 @@ fn build_main() -> impl Widget<State> {
}
fn build_log_view() -> impl Widget<State> {
let list = List::new(|| {
Label::raw()
.with_font(FontDescriptor::new(FontFamily::MONOSPACE))
.with_line_break_mode(LineBreaking::WordWrap)
})
.lens(State::log)
.padding(4.)
.scroll()
.vertical()
.controller(AutoScrollController);
let font = FontDescriptor::new(FontFamily::MONOSPACE);
let label = Label::raw()
.with_font(font)
.with_line_break_mode(LineBreaking::WordWrap)
.lens(State::log)
.padding(4.)
.scroll()
.vertical()
.controller(AutoScrollController);
let inner = Border::new(list)
.with_color(theme::COLOR_FG2)
.with_top_border(1.);
SizedBox::new(inner).expand_width().height(128.0)
SizedBox::new(label).expand_width().height(128.0)
}
fn build_window() -> impl Widget<State> {
@ -527,10 +312,5 @@ fn build_window() -> impl Widget<State> {
.with_child(build_top_bar())
.with_flex_child(build_main(), 1.0)
.with_child(build_log_view())
.controller(DirtyStateController)
.on_added(|_, ctx: &mut LifeCycleCtx, _, _| {
ctx.submit_command(
ACTION_SET_WINDOW_HANDLE.with(SingleUse::new((*WINDOW_ID, ctx.window().clone()))),
);
})
.controller(SaveSettingsController)
}

View file

@ -1,92 +0,0 @@
use ansi_parser::{AnsiParser, AnsiSequence, Output};
use druid::text::{RichText, RichTextBuilder};
use druid::{Color, FontStyle, FontWeight};
use crate::ui::theme;
#[derive(Default, Debug)]
struct TextState {
color: Option<Color>,
dim: bool,
bold: bool,
underline: bool,
strikethrough: bool,
italic: bool,
}
pub fn ansi_to_rich_text(input: &str) -> RichText {
let mut builder = RichTextBuilder::new();
let mut state = TextState::default();
for token in input.ansi_parse() {
match token {
Output::TextBlock(text) => {
let mut attr = builder.push(text);
attr.underline(state.underline);
attr.strikethrough(state.strikethrough);
if state.bold {
attr.weight(FontWeight::BOLD);
}
if state.italic {
attr.style(FontStyle::Italic);
}
if let Some(color) = state.color {
attr.text_color(color);
}
}
Output::Escape(AnsiSequence::SetGraphicsMode(values)) => {
for v in values {
match v {
0 => {
state = Default::default();
break;
}
1 => state.bold = true,
2 => state.dim = true,
3 => state.italic = true,
4 => state.underline = true,
9 => state.strikethrough = true,
22 => {
state.bold = false;
state.dim = false;
}
23 => state.italic = false,
24 => state.underline = false,
29 => state.underline = false,
30..=40 | 90..=100 => {
let mut col = v - 30;
if col > 9 {
state.bold = true;
col -= 60;
}
state.color = match col {
// This escape code is usually called 'black', but is actually used
// as "foreground color", in regards to light themes.
0 => Some(theme::COLOR_FG),
1 => Some(theme::COLOR_RED_LIGHT),
2 => Some(theme::COLOR_GREEN_LIGHT),
3 => Some(theme::COLOR_YELLOW_LIGHT),
4 => Some(theme::COLOR_BLUE_LIGHT),
5 => Some(theme::COLOR_PURPLE_LIGHT),
6 => Some(theme::COLOR_AQUA_LIGHT),
// Similarly, 'white' is the background color
7 => Some(theme::COLOR_BG),
9 => None,
_ => unreachable!(),
};
}
_ => {}
}
}
}
Output::Escape(_) => {}
}
}
builder.build()
}

View file

@ -1,13 +1,12 @@
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::{fs, path::Path};
use clap::{parser::ValueSource, ArgMatches};
use color_eyre::{eyre::Context, Result};
use serde::{Deserialize, Serialize};
use tokio::fs;
use crate::state::{ActionState, ModInfo};
use crate::state::{ModInfo, State};
#[derive(Clone, Debug, Serialize)]
pub(crate) struct LoadOrderEntrySerialize<'a> {
@ -28,22 +27,17 @@ impl<'a> From<&'a ModInfo> for LoadOrderEntrySerialize<'a> {
pub(crate) struct ConfigSerialize<'a> {
game_dir: &'a Path,
data_dir: &'a Path,
nexus_api_key: &'a String,
mod_order: Vec<LoadOrderEntrySerialize<'a>>,
unsafe_io: bool,
}
impl<'a> From<&'a ActionState> for ConfigSerialize<'a> {
fn from(state: &'a ActionState) -> Self {
impl<'a> From<&'a State> for ConfigSerialize<'a> {
fn from(state: &'a State) -> Self {
Self {
game_dir: &state.game_dir,
data_dir: &state.data_dir,
nexus_api_key: &state.nexus_api_key,
unsafe_io: state.is_io_enabled,
mod_order: state
.mods
.iter()
.map(Arc::as_ref)
.map(LoadOrderEntrySerialize::from)
.collect(),
}
@ -60,17 +54,13 @@ pub(crate) struct LoadOrderEntry {
pub(crate) struct Config {
#[serde(skip)]
pub path: PathBuf,
#[serde(default = "get_default_data_dir")]
pub data_dir: PathBuf,
pub data_dir: Option<PathBuf>,
pub game_dir: Option<PathBuf>,
#[serde(default)]
pub unsafe_io: bool,
pub nexus_api_key: Option<String>,
#[serde(default)]
pub mod_order: Vec<LoadOrderEntry>,
}
#[cfg(not(target_os = "windows"))]
#[cfg(not(arget_os = "windows"))]
pub fn get_default_config_path() -> PathBuf {
let config_dir = std::env::var("XDG_CONFIG_DIR").unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| {
@ -89,7 +79,7 @@ pub fn get_default_config_path() -> PathBuf {
PathBuf::from(config_dir).join("dtmm").join("dtmm.cfg")
}
#[cfg(not(target_os = "windows"))]
#[cfg(not(arget_os = "windows"))]
pub fn get_default_data_dir() -> PathBuf {
let data_dir = std::env::var("XDG_DATA_DIR").unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| {
@ -104,67 +94,59 @@ pub fn get_default_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
pub fn get_default_data_dir() -> PathBuf {
let data_dir = std::env::var("LOCALAPPDATA").expect("appdata env var not set");
let data_dir = std::env::var("APPDATA").expect("appdata env var not set");
PathBuf::from(data_dir).join("dtmm")
}
#[tracing::instrument]
pub(crate) async fn read_config<P>(path: P, is_default: bool) -> Result<Config>
#[tracing::instrument(skip(matches),fields(path = ?matches.get_one::<PathBuf>("config")))]
pub(crate) fn read_config<P>(default: P, matches: &ArgMatches) -> Result<Config>
where
P: Into<PathBuf> + std::fmt::Debug,
{
let path = path.into();
let default_path = get_default_config_path();
let path = matches
.get_one::<PathBuf>("config")
.expect("argument missing despite default");
let default_path = default.into();
match fs::read(&path).await {
match fs::read(path) {
Ok(data) => {
let data = String::from_utf8(data).wrap_err_with(|| {
format!("Config file '{}' contains invalid UTF-8", path.display())
format!("config file {} contains invalid UTF-8", path.display())
})?;
let mut cfg: Config = serde_sjson::from_str(&data)
.wrap_err_with(|| format!("Invalid config file {}", path.display()))?;
cfg.path = path;
tracing::debug!("Read config file '{}': {:?}", cfg.path.display(), cfg);
.wrap_err_with(|| format!("invalid config file {}", path.display()))?;
cfg.path = path.clone();
Ok(cfg)
}
Err(err) if err.kind() == ErrorKind::NotFound => {
if !is_default {
if matches.value_source("config") != Some(ValueSource::DefaultValue) {
return Err(err)
.wrap_err_with(|| format!("Failed to read config file {}", path.display()))?;
.wrap_err_with(|| format!("failed to read config file {}", path.display()))?;
}
tracing::debug!(
"Config file not found at '{}', creating default.",
path.display()
);
{
let parent = default_path
.parent()
.expect("a file path always has a parent directory");
fs::create_dir_all(parent).await.wrap_err_with(|| {
format!("Failed to create directories {}", parent.display())
fs::create_dir_all(parent).wrap_err_with(|| {
format!("failed to create directories {}", parent.display())
})?;
}
let config = Config {
path: default_path,
data_dir: get_default_data_dir(),
data_dir: Some(get_default_data_dir()),
game_dir: None,
nexus_api_key: None,
mod_order: Vec::new(),
unsafe_io: false,
};
{
let data = serde_sjson::to_string(&config)
.wrap_err("Failed to serialize default config value")?;
fs::write(&config.path, data).await.wrap_err_with(|| {
.wrap_err("failed to serialize default config value")?;
fs::write(&config.path, data).wrap_err_with(|| {
format!(
"Failed to write default config to {}",
"failed to write default config to {}",
config.path.display()
)
})?;
@ -173,7 +155,7 @@ where
Ok(config)
}
Err(err) => {
Err(err).wrap_err_with(|| format!("Failed to read config file {}", path.display()))
Err(err).wrap_err_with(|| format!("failed to read config file {}", path.display()))
}
}
}

View file

@ -1,4 +1,3 @@
use clap::ValueEnum;
use tokio::sync::mpsc::UnboundedSender;
use tracing_error::ErrorLayer;
use tracing_subscriber::filter::FilterFn;
@ -8,34 +7,12 @@ use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::prelude::*;
use tracing_subscriber::EnvFilter;
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
impl From<LogLevel> for EnvFilter {
fn from(level: LogLevel) -> Self {
let filter = match level {
LogLevel::Trace => "error,dtmm=trace,sdk=trace",
LogLevel::Debug => "error,dtmm=debug,sdk=debug",
LogLevel::Info => "error,dtmm=info",
LogLevel::Warn => "error,dtmm=warn",
LogLevel::Error => "error",
};
EnvFilter::new(filter)
}
}
pub struct ChannelWriter {
tx: UnboundedSender<Vec<u8>>,
tx: UnboundedSender<String>,
}
impl ChannelWriter {
pub fn new(tx: UnboundedSender<Vec<u8>>) -> Self {
pub fn new(tx: UnboundedSender<String>) -> Self {
Self { tx }
}
}
@ -43,9 +20,11 @@ impl ChannelWriter {
impl std::io::Write for ChannelWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let tx = self.tx.clone();
let string = String::from_utf8_lossy(buf).to_string();
// The `send` errors when the receiving end has closed.
// But there's not much we can do at that point, so we just ignore it.
let _ = tx.send(buf.to_vec());
let _ = tx.send(string);
Ok(buf.len())
}
@ -55,36 +34,27 @@ impl std::io::Write for ChannelWriter {
}
}
pub fn create_tracing_subscriber(level: Option<LogLevel>, tx: Option<UnboundedSender<Vec<u8>>>) {
let mut env_layer = if let Some(level) = level {
EnvFilter::from(level)
} else if cfg!(debug_assertions) {
pub fn create_tracing_subscriber(tx: UnboundedSender<String>) {
let env_layer = if cfg!(debug_assertions) {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
} else {
EnvFilter::new("error,dtmm=info")
};
// The internal implementation of Druid's GTK file dialog turns
// cancelling the dialog into an error. The, also internal, wrapper
// then logs and swallows the error.
// Therefore, as a consumer of the library, we don't have any way
// to customize this behavior, and instead have to filter out the
// tracing event.
env_layer = env_layer.add_directive(
"druid_shell::backend::gtk::window=off"
.parse()
.expect("Invalid env filter directive"),
);
let stdout_layer = if cfg!(debug_assertions) {
let layer = fmt::layer().pretty();
Some(layer)
} else {
None
};
let stdout_layer = fmt::layer().pretty();
let channel_layer = tx.map(|tx| {
fmt::layer()
.event_format(dtmt_shared::Formatter)
.fmt_fields(debug_fn(dtmt_shared::format_fields))
.with_writer(move || ChannelWriter::new(tx.clone()))
.with_filter(FilterFn::new(dtmt_shared::filter_fields))
});
let channel_layer = fmt::layer()
// TODO: Re-enable and implement a formatter for the Druid widget
.with_ansi(false)
.event_format(dtmt_shared::Formatter)
.fmt_fields(debug_fn(dtmt_shared::format_fields))
.with_writer(move || ChannelWriter::new(tx.clone()))
.with_filter(FilterFn::new(dtmt_shared::filter_fields));
tracing_subscriber::registry()
.with(env_layer)

View file

@ -4,40 +4,31 @@ version = "0.3.0"
edition = "2021"
[dependencies]
async-recursion = { workspace = true }
clap = { workspace = true }
cli-table = { workspace = true }
color-eyre = { workspace = true }
confy = { workspace = true }
csv-async = { workspace = true }
dtmt-shared = { workspace = true }
futures = { workspace = true }
futures-util = { workspace = true }
glob = { workspace = true }
luajit2-sys = { workspace = true }
minijinja = { workspace = true }
nanorand = { workspace = true }
notify = { workspace = true }
oodle = { workspace = true }
path-clean = { workspace = true }
path-slash = { workspace = true }
pin-project-lite = { workspace = true }
promptly = { workspace = true }
sdk = { workspace = true }
serde = { workspace = true }
serde_sjson = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
tracing-error = { workspace = true }
tracing-subscriber = { workspace = true }
zip = { workspace = true }
# Cannot be a workspace dependencies when it's optional
shlex = { version = "1.2.0", optional = true }
clap = { version = "4.0.15", features = ["color", "derive", "std", "cargo", "unicode"] }
cli-table = { version = "0.4.7", default-features = false, features = ["derive"] }
color-eyre = "0.6.2"
confy = "0.5.1"
csv-async = { version = "1.2.4", features = ["tokio", "serde"] }
dtmt-shared = { path = "../../lib/dtmt-shared", version = "*" }
futures = "0.3.25"
futures-util = "0.3.24"
glob = "0.3.0"
libloading = "0.7.4"
nanorand = "0.7.0"
oodle = { path = "../../lib/oodle", version = "*" }
pin-project-lite = "0.2.9"
promptly = "0.3.1"
sdk = { path = "../../lib/sdk", version = "*" }
serde_sjson = { path = "../../lib/serde_sjson", version = "*" }
serde = { version = "1.0.147", features = ["derive"] }
string_template = "0.2.1"
tokio-stream = { version = "0.1.11", features = ["fs", "io-util"] }
tokio = { version = "1.21.2", features = ["rt-multi-thread", "fs", "process", "macros", "tracing", "io-util", "io-std"] }
tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
tracing = { version = "0.1.37", features = ["async-await"] }
zip = "0.6.3"
path-clean = "1.0.1"
[dev-dependencies]
tempfile = "3.3.0"
[features]
shlex-bench = ["dep:shlex"]

View file

@ -1,5 +1,3 @@
use std::collections::{HashMap, HashSet};
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@ -9,18 +7,15 @@ use color_eyre::{Help, Report};
use dtmt_shared::ModConfig;
use futures::future::try_join_all;
use futures::StreamExt;
use path_slash::PathExt;
use sdk::filetype::package::Package;
use sdk::murmur::IdString64;
use sdk::{Bundle, BundleFile};
use tokio::fs::{self, File};
use tokio::io::AsyncReadExt;
use tokio::sync::Mutex;
use crate::mods::archive::Archive;
const PROJECT_CONFIG_NAME: &str = "dtmt.cfg";
type FileIndexMap = HashMap<String, HashSet<String>>;
pub(crate) fn command_definition() -> Command {
Command::new("build")
.about("Build a project")
@ -33,35 +28,20 @@ pub(crate) fn command_definition() -> Command {
If omitted, dtmt will search from the current working directory upward.",
),
)
.arg(
Arg::new("out")
.long("out")
.short('o')
.default_value("out")
.value_parser(value_parser!(PathBuf))
.help("The directory to write output files to."),
)
.arg(
Arg::new("deploy")
.long("deploy")
.short('d')
.value_parser(value_parser!(PathBuf))
.help(
"If the path to the game (without the trailing '/bundle') is specified, \
deploy the newly built bundles. \
This will not adjust the bundle database or package files, so if files are \
added or removed, you will have to import into DTMM and re-deploy there.",
),
)
.arg(Arg::new("oodle").long("oodle").help(
"The oodle library to load. This may either be:\n\
- A library name that will be searched for in the system's default paths.\n\
- A file path relative to the current working directory.\n\
- An absolute file path.",
))
}
/// Try to find a `dtmt.cfg` in the given directory or traverse up the parents.
#[tracing::instrument]
async fn find_project_config(dir: Option<PathBuf>) -> Result<ModConfig> {
let (path, mut file) = if let Some(path) = dir {
let file = File::open(&path.join(PROJECT_CONFIG_NAME))
.await
.wrap_err_with(|| format!("Failed to open file: {}", path.display()))
.wrap_err_with(|| format!("failed to open file: {}", path.display()))
.with_suggestion(|| {
format!(
"Make sure the file at '{}' exists and is readable",
@ -85,7 +65,7 @@ async fn find_project_config(dir: Option<PathBuf>) -> Result<ModConfig> {
}
Err(err) => {
let err = Report::new(err)
.wrap_err(format!("Failed to open file: {}", path.display()));
.wrap_err(format!("failed to open file: {}", path.display()));
return Err(err);
}
}
@ -95,52 +75,47 @@ async fn find_project_config(dir: Option<PathBuf>) -> Result<ModConfig> {
let mut buf = String::new();
file.read_to_string(&mut buf)
.await
.wrap_err("Invalid UTF-8")?;
.wrap_err("invalid UTF-8")?;
let mut cfg: ModConfig =
serde_sjson::from_str(&buf).wrap_err("Failed to deserialize mod config")?;
serde_sjson::from_str(&buf).wrap_err("failed to deserialize mod config")?;
cfg.dir = path;
Ok(cfg)
}
/// Iterate over the paths in the given `Package` and
/// compile each file by its file type.
#[tracing::instrument(skip_all)]
async fn compile_package_files(pkg: &Package, cfg: &ModConfig) -> Result<Vec<BundleFile>> {
let root = Arc::new(&cfg.dir);
let name_overrides = &cfg.name_overrides;
async fn compile_package_files<P>(pkg: &Package, root: P) -> Result<Vec<BundleFile>>
where
P: AsRef<Path> + std::fmt::Debug,
{
let root = Arc::new(root.as_ref());
let tasks = pkg
.iter()
.flat_map(|(file_type, names)| {
names.iter().map(|name| {
.flat_map(|(file_type, paths)| {
paths.iter().map(|path| {
(
*file_type,
name,
path,
// Cloning the `Arc` here solves the issue that in the next `.map`, I need to
// `move` the closure parameters, but can't `move` `root` before it was cloned.
root.clone(),
)
})
})
.map(|(file_type, name, root)| async move {
let path = PathBuf::from(name);
let sjson = fs::read_to_string(&path)
.await
.wrap_err_with(|| format!("Failed to read file '{}'", path.display()))?;
.map(|(file_type, path, root)| async move {
let sjson = fs::read_to_string(&path).await?;
let name = path.with_extension("").to_slash_lossy().to_string();
let name = if let Some(new_name) = name_overrides.get(&name) {
let new_name = match u64::from_str_radix(new_name, 16) {
Ok(hash) => IdString64::from(hash),
Err(_) => IdString64::from(new_name.clone()),
};
tracing::info!("Overriding '{}' -> '{}'", name, new_name.display());
new_name
} else {
IdString64::from(name.clone())
};
BundleFile::from_sjson(name, file_type, sjson, root.as_ref()).await
let mut path = path.clone();
path.set_extension("");
BundleFile::from_sjson(
path.to_string_lossy().to_string(),
file_type,
sjson,
root.as_ref(),
)
.await
});
let results = futures::stream::iter(tasks)
@ -151,29 +126,10 @@ async fn compile_package_files(pkg: &Package, cfg: &ModConfig) -> Result<Vec<Bun
results.into_iter().collect()
}
/// Read a `.package` file, collect the referenced files
/// and compile all of them into a bundle.
#[tracing::instrument]
async fn build_package(
cfg: &ModConfig,
package: impl AsRef<Path> + std::fmt::Debug,
) -> Result<Bundle> {
let root = &cfg.dir;
let package = package.as_ref();
#[tracing::instrument(skip_all, fields(files = files.len()))]
fn compile_bundle(name: String, files: Vec<BundleFile>) -> Result<Bundle> {
let mut bundle = Bundle::new(name);
let mut path = root.join(package);
path.set_extension("package");
let sjson = fs::read_to_string(&path)
.await
.wrap_err_with(|| format!("Failed to read file {}", path.display()))?;
let pkg_name = package.to_slash_lossy().to_string();
let pkg = Package::from_sjson(sjson, pkg_name.clone(), root)
.await
.wrap_err_with(|| format!("Invalid package file {}", &pkg_name))?;
let files = compile_package_files(&pkg, cfg).await?;
let mut bundle = Bundle::new(pkg_name);
for file in files {
bundle.add_file(file);
}
@ -181,13 +137,38 @@ async fn build_package(
Ok(bundle)
}
/// Cleans the path of internal parent (`../`) or self (`./`) components,
/// and ensures that it is relative.
#[tracing::instrument]
async fn build_package<P1, P2>(package: P1, root: P2) -> Result<Bundle>
where
P1: AsRef<Path> + std::fmt::Debug,
P2: AsRef<Path> + std::fmt::Debug,
{
let root = root.as_ref();
let package = package.as_ref();
let mut path = root.join(package);
path.set_extension("package");
let sjson = fs::read_to_string(&path)
.await
.wrap_err_with(|| format!("failed to read file {}", path.display()))?;
let pkg_name = package.to_string_lossy().to_string();
let pkg = Package::from_sjson(sjson, pkg_name.clone(), root)
.await
.wrap_err_with(|| format!("invalid package file {}", &pkg_name))?;
compile_package_files(&pkg, root)
.await
.wrap_err("failed to compile package")
.and_then(|files| compile_bundle(pkg_name, files))
.wrap_err("failed to build bundle")
}
fn normalize_file_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
let path = path.as_ref();
if path.is_absolute() || path.has_root() {
let err = eyre::eyre!("Path is absolute: {}", path.display());
let err = eyre::eyre!("path is absolute: {}", path.display());
return Err(err).with_suggestion(|| "Specify a relative file path.".to_string());
}
@ -200,94 +181,74 @@ fn normalize_file_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
Ok(path)
}
#[tracing::instrument]
pub(crate) async fn read_project_config(dir: Option<PathBuf>) -> Result<ModConfig> {
let mut cfg = find_project_config(dir).await?;
#[tracing::instrument(skip_all)]
pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
let cfg = {
let dir = matches.get_one::<PathBuf>("directory").cloned();
let mut cfg = find_project_config(dir).await?;
if let Some(path) = cfg.image {
let path = normalize_file_path(path)
.wrap_err("Invalid config field 'image'")
cfg.resources.init = normalize_file_path(cfg.resources.init)
.wrap_err("invalid config field 'resources.init'")
.with_suggestion(|| {
"Specify a file path relative to and child path of the \
directory where 'dtmt.cfg' is."
.to_string()
})?;
cfg.image = Some(path);
}
cfg.resources.init = normalize_file_path(cfg.resources.init)
.wrap_err("Invalid config field 'resources.init'")
.with_suggestion(|| {
"Specify a file path relative to and child path of the \
directory where 'dtmt.cfg' is."
.to_string()
})
.with_suggestion(|| {
"Use 'dtmt new' in a separate directory to generate \
})
.with_suggestion(|| {
"Use 'dtmt new' in a separate directory to generate \
a valid mod template."
.to_string()
})?;
.to_string()
})?;
if let Some(path) = cfg.resources.data {
let path = normalize_file_path(path)
.wrap_err("Invalid config field 'resources.data'")
.with_suggestion(|| {
"Specify a file path relative to and child path of the \
if let Some(path) = cfg.resources.data {
let path = normalize_file_path(path)
.wrap_err("invalid config field 'resources.data'")
.with_suggestion(|| {
"Specify a file path relative to and child path of the \
directory where 'dtmt.cfg' is."
.to_string()
})
.with_suggestion(|| {
"Use 'dtmt new' in a separate directory to generate \
.to_string()
})
.with_suggestion(|| {
"Use 'dtmt new' in a separate directory to generate \
a valid mod template."
.to_string()
})?;
cfg.resources.data = Some(path);
}
.to_string()
})?;
cfg.resources.data = Some(path);
}
if let Some(path) = cfg.resources.localization {
let path = normalize_file_path(path)
.wrap_err("Invalid config field 'resources.localization'")
.with_suggestion(|| {
"Specify a file path relative to and child path of the \
if let Some(path) = cfg.resources.localization {
let path = normalize_file_path(path)
.wrap_err("invalid config field 'resources.localization'")
.with_suggestion(|| {
"Specify a file path relative to and child path of the \
directory where 'dtmt.cfg' is."
.to_string()
})
.with_suggestion(|| {
"Use 'dtmt new' in a separate directory to generate \
.to_string()
})
.with_suggestion(|| {
"Use 'dtmt new' in a separate directory to generate \
a valid mod template."
.to_string()
})?;
cfg.resources.localization = Some(path);
}
.to_string()
})?;
cfg.resources.localization = Some(path);
}
Ok(cfg)
}
cfg
};
#[tracing::instrument]
pub(crate) async fn build<P>(
cfg: &ModConfig,
out_path: impl AsRef<Path> + std::fmt::Debug,
game_dir: Arc<Option<P>>,
) -> Result<()>
where
P: AsRef<Path> + std::fmt::Debug,
{
let out_path = out_path.as_ref();
let dest = {
let mut path = PathBuf::from(&cfg.id);
path.set_extension("zip");
Arc::new(path)
};
let cfg = Arc::new(cfg);
fs::create_dir_all(out_path)
.await
.wrap_err_with(|| format!("Failed to create output directory '{}'", out_path.display()))?;
let file_map = Arc::new(Mutex::new(FileIndexMap::new()));
tracing::debug!(?cfg);
let tasks = cfg
.packages
.iter()
// The closure below would capture the `Arc`s before they could be cloned,
// so instead we need to clone them in a non-move block and inject them
// via parameters.
.map(|path| (path, cfg.clone(), file_map.clone(), game_dir.clone()))
.map(|(path, cfg, file_map, game_dir)| async move {
.map(|path| (path, cfg.clone()))
.map(|(path, cfg)| async move {
if path.extension().is_some() {
eyre::bail!(
"Package name must be specified without file extension: {}",
@ -295,120 +256,45 @@ where
);
}
let bundle = build_package(&cfg, path).await.wrap_err_with(|| {
build_package(path, &cfg.dir).await.wrap_err_with(|| {
format!(
"Failed to build package '{}' at '{}'",
"failed to build package {} in {}",
path.display(),
cfg.dir.display()
)
})?;
let bundle_name = match bundle.name() {
IdString64::Hash(_) => {
eyre::bail!("bundle name must be known as string. got hash")
}
IdString64::String(s) => s.clone(),
};
{
let mut file_map = file_map.lock().await;
let map_entry = file_map.entry(bundle_name).or_default();
for file in bundle.files() {
map_entry.insert(file.name(false, None));
}
}
let name = bundle.name().to_murmur64().to_string().to_ascii_lowercase();
let path = out_path.join(&name);
let data = bundle.to_binary()?;
tracing::trace!(
"Writing bundle {} to '{}'",
bundle.name().display(),
path.display()
);
fs::write(&path, &data)
.await
.wrap_err_with(|| format!("Failed to write bundle to '{}'", path.display()))?;
if let Some(game_dir) = game_dir.as_ref() {
let path = game_dir.as_ref().join(&name);
tracing::trace!(
"Deploying bundle {} to '{}'",
bundle.name().display(),
path.display()
);
fs::write(&path, &data)
.await
.wrap_err_with(|| format!("Failed to write bundle to '{}'", path.display()))?;
}
Ok(())
})
});
try_join_all(tasks)
let bundles = try_join_all(tasks)
.await
.wrap_err("Failed to build mod bundles")?;
.wrap_err("failed to build mod bundles")?;
let config_file = {
let path = cfg.dir.join("dtmt.cfg");
fs::read(&path)
.await
.wrap_err_with(|| format!("failed to read mod config at {}", path.display()))?
};
{
let path = out_path.join("files.sjson");
tracing::trace!(path = %path.display(), "Writing file index");
let file_map = file_map.lock().await;
let data = serde_sjson::to_string(file_map.deref())?;
fs::write(&path, data)
.await
.wrap_err_with(|| format!("Failed to write file index to '{}'", path.display()))?;
}
if let Some(img_path) = &cfg.image {
let path = cfg.dir.join(img_path);
let dest = out_path.join(img_path);
tracing::trace!(src = %path.display(), dest = %dest.display(), "Copying image file");
if let Some(parent) = dest.parent() {
fs::create_dir_all(&parent)
.await
.wrap_err_with(|| format!("Failed to create directory '{}'", parent.display()))?;
}
fs::copy(&path, &dest).await.wrap_err_with(|| {
format!(
"Failed to copy image from '{}' to '{}'",
path.display(),
dest.display()
)
})?;
}
tracing::info!("Compiled bundles written to '{}'", out_path.display());
if let Some(game_dir) = game_dir.as_ref() {
tracing::info!("Deployed bundles to '{}'", game_dir.as_ref().display());
let dest = dest.clone();
let id = cfg.id.clone();
tokio::task::spawn_blocking(move || {
let mut archive = Archive::new(id);
archive.add_config(config_file);
for bundle in bundles {
archive.add_bundle(bundle);
}
archive
.write(dest.as_ref())
.wrap_err("failed to write mod archive")
})
.await??;
}
Ok(())
}
#[tracing::instrument(skip_all)]
pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
let cfg = read_project_config(matches.get_one::<PathBuf>("directory").cloned()).await?;
let game_dir = matches
.get_one::<PathBuf>("deploy")
.map(|p| p.join("bundle"));
let out_path = matches
.get_one::<PathBuf>("out")
.expect("parameter should have default value");
tracing::debug!(?cfg, ?game_dir, ?out_path);
let game_dir = Arc::new(game_dir);
build(&cfg, out_path, game_dir).await?;
tracing::info!("Mod archive written to {}", dest.display());
Ok(())
}

View file

@ -1,174 +0,0 @@
use std::{io::Cursor, path::PathBuf};
use clap::{value_parser, Arg, ArgMatches, Command};
use color_eyre::{eyre::Context as _, Result};
use sdk::murmur::{HashGroup, IdString64, Murmur64};
use sdk::{BundleDatabase, FromBinary as _};
use tokio::fs;
pub(crate) fn command_definition() -> Command {
Command::new("db")
.about("Various operations regarding `bundle_database.data`.")
.subcommand_required(true)
.subcommand(
Command::new("list-files")
.about("List bundle contents")
.arg(
Arg::new("database")
.required(true)
.help("Path to the bundle database")
.value_parser(value_parser!(PathBuf)),
)
.arg(
Arg::new("bundle")
.help("The bundle name. If omitted, all bundles will be listed.")
.required(false),
),
)
.subcommand(
Command::new("list-bundles").about("List bundles").arg(
Arg::new("database")
.required(true)
.help("Path to the bundle database")
.value_parser(value_parser!(PathBuf)),
),
)
.subcommand(
Command::new("find-file")
.about("Find the bundle a file belongs to")
.arg(
Arg::new("database")
.required(true)
.help("Path to the bundle database")
.value_parser(value_parser!(PathBuf)),
)
.arg(
Arg::new("file-name")
.required(true)
.help("Name of the file. May be a hash in hex representation or a string"),
),
)
}
#[tracing::instrument(skip_all)]
pub(crate) async fn run(ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
let Some((op, sub_matches)) = matches.subcommand() else {
unreachable!("clap is configured to require a subcommand");
};
let database = {
let path = sub_matches
.get_one::<PathBuf>("database")
.expect("argument is required");
let binary = fs::read(&path)
.await
.wrap_err_with(|| format!("Failed to read file '{}'", path.display()))?;
let mut r = Cursor::new(binary);
BundleDatabase::from_binary(&mut r).wrap_err("Failed to parse bundle database")?
};
match op {
"list-files" => {
let index = database.files();
if let Some(bundle) = sub_matches.get_one::<String>("bundle") {
let hash = u64::from_str_radix(bundle, 16)
.map(Murmur64::from)
.wrap_err("Invalid hex sequence")?;
if let Some(files) = index.get(&hash) {
for file in files {
let name = ctx.lookup_hash(file.name, HashGroup::Filename);
let extension = file.extension.ext_name();
println!("{}.{}", name.display(), extension);
}
} else {
tracing::info!("Bundle {} not found in the database", bundle);
}
} else {
for (bundle_hash, files) in index.iter() {
let bundle_name = ctx.lookup_hash(*bundle_hash, HashGroup::Filename);
match bundle_name {
IdString64::String(name) => {
println!("{:016x} {}", bundle_hash, name);
}
IdString64::Hash(hash) => {
println!("{:016x}", hash);
}
}
for file in files {
let name = ctx.lookup_hash(file.name, HashGroup::Filename);
let extension = file.extension.ext_name();
match name {
IdString64::String(name) => {
println!("\t{:016x}.{:<12} {}", file.name, extension, name);
}
IdString64::Hash(hash) => {
println!("\t{:016x}.{}", hash, extension);
}
}
}
println!();
}
}
Ok(())
}
"list-bundles" => {
for bundle_hash in database.bundles().keys() {
let bundle_name = ctx.lookup_hash(*bundle_hash, HashGroup::Filename);
match bundle_name {
IdString64::String(name) => {
println!("{:016x} {}", bundle_hash, name);
}
IdString64::Hash(hash) => {
println!("{:016x}", hash);
}
}
}
Ok(())
}
"find-file" => {
let name = sub_matches
.get_one::<String>("file-name")
.expect("required argument");
let name = match u64::from_str_radix(name, 16).map(Murmur64::from) {
Ok(hash) => hash,
Err(_) => Murmur64::hash(name),
};
let bundles = database.files().iter().filter_map(|(bundle_hash, files)| {
if files.iter().any(|file| file.name == name) {
Some(bundle_hash)
} else {
None
}
});
let mut found = false;
for bundle in bundles {
found = true;
println!("{:016x}", bundle);
}
if !found {
std::process::exit(1);
}
Ok(())
}
_ => unreachable!(
"clap is configured to require a subcommand, and they're all handled above"
),
}
}

View file

@ -1,20 +1,17 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
use color_eyre::eyre::{self, bail, Context, Result};
use color_eyre::{Help, Report};
use color_eyre::eyre::{self, Context, Result};
use color_eyre::{Help, Report, SectionExt};
use futures::future::try_join_all;
use futures::StreamExt;
use glob::Pattern;
use sdk::{Bundle, BundleFile, CmdLine};
use sdk::{Bundle, BundleFile};
use tokio::fs;
use crate::cmd::util::resolve_bundle_paths;
use crate::shell_parse::ShellParser;
#[inline]
fn parse_glob_pattern(s: &str) -> Result<Pattern, String> {
match Pattern::new(s) {
Ok(p) => Ok(p),
@ -22,7 +19,6 @@ fn parse_glob_pattern(s: &str) -> Result<Pattern, String> {
}
}
#[inline]
fn flatten_name(s: &str) -> String {
s.replace('/', "_")
}
@ -37,7 +33,7 @@ pub(crate) fn command_definition() -> Command {
.value_parser(value_parser!(PathBuf))
.help(
"Path to the bundle(s) to read. If this points to a directory instead \
of a file, all files in that directory will be checked.",
of a file, all files in that directory will be checked.",
),
)
.arg(
@ -93,81 +89,30 @@ pub(crate) fn command_definition() -> Command {
Arg::new("ljd")
.long("ljd")
.help(
"A custom command line to execute ljd with. It is treated as follows:\n\
* if the argument is a valid path to an existing file:\n\
** if the file is called 'main.py', it is assumed that 'python.exe' \
exists in PATH to execute this with.\n\
** otherwise it is treated as an executable\n\
* if it's a single word, it's treated as an executable in PATH\n\
* otherwise it is treated as a command line template.\n\
In any case, the application being run must accept ljd's flags '-c' and '-f'.",
"Path to a custom ljd executable. If not set, \
`ljd` will be called from PATH.",
)
.default_value("ljd"),
)
// .arg(
// Arg::new("revorb")
// .long("revorb")
// .help(
// "Path to a custom revorb executable. If not set, \
// `revorb` will be called from PATH.",
// )
// .default_value("revorb"),
// )
// .arg(
// Arg::new("ww2ogg")
// .long("ww2ogg")
// .help(
// "Path to a custom ww2ogg executable. If not set, \
// `ww2ogg` will be called from PATH.\nSee the documentation for how \
// to set up the script for this.",
// )
// .default_value("ww2ogg"),
// )
}
#[tracing::instrument]
async fn parse_command_line_template(tmpl: &String) -> Result<CmdLine> {
if tmpl.trim().is_empty() {
eyre::bail!("Command line template must not be empty");
}
let mut cmd = if matches!(fs::try_exists(tmpl).await, Ok(true)) {
let path = PathBuf::from(tmpl);
if path.file_name() == Some(OsStr::new("main.py")) {
let mut cmd = CmdLine::new("python");
cmd.arg(path);
cmd
} else {
CmdLine::new(path)
}
} else {
let mut parsed = ShellParser::new(tmpl.as_bytes());
// Safety: The initial `tmpl` was a `&String` (i.e. valid UTF-8), and `shlex` does not
// insert or remove characters, nor does it split UTF-8 characters.
// So the resulting byte stream is still valid UTF-8.
let mut cmd = CmdLine::new(unsafe {
let bytes = parsed.next().expect("Template is not empty");
String::from_utf8_unchecked(bytes.to_vec())
});
while let Some(arg) = parsed.next() {
// Safety: See above.
cmd.arg(unsafe { String::from_utf8_unchecked(arg.to_vec()) });
}
if parsed.errored {
bail!("Invalid command line template");
}
cmd
};
// Add ljd flags
cmd.arg("-c");
tracing::debug!("Parsed command line template: {:?}", cmd);
Ok(cmd)
.arg(
Arg::new("revorb")
.long("revorb")
.help(
"Path to a custom revorb executable. If not set, \
`revorb` will be called from PATH.",
)
.default_value("revorb"),
)
.arg(
Arg::new("ww2ogg")
.long("ww2ogg")
.help(
"Path to a custom ww2ogg executable. If not set, \
`ww2ogg` will be called from PATH.\nSee the documentation for how \
to set up the script for this.",
)
.default_value("ww2ogg"),
)
}
#[tracing::instrument(skip_all)]
@ -176,19 +121,16 @@ pub(crate) async fn run(mut ctx: sdk::Context, matches: &ArgMatches) -> Result<(
let ljd_bin = matches
.get_one::<String>("ljd")
.expect("no default value for 'ljd' parameter");
// let revorb_bin = matches
// .get_one::<String>("revorb")
// .expect("no default value for 'revorb' parameter");
// let ww2ogg_bin = matches
// .get_one::<String>("ww2ogg")
// .expect("no default value for 'ww2ogg' parameter");
let revorb_bin = matches
.get_one::<String>("revorb")
.expect("no default value for 'revorb' parameter");
let ww2ogg_bin = matches
.get_one::<String>("ww2ogg")
.expect("no default value for 'ww2ogg' parameter");
ctx.ljd = parse_command_line_template(ljd_bin)
.await
.map(Option::Some)
.wrap_err("Failed to parse command line template for flag 'ljd'")?;
// ctx.revorb = Some(revorb_bin.clone());
// ctx.ww2ogg = Some(ww2ogg_bin.clone());
ctx.ljd = Some(ljd_bin.clone());
ctx.revorb = Some(revorb_bin.clone());
ctx.ww2ogg = Some(ww2ogg_bin.clone());
}
let includes = match matches.get_many::<Pattern>("include") {
@ -369,25 +311,14 @@ where
path.push(name);
if options.dry_run {
tracing::info!("Dry Run: Writing file '{}'", path.display());
tracing::info!(path = %path.display(), "Writing file");
} else {
tracing::info!("Writing file '{}'", path.display());
tracing::debug!(path = %path.display(), "Writing file");
tasks.push(tokio::spawn(async move {
if let Some(parent) = path.parent() {
fs::create_dir_all(&parent).await.wrap_err_with(|| {
format!(
"failed to create parent directories '{}'",
parent.display()
)
})?;
}
fs::write(&path, file.data()).await.wrap_err_with(|| {
format!(
"failed to write extracted file to disc: '{}'",
path.display()
)
})
fs::write(&path, file.data())
.await
.wrap_err("failed to write extracted file to disc")
.with_section(|| path.display().to_string().header("Path"))
}));
}
}
@ -411,9 +342,9 @@ where
path.push(name);
if options.dry_run {
tracing::info!("Dry Run: Writing file '{}'", path.display());
tracing::info!(path = %path.display(), "Writing file");
} else {
tracing::info!("Writing file '{}'", path.display());
tracing::debug!(path = %path.display(), "Writing file");
tasks.push(tokio::spawn(async move {
let parent = match path.parent() {
Some(parent) => parent,
@ -425,19 +356,17 @@ where
}
};
fs::create_dir_all(parent).await.wrap_err_with(|| {
format!(
"failed to create parent directory: '{}'",
parent.display()
)
})?;
fs::create_dir_all(parent)
.await
.wrap_err("failed to create parent directory")
.with_section(|| {
parent.display().to_string().header("Path")
})?;
fs::write(&path, file.data()).await.wrap_err_with(|| {
format!(
"failed to write extracted file to disc: '{}'",
path.display()
)
})
fs::write(&path, file.data())
.await
.wrap_err("failed to write extracted file to disc")
.with_section(|| path.display().to_string().header("Path"))
}));
}
}
@ -445,7 +374,10 @@ where
}
}
Err(err) => {
let err = err.wrap_err(format!("Failed to decompile file {}", name));
let err = err
.wrap_err("Failed to decompile")
.with_section(|| name.header("File"));
tracing::error!("{:?}", err);
}
};

View file

@ -61,7 +61,7 @@ pub(crate) async fn run(ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
if let Some(name) = matches.get_one::<String>("replace") {
let mut file = File::open(&file_path)
.await
.wrap_err_with(|| format!("Failed to open '{}'", file_path.display()))?;
.wrap_err_with(|| format!("failed to open '{}'", file_path.display()))?;
if let Some(variant) = bundle
.files_mut()
@ -72,7 +72,7 @@ pub(crate) async fn run(ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
let mut data = Vec::new();
file.read_to_end(&mut data)
.await
.wrap_err("Failed to read input file")?;
.wrap_err("failed to read input file")?;
variant.set_data(data);
} else {
let err = eyre::eyre!("No file '{}' in this bundle.", name)
@ -99,11 +99,11 @@ pub(crate) async fn run(ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
let out_path = matches.get_one::<PathBuf>("output").unwrap_or(bundle_path);
let data = bundle
.to_binary()
.wrap_err("Failed to write changed bundle to output")?;
.wrap_err("failed to write changed bundle to output")?;
fs::write(out_path, &data)
.await
.wrap_err("Failed to write data to output file")?;
.wrap_err("failed to write data to output file")?;
Ok(())
} else {

View file

@ -36,18 +36,6 @@ enum OutputFormat {
Text,
}
fn format_byte_size(size: usize) -> String {
if size < 1024 {
format!("{} Bytes", size)
} else if size < 1024 * 1024 {
format!("{} kB", size / 1024)
} else if size < 1024 * 1024 * 1024 {
format!("{} MB", size / (1024 * 1024))
} else {
format!("{} GB", size / (1024 * 1024 * 1024))
}
}
#[tracing::instrument(skip(ctx))]
async fn print_bundle_contents<P>(ctx: &sdk::Context, path: P, fmt: OutputFormat) -> Result<()>
where
@ -62,11 +50,7 @@ where
match fmt {
OutputFormat::Text => {
println!(
"Bundle: {} ({:016x})",
bundle.name().display(),
bundle.name()
);
println!("Bundle: {}", bundle.name().display());
for f in bundle.files().iter() {
if f.variants().len() != 1 {
@ -79,10 +63,9 @@ where
let v = &f.variants()[0];
println!(
"\t{}.{}: {} ({})",
"\t{}.{}: {} bytes",
f.base_name().display(),
f.file_type().ext_name(),
format_byte_size(v.size()),
v.size()
);
}
@ -115,7 +98,7 @@ pub(crate) async fn run(ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
async move {
if let Err(err) = print_bundle_contents(&ctx, &p, fmt)
.await
.wrap_err_with(|| format!("Failed to list contents of bundle {}", p.display()))
.wrap_err_with(|| format!("failed to list contents of bundle {}", p.display()))
{
tracing::error!("{err:?}");
}

View file

@ -1,7 +1,6 @@
use clap::{ArgMatches, Command};
use clap::{Arg, ArgMatches, Command};
use color_eyre::eyre::Result;
mod db;
mod decompress;
mod extract;
mod inject;
@ -11,11 +10,16 @@ pub(crate) fn command_definition() -> Command {
Command::new("bundle")
.subcommand_required(true)
.about("Manipulate the game's bundle files")
.arg(Arg::new("oodle").long("oodle").help(
"The oodle library to load. This may either be:\n\
- A library name that will be searched for in the system's default paths.\n\
- A file path relative to the current working directory.\n\
- An absolute file path.",
))
.subcommand(decompress::command_definition())
.subcommand(extract::command_definition())
.subcommand(inject::command_definition())
.subcommand(list::command_definition())
.subcommand(db::command_definition())
}
#[tracing::instrument(skip_all)]
@ -25,7 +29,6 @@ pub(crate) async fn run(ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
Some(("extract", sub_matches)) => extract::run(ctx, sub_matches).await,
Some(("inject", sub_matches)) => inject::run(ctx, sub_matches).await,
Some(("list", sub_matches)) => list::run(ctx, sub_matches).await,
Some(("db", sub_matches)) => db::run(ctx, sub_matches).await,
_ => unreachable!(
"clap is configured to require a subcommand, and they're all handled above"
),

View file

@ -122,7 +122,7 @@ pub(crate) async fn run(mut ctx: sdk::Context, matches: &ArgMatches) -> Result<(
.expect("required argument not found");
u64::from_str_radix(s, 16)
.wrap_err("Failed to parse argument as hexadecimal string")?
.wrap_err("failed to parse argument as hexadecimal string")?
};
let groups = sub_matches
@ -145,10 +145,7 @@ pub(crate) async fn run(mut ctx: sdk::Context, matches: &ArgMatches) -> Result<(
.get_one::<HashGroup>("group")
.expect("required argument not found");
let r: BufReader<Box<dyn tokio::io::AsyncRead + std::marker::Unpin>> = if let Some(name) =
path.file_name()
&& name == "-"
{
let r: BufReader<Box<dyn tokio::io::AsyncRead + std::marker::Unpin>> = if let Some(name) = path.file_name() && name == "-" {
let f = tokio::io::stdin();
BufReader::new(Box::new(f))
} else {

View file

@ -1,407 +0,0 @@
use std::collections::HashMap;
use std::ffi::{CStr, CString};
use std::path::{Path, PathBuf};
use clap::{value_parser, Arg, ArgMatches, Command};
use color_eyre::eyre::{self, Context};
use color_eyre::{Help, Report, Result};
use dtmt_shared::{ModConfig, ModConfigResources, ModDependency};
use futures::FutureExt;
use luajit2_sys as lua;
use tokio::fs;
use tokio_stream::wrappers::ReadDirStream;
use tokio_stream::StreamExt;
pub(crate) fn command_definition() -> Command {
Command::new("migrate")
.about("Migrate a mod project from the loose file structure to DTMT.")
.arg(
Arg::new("mod-file")
.required(true)
.value_parser(value_parser!(PathBuf))
.help("The path to the mod's '<id>.mod' file."),
)
.arg(
Arg::new("directory")
.required(true)
.value_parser(value_parser!(PathBuf))
.help(
"The directory to create the mod in. Within this directory, \
DTMT will create a new folder named after the mod ID and migrate files \
into that folder.",
),
)
}
#[derive(Clone, Debug)]
struct ModFile {
id: String,
init: PathBuf,
data: Option<PathBuf>,
localization: Option<PathBuf>,
}
// This piece of Lua code stubs DMF functions and runs a mod's `.mod` file to extract
// the contained information.
static MOD_FILE_RUNNER: &str = r#"
_DATA = {}
function fassert() end
function new_mod(id, options)
_DATA.id = id
_DATA.init = options.mod_script
_DATA.data = options.mod_data
_DATA.localization = options.mod_localization
end
dmf = {
dofile = function(self, file)
_DATA.init = file
end
}
_MOD().run()
"#;
#[tracing::instrument]
async fn evaluate_mod_file(path: impl AsRef<Path> + std::fmt::Debug) -> Result<ModFile> {
let path = path.as_ref();
let code = fs::read(path)
.await
.wrap_err_with(|| format!("Failed to read file '{}'", path.display()))?;
tokio::task::spawn_blocking(move || unsafe {
let state = lua::luaL_newstate();
lua::luaL_openlibs(state);
let code = CString::new(code).expect("Cannot build CString");
let name = CString::new("_MOD").expect("Cannot build CString");
match lua::luaL_loadstring(state, code.as_ptr()) as u32 {
lua::LUA_OK => {}
lua::LUA_ERRSYNTAX => {
let err = lua::lua_tostring(state, -1);
let err = CStr::from_ptr(err).to_string_lossy().to_string();
lua::lua_close(state);
eyre::bail!("Invalid syntax: {}", err);
}
lua::LUA_ERRMEM => {
lua::lua_close(state);
eyre::bail!("Failed to allocate sufficient memory")
}
_ => unreachable!(),
}
tracing::trace!("Loaded '.mod' code");
lua::lua_setglobal(state, name.as_ptr());
let code = CString::new(MOD_FILE_RUNNER).expect("Cannot build CString");
match lua::luaL_loadstring(state, code.as_ptr()) as u32 {
lua::LUA_OK => {}
lua::LUA_ERRSYNTAX => {
let err = lua::lua_tostring(state, -1);
let err = CStr::from_ptr(err).to_string_lossy().to_string();
lua::lua_close(state);
eyre::bail!("Invalid syntax: {}", err);
}
lua::LUA_ERRMEM => {
lua::lua_close(state);
eyre::bail!("Failed to allocate sufficient memory")
}
_ => unreachable!(),
}
match lua::lua_pcall(state, 0, 1, 0) as u32 {
lua::LUA_OK => {}
lua::LUA_ERRRUN => {
let err = lua::lua_tostring(state, -1);
let err = CStr::from_ptr(err).to_string_lossy().to_string();
lua::lua_close(state);
eyre::bail!("Failed to evaluate '.mod' file: {}", err);
}
lua::LUA_ERRMEM => {
lua::lua_close(state);
eyre::bail!("Failed to allocate sufficient memory")
}
// We don't use an error handler function, so this should be unreachable
lua::LUA_ERRERR => unreachable!(),
_ => unreachable!(),
}
tracing::trace!("Loaded file runner code");
let name = CString::new("_DATA").expect("Cannot build CString");
lua::lua_getglobal(state, name.as_ptr());
let id = {
let name = CString::new("id").expect("Cannot build CString");
lua::lua_getfield(state, -1, name.as_ptr());
let val = {
let ptr = lua::lua_tostring(state, -1);
let str = CStr::from_ptr(ptr);
str.to_str()
.expect("ID value is not a valid string")
.to_string()
};
lua::lua_pop(state, 1);
val
};
let path_prefix = format!("{id}/");
let init = {
let name = CString::new("init").expect("Cannot build CString");
lua::lua_getfield(state, -1, name.as_ptr());
let val = {
let ptr = lua::lua_tostring(state, -1);
let str = CStr::from_ptr(ptr);
str.to_str().expect("ID value is not a valid string")
};
lua::lua_pop(state, 1);
PathBuf::from(val.strip_prefix(&path_prefix).unwrap_or(val))
};
let data = {
let name = CString::new("data").expect("Cannot build CString");
lua::lua_getfield(state, -1, name.as_ptr());
if lua::lua_isnil(state, -1) > 0 {
None
} else {
let val = {
let ptr = lua::lua_tostring(state, -1);
let str = CStr::from_ptr(ptr);
str.to_str().expect("ID value is not a valid string")
};
lua::lua_pop(state, 1);
Some(PathBuf::from(val.strip_prefix(&path_prefix).unwrap_or(val)))
}
};
let localization = {
let name = CString::new("localization").expect("Cannot build CString");
lua::lua_getfield(state, -1, name.as_ptr());
if lua::lua_isnil(state, -1) > 0 {
None
} else {
let val = {
let ptr = lua::lua_tostring(state, -1);
let str = CStr::from_ptr(ptr);
str.to_str().expect("ID value is not a valid string")
};
lua::lua_pop(state, 1);
Some(PathBuf::from(val.strip_prefix(&path_prefix).unwrap_or(val)))
}
};
lua::lua_close(state);
let mod_file = ModFile {
id,
init,
data,
localization,
};
tracing::trace!(?mod_file);
Ok(mod_file)
})
.await
.map_err(Report::new)
.flatten()
.wrap_err("Failed to run mod file handler")
}
#[async_recursion::async_recursion]
#[tracing::instrument]
async fn process_directory<P1, P2>(path: P1, prefix: P2) -> Result<()>
where
P1: AsRef<Path> + std::fmt::Debug + std::marker::Send,
P2: AsRef<Path> + std::fmt::Debug + std::marker::Send,
{
let path = path.as_ref();
let prefix = prefix.as_ref();
let read_dir = fs::read_dir(&path)
.await
.wrap_err_with(|| format!("Failed to read directory '{}'", path.display()))?;
let stream = ReadDirStream::new(read_dir).map(|res| res.wrap_err("Failed to read dir entry"));
tokio::pin!(stream);
while let Some(res) = stream.next().await {
let entry = res?;
let in_path = entry.path();
let out_path = prefix.join(entry.file_name());
let t = entry.file_type().await?;
if t.is_dir() {
process_directory(in_path, out_path).await?;
} else {
tracing::trace!(
"Copying file '{}' -> '{}'",
in_path.display(),
out_path.display()
);
let res = fs::create_dir_all(prefix)
.then(|_| fs::copy(&in_path, &out_path))
.await
.wrap_err_with(|| {
format!(
"Failed to copy '{}' -> '{}'",
in_path.display(),
out_path.display()
)
});
if let Err(err) = res {
tracing::error!("{:?}", err);
}
}
}
Ok(())
}
#[tracing::instrument(skip_all)]
pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
let (mod_file, in_dir) = {
let path = matches
.get_one::<PathBuf>("mod-file")
.expect("Parameter is required");
let mod_file = evaluate_mod_file(&path)
.await
.wrap_err("Failed to evaluate '.mod' file")?;
(
mod_file,
path.parent().expect("A file path always has a parent"),
)
};
let out_dir = matches
.get_one::<PathBuf>("directory")
.expect("Parameter is required");
{
let is_dir = fs::metadata(out_dir)
.await
.map(|meta| meta.is_dir())
.unwrap_or(false);
if !is_dir {
let err = eyre::eyre!("Invalid output directory '{}'", out_dir.display());
return Err(err)
.with_suggestion(|| "Make sure the directory exists and is writable.".to_string());
}
}
let out_dir = out_dir.join(&mod_file.id);
fs::create_dir(&out_dir)
.await
.wrap_err_with(|| format!("Failed to create mod directory '{}'", out_dir.display()))?;
tracing::info!("Created mod directory '{}'", out_dir.display());
println!(
"Enter additional information about your mod '{}'!",
&mod_file.id
);
let name = promptly::prompt_default("Display name", mod_file.id.clone())
.map(|s: String| s.trim().to_string())?;
let summary = promptly::prompt("Short summary").map(|s: String| s.trim().to_string())?;
let author =
promptly::prompt_opt("Author").map(|opt| opt.map(|s: String| s.trim().to_string()))?;
let version = promptly::prompt_default("Version", String::from("0.1.0"))
.map(|s: String| s.trim().to_string())?;
let categories = promptly::prompt("Categories (comma separated list)")
.map(|s: String| s.trim().to_string())
.map(|s: String| s.split(',').map(|s| s.trim().to_string()).collect())?;
let packages = vec![PathBuf::from("packages/mods").join(&mod_file.id)];
let dtmt_cfg = ModConfig {
dir: out_dir,
id: mod_file.id,
name,
summary,
author,
version,
description: None,
image: None,
categories,
packages,
resources: ModConfigResources {
init: mod_file.init,
data: mod_file.data,
localization: mod_file.localization,
},
depends: vec![ModDependency::ID(String::from("DMF"))],
bundled: true,
name_overrides: HashMap::new(),
};
tracing::debug!(?dtmt_cfg);
{
let path = dtmt_cfg.dir.join("dtmt.cfg");
let data = serde_sjson::to_string(&dtmt_cfg).wrap_err("Failed to serialize dtmt.cfg")?;
fs::write(&path, &data)
.await
.wrap_err_with(|| format!("Failed to write '{}'", path.display()))?;
tracing::info!("Created mod configuration at '{}'", path.display());
}
{
let path = dtmt_cfg
.dir
.join(&dtmt_cfg.packages[0])
.with_extension("package");
let data = {
let mut map = HashMap::new();
map.insert("lua", vec![format!("scripts/mods/{}/*", dtmt_cfg.id)]);
map
};
let data = serde_sjson::to_string(&data).wrap_err("Failed to serialize package file")?;
fs::create_dir_all(path.parent().unwrap())
.then(|_| fs::write(&path, &data))
.await
.wrap_err_with(|| format!("Failed to write '{}'", path.display()))?;
tracing::info!("Created package file at '{}'", path.display());
}
{
let path = in_dir.join("scripts");
let scripts_dir = dtmt_cfg.dir.join("scripts");
process_directory(&path, &scripts_dir)
.await
.wrap_err_with(|| {
format!(
"Failed to copy files from '{}' to '{}'",
path.display(),
scripts_dir.display()
)
})?;
tracing::info!("Copied script files to '{}'", scripts_dir.display());
}
Ok(())
}

View file

@ -1,75 +1,38 @@
use std::collections::HashMap;
use std::path::PathBuf;
use clap::{Arg, ArgMatches, Command};
use color_eyre::eyre::{self, Context, Result};
use color_eyre::Help;
use futures::{StreamExt, TryStreamExt};
use minijinja::Environment;
use string_template::Template;
use tokio::fs::{self, DirBuilder};
const TEMPLATES: [(&str, &str); 5] = [
(
"dtmt.cfg",
r#"//
// This is your mod's main configuration file. It tells DTMT how to build the mod,
// and DTMM what to display to your users.
// Certain files have been pre-filled by the template, the ones commented out (`//`)
// are optional.
//
// A unique identifier (preferably lower case, alphanumeric)
id = "{{id}}"
// The display name that your users will see.
// This doesn't have to be unique, but you still want to avoid being confused with other
// mods.
r#"id = "{{id}}"
name = "{{name}}"
// It's good practice to increase this number whenever you publish changes.
// It's up to you if you use SemVer or something simpler like `1970-12-24`. It should sort and
// compare well, though.
description = "This is my new mod '{{name}}'!"
version = "0.1.0"
// author = ""
// A one- or two-line short description.
summary = "This is my new mod '{{name}}'!"
// description = ""
// image = "assets/logo.png"
// Can contain arbitrary strings. But to keep things consistent and useful,
// capitalize names and check existing mods for matching categories.
categories = [
Misc
// UI
// QoL
// Tools
]
// A list of mod IDs that this mod depends on. You can find
// those IDs by downloading the mod and extracting their `dtmt.cfg`.
// To make your fellow modders' lives easier, publish your own mods' IDs
// somewhere visible, such as the Nexusmods page.
depends = [
DMF
]
// The primary resources that serve as the entry point to your
// mod's code. Unless for very specific use cases, the generated
// values shouldn't be changed.
resources = {
init = "scripts/mods/{{id}}/init"
data = "scripts/mods/{{id}}/data"
localization = "scripts/mods/{{id}}/localization"
}
// The list of packages, or bundles, to build.
// Each one corresponds to a package definition in the named folder.
// For mods that contain only code and/or a few small assets, a single
// package will suffice.
packages = [
"packages/mods/{{id}}"
"packages/{{id}}"
]
depends = [
"dmf"
]
"#,
),
(
"packages/mods/{{id}}.package",
"packages/{{id}}.package",
r#"lua = [
"scripts/mods/{{id}}/*"
]
@ -80,6 +43,7 @@ packages = [
r#"local mod = get_mod("{{id}}")
-- Your mod code goes here.
-- https://vmf-docs.verminti.de
"#,
),
(
@ -122,7 +86,7 @@ pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()>
let root = if let Some(dir) = matches.get_one::<String>("root") {
if dir == "." {
std::env::current_dir()
.wrap_err("The current working dir is invalid")
.wrap_err("the current working dir is invalid")
.with_suggestion(|| "Change to a different directory.")?
} else {
PathBuf::from(dir)
@ -157,45 +121,34 @@ pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()>
tracing::debug!(root = %root.display(), name, id);
let render_ctx = minijinja::context!(name => name.as_str(), id => id.as_str());
let env = Environment::new();
let mut data = HashMap::new();
data.insert("name", name.as_str());
data.insert("id", id.as_str());
let templates = TEMPLATES
.iter()
.map(|(path_tmpl, content_tmpl)| {
env.render_str(path_tmpl, &render_ctx)
.wrap_err_with(|| format!("Failed to render template: {}", path_tmpl))
.and_then(|path| {
env.render_named_str(&path, content_tmpl, &render_ctx)
.wrap_err_with(|| format!("Failed to render template '{}'", &path))
.map(|content| (root.join(path), content))
})
let path = Template::new(path_tmpl).render(&data);
let content = Template::new(content_tmpl).render(&data);
(root.join(path), content)
})
.map(|res| async move {
match res {
Ok((path, content)) => {
let dir = path
.parent()
.ok_or_else(|| eyre::eyre!("invalid root path"))?;
.map(|(path, content)| async move {
let dir = path
.parent()
.ok_or_else(|| eyre::eyre!("invalid root path"))?;
DirBuilder::new()
.recursive(true)
.create(&dir)
.await
.wrap_err_with(|| {
format!("Failed to create directory {}", dir.display())
})?;
DirBuilder::new()
.recursive(true)
.create(&dir)
.await
.wrap_err_with(|| format!("failed to create directory {}", dir.display()))?;
tracing::trace!("Writing file {}", path.display());
tracing::trace!("Writing file {}", path.display());
fs::write(&path, content.as_bytes())
.await
.wrap_err_with(|| {
format!("Failed to write content to path {}", path.display())
})
}
Err(e) => Err(e),
}
fs::write(&path, content.as_bytes())
.await
.wrap_err_with(|| format!("failed to write content to path {}", path.display()))
});
futures::stream::iter(templates)

View file

@ -1,147 +0,0 @@
use std::io::{Cursor, Write};
use std::path::{Path, PathBuf};
use clap::{value_parser, Arg, ArgMatches, Command};
use color_eyre::eyre::{Context, Result};
use color_eyre::Help;
use dtmt_shared::ModConfig;
use path_slash::{PathBufExt, PathExt};
use tokio::fs;
use tokio_stream::wrappers::ReadDirStream;
use tokio_stream::StreamExt;
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
use crate::cmd::build::read_project_config;
pub(crate) fn command_definition() -> Command {
Command::new("package")
.about("Package compiled bundles for distribution")
.arg(
Arg::new("project")
.required(false)
.value_parser(value_parser!(PathBuf))
.help(
"The path to the project to build. \
If omitted, dtmt will search from the current working directory upward.",
),
)
.arg(
Arg::new("directory")
.long("directory")
.short('d')
.default_value("out")
.value_parser(value_parser!(PathBuf))
.help(
"The path to the directory were the compiled bundles were written to. \
This is the same directory as `dtmt build -o`",
),
)
.arg(
Arg::new("out")
.long("out")
.short('o')
.value_parser(value_parser!(PathBuf))
.help(
"The path to write the packaged file to. Will default to a file in the \
current working directory",
),
)
}
#[async_recursion::async_recursion]
async fn process_directory<P1, P2, W>(zip: &mut ZipWriter<W>, path: P1, prefix: P2) -> Result<()>
where
P1: AsRef<Path> + std::marker::Send,
P2: AsRef<Path> + std::marker::Send,
W: std::io::Write + std::io::Seek + std::marker::Send,
{
let path = path.as_ref();
let prefix = prefix.as_ref();
zip.add_directory(prefix.to_slash_lossy(), SimpleFileOptions::default())?;
let read_dir = fs::read_dir(&path)
.await
.wrap_err_with(|| format!("Failed to read directory '{}'", path.display()))?;
let stream = ReadDirStream::new(read_dir).map(|res| res.wrap_err("Failed to read dir entry"));
tokio::pin!(stream);
while let Some(res) = stream.next().await {
let entry = res?;
let in_path = entry.path();
let out_path = prefix.join(entry.file_name());
let t = entry.file_type().await?;
if t.is_file() || t.is_symlink() {
let data = fs::read(&in_path)
.await
.wrap_err_with(|| format!("Failed to read '{}'", in_path.display()))?;
{
zip.start_file(out_path.to_slash_lossy(), SimpleFileOptions::default())?;
zip.write_all(&data)?;
}
} else if t.is_dir() {
process_directory(zip, in_path, out_path).await?;
}
}
Ok(())
}
pub(crate) async fn package<P1, P2>(cfg: &ModConfig, path: P1, dest: P2) -> Result<()>
where
P1: AsRef<Path>,
P2: AsRef<Path>,
{
let path = path.as_ref();
let dest = dest.as_ref();
let mut zip = ZipWriter::new(Cursor::new(Vec::with_capacity(1024)));
process_directory(&mut zip, path, PathBuf::from(&cfg.id))
.await
.wrap_err("Failed to add directory to archive")?;
{
let name = PathBuf::from(&cfg.id).join("dtmt.cfg");
let path = cfg.dir.join("dtmt.cfg");
let data = fs::read(&path)
.await
.wrap_err_with(|| format!("Failed to read mod config at {}", path.display()))?;
zip.start_file(name.to_slash_lossy(), SimpleFileOptions::default())?;
zip.write_all(&data)?;
}
let data = zip.finish()?;
fs::write(dest, data.into_inner())
.await
.wrap_err_with(|| format!("Failed to write mod archive to '{}'", dest.display()))
.with_suggestion(|| "Make sure that parent directories exist.".to_string())?;
tracing::info!("Mod archive written to {}", dest.display());
Ok(())
}
#[tracing::instrument(skip_all)]
pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
let cfg = read_project_config(matches.get_one::<PathBuf>("project").cloned()).await?;
let dest = matches
.get_one::<PathBuf>("out")
.map(path_clean::clean)
.unwrap_or_else(|| PathBuf::from(format!("{}.zip", cfg.id)));
let path = cfg.dir.join(
matches
.get_one::<PathBuf>("directory")
.expect("parameter has default value"),
);
package(&cfg, path, dest).await
}

View file

@ -8,7 +8,7 @@ use tokio::fs;
use tokio_stream::wrappers::ReadDirStream;
#[tracing::instrument]
pub async fn process_path<P>(path: P) -> Vec<PathBuf>
pub async fn foo<P>(path: P) -> Vec<PathBuf>
where
P: AsRef<Path> + std::fmt::Debug,
{
@ -98,10 +98,7 @@ where
I: Iterator<Item = PathBuf> + std::fmt::Debug,
{
let tasks = paths.map(|p| async move {
// Clippy doesn't understand that the block here is required to `move` in the reference.
// The task is spawned to make sure tokio can distribute these over threads.
#[allow(clippy::redundant_async_block)]
match tokio::spawn(async move { process_path(&p).await }).await {
match tokio::spawn(async move { foo(&p).await }).await {
Ok(paths) => paths,
Err(err) => {
tracing::error!(%err, "failed to spawn task to resolve bundle paths");
@ -114,9 +111,6 @@ where
results.into_iter().flatten().collect()
}
// `tracing::instrument` generates code that triggers this warning.
// Not much we can do to prevent that.
#[allow(clippy::let_with_type_underscore)]
#[tracing::instrument(skip_all)]
pub fn resolve_bundle_paths<I>(paths: I) -> impl Stream<Item = PathBuf>
where
@ -135,12 +129,12 @@ mod tests {
use tempfile::tempdir;
use tokio::process::Command;
use super::process_path;
use super::foo;
#[tokio::test]
async fn resolve_single_file() {
let path = PathBuf::from("foo");
let paths = process_path(&path).await;
let paths = foo(&path).await;
assert_eq!(paths.len(), 1);
assert_eq!(paths[0], path);
}
@ -148,7 +142,7 @@ mod tests {
#[tokio::test]
async fn resolve_empty_directory() {
let dir = tempdir().expect("failed to create temporary directory");
let paths = process_path(dir).await;
let paths = foo(dir).await;
assert!(paths.is_empty());
}
@ -176,7 +170,7 @@ mod tests {
.await
.expect("failed to create temporary files");
let paths = process_path(dir).await;
let paths = foo(dir).await;
assert_eq!(bundle_names.len(), paths.len());

View file

@ -1,231 +1,24 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use std::path::PathBuf;
use clap::{value_parser, Arg, ArgAction, ArgMatches, Command};
use color_eyre::eyre::{Context, Result};
use dtmt_shared::ModConfig;
use notify::{Event, Watcher};
use clap::{value_parser, Arg, ArgMatches, Command};
use color_eyre::eyre::Result;
use crate::cmd::build::{build, read_project_config};
use super::package::package;
pub(crate) fn command_definition() -> Command {
pub(crate) fn _command_definition() -> Command {
Command::new("watch")
.about("Watch for file system changes and re-build the mod archive.")
.arg(
Arg::new("debounce")
.long("debounce")
.short('b')
.default_value("150")
.value_parser(value_parser!(u64))
.help(
"The delay to debounce events by. This avoids continously \
rebuilding on rapid file changes, such as version control checkouts.",
),
)
.about("Re-build the given directory on file changes.")
.arg(
Arg::new("directory")
.required(false)
.default_value(".")
.value_parser(value_parser!(PathBuf))
.help(
"The path to the project to build. \
If omitted, the current working directory is used.",
If omitted, the current working directory is used.",
),
)
.arg(
Arg::new("out")
.long("out")
.short('o')
.default_value("out")
.value_parser(value_parser!(PathBuf))
.help("The directory to write output files to."),
)
.arg(
Arg::new("deploy")
.long("deploy")
.short('d')
.value_parser(value_parser!(PathBuf))
.help(
"If the path to the game (without the trailing '/bundle') is specified, \
deploy the newly built bundles. \
This will not adjust the bundle database or package files, so if files are \
added or removed, you will have to import into DTMM and re-deploy there.",
),
)
.arg(
Arg::new("archive")
.long("archive")
.short('a')
.value_parser(value_parser!(PathBuf))
.help(
"The path to write the packaged file to. Will default to a file in the \
current working directory",
),
)
.arg(
Arg::new("ignore")
.long("ignore")
.short('i')
.value_parser(value_parser!(PathBuf))
.action(ArgAction::Append)
.help(
"A directory or file path to ignore. May be specified multiple times. \
The values of 'out' and 'archive' are ignored automatically.",
),
)
}
#[tracing::instrument]
async fn compile(
cfg: &ModConfig,
out_path: impl AsRef<Path> + std::fmt::Debug,
archive_path: impl AsRef<Path> + std::fmt::Debug,
game_dir: Arc<Option<impl AsRef<Path> + std::fmt::Debug>>,
) -> Result<()> {
let out_path = out_path.as_ref();
build(cfg, out_path, game_dir)
.await
.wrap_err("Failed to build bundles")?;
package(cfg, out_path, archive_path)
.await
.wrap_err("Failed to package bundles")
}
#[tracing::instrument(skip_all)]
pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()> {
let cfg = read_project_config(matches.get_one::<PathBuf>("directory").cloned())
.await
.wrap_err("failed to load project config")?;
tracing::debug!(?cfg);
let cfg = Arc::new(cfg);
let game_dir = matches
.get_one::<PathBuf>("deploy")
.map(path_clean::clean)
.map(|p| if p.is_absolute() { p } else { cfg.dir.join(p) })
.map(|p| p.join("bundle"));
let out_path = matches
.get_one::<PathBuf>("out")
.map(path_clean::clean)
.map(|p| if p.is_absolute() { p } else { cfg.dir.join(p) })
.expect("parameter should have default value");
let archive_path = matches
.get_one::<PathBuf>("archive")
.map(path_clean::clean)
.map(|p| if p.is_absolute() { p } else { cfg.dir.join(p) })
.unwrap_or_else(|| cfg.dir.join(format!("{}.zip", cfg.id)));
let ignored = {
let mut ignored: Vec<_> = matches
.get_many::<PathBuf>("ignore")
.unwrap_or_default()
.map(path_clean::clean)
.map(|p| if p.is_absolute() { p } else { cfg.dir.join(p) })
.collect();
ignored.push(out_path.clone());
ignored.push(archive_path.clone());
ignored
};
if tracing::enabled!(tracing::Level::INFO) {
let list = ignored.iter().fold(String::new(), |mut s, p| {
s.push_str("\n - ");
s.push_str(&p.display().to_string());
s
});
tracing::info!("Ignoring:{}", list);
}
let game_dir = Arc::new(game_dir);
let duration =
Duration::from_millis(matches.get_one::<u64>("debounce").copied().unwrap_or(150));
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
let ignored = match &res {
Ok(evt) => evt.paths.iter().any(|p1| {
let p1 = path_clean::clean(p1);
ignored.iter().any(|p2| p1.starts_with(p2))
}),
Err(_) => false,
};
tracing::trace!(?res, ignored, "Received file system event");
if !ignored {
if let Err(err) = tx.send(res) {
tracing::error!("Failed to send file system event: {:?}", err);
}
}
})
.wrap_err("failed to create file system watcher")?;
tracing::info!("Starting file watcher on '{}'", cfg.dir.display());
let path = cfg.dir.clone();
watcher
.watch(&path, notify::RecursiveMode::Recursive)
.wrap_err_with(|| {
format!(
"failed to watch directory for file changes: {}",
path.display()
)
})?;
tracing::trace!("Starting debounce loop");
let mut dirty = false;
loop {
// While we could just always await on the timeout, splitting things like this
// optimizes the case when no events happen for a while. Rather than being woken every
// `duration` just to do nothing, this way we always wait for a new event first until
// we start the debounce timeouts.
if dirty {
match tokio::time::timeout(duration, rx.recv()).await {
// The error is the wanted case, as it signals that we haven't received an
// event within `duration`, which es what the debounce is supposed to wait for.
Err(_) => {
tracing::trace!("Received debounce timeout, running build");
if let Err(err) =
compile(&cfg, &out_path, &archive_path, game_dir.clone()).await
{
tracing::error!("Failed to build mod archive: {:?}", err);
}
dirty = false;
}
Ok(None) => {
break;
}
// We received a value before the timeout, so we reset it
Ok(_) => {
tracing::trace!("Received value before timeout, resetting");
}
}
} else {
match rx.recv().await {
Some(_) => {
tracing::trace!("Received event, starting debounce");
dirty = true;
}
None => {
break;
}
}
}
}
tracing::trace!("Event channel closed");
if let Err(err) = compile(&cfg, &out_path, &archive_path, game_dir.clone()).await {
tracing::error!("Failed to build mod archive: {:?}", err);
}
Ok(())
pub(crate) async fn run(_ctx: sdk::Context, _matches: &ArgMatches) -> Result<()> {
unimplemented!()
}

View file

@ -1,8 +1,5 @@
#![feature(io_error_more)]
#![feature(let_chains)]
#![feature(result_flattening)]
#![feature(test)]
#![windows_subsystem = "console"]
use std::path::PathBuf;
use std::sync::Arc;
@ -21,14 +18,15 @@ mod cmd {
pub mod build;
pub mod bundle;
pub mod dictionary;
pub mod migrate;
pub mod murmur;
pub mod new;
pub mod package;
mod util;
pub mod watch;
}
mod shell_parse;
mod mods {
pub mod archive;
}
#[derive(Default, Deserialize, Serialize)]
struct GlobalConfig {
@ -56,11 +54,9 @@ async fn main() -> Result<()> {
.subcommand(cmd::build::command_definition())
.subcommand(cmd::bundle::command_definition())
.subcommand(cmd::dictionary::command_definition())
.subcommand(cmd::migrate::command_definition())
.subcommand(cmd::murmur::command_definition())
.subcommand(cmd::new::command_definition())
.subcommand(cmd::package::command_definition())
.subcommand(cmd::watch::command_definition())
// .subcommand(cmd::watch::command_definition())
.get_matches();
dtmt_shared::create_tracing_subscriber();
@ -80,7 +76,7 @@ async fn main() -> Result<()> {
tokio::spawn(async move {
let res = File::open(&path)
.await
.wrap_err_with(|| format!("Failed to open dictionary file: {}", path.display()));
.wrap_err_with(|| format!("failed to open dictionary file: {}", path.display()));
let f = match res {
Ok(f) => f,
@ -107,7 +103,7 @@ async fn main() -> Result<()> {
tokio::spawn(async move {
let conf = tokio::task::spawn_blocking(|| {
confy::load::<GlobalConfig>(clap::crate_name!(), None)
.wrap_err("Failed to load global configuration")
.wrap_err("failed to load global configuration")
})
.await;
@ -130,14 +126,12 @@ async fn main() -> Result<()> {
};
match matches.subcommand() {
Some(("build", sub_matches)) => cmd::build::run(ctx, sub_matches).await?,
Some(("bundle", sub_matches)) => cmd::bundle::run(ctx, sub_matches).await?,
Some(("dictionary", sub_matches)) => cmd::dictionary::run(ctx, sub_matches).await?,
Some(("migrate", sub_matches)) => cmd::migrate::run(ctx, sub_matches).await?,
Some(("murmur", sub_matches)) => cmd::murmur::run(ctx, sub_matches).await?,
Some(("new", sub_matches)) => cmd::new::run(ctx, sub_matches).await?,
Some(("package", sub_matches)) => cmd::package::run(ctx, sub_matches).await?,
Some(("build", sub_matches)) => cmd::build::run(ctx, sub_matches).await?,
Some(("watch", sub_matches)) => cmd::watch::run(ctx, sub_matches).await?,
Some(("dictionary", sub_matches)) => cmd::dictionary::run(ctx, sub_matches).await?,
_ => unreachable!(
"clap is configured to require a subcommand, and they're all handled above"
),

View file

@ -0,0 +1,98 @@
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use color_eyre::eyre::{self, Context};
use color_eyre::Result;
use sdk::murmur::IdString64;
use sdk::Bundle;
use zip::ZipWriter;
pub struct Archive {
name: String,
bundles: Vec<Bundle>,
config_file: Option<Vec<u8>>,
}
impl Archive {
pub fn new(name: String) -> Self {
Self {
name,
bundles: Vec::new(),
config_file: None,
}
}
pub fn add_bundle(&mut self, bundle: Bundle) {
self.bundles.push(bundle)
}
pub fn add_config(&mut self, content: Vec<u8>) {
self.config_file = Some(content);
}
pub fn write<P>(&self, path: P) -> Result<()>
where
P: AsRef<Path>,
{
let config_file = self
.config_file
.as_ref()
.ok_or_else(|| eyre::eyre!("Config file is missing in mod archive"))?;
let f = File::create(path.as_ref()).wrap_err_with(|| {
format!(
"failed to open file for reading: {}",
path.as_ref().display()
)
})?;
let mut zip = ZipWriter::new(f);
zip.add_directory(&self.name, Default::default())?;
let base_path = PathBuf::from(&self.name);
{
let name = base_path.join("dtmt.cfg");
zip.start_file(name.to_string_lossy(), Default::default())?;
zip.write_all(config_file)?;
}
let mut file_map = HashMap::new();
for bundle in self.bundles.iter() {
let bundle_name = match bundle.name() {
IdString64::Hash(_) => eyre::bail!("bundle name must be known as string. got hash"),
IdString64::String(s) => s,
};
let map_entry: &mut HashSet<_> = file_map.entry(bundle_name).or_default();
for file in bundle.files() {
map_entry.insert(file.name(false, None));
}
let name = bundle.name().to_murmur64();
let path = base_path.join(name.to_string().to_ascii_lowercase());
zip.start_file(path.to_string_lossy(), Default::default())?;
let data = bundle.to_binary()?;
zip.write_all(&data)?;
}
{
let data = serde_sjson::to_string(&file_map)?;
zip.start_file(
base_path.join("files.sjson").to_string_lossy(),
Default::default(),
)?;
zip.write_all(data.as_bytes())?;
}
zip.finish()?;
Ok(())
}
}

View file

@ -1,189 +0,0 @@
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
enum ParserState {
Start,
Word,
SingleQuote,
DoubleQuote,
}
pub struct ShellParser<'a> {
bytes: &'a [u8],
offset: usize,
pub errored: bool,
}
impl<'a> ShellParser<'a> {
pub fn new(bytes: &'a [u8]) -> Self {
Self {
bytes,
offset: 0,
errored: false,
}
}
fn parse_word(&mut self) -> Option<&'a [u8]> {
// The start of the current word. Certain leading characters should be ignored,
// so this might change.
let mut start = self.offset;
let mut state = ParserState::Start;
while self.offset < self.bytes.len() {
let c = self.bytes[self.offset];
self.offset += 1;
match state {
ParserState::Start => match c {
// Ignore leading whitespace
b' ' | b'\t' | b'\n' => start += 1,
b'\'' => {
state = ParserState::SingleQuote;
start += 1;
}
b'"' => {
state = ParserState::DoubleQuote;
start += 1;
}
_ => {
state = ParserState::Word;
}
},
ParserState::Word => match c {
// Unquoted whitespace ends the current word
b' ' | b'\t' | b'\n' => {
return Some(&self.bytes[start..self.offset - 1]);
}
_ => {}
},
ParserState::SingleQuote => match c {
b'\'' => {
return Some(&self.bytes[start..(self.offset - 1)]);
}
_ => {}
},
ParserState::DoubleQuote => match c {
b'"' => {
return Some(&self.bytes[start..(self.offset - 1)]);
}
_ => {}
},
}
}
match state {
ParserState::Start => None,
ParserState::Word => Some(&self.bytes[start..self.offset]),
ParserState::SingleQuote | ParserState::DoubleQuote => {
self.errored = true;
None
}
}
}
}
impl<'a> Iterator for ShellParser<'a> {
type Item = &'a [u8];
fn next(&mut self) -> Option<Self::Item> {
self.parse_word()
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_one_word() {
let mut it = ShellParser::new(b"hello");
assert_eq!(it.next(), Some("hello".as_bytes()));
assert_eq!(it.next(), None);
}
#[test]
fn test_one_single() {
let mut it = ShellParser::new(b"'hello'");
assert_eq!(it.next(), Some("hello".as_bytes()));
assert_eq!(it.next(), None);
}
#[test]
fn test_open_quote() {
let mut it = ShellParser::new(b"'hello");
assert_eq!(it.next(), None);
assert!(it.errored)
}
#[test]
fn test_ww2ogg() {
let mut it = ShellParser::new(
b"ww2ogg.exe --pcb \"/usr/share/ww2ogg/packed_cookbook_aoTuV_603.bin\"",
);
assert_eq!(it.next(), Some("ww2ogg.exe".as_bytes()));
assert_eq!(it.next(), Some("--pcb".as_bytes()));
assert_eq!(
it.next(),
Some("/usr/share/ww2ogg/packed_cookbook_aoTuV_603.bin".as_bytes())
);
assert_eq!(it.next(), None);
}
}
#[cfg(test)]
mod bench {
extern crate test;
use super::*;
#[cfg(feature = "shlex-bench")]
use shlex::bytes::Shlex;
use test::Bencher;
mod ww2ogg {
use super::*;
#[bench]
fn custom(b: &mut Bencher) {
let val = test::black_box(
b"ww2ogg.exe --pcb \"/usr/share/ww2ogg/packed_cookbook_aoTuV_603.bin\"",
);
b.iter(|| {
let it = ShellParser::new(val);
let _: Vec<_> = test::black_box(it.collect());
})
}
#[cfg(feature = "shlex-bench")]
#[bench]
fn shlex(b: &mut Bencher) {
let val = test::black_box(
b"ww2ogg.exe --pcb \"/usr/share/ww2ogg/packed_cookbook_aoTuV_603.bin\"",
);
b.iter(|| {
let it = Shlex::new(val);
let _: Vec<_> = test::black_box(it.collect());
})
}
}
mod one_single {
use super::*;
#[bench]
fn custom(b: &mut Bencher) {
let val = test::black_box(b"'hello'");
b.iter(|| {
let it = ShellParser::new(val);
let _: Vec<_> = test::black_box(it.collect());
})
}
#[cfg(feature = "shlex-bench")]
#[bench]
fn shlex(b: &mut Bencher) {
let val = test::black_box(b"'hello'");
b.iter(|| {
let it = Shlex::new(val);
let _: Vec<_> = test::black_box(it.collect());
})
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 B

After

Width:  |  Height:  |  Size: 58 KiB

@ -1 +0,0 @@
Subproject commit 27beb4bc1ffd2865a432e13f0588b5351ff419bf

@ -1 +0,0 @@
Subproject commit 228b8ca37ee79ab9afa45c40da415e4dcb029751

View file

@ -6,11 +6,8 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
ansi_term = { workspace = true }
color-eyre = { workspace = true }
serde = { workspace = true }
steamlocate = { workspace = true }
time = { workspace = true }
tracing = { workspace = true }
tracing-error = { workspace = true }
tracing-subscriber = { workspace = true }
serde = "1.0.152"
time = { version = "0.3.19", features = ["formatting", "local-offset", "macros"] }
tracing = "0.1.37"
tracing-error = "0.2.0"
tracing-subscriber = "0.3.16"

View file

@ -1,102 +1,28 @@
use std::collections::HashMap;
use std::path::PathBuf;
mod log;
use color_eyre::eyre::{OptionExt as _, WrapErr as _};
use color_eyre::Result;
use serde::{Deserialize, Serialize};
use steamlocate::SteamDir;
use time::OffsetDateTime;
use std::path::PathBuf;
pub use log::*;
mod log;
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct ModConfigResources {
pub init: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub data: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub localization: Option<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ModOrder {
Before,
After,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ModDependency {
ID(String),
Config { id: String, order: ModOrder },
}
// A bit dumb, but serde doesn't support literal values with the
// `default` attribute, only paths.
fn default_true() -> bool {
true
}
// Similarly dumb, as the `skip_serializing_if` attribute needs a function
fn is_true(val: &bool) -> bool {
*val
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct ModConfig {
#[serde(skip)]
pub dir: PathBuf,
pub dir: std::path::PathBuf,
pub id: String,
pub name: String,
pub summary: String,
pub description: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<PathBuf>,
#[serde(default)]
pub categories: Vec<String>,
#[serde(default)]
pub packages: Vec<PathBuf>,
pub packages: Vec<std::path::PathBuf>,
pub resources: ModConfigResources,
#[serde(default)]
pub depends: Vec<ModDependency>,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
pub bundled: bool,
#[serde(default)]
pub name_overrides: HashMap<String, String>,
}
pub const STEAMAPP_ID: u32 = 1361210;
#[derive(Debug)]
pub struct GameInfo {
pub path: PathBuf,
pub last_updated: OffsetDateTime,
}
pub fn collect_game_info() -> Result<Option<GameInfo>> {
let dir = SteamDir::locate().wrap_err("Failed to locate Steam installation")?;
let found = dir
.find_app(STEAMAPP_ID)
.wrap_err("Failed to look up game by Steam app ID")?;
let Some((app, _)) = found else {
return Ok(None);
};
let last_updated = app
.last_updated
.ok_or_eyre("Missing field 'last_updated'")?;
Ok(Some(GameInfo {
path: app.install_dir.into(),
last_updated: last_updated.into(),
}))
pub depends: Vec<String>,
}

View file

@ -1,11 +1,10 @@
use std::fmt::Result;
use ansi_term::Color;
use time::format_description::FormatItem;
use time::macros::format_description;
use time::OffsetDateTime;
use tracing::field::Field;
use tracing::{Event, Level, Metadata, Subscriber};
use tracing::{Event, Metadata, Subscriber};
use tracing_error::ErrorLayer;
use tracing_subscriber::filter::FilterFn;
use tracing_subscriber::fmt::format::{debug_fn, Writer};
@ -50,28 +49,7 @@ where
let time = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
let time = time.format(TIME_FORMAT).map_err(|_| std::fmt::Error)?;
let level = meta.level();
// Sadly, tracing's `Level` is a struct, not an enum, so we can't properly `match` it.
let color = if *level == Level::TRACE {
Color::Purple
} else if *level == Level::DEBUG {
Color::Blue
} else if *level == Level::INFO {
Color::Green
} else if *level == Level::WARN {
Color::Yellow
} else if *level == Level::ERROR {
Color::Red
} else {
unreachable!()
};
write!(
writer,
"[{}] [{:>5}] ",
time,
color.bold().paint(format!("{}", level))
)?;
write!(writer, "[{}] [{:>5}] ", time, meta.level())?;
ctx.field_format().format_fields(writer.by_ref(), event)?;
@ -84,7 +62,7 @@ pub fn create_tracing_subscriber() {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::try_new("info").unwrap());
let (dev_stdout_layer, prod_stdout_layer, filter_layer) = if cfg!(debug_assertions) {
let fmt_layer = fmt::layer().pretty().with_writer(std::io::stderr);
let fmt_layer = fmt::layer().pretty();
(Some(fmt_layer), None, None)
} else {
// Creates a layer that
@ -93,7 +71,6 @@ pub fn create_tracing_subscriber() {
// - does not print spans/targets
// - only prints time, not date
let fmt_layer = fmt::layer()
.with_writer(std::io::stderr)
.event_format(Formatter)
.fmt_fields(debug_fn(format_fields));

@ -1 +0,0 @@
Subproject commit 6d94a4dd2c296bf1f044ee4c70fb10dca4c1c241

View file

@ -1,21 +0,0 @@
[package]
name = "nexusmods"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
futures = "0.3.26"
lazy_static = "1.4.0"
regex = "1.7.1"
reqwest = { version = "0.12.4" }
serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.94"
thiserror = "2.0.0"
time = { version = "0.3.20", features = ["serde"] }
tracing = "0.1.37"
url = { version = "2.3.1", features = ["serde"] }
[dev-dependencies]
tokio = { version = "1.26.0", features = ["rt", "macros"] }

View file

@ -1,339 +0,0 @@
use std::collections::HashMap;
use std::convert::Infallible;
use lazy_static::lazy_static;
use regex::Regex;
use reqwest::header::{HeaderMap, HeaderValue, InvalidHeaderValue};
use reqwest::{Client, IntoUrl, RequestBuilder, Url};
use serde::Deserialize;
use thiserror::Error;
mod types;
use time::OffsetDateTime;
pub use types::*;
// TODO: Add OS information
const USER_AGENT: &str = concat!("DTMM/", env!("CARGO_PKG_VERSION"));
const GAME_ID: &str = "warhammer40kdarktide";
lazy_static! {
static ref BASE_URL: Url = Url::parse("https://api.nexusmods.com/v1/").unwrap();
static ref BASE_URL_GAME: Url =
Url::parse("https://api.nexusmods.com/v1/games/warhammer40kdarktide/").unwrap();
}
#[derive(Error, Debug)]
pub enum Error {
#[error("HTTP error: {0:?}")]
HTTP(#[from] reqwest::Error),
#[error("invalid URL: {0:?}")]
URLParseError(#[from] url::ParseError),
#[error("failed to deserialize due to {error}: {json}")]
Deserialize {
json: String,
error: serde_json::Error,
},
#[error(transparent)]
InvalidHeaderValue(#[from] InvalidHeaderValue),
#[error("this error cannot happen")]
Infallible(#[from] Infallible),
#[error("invalid NXM URL '{url}': {0}", url = .1.as_str())]
InvalidNXM(&'static str, Url),
#[error("{0}")]
Custom(String),
}
pub type Result<T> = std::result::Result<T, Error>;
pub struct Nxm {
pub mod_id: u64,
pub file_id: u64,
pub user_id: u64,
pub key: String,
pub expires: OffsetDateTime,
}
pub struct Api {
client: Client,
}
impl Api {
pub fn new(key: String) -> Result<Self> {
let mut headers = HeaderMap::new();
headers.insert("accept", HeaderValue::from_static("application/json"));
headers.insert("apikey", HeaderValue::from_str(&key)?);
let client = Client::builder()
.user_agent(USER_AGENT)
.default_headers(headers)
.build()?;
Ok(Self { client })
}
#[tracing::instrument(skip(self))]
async fn send<T>(&self, req: RequestBuilder) -> Result<T>
where
T: for<'a> Deserialize<'a>,
{
let res = req.send().await?.error_for_status()?;
tracing::trace!(?res);
let json = res.text().await?;
serde_json::from_str(&json).map_err(|error| Error::Deserialize { json, error })
}
#[tracing::instrument(skip(self))]
pub async fn user_validate(&self) -> Result<User> {
let url = BASE_URL.join("users/validate.json")?;
let req = self.client.get(url);
self.send(req).await
}
#[tracing::instrument(skip(self))]
pub async fn mods_updated(&self, period: UpdatePeriod) -> Result<Vec<UpdateInfo>> {
let url = BASE_URL_GAME.join("mods/updated.json")?;
let req = self.client.get(url).query(&[period]);
self.send(req).await
}
#[tracing::instrument(skip(self))]
pub async fn mods_id(&self, id: u64) -> Result<Mod> {
let url = BASE_URL_GAME.join(&format!("mods/{}.json", id))?;
let req = self.client.get(url);
self.send(req).await
}
#[tracing::instrument(skip(self))]
pub async fn file_version<T>(&self, id: u64, timestamp: T) -> Result<String>
where
T: std::fmt::Debug,
OffsetDateTime: PartialEq<T>,
{
let url = BASE_URL_GAME.join(&format!("mods/{id}/files.json"))?;
let req = self.client.get(url);
let files: FileList = self.send(req).await?;
let Some(file) = files
.files
.into_iter()
.find(|file| file.uploaded_timestamp == timestamp)
else {
let err = Error::Custom("Timestamp does not match any file".into());
return Err(err);
};
Ok(file.version)
}
#[tracing::instrument(skip(self))]
pub async fn picture(&self, url: impl IntoUrl + std::fmt::Debug) -> Result<Vec<u8>> {
let res = self.client.get(url).send().await?.error_for_status()?;
res.bytes()
.await
.map(|bytes| bytes.to_vec())
.map_err(From::from)
}
#[tracing::instrument(skip(self))]
pub async fn get_file_by_id(&self, mod_id: u64, file_id: u64) -> Result<File> {
let url = BASE_URL_GAME.join(&format!("mods/{mod_id}/files/{file_id}.json"))?;
let req = self.client.get(url);
self.send(req).await
}
pub fn parse_file_name<S: AsRef<str>>(
name: S,
) -> Option<(String, u64, String, OffsetDateTime)> {
lazy_static! {
static ref RE: Regex = Regex::new(r#"^(?P<name>.+?)-(?P<mod_id>[1-9]\d*)-(?P<version>.+?)-(?P<updated>[1-9]\d*)(?:\.\w+)?$"#).unwrap();
}
RE.captures(name.as_ref()).and_then(|cap| {
let name = cap.name("name").map(|s| s.as_str().to_string())?;
let mod_id = cap.name("mod_id").and_then(|s| s.as_str().parse().ok())?;
let version = cap.name("version").map(|s| s.as_str().replace('-', "."))?;
let updated = cap
.name("updated")
.and_then(|s| s.as_str().parse().ok())
.and_then(|s| OffsetDateTime::from_unix_timestamp(s).ok())?;
Some((name, mod_id, version, updated))
})
}
#[tracing::instrument(skip(self))]
pub async fn mods_download_link(
&self,
mod_id: u64,
file_id: u64,
key: String,
expires: OffsetDateTime,
) -> Result<Vec<DownloadLink>> {
let url =
BASE_URL_GAME.join(&format!("mods/{mod_id}/files/{file_id}/download_link.json"))?;
let req = self
.client
.get(url)
.query(&[("key", key)])
.query(&[("expires", expires.unix_timestamp())]);
self.send(req).await
}
pub async fn handle_nxm(&self, url: Url) -> Result<(Mod, File, Vec<u8>)> {
let nxm = Self::parse_nxm(url.clone())?;
let user = self.user_validate().await?;
if nxm.user_id != user.user_id {
return Err(Error::InvalidNXM("user_id mismtach", url));
}
let (mod_data, file_info, download_info) = futures::try_join!(
self.mods_id(nxm.mod_id),
self.get_file_by_id(nxm.mod_id, nxm.file_id),
self.mods_download_link(nxm.mod_id, nxm.file_id, nxm.key, nxm.expires)
)?;
let Some(download_url) = download_info.first().map(|i| i.uri.clone()) else {
return Err(Error::InvalidNXM("no download link", url));
};
let req = self.client.get(download_url);
let data = req.send().await?.bytes().await?;
Ok((mod_data, file_info, data.to_vec()))
}
pub fn parse_nxm(nxm: Url) -> Result<Nxm> {
if nxm.scheme() != "nxm" {
return Err(Error::InvalidNXM("Invalid scheme", nxm));
}
// Now it makes sense, why Nexus calls this field `game_domain_name`, when it's just
// another path segment in the regular API calls.
if nxm.host_str() != Some(GAME_ID) {
return Err(Error::InvalidNXM("Invalid game domain name", nxm));
}
let Some(mut segments) = nxm.path_segments() else {
return Err(Error::InvalidNXM("Missing path segments", nxm));
};
if segments.next() != Some("mods") {
return Err(Error::InvalidNXM(
"Unexpected path segment, expected 'mods'",
nxm,
));
}
let Some(mod_id) = segments.next().and_then(|id| id.parse().ok()) else {
return Err(Error::InvalidNXM("Invalid mod ID", nxm));
};
if segments.next() != Some("files") {
return Err(Error::InvalidNXM(
"Unexpected path segment, expected 'files'",
nxm,
));
}
let Some(file_id) = segments.next().and_then(|id| id.parse().ok()) else {
return Err(Error::InvalidNXM("Invalid file ID", nxm));
};
let mut query = HashMap::new();
let pairs = nxm.query_pairs();
for (key, val) in pairs {
query.insert(key, val);
}
let Some(key) = query.get("key") else {
return Err(Error::InvalidNXM("Missing query field 'key'", nxm));
};
let expires = query
.get("expires")
.and_then(|expires| expires.parse().ok())
.and_then(|expires| OffsetDateTime::from_unix_timestamp(expires).ok());
let Some(expires) = expires else {
return Err(Error::InvalidNXM("Missing query field 'expires'", nxm));
};
let user_id = query.get("user_id").and_then(|id| id.parse().ok());
let Some(user_id) = user_id else {
return Err(Error::InvalidNXM("Missing query field 'user_id'", nxm));
};
Ok(Nxm {
mod_id,
file_id,
key: key.to_string(),
expires,
user_id,
})
}
}
#[cfg(test)]
mod test {
use reqwest::Url;
use time::OffsetDateTime;
use crate::Api;
fn make_api() -> Api {
let key = std::env::var("NEXUSMODS_API_KEY").expect("'NEXUSMODS_API_KEY' env var missing");
Api::new(key).expect("failed to build API client")
}
#[tokio::test]
async fn mods_updated() {
let client = make_api();
client
.mods_updated(Default::default())
.await
.expect("failed to query 'mods_updated'");
}
#[tokio::test]
async fn user_validate() {
let client = make_api();
client
.user_validate()
.await
.expect("failed to query 'user_validate'");
}
#[tokio::test]
async fn mods_id() {
let client = make_api();
let dmf_id = 8;
client
.mods_id(dmf_id)
.await
.expect("failed to query 'mods_id'");
}
#[test]
fn parse_file_name() {
let file = "Darktide Mod Framework-8-23-3-04-1677966575.zip";
let (name, mod_id, version, updated) = Api::parse_file_name(file).unwrap();
assert_eq!(name, String::from("Darktide Mod Framework"));
assert_eq!(mod_id, 8);
assert_eq!(version, String::from("23-3-04"));
assert_eq!(
updated,
OffsetDateTime::from_unix_timestamp(1677966575).unwrap()
);
}
#[test]
fn parse_nxm() {
let nxm = Url::parse("nxm://warhammer40kdarktide/mods/8/files/1000172397?key=VZ86Guj_LosPvtkD90-ZQg&expires=1678359882&user_id=1234567").expect("invalid NXM example");
Api::parse_nxm(nxm).expect("failed to parse nxm link");
}
}

View file

@ -1,140 +0,0 @@
use reqwest::Url;
use serde::ser::SerializeTuple;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
#[derive(Debug, Deserialize)]
pub struct User {
pub user_id: u64,
pub name: String,
pub profile_url: Url,
// pub is_premium: bool,
// pub is_supporter: bool,
// pub email: String,
}
#[derive(Copy, Clone, Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModStatus {
Published,
}
#[derive(Copy, Clone, Debug, Deserialize)]
pub enum EndorseStatus {
Endorsed,
Undecided,
}
#[derive(Debug, Deserialize)]
pub struct ModEndorsement {
pub endorse_status: EndorseStatus,
#[serde(with = "time::serde::timestamp::option")]
pub timestamp: Option<OffsetDateTime>,
pub version: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct Mod {
pub name: String,
pub description: String,
pub summary: String,
pub picture_url: Url,
pub uid: u64,
pub mod_id: u64,
pub category_id: u64,
pub version: String,
#[serde(with = "time::serde::timestamp")]
pub created_timestamp: OffsetDateTime,
// created_time: OffsetDateTime,
#[serde(with = "time::serde::timestamp")]
pub updated_timestamp: OffsetDateTime,
// updated_time: OffsetDateTime,
pub author: String,
pub uploaded_by: String,
pub uploaded_users_profile_url: Url,
pub status: ModStatus,
pub available: bool,
pub endorsement: ModEndorsement,
// pub mod_downloads: u64,
// pub mod_unique_downloads: u64,
// pub game_id: u64,
// pub allow_rating: bool,
// pub domain_name: String,
// pub endorsement_count: u64,
// pub contains_adult_content: bool,
}
#[derive(Debug, Deserialize)]
pub struct File {
pub id: Vec<u64>,
pub uid: u64,
pub file_id: u64,
pub name: String,
pub version: String,
pub category_id: u64,
pub category_name: String,
pub is_primary: bool,
pub size: u64,
pub file_name: String,
#[serde(with = "time::serde::timestamp")]
pub uploaded_timestamp: OffsetDateTime,
pub mod_version: String,
pub external_virus_scan_url: String,
pub description: String,
pub size_kb: u64,
pub size_in_bytes: u64,
pub changelog_html: Option<String>,
pub content_preview_link: String,
}
#[derive(Debug, Deserialize)]
pub struct FileList {
pub files: Vec<File>,
// pub file_updates: Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct DownloadLink {
pub name: String,
pub short_name: String,
#[serde(alias = "URI")]
pub uri: Url,
}
#[derive(Debug, Deserialize)]
pub struct UpdateInfo {
pub mod_id: u64,
#[serde(with = "time::serde::timestamp")]
pub latest_file_update: OffsetDateTime,
#[serde(with = "time::serde::timestamp")]
pub latest_mod_activity: OffsetDateTime,
}
#[derive(Copy, Clone, Debug)]
pub enum UpdatePeriod {
Day,
Week,
Month,
}
impl Default for UpdatePeriod {
fn default() -> Self {
Self::Week
}
}
impl Serialize for UpdatePeriod {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut tup = serializer.serialize_tuple(2)?;
tup.serialize_element("period")?;
tup.serialize_element(match self {
Self::Day => "1d",
Self::Week => "1w",
Self::Month => "1m",
})?;
tup.end()
}
}

View file

@ -6,8 +6,8 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
color-eyre = { workspace = true }
tracing = { workspace = true }
color-eyre = "0.6.2"
tracing = "0.1.37"
[build-dependencies]
bindgen = "0.71.0"
bindgen = "0.64.0"

View file

@ -1,22 +1,21 @@
extern crate bindgen;
use std::env;
use std::path::PathBuf;
fn main() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("No CARGO_MANIFEST_DIR");
println!("cargo:rustc-link-search=native={}", &manifest_dir);
// Tell cargo to look for shared libraries in the specified directory
// println!("cargo:rustc-link-search=/path/to/lib");
if std::env::var("CARGO_CFG_TARGET_FAMILY") == Ok(String::from("windows")) {
let lib_name = if cfg!(debug_assertions) {
"oo2core_win64_debug"
} else {
"oo2core_win64"
};
println!("cargo:rustc-link-lib=static={}", lib_name);
// Tell cargo to tell rustc to link the system bzip2
// shared library.
if cfg!(target_os = "windows") {
println!("cargo:rustc-link-lib=oo2core_8_win64");
} else {
println!("cargo:rustc-link-lib=static=oo2corelinux64");
println!("cargo:rustc-link-lib=stdc++");
println!("cargo:rustc-link-lib=oo2corelinux64");
}
// Tell cargo to invalidate the built crate whenever the wrapper changes
println!("cargo:rerun-if-changed=oodle2.h");
// The bindgen::Builder is the main entry point
@ -31,7 +30,7 @@ fn main() {
.blocklist_file("stdlib.h")
// Tell cargo to invalidate the built crate whenever any of the
// included header files changed.
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.parse_callbacks(Box::new(bindgen::CargoCallbacks))
// Finish the builder and generate the bindings.
.generate()
// Unwrap the Result and panic on failure.

View file

@ -7,7 +7,6 @@ use std::ptr;
use color_eyre::{eyre, Result};
#[allow(dead_code)]
#[allow(clippy::identity_op)]
mod bindings {
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}

View file

@ -4,23 +4,23 @@ version = "0.3.0"
edition = "2021"
[dependencies]
async-recursion = { workspace = true }
bitflags = { workspace = true }
byteorder = { workspace = true }
color-eyre = { workspace = true }
csv-async = { workspace = true }
fastrand = { workspace = true }
futures = { workspace = true }
futures-util = { workspace = true }
glob = { workspace = true }
luajit2-sys = { workspace = true }
nanorand = { workspace = true }
oodle = { workspace = true }
path-slash = { workspace = true }
pin-project-lite = { workspace = true }
serde = { workspace = true }
serde_sjson = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
tracing-error = { workspace = true }
bitflags = "1.3.2"
byteorder = "1.4.3"
color-eyre = "0.6.2"
csv-async = { version = "1.2.4", features = ["tokio", "serde"] }
fastrand = "1.8.0"
futures = "0.3.25"
futures-util = "0.3.24"
glob = "0.3.0"
libloading = "0.7.4"
nanorand = "0.7.0"
pin-project-lite = "0.2.9"
serde = { version = "1.0.147", features = ["derive"] }
serde_sjson = { path = "../../lib/serde_sjson", version = "*" }
oodle = { path = "../../lib/oodle", version = "*" }
tokio = { version = "1.21.2", features = ["rt-multi-thread", "fs", "process", "macros", "tracing", "io-util", "io-std"] }
tokio-stream = { version = "0.1.11", features = ["fs", "io-util"] }
tracing = { version = "0.1.37", features = ["async-await"] }
tracing-error = "0.2.0"
luajit2-sys = "0.0.2"
async-recursion = "1.0.2"

View file

@ -43,7 +43,6 @@ impl<T: FromBinary> FromBinary for Vec<T> {
}
pub mod sync {
use std::ffi::CStr;
use std::io::{self, Read, Seek, SeekFrom};
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
@ -134,23 +133,6 @@ pub mod sync {
make_skip!(skip_u8, read_u8, u8);
make_skip!(skip_u32, read_u32, u32);
// Implementation based on https://en.wikipedia.com/wiki/LEB128
fn read_uleb128(&mut self) -> io::Result<u64> {
let mut result: u64 = 0;
let mut shift: u64 = 0;
loop {
let byte = ReadExt::read_u8(self)? as u64;
result |= (byte & 0x7f) << shift;
if byte < 0x80 {
return Ok(result);
}
shift += 7;
}
}
fn skip_padding(&mut self) -> io::Result<()> {
let pos = self.stream_position()?;
let padding_size = 16 - (pos % 16);
@ -166,13 +148,25 @@ pub mod sync {
}
fn read_string_len(&mut self, len: usize) -> Result<String> {
let pos = self.stream_position();
let mut buf = vec![0; len];
let res = self
.read_exact(&mut buf)
.map_err(Report::new)
.and_then(|_| {
String::from_utf8(buf).map_err(|err| {
let ascii = String::from_utf8_lossy(err.as_bytes()).to_string();
let bytes = format!("{:?}", err.as_bytes());
Report::new(err)
.with_section(move || bytes.header("Bytes:"))
.with_section(move || ascii.header("ASCII:"))
})
});
let res = read_string_len(self, len);
if res.is_ok() {
return res;
}
let pos = self.stream_position();
if pos.is_ok() {
res.with_section(|| {
format!("{pos:#X} ({pos})", pos = pos.unwrap()).header("Position: ")
@ -232,22 +226,4 @@ pub mod sync {
Err(err).with_section(|| format!("{pos:#X} ({pos})").header("Position: "))
}
fn read_string_len(mut r: impl Read, len: usize) -> Result<String> {
let mut buf = vec![0; len];
r.read_exact(&mut buf)
.wrap_err_with(|| format!("Failed to read {} bytes", len))?;
let res = match CStr::from_bytes_until_nul(&buf) {
Ok(s) => {
let s = s.to_str()?;
Ok(s.to_string())
}
Err(_) => String::from_utf8(buf.clone()).map_err(Report::new),
};
res.wrap_err("Invalid binary for UTF8 string")
.with_section(|| format!("{}", String::from_utf8_lossy(&buf)).header("ASCI:"))
.with_section(|| format!("{:x?}", buf).header("Bytes:"))
}
}

View file

@ -13,21 +13,21 @@ use crate::binary::ToBinary;
use crate::murmur::Murmur64;
use crate::Bundle;
use super::filetype::BundleFileType;
use super::file::BundleFileType;
const DATABASE_VERSION: u32 = 0x6;
const FILE_VERSION: u32 = 0x4;
pub struct BundleFile {
pub name: String,
pub stream: String,
pub platform_specific: bool,
pub file_time: u64,
name: String,
stream: String,
platform_specific: bool,
file_time: u64,
}
pub struct FileName {
pub extension: BundleFileType,
pub name: Murmur64,
extension: BundleFileType,
name: Murmur64,
}
pub struct BundleDatabase {
@ -36,34 +36,7 @@ pub struct BundleDatabase {
bundle_contents: HashMap<Murmur64, Vec<FileName>>,
}
// Implements the partial Murmur that's used by the engine to compute bundle resource hashes,
// but in a way that the loop can be done outside the function.
#[inline(always)]
fn add_to_resource_hash(mut k: u64, name: impl Into<u64>) -> u64 {
const M: u64 = 0xc6a4a7935bd1e995;
const R: u64 = 47;
let mut h: u64 = name.into();
k = k.wrapping_mul(M);
k ^= k >> R;
k = k.wrapping_mul(M);
h ^= k;
k = M.wrapping_mul(h);
k
}
impl BundleDatabase {
pub fn bundles(&self) -> &HashMap<Murmur64, Vec<BundleFile>> {
&self.stored_files
}
pub fn files(&self) -> &HashMap<Murmur64, Vec<FileName>> {
&self.bundle_contents
}
pub fn add_bundle(&mut self, bundle: &Bundle) {
let hash = bundle.name().to_murmur64();
let name = hash.to_string();
@ -96,26 +69,20 @@ impl BundleDatabase {
}
}
let mut resource_hash = 0;
for f in bundle.files() {
let name = f.base_name().to_murmur64();
let file_name = FileName {
extension: f.file_type(),
name,
name: f.base_name().to_murmur64(),
};
resource_hash = add_to_resource_hash(resource_hash, name);
// TODO: Compute actual resource hash
self.resource_hashes.insert(hash, 0);
// TODO: Make sure each file name only exists once. Probably best to turn
// the `Vec` into a sorted `HashSet`.
self.bundle_contents
.entry(hash)
.or_default()
.push(file_name);
}
self.resource_hashes.insert(hash, resource_hash);
}
}
@ -136,7 +103,7 @@ impl FromBinary for BundleDatabase {
let mut stored_files = HashMap::with_capacity(num_entries);
for _ in 0..num_entries {
let hash = r.read_u64().map(Murmur64::from)?;
let hash = Murmur64::from(r.read_u64()?);
let num_files = r.read_u32()? as usize;
let mut files = Vec::with_capacity(num_files);
@ -194,7 +161,7 @@ impl FromBinary for BundleDatabase {
let mut resource_hashes = HashMap::with_capacity(num_hashes);
for _ in 0..num_hashes {
let name = r.read_u64().map(Murmur64::from)?;
let name = Murmur64::from(r.read_u64()?);
let hash = r.read_u64()?;
resource_hashes.insert(name, hash);
@ -204,14 +171,14 @@ impl FromBinary for BundleDatabase {
let mut bundle_contents = HashMap::with_capacity(num_contents);
for _ in 0..num_contents {
let hash = r.read_u64().map(Murmur64::from)?;
let hash = Murmur64::from(r.read_u64()?);
let num_files = r.read_u32()? as usize;
let mut files = Vec::with_capacity(num_files);
for _ in 0..num_files {
let extension = r.read_u64().map(BundleFileType::from)?;
let name = r.read_u64().map(Murmur64::from)?;
let extension = BundleFileType::from(r.read_u64()?);
let name = Murmur64::from(r.read_u64()?);
files.push(FileName { extension, name });
}

View file

@ -1,3 +1,4 @@
use std::ffi::CString;
use std::io::{Cursor, Read, Seek, Write};
use std::path::Path;
@ -5,12 +6,407 @@ use bitflags::bitflags;
use color_eyre::eyre::Context;
use color_eyre::{eyre, Result};
use futures::future::join_all;
use serde::Serialize;
use crate::binary::sync::*;
use crate::filetype::*;
use crate::murmur::{HashGroup, IdString64, Murmur64};
use super::filetype::BundleFileType;
#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)]
pub enum BundleFileType {
Animation,
AnimationCurves,
Apb,
BakedLighting,
Bik,
BlendSet,
Bones,
Chroma,
CommonPackage,
Config,
Crypto,
Data,
Entity,
Flow,
Font,
Ies,
Ini,
Input,
Ivf,
Keys,
Level,
Lua,
Material,
Mod,
MouseCursor,
NavData,
NetworkConfig,
OddleNet,
Package,
Particles,
PhysicsProperties,
RenderConfig,
RtPipeline,
Scene,
Shader,
ShaderLibrary,
ShaderLibraryGroup,
ShadingEnvionmentMapping,
ShadingEnvironment,
Slug,
SlugAlbum,
SoundEnvironment,
SpuJob,
StateMachine,
StaticPVS,
Strings,
SurfaceProperties,
Texture,
TimpaniBank,
TimpaniMaster,
Tome,
Ugg,
Unit,
Upb,
VectorField,
Wav,
WwiseBank,
WwiseDep,
WwiseEvent,
WwiseMetadata,
WwiseStream,
Xml,
Unknown(Murmur64),
}
impl BundleFileType {
pub fn ext_name(&self) -> String {
match self {
BundleFileType::AnimationCurves => String::from("animation_curves"),
BundleFileType::Animation => String::from("animation"),
BundleFileType::Apb => String::from("apb"),
BundleFileType::BakedLighting => String::from("baked_lighting"),
BundleFileType::Bik => String::from("bik"),
BundleFileType::BlendSet => String::from("blend_set"),
BundleFileType::Bones => String::from("bones"),
BundleFileType::Chroma => String::from("chroma"),
BundleFileType::CommonPackage => String::from("common_package"),
BundleFileType::Config => String::from("config"),
BundleFileType::Crypto => String::from("crypto"),
BundleFileType::Data => String::from("data"),
BundleFileType::Entity => String::from("entity"),
BundleFileType::Flow => String::from("flow"),
BundleFileType::Font => String::from("font"),
BundleFileType::Ies => String::from("ies"),
BundleFileType::Ini => String::from("ini"),
BundleFileType::Input => String::from("input"),
BundleFileType::Ivf => String::from("ivf"),
BundleFileType::Keys => String::from("keys"),
BundleFileType::Level => String::from("level"),
BundleFileType::Lua => String::from("lua"),
BundleFileType::Material => String::from("material"),
BundleFileType::Mod => String::from("mod"),
BundleFileType::MouseCursor => String::from("mouse_cursor"),
BundleFileType::NavData => String::from("nav_data"),
BundleFileType::NetworkConfig => String::from("network_config"),
BundleFileType::OddleNet => String::from("oodle_net"),
BundleFileType::Package => String::from("package"),
BundleFileType::Particles => String::from("particles"),
BundleFileType::PhysicsProperties => String::from("physics_properties"),
BundleFileType::RenderConfig => String::from("render_config"),
BundleFileType::RtPipeline => String::from("rt_pipeline"),
BundleFileType::Scene => String::from("scene"),
BundleFileType::ShaderLibraryGroup => String::from("shader_library_group"),
BundleFileType::ShaderLibrary => String::from("shader_library"),
BundleFileType::Shader => String::from("shader"),
BundleFileType::ShadingEnvionmentMapping => String::from("shading_environment_mapping"),
BundleFileType::ShadingEnvironment => String::from("shading_environment"),
BundleFileType::SlugAlbum => String::from("slug_album"),
BundleFileType::Slug => String::from("slug"),
BundleFileType::SoundEnvironment => String::from("sound_environment"),
BundleFileType::SpuJob => String::from("spu_job"),
BundleFileType::StateMachine => String::from("state_machine"),
BundleFileType::StaticPVS => String::from("static_pvs"),
BundleFileType::Strings => String::from("strings"),
BundleFileType::SurfaceProperties => String::from("surface_properties"),
BundleFileType::Texture => String::from("texture"),
BundleFileType::TimpaniBank => String::from("timpani_bank"),
BundleFileType::TimpaniMaster => String::from("timpani_master"),
BundleFileType::Tome => String::from("tome"),
BundleFileType::Ugg => String::from("ugg"),
BundleFileType::Unit => String::from("unit"),
BundleFileType::Upb => String::from("upb"),
BundleFileType::VectorField => String::from("vector_field"),
BundleFileType::Wav => String::from("wav"),
BundleFileType::WwiseBank => String::from("wwise_bank"),
BundleFileType::WwiseDep => String::from("wwise_dep"),
BundleFileType::WwiseEvent => String::from("wwise_event"),
BundleFileType::WwiseMetadata => String::from("wwise_metadata"),
BundleFileType::WwiseStream => String::from("wwise_stream"),
BundleFileType::Xml => String::from("xml"),
BundleFileType::Unknown(s) => format!("{s:016X}"),
}
}
pub fn decompiled_ext_name(&self) -> String {
match self {
BundleFileType::Texture => String::from("dds"),
BundleFileType::WwiseBank => String::from("bnk"),
BundleFileType::WwiseStream => String::from("ogg"),
_ => self.ext_name(),
}
}
pub fn hash(&self) -> Murmur64 {
Murmur64::from(*self)
}
}
impl std::str::FromStr for BundleFileType {
type Err = color_eyre::Report;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let val = match s {
"animation_curves" => BundleFileType::AnimationCurves,
"animation" => BundleFileType::Animation,
"apb" => BundleFileType::Apb,
"baked_lighting" => BundleFileType::BakedLighting,
"bik" => BundleFileType::Bik,
"blend_set" => BundleFileType::BlendSet,
"bones" => BundleFileType::Bones,
"chroma" => BundleFileType::Chroma,
"common_package" => BundleFileType::CommonPackage,
"config" => BundleFileType::Config,
"crypto" => BundleFileType::Crypto,
"data" => BundleFileType::Data,
"entity" => BundleFileType::Entity,
"flow" => BundleFileType::Flow,
"font" => BundleFileType::Font,
"ies" => BundleFileType::Ies,
"ini" => BundleFileType::Ini,
"input" => BundleFileType::Input,
"ivf" => BundleFileType::Ivf,
"keys" => BundleFileType::Keys,
"level" => BundleFileType::Level,
"lua" => BundleFileType::Lua,
"material" => BundleFileType::Material,
"mod" => BundleFileType::Mod,
"mouse_cursor" => BundleFileType::MouseCursor,
"nav_data" => BundleFileType::NavData,
"network_config" => BundleFileType::NetworkConfig,
"oodle_net" => BundleFileType::OddleNet,
"package" => BundleFileType::Package,
"particles" => BundleFileType::Particles,
"physics_properties" => BundleFileType::PhysicsProperties,
"render_config" => BundleFileType::RenderConfig,
"rt_pipeline" => BundleFileType::RtPipeline,
"scene" => BundleFileType::Scene,
"shader_library_group" => BundleFileType::ShaderLibraryGroup,
"shader_library" => BundleFileType::ShaderLibrary,
"shader" => BundleFileType::Shader,
"shading_environment_mapping" => BundleFileType::ShadingEnvionmentMapping,
"shading_environment" => BundleFileType::ShadingEnvironment,
"slug_album" => BundleFileType::SlugAlbum,
"slug" => BundleFileType::Slug,
"sound_environment" => BundleFileType::SoundEnvironment,
"spu_job" => BundleFileType::SpuJob,
"state_machine" => BundleFileType::StateMachine,
"static_pvs" => BundleFileType::StaticPVS,
"strings" => BundleFileType::Strings,
"surface_properties" => BundleFileType::SurfaceProperties,
"texture" => BundleFileType::Texture,
"timpani_bank" => BundleFileType::TimpaniBank,
"timpani_master" => BundleFileType::TimpaniMaster,
"tome" => BundleFileType::Tome,
"ugg" => BundleFileType::Ugg,
"unit" => BundleFileType::Unit,
"upb" => BundleFileType::Upb,
"vector_field" => BundleFileType::VectorField,
"wav" => BundleFileType::Wav,
"wwise_bank" => BundleFileType::WwiseBank,
"wwise_dep" => BundleFileType::WwiseDep,
"wwise_event" => BundleFileType::WwiseEvent,
"wwise_metadata" => BundleFileType::WwiseMetadata,
"wwise_stream" => BundleFileType::WwiseStream,
"xml" => BundleFileType::Xml,
s => eyre::bail!("Unknown type string '{}'", s),
};
Ok(val)
}
}
impl Serialize for BundleFileType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let value = self.ext_name();
value.serialize(serializer)
}
}
impl From<Murmur64> for BundleFileType {
fn from(value: Murmur64) -> Self {
Self::from(Into::<u64>::into(value))
}
}
impl From<u64> for BundleFileType {
fn from(hash: u64) -> BundleFileType {
match hash {
0x931e336d7646cc26 => BundleFileType::Animation,
0xdcfb9e18fff13984 => BundleFileType::AnimationCurves,
0x3eed05ba83af5090 => BundleFileType::Apb,
0x7ffdb779b04e4ed1 => BundleFileType::BakedLighting,
0xaa5965f03029fa18 => BundleFileType::Bik,
0xe301e8af94e3b5a3 => BundleFileType::BlendSet,
0x18dead01056b72e9 => BundleFileType::Bones,
0xb7893adf7567506a => BundleFileType::Chroma,
0xfe9754bd19814a47 => BundleFileType::CommonPackage,
0x82645835e6b73232 => BundleFileType::Config,
0x69108ded1e3e634b => BundleFileType::Crypto,
0x8fd0d44d20650b68 => BundleFileType::Data,
0x9831ca893b0d087d => BundleFileType::Entity,
0x92d3ee038eeb610d => BundleFileType::Flow,
0x9efe0a916aae7880 => BundleFileType::Font,
0x8f7d5a2c0f967655 => BundleFileType::Ies,
0xd526a27da14f1dc5 => BundleFileType::Ini,
0x2bbcabe5074ade9e => BundleFileType::Input,
0xfa4a8e091a91201e => BundleFileType::Ivf,
0xa62f9297dc969e85 => BundleFileType::Keys,
0x2a690fd348fe9ac5 => BundleFileType::Level,
0xa14e8dfa2cd117e2 => BundleFileType::Lua,
0xeac0b497876adedf => BundleFileType::Material,
0x3fcdd69156a46417 => BundleFileType::Mod,
0xb277b11fe4a61d37 => BundleFileType::MouseCursor,
0x169de9566953d264 => BundleFileType::NavData,
0x3b1fa9e8f6bac374 => BundleFileType::NetworkConfig,
0xb0f2c12eb107f4d8 => BundleFileType::OddleNet,
0xad9c6d9ed1e5e77a => BundleFileType::Package,
0xa8193123526fad64 => BundleFileType::Particles,
0xbf21403a3ab0bbb1 => BundleFileType::PhysicsProperties,
0x27862fe24795319c => BundleFileType::RenderConfig,
0x9ca183c2d0e76dee => BundleFileType::RtPipeline,
0x9d0a795bfe818d19 => BundleFileType::Scene,
0xcce8d5b5f5ae333f => BundleFileType::Shader,
0xe5ee32a477239a93 => BundleFileType::ShaderLibrary,
0x9e5c3cc74575aeb5 => BundleFileType::ShaderLibraryGroup,
0x250e0a11ac8e26f8 => BundleFileType::ShadingEnvionmentMapping,
0xfe73c7dcff8a7ca5 => BundleFileType::ShadingEnvironment,
0xa27b4d04a9ba6f9e => BundleFileType::Slug,
0xe9fc9ea7042e5ec0 => BundleFileType::SlugAlbum,
0xd8b27864a97ffdd7 => BundleFileType::SoundEnvironment,
0xf97af9983c05b950 => BundleFileType::SpuJob,
0xa486d4045106165c => BundleFileType::StateMachine,
0xe3f0baa17d620321 => BundleFileType::StaticPVS,
0x0d972bab10b40fd3 => BundleFileType::Strings,
0xad2d3fa30d9ab394 => BundleFileType::SurfaceProperties,
0xcd4238c6a0c69e32 => BundleFileType::Texture,
0x99736be1fff739a4 => BundleFileType::TimpaniBank,
0x00a3e6c59a2b9c6c => BundleFileType::TimpaniMaster,
0x19c792357c99f49b => BundleFileType::Tome,
0x712d6e3dd1024c9c => BundleFileType::Ugg,
0xe0a48d0be9a7453f => BundleFileType::Unit,
0xa99510c6e86dd3c2 => BundleFileType::Upb,
0xf7505933166d6755 => BundleFileType::VectorField,
0x786f65c00a816b19 => BundleFileType::Wav,
0x535a7bd3e650d799 => BundleFileType::WwiseBank,
0xaf32095c82f2b070 => BundleFileType::WwiseDep,
0xaabdd317b58dfc8a => BundleFileType::WwiseEvent,
0xd50a8b7e1c82b110 => BundleFileType::WwiseMetadata,
0x504b55235d21440e => BundleFileType::WwiseStream,
0x76015845a6003765 => BundleFileType::Xml,
_ => BundleFileType::Unknown(Murmur64::from(hash)),
}
}
}
impl From<BundleFileType> for u64 {
fn from(t: BundleFileType) -> u64 {
match t {
BundleFileType::Animation => 0x931e336d7646cc26,
BundleFileType::AnimationCurves => 0xdcfb9e18fff13984,
BundleFileType::Apb => 0x3eed05ba83af5090,
BundleFileType::BakedLighting => 0x7ffdb779b04e4ed1,
BundleFileType::Bik => 0xaa5965f03029fa18,
BundleFileType::BlendSet => 0xe301e8af94e3b5a3,
BundleFileType::Bones => 0x18dead01056b72e9,
BundleFileType::Chroma => 0xb7893adf7567506a,
BundleFileType::CommonPackage => 0xfe9754bd19814a47,
BundleFileType::Config => 0x82645835e6b73232,
BundleFileType::Crypto => 0x69108ded1e3e634b,
BundleFileType::Data => 0x8fd0d44d20650b68,
BundleFileType::Entity => 0x9831ca893b0d087d,
BundleFileType::Flow => 0x92d3ee038eeb610d,
BundleFileType::Font => 0x9efe0a916aae7880,
BundleFileType::Ies => 0x8f7d5a2c0f967655,
BundleFileType::Ini => 0xd526a27da14f1dc5,
BundleFileType::Input => 0x2bbcabe5074ade9e,
BundleFileType::Ivf => 0xfa4a8e091a91201e,
BundleFileType::Keys => 0xa62f9297dc969e85,
BundleFileType::Level => 0x2a690fd348fe9ac5,
BundleFileType::Lua => 0xa14e8dfa2cd117e2,
BundleFileType::Material => 0xeac0b497876adedf,
BundleFileType::Mod => 0x3fcdd69156a46417,
BundleFileType::MouseCursor => 0xb277b11fe4a61d37,
BundleFileType::NavData => 0x169de9566953d264,
BundleFileType::NetworkConfig => 0x3b1fa9e8f6bac374,
BundleFileType::OddleNet => 0xb0f2c12eb107f4d8,
BundleFileType::Package => 0xad9c6d9ed1e5e77a,
BundleFileType::Particles => 0xa8193123526fad64,
BundleFileType::PhysicsProperties => 0xbf21403a3ab0bbb1,
BundleFileType::RenderConfig => 0x27862fe24795319c,
BundleFileType::RtPipeline => 0x9ca183c2d0e76dee,
BundleFileType::Scene => 0x9d0a795bfe818d19,
BundleFileType::Shader => 0xcce8d5b5f5ae333f,
BundleFileType::ShaderLibrary => 0xe5ee32a477239a93,
BundleFileType::ShaderLibraryGroup => 0x9e5c3cc74575aeb5,
BundleFileType::ShadingEnvionmentMapping => 0x250e0a11ac8e26f8,
BundleFileType::ShadingEnvironment => 0xfe73c7dcff8a7ca5,
BundleFileType::Slug => 0xa27b4d04a9ba6f9e,
BundleFileType::SlugAlbum => 0xe9fc9ea7042e5ec0,
BundleFileType::SoundEnvironment => 0xd8b27864a97ffdd7,
BundleFileType::SpuJob => 0xf97af9983c05b950,
BundleFileType::StateMachine => 0xa486d4045106165c,
BundleFileType::StaticPVS => 0xe3f0baa17d620321,
BundleFileType::Strings => 0x0d972bab10b40fd3,
BundleFileType::SurfaceProperties => 0xad2d3fa30d9ab394,
BundleFileType::Texture => 0xcd4238c6a0c69e32,
BundleFileType::TimpaniBank => 0x99736be1fff739a4,
BundleFileType::TimpaniMaster => 0x00a3e6c59a2b9c6c,
BundleFileType::Tome => 0x19c792357c99f49b,
BundleFileType::Ugg => 0x712d6e3dd1024c9c,
BundleFileType::Unit => 0xe0a48d0be9a7453f,
BundleFileType::Upb => 0xa99510c6e86dd3c2,
BundleFileType::VectorField => 0xf7505933166d6755,
BundleFileType::Wav => 0x786f65c00a816b19,
BundleFileType::WwiseBank => 0x535a7bd3e650d799,
BundleFileType::WwiseDep => 0xaf32095c82f2b070,
BundleFileType::WwiseEvent => 0xaabdd317b58dfc8a,
BundleFileType::WwiseMetadata => 0xd50a8b7e1c82b110,
BundleFileType::WwiseStream => 0x504b55235d21440e,
BundleFileType::Xml => 0x76015845a6003765,
BundleFileType::Unknown(hash) => hash.into(),
}
}
}
impl From<BundleFileType> for Murmur64 {
fn from(t: BundleFileType) -> Murmur64 {
let hash: u64 = t.into();
Murmur64::from(hash)
}
}
impl std::fmt::Display for BundleFileType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.ext_name())
}
}
#[derive(Debug)]
struct BundleFileHeader {
@ -106,7 +502,7 @@ impl BundleFileVariant {
}
bitflags! {
#[derive(Default, Clone, Copy, Debug)]
#[derive(Default)]
pub struct Properties: u32 {
const DATA = 0b100;
}
@ -120,7 +516,7 @@ pub struct BundleFile {
}
impl BundleFile {
pub fn new(name: impl Into<IdString64>, file_type: BundleFileType) -> Self {
pub fn new(name: String, file_type: BundleFileType) -> Self {
Self {
file_type,
name: name.into(),
@ -152,7 +548,7 @@ impl BundleFile {
let _enter = span.enter();
let header = BundleFileVariant::read_header(r)
.wrap_err_with(|| format!("Failed to read header {i}"))?;
.wrap_err_with(|| format!("failed to read header {i}"))?;
// TODO: Figure out how `header.unknown_1` correlates to `properties::DATA`
// if props.contains(Properties::DATA) {
@ -176,18 +572,18 @@ impl BundleFile {
let data = vec![];
let s = r
.read_string_len(header.size)
.wrap_err("Failed to read data file name")?;
.wrap_err("failed to read data file name")?;
(data, Some(s))
} else {
let mut data = vec![0; header.size];
r.read_exact(&mut data)
.wrap_err_with(|| format!("Failed to read file {i}"))?;
.wrap_err_with(|| format!("failed to read file {i}"))?;
let data_file_name = if header.len_data_file_name > 0 {
let s = r
.read_string_len(header.len_data_file_name)
.wrap_err("Failed to read data file name")?;
.wrap_err("failed to read data file name")?;
Some(s)
} else {
None
@ -252,15 +648,23 @@ impl BundleFile {
Ok(w.into_inner())
}
#[tracing::instrument("File::from_sjson", skip(sjson, name), fields(name = %name.display()))]
pub async fn from_sjson(
name: IdString64,
#[tracing::instrument(name = "File::from_sjson", skip(sjson))]
pub async fn from_sjson<P, S>(
name: String,
file_type: BundleFileType,
sjson: impl AsRef<str>,
root: impl AsRef<Path> + std::fmt::Debug,
) -> Result<Self> {
sjson: S,
root: P,
) -> Result<Self>
where
P: AsRef<Path> + std::fmt::Debug,
S: AsRef<str>,
{
match file_type {
BundleFileType::Lua => lua::compile(name, sjson).wrap_err("Failed to compile Lua file"),
BundleFileType::Lua => {
let sjson =
CString::new(sjson.as_ref()).wrap_err("failed to build CString from SJSON")?;
lua::compile(name, sjson)
}
BundleFileType::Unknown(_) => {
eyre::bail!("Unknown file type. Cannot compile from SJSON");
}
@ -299,7 +703,10 @@ impl BundleFile {
s
}
pub fn matches_name(&self, name: impl Into<IdString64>) -> bool {
pub fn matches_name<S>(&self, name: S) -> bool
where
S: Into<IdString64>,
{
let name = name.into();
if self.name == name {
return true;
@ -377,7 +784,7 @@ impl BundleFile {
}
};
let res = res.wrap_err_with(|| format!("Failed to decompile file {name}"));
let res = res.wrap_err_with(|| format!("failed to decompile file {name}"));
match res {
Ok(files) => files,
Err(err) => {

View file

@ -1,400 +0,0 @@
use color_eyre::{eyre, Result};
use serde::Serialize;
use crate::murmur::Murmur64;
#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)]
pub enum BundleFileType {
Animation,
AnimationCurves,
Apb,
BakedLighting,
Bik,
BlendSet,
Bones,
Chroma,
CommonPackage,
Config,
Crypto,
Data,
Entity,
Flow,
Font,
Ies,
Ini,
Input,
Ivf,
Keys,
Level,
Lua,
Material,
Mod,
MouseCursor,
NavData,
NetworkConfig,
OddleNet,
Package,
Particles,
PhysicsProperties,
RenderConfig,
RtPipeline,
Scene,
Shader,
ShaderLibrary,
ShaderLibraryGroup,
ShadingEnvionmentMapping,
ShadingEnvironment,
Slug,
SlugAlbum,
SoundEnvironment,
SpuJob,
StateMachine,
StaticPVS,
Strings,
SurfaceProperties,
Texture,
TimpaniBank,
TimpaniMaster,
Tome,
Ugg,
Unit,
Upb,
VectorField,
Wav,
WwiseBank,
WwiseDep,
WwiseEvent,
WwiseMetadata,
WwiseStream,
Xml,
Unknown(Murmur64),
}
impl BundleFileType {
pub fn ext_name(&self) -> String {
match self {
BundleFileType::AnimationCurves => String::from("animation_curves"),
BundleFileType::Animation => String::from("animation"),
BundleFileType::Apb => String::from("apb"),
BundleFileType::BakedLighting => String::from("baked_lighting"),
BundleFileType::Bik => String::from("bik"),
BundleFileType::BlendSet => String::from("blend_set"),
BundleFileType::Bones => String::from("bones"),
BundleFileType::Chroma => String::from("chroma"),
BundleFileType::CommonPackage => String::from("common_package"),
BundleFileType::Config => String::from("config"),
BundleFileType::Crypto => String::from("crypto"),
BundleFileType::Data => String::from("data"),
BundleFileType::Entity => String::from("entity"),
BundleFileType::Flow => String::from("flow"),
BundleFileType::Font => String::from("font"),
BundleFileType::Ies => String::from("ies"),
BundleFileType::Ini => String::from("ini"),
BundleFileType::Input => String::from("input"),
BundleFileType::Ivf => String::from("ivf"),
BundleFileType::Keys => String::from("keys"),
BundleFileType::Level => String::from("level"),
BundleFileType::Lua => String::from("lua"),
BundleFileType::Material => String::from("material"),
BundleFileType::Mod => String::from("mod"),
BundleFileType::MouseCursor => String::from("mouse_cursor"),
BundleFileType::NavData => String::from("nav_data"),
BundleFileType::NetworkConfig => String::from("network_config"),
BundleFileType::OddleNet => String::from("oodle_net"),
BundleFileType::Package => String::from("package"),
BundleFileType::Particles => String::from("particles"),
BundleFileType::PhysicsProperties => String::from("physics_properties"),
BundleFileType::RenderConfig => String::from("render_config"),
BundleFileType::RtPipeline => String::from("rt_pipeline"),
BundleFileType::Scene => String::from("scene"),
BundleFileType::ShaderLibraryGroup => String::from("shader_library_group"),
BundleFileType::ShaderLibrary => String::from("shader_library"),
BundleFileType::Shader => String::from("shader"),
BundleFileType::ShadingEnvionmentMapping => String::from("shading_environment_mapping"),
BundleFileType::ShadingEnvironment => String::from("shading_environment"),
BundleFileType::SlugAlbum => String::from("slug_album"),
BundleFileType::Slug => String::from("slug"),
BundleFileType::SoundEnvironment => String::from("sound_environment"),
BundleFileType::SpuJob => String::from("spu_job"),
BundleFileType::StateMachine => String::from("state_machine"),
BundleFileType::StaticPVS => String::from("static_pvs"),
BundleFileType::Strings => String::from("strings"),
BundleFileType::SurfaceProperties => String::from("surface_properties"),
BundleFileType::Texture => String::from("texture"),
BundleFileType::TimpaniBank => String::from("timpani_bank"),
BundleFileType::TimpaniMaster => String::from("timpani_master"),
BundleFileType::Tome => String::from("tome"),
BundleFileType::Ugg => String::from("ugg"),
BundleFileType::Unit => String::from("unit"),
BundleFileType::Upb => String::from("upb"),
BundleFileType::VectorField => String::from("vector_field"),
BundleFileType::Wav => String::from("wav"),
BundleFileType::WwiseBank => String::from("wwise_bank"),
BundleFileType::WwiseDep => String::from("wwise_dep"),
BundleFileType::WwiseEvent => String::from("wwise_event"),
BundleFileType::WwiseMetadata => String::from("wwise_metadata"),
BundleFileType::WwiseStream => String::from("wwise_stream"),
BundleFileType::Xml => String::from("xml"),
BundleFileType::Unknown(s) => format!("{s:016X}"),
}
}
pub fn decompiled_ext_name(&self) -> String {
match self {
BundleFileType::Texture => String::from("dds"),
BundleFileType::WwiseBank => String::from("bnk"),
BundleFileType::WwiseStream => String::from("ogg"),
_ => self.ext_name(),
}
}
pub fn hash(&self) -> Murmur64 {
Murmur64::from(*self)
}
}
impl std::str::FromStr for BundleFileType {
type Err = color_eyre::Report;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let val = match s {
"animation_curves" => BundleFileType::AnimationCurves,
"animation" => BundleFileType::Animation,
"apb" => BundleFileType::Apb,
"baked_lighting" => BundleFileType::BakedLighting,
"bik" => BundleFileType::Bik,
"blend_set" => BundleFileType::BlendSet,
"bones" => BundleFileType::Bones,
"chroma" => BundleFileType::Chroma,
"common_package" => BundleFileType::CommonPackage,
"config" => BundleFileType::Config,
"crypto" => BundleFileType::Crypto,
"data" => BundleFileType::Data,
"entity" => BundleFileType::Entity,
"flow" => BundleFileType::Flow,
"font" => BundleFileType::Font,
"ies" => BundleFileType::Ies,
"ini" => BundleFileType::Ini,
"input" => BundleFileType::Input,
"ivf" => BundleFileType::Ivf,
"keys" => BundleFileType::Keys,
"level" => BundleFileType::Level,
"lua" => BundleFileType::Lua,
"material" => BundleFileType::Material,
"mod" => BundleFileType::Mod,
"mouse_cursor" => BundleFileType::MouseCursor,
"nav_data" => BundleFileType::NavData,
"network_config" => BundleFileType::NetworkConfig,
"oodle_net" => BundleFileType::OddleNet,
"package" => BundleFileType::Package,
"particles" => BundleFileType::Particles,
"physics_properties" => BundleFileType::PhysicsProperties,
"render_config" => BundleFileType::RenderConfig,
"rt_pipeline" => BundleFileType::RtPipeline,
"scene" => BundleFileType::Scene,
"shader_library_group" => BundleFileType::ShaderLibraryGroup,
"shader_library" => BundleFileType::ShaderLibrary,
"shader" => BundleFileType::Shader,
"shading_environment_mapping" => BundleFileType::ShadingEnvionmentMapping,
"shading_environment" => BundleFileType::ShadingEnvironment,
"slug_album" => BundleFileType::SlugAlbum,
"slug" => BundleFileType::Slug,
"sound_environment" => BundleFileType::SoundEnvironment,
"spu_job" => BundleFileType::SpuJob,
"state_machine" => BundleFileType::StateMachine,
"static_pvs" => BundleFileType::StaticPVS,
"strings" => BundleFileType::Strings,
"surface_properties" => BundleFileType::SurfaceProperties,
"texture" => BundleFileType::Texture,
"timpani_bank" => BundleFileType::TimpaniBank,
"timpani_master" => BundleFileType::TimpaniMaster,
"tome" => BundleFileType::Tome,
"ugg" => BundleFileType::Ugg,
"unit" => BundleFileType::Unit,
"upb" => BundleFileType::Upb,
"vector_field" => BundleFileType::VectorField,
"wav" => BundleFileType::Wav,
"wwise_bank" => BundleFileType::WwiseBank,
"wwise_dep" => BundleFileType::WwiseDep,
"wwise_event" => BundleFileType::WwiseEvent,
"wwise_metadata" => BundleFileType::WwiseMetadata,
"wwise_stream" => BundleFileType::WwiseStream,
"xml" => BundleFileType::Xml,
s => eyre::bail!("Unknown type string '{}'", s),
};
Ok(val)
}
}
impl Serialize for BundleFileType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let value = self.ext_name();
value.serialize(serializer)
}
}
impl From<Murmur64> for BundleFileType {
fn from(value: Murmur64) -> Self {
Self::from(Into::<u64>::into(value))
}
}
impl From<u64> for BundleFileType {
fn from(hash: u64) -> BundleFileType {
match hash {
0x931e336d7646cc26 => BundleFileType::Animation,
0xdcfb9e18fff13984 => BundleFileType::AnimationCurves,
0x3eed05ba83af5090 => BundleFileType::Apb,
0x7ffdb779b04e4ed1 => BundleFileType::BakedLighting,
0xaa5965f03029fa18 => BundleFileType::Bik,
0xe301e8af94e3b5a3 => BundleFileType::BlendSet,
0x18dead01056b72e9 => BundleFileType::Bones,
0xb7893adf7567506a => BundleFileType::Chroma,
0xfe9754bd19814a47 => BundleFileType::CommonPackage,
0x82645835e6b73232 => BundleFileType::Config,
0x69108ded1e3e634b => BundleFileType::Crypto,
0x8fd0d44d20650b68 => BundleFileType::Data,
0x9831ca893b0d087d => BundleFileType::Entity,
0x92d3ee038eeb610d => BundleFileType::Flow,
0x9efe0a916aae7880 => BundleFileType::Font,
0x8f7d5a2c0f967655 => BundleFileType::Ies,
0xd526a27da14f1dc5 => BundleFileType::Ini,
0x2bbcabe5074ade9e => BundleFileType::Input,
0xfa4a8e091a91201e => BundleFileType::Ivf,
0xa62f9297dc969e85 => BundleFileType::Keys,
0x2a690fd348fe9ac5 => BundleFileType::Level,
0xa14e8dfa2cd117e2 => BundleFileType::Lua,
0xeac0b497876adedf => BundleFileType::Material,
0x3fcdd69156a46417 => BundleFileType::Mod,
0xb277b11fe4a61d37 => BundleFileType::MouseCursor,
0x169de9566953d264 => BundleFileType::NavData,
0x3b1fa9e8f6bac374 => BundleFileType::NetworkConfig,
0xb0f2c12eb107f4d8 => BundleFileType::OddleNet,
0xad9c6d9ed1e5e77a => BundleFileType::Package,
0xa8193123526fad64 => BundleFileType::Particles,
0xbf21403a3ab0bbb1 => BundleFileType::PhysicsProperties,
0x27862fe24795319c => BundleFileType::RenderConfig,
0x9ca183c2d0e76dee => BundleFileType::RtPipeline,
0x9d0a795bfe818d19 => BundleFileType::Scene,
0xcce8d5b5f5ae333f => BundleFileType::Shader,
0xe5ee32a477239a93 => BundleFileType::ShaderLibrary,
0x9e5c3cc74575aeb5 => BundleFileType::ShaderLibraryGroup,
0x250e0a11ac8e26f8 => BundleFileType::ShadingEnvionmentMapping,
0xfe73c7dcff8a7ca5 => BundleFileType::ShadingEnvironment,
0xa27b4d04a9ba6f9e => BundleFileType::Slug,
0xe9fc9ea7042e5ec0 => BundleFileType::SlugAlbum,
0xd8b27864a97ffdd7 => BundleFileType::SoundEnvironment,
0xf97af9983c05b950 => BundleFileType::SpuJob,
0xa486d4045106165c => BundleFileType::StateMachine,
0xe3f0baa17d620321 => BundleFileType::StaticPVS,
0x0d972bab10b40fd3 => BundleFileType::Strings,
0xad2d3fa30d9ab394 => BundleFileType::SurfaceProperties,
0xcd4238c6a0c69e32 => BundleFileType::Texture,
0x99736be1fff739a4 => BundleFileType::TimpaniBank,
0x00a3e6c59a2b9c6c => BundleFileType::TimpaniMaster,
0x19c792357c99f49b => BundleFileType::Tome,
0x712d6e3dd1024c9c => BundleFileType::Ugg,
0xe0a48d0be9a7453f => BundleFileType::Unit,
0xa99510c6e86dd3c2 => BundleFileType::Upb,
0xf7505933166d6755 => BundleFileType::VectorField,
0x786f65c00a816b19 => BundleFileType::Wav,
0x535a7bd3e650d799 => BundleFileType::WwiseBank,
0xaf32095c82f2b070 => BundleFileType::WwiseDep,
0xaabdd317b58dfc8a => BundleFileType::WwiseEvent,
0xd50a8b7e1c82b110 => BundleFileType::WwiseMetadata,
0x504b55235d21440e => BundleFileType::WwiseStream,
0x76015845a6003765 => BundleFileType::Xml,
_ => BundleFileType::Unknown(Murmur64::from(hash)),
}
}
}
impl From<BundleFileType> for u64 {
fn from(t: BundleFileType) -> u64 {
match t {
BundleFileType::Animation => 0x931e336d7646cc26,
BundleFileType::AnimationCurves => 0xdcfb9e18fff13984,
BundleFileType::Apb => 0x3eed05ba83af5090,
BundleFileType::BakedLighting => 0x7ffdb779b04e4ed1,
BundleFileType::Bik => 0xaa5965f03029fa18,
BundleFileType::BlendSet => 0xe301e8af94e3b5a3,
BundleFileType::Bones => 0x18dead01056b72e9,
BundleFileType::Chroma => 0xb7893adf7567506a,
BundleFileType::CommonPackage => 0xfe9754bd19814a47,
BundleFileType::Config => 0x82645835e6b73232,
BundleFileType::Crypto => 0x69108ded1e3e634b,
BundleFileType::Data => 0x8fd0d44d20650b68,
BundleFileType::Entity => 0x9831ca893b0d087d,
BundleFileType::Flow => 0x92d3ee038eeb610d,
BundleFileType::Font => 0x9efe0a916aae7880,
BundleFileType::Ies => 0x8f7d5a2c0f967655,
BundleFileType::Ini => 0xd526a27da14f1dc5,
BundleFileType::Input => 0x2bbcabe5074ade9e,
BundleFileType::Ivf => 0xfa4a8e091a91201e,
BundleFileType::Keys => 0xa62f9297dc969e85,
BundleFileType::Level => 0x2a690fd348fe9ac5,
BundleFileType::Lua => 0xa14e8dfa2cd117e2,
BundleFileType::Material => 0xeac0b497876adedf,
BundleFileType::Mod => 0x3fcdd69156a46417,
BundleFileType::MouseCursor => 0xb277b11fe4a61d37,
BundleFileType::NavData => 0x169de9566953d264,
BundleFileType::NetworkConfig => 0x3b1fa9e8f6bac374,
BundleFileType::OddleNet => 0xb0f2c12eb107f4d8,
BundleFileType::Package => 0xad9c6d9ed1e5e77a,
BundleFileType::Particles => 0xa8193123526fad64,
BundleFileType::PhysicsProperties => 0xbf21403a3ab0bbb1,
BundleFileType::RenderConfig => 0x27862fe24795319c,
BundleFileType::RtPipeline => 0x9ca183c2d0e76dee,
BundleFileType::Scene => 0x9d0a795bfe818d19,
BundleFileType::Shader => 0xcce8d5b5f5ae333f,
BundleFileType::ShaderLibrary => 0xe5ee32a477239a93,
BundleFileType::ShaderLibraryGroup => 0x9e5c3cc74575aeb5,
BundleFileType::ShadingEnvionmentMapping => 0x250e0a11ac8e26f8,
BundleFileType::ShadingEnvironment => 0xfe73c7dcff8a7ca5,
BundleFileType::Slug => 0xa27b4d04a9ba6f9e,
BundleFileType::SlugAlbum => 0xe9fc9ea7042e5ec0,
BundleFileType::SoundEnvironment => 0xd8b27864a97ffdd7,
BundleFileType::SpuJob => 0xf97af9983c05b950,
BundleFileType::StateMachine => 0xa486d4045106165c,
BundleFileType::StaticPVS => 0xe3f0baa17d620321,
BundleFileType::Strings => 0x0d972bab10b40fd3,
BundleFileType::SurfaceProperties => 0xad2d3fa30d9ab394,
BundleFileType::Texture => 0xcd4238c6a0c69e32,
BundleFileType::TimpaniBank => 0x99736be1fff739a4,
BundleFileType::TimpaniMaster => 0x00a3e6c59a2b9c6c,
BundleFileType::Tome => 0x19c792357c99f49b,
BundleFileType::Ugg => 0x712d6e3dd1024c9c,
BundleFileType::Unit => 0xe0a48d0be9a7453f,
BundleFileType::Upb => 0xa99510c6e86dd3c2,
BundleFileType::VectorField => 0xf7505933166d6755,
BundleFileType::Wav => 0x786f65c00a816b19,
BundleFileType::WwiseBank => 0x535a7bd3e650d799,
BundleFileType::WwiseDep => 0xaf32095c82f2b070,
BundleFileType::WwiseEvent => 0xaabdd317b58dfc8a,
BundleFileType::WwiseMetadata => 0xd50a8b7e1c82b110,
BundleFileType::WwiseStream => 0x504b55235d21440e,
BundleFileType::Xml => 0x76015845a6003765,
BundleFileType::Unknown(hash) => hash.into(),
}
}
}
impl From<BundleFileType> for Murmur64 {
fn from(t: BundleFileType) -> Murmur64 {
let hash: u64 = t.into();
Murmur64::from(hash)
}
}
impl std::fmt::Display for BundleFileType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.ext_name())
}
}

Some files were not shown because too many files have changed in this diff Show more