Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 38 additions & 183 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
shell: pwsh
run: |
"run_attempt=${{ github.run_attempt }}" >> $env:GITHUB_OUTPUT
zig build install test-toolchains test-codegen test-deps test-il2cpp test-mod test-spud `
zig build install test-toolchains test-codegen test-deps test-il2cpp test-mod test-sync test-spud `
-Dtarget=x86_64-windows-msvc -Doptimize=ReleaseFast -Dtest-optimize=Debug `
"-Dstfc_source_state_id=git:${{ github.sha }}" `
"-Dstfc_base_commit=${{ github.sha }}" `
Expand Down Expand Up @@ -75,20 +75,34 @@ jobs:
zig build install build-tests -Dtarget=x86_64-windows-msvc -Doptimize=ReleaseFast -Dtest-optimize=Debug
python3 scripts/verify-windows-binary.py

# The native fixtures need no SDK, so they run on Linux too, where they are cheapest.
test-linux:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
with:
version: ${{ env.ZIG_VERSION }}
- name: Test
run: zig build test-toolchains test-codegen test-il2cpp test-mod test-sync test-launcher test-spud

build-mac:
name: build-mac (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
# The relocation test only has work on x86_64.
- arch: arm64
target: aarch64-macos.14.6
runner: macos-26
artifact: macos-arm64-build
tests: test-sync test-launcher
- arch: x86_64
target: x86_64-macos.14.6
runner: macos-26-intel
artifact: macos-x86_64-build
tests: test-sync test-launcher test-spud
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v7
Expand All @@ -99,26 +113,17 @@ jobs:
run: python3 scripts/stamp-version-hash.py "${{ github.sha }}"
- name: Build and test
run: |
zig build install launcher test-toolchains test-codegen test-deps test-il2cpp test-mod test-spud \
zig build install launcher test-toolchains test-codegen test-deps test-il2cpp test-mod ${{ matrix.tests }} \
-Dtarget=${{ matrix.target }} -Doptimize=ReleaseFast -Dtest-optimize=Debug --summary all
- name: Verify portable linkage
run: |
for binary in zig-out/macOSLauncher.app/Contents/MacOS/macOSLauncher zig-out/bin/stfc-community-mod-loader zig-out/lib/libstfc-community-mod.dylib; do
otool -L "$binary"
if otool -L "$binary" | grep -vE '^[[:space:]]+/usr/lib/libcurl(\.[0-9]+)*\.dylib[[:space:]]' | grep -E '/opt/homebrew/|/usr/local/(opt|Cellar)/|(^|/)lib(ssl|crypto|curl)(\.[0-9]+)*\.dylib'; then
echo "::error::Forbidden dynamic dependency in $binary"
exit 1
fi
done
scripts/check-mac-linkage.sh zig-out/macOSLauncher.app/Contents/MacOS/macOSLauncher \
zig-out/bin/stfc-community-mod-loader zig-out/lib/libstfc-community-mod.dylib
# The install prefix as scripts/assemble-mac-app.sh expects it.
- name: Package intermediate build
run: |
mkdir -p .artifacts/mac
cp zig-out/macOSLauncher.app/Contents/MacOS/macOSLauncher .artifacts/mac/
cp zig-out/bin/stfc-community-mod-loader zig-out/lib/libstfc-community-mod.dylib .artifacts/mac/
if [[ '${{ matrix.arch }}' == 'arm64' ]]; then
cp -R zig-out/macOSLauncher.app .artifacts/mac/
fi
tar -cf macos-${{ matrix.arch }}-build.tar -C .artifacts/mac .
tar -cf macos-${{ matrix.arch }}-build.tar -C zig-out \
macOSLauncher.app bin/stfc-community-mod-loader lib/libstfc-community-mod.dylib
- uses: actions/upload-artifact@v7.0.1
with:
name: ${{ matrix.artifact }}
Expand Down Expand Up @@ -211,141 +216,26 @@ jobs:
path: .artifacts/x86_64

- name: Restore architecture builds
shell: bash
run: |
set -euo pipefail

mkdir -p zig-out/staging/arm64
mkdir -p zig-out/staging/x86_64

tar -xf .artifacts/arm64/macos-arm64-build.tar \
-C zig-out/staging/arm64

tar -xf .artifacts/x86_64/macos-x86_64-build.tar \
-C zig-out/staging/x86_64

test -f zig-out/staging/arm64/macOSLauncher
test -f zig-out/staging/x86_64/macOSLauncher

test -f zig-out/staging/arm64/stfc-community-mod-loader
test -f zig-out/staging/x86_64/stfc-community-mod-loader

test -f zig-out/staging/arm64/libstfc-community-mod.dylib
test -f zig-out/staging/x86_64/libstfc-community-mod.dylib

test -f \
zig-out/staging/arm64/macOSLauncher.app/Contents/Resources/Assets.car

test -f \
zig-out/staging/arm64/macOSLauncher.app/Contents/Info.plist
for arch in arm64 x86_64; do
mkdir -p "zig-out/staging/$arch"
tar -xf ".artifacts/$arch/macos-$arch-build.tar" -C "zig-out/staging/$arch"
done

- name: Create universal App Bundle
shell: bash
- name: Assemble universal app bundle
run: |
set -euo pipefail

APP="zig-out/staging/arm64/macOSLauncher.app"

lipo -create \
zig-out/staging/arm64/macOSLauncher \
zig-out/staging/x86_64/macOSLauncher \
-output "$APP/Contents/MacOS/macOSLauncher"

lipo -create \
zig-out/staging/arm64/stfc-community-mod-loader \
zig-out/staging/x86_64/stfc-community-mod-loader \
-output "$APP/Contents/stfc-community-mod-loader"

lipo -create \
zig-out/staging/arm64/libstfc-community-mod.dylib \
zig-out/staging/x86_64/libstfc-community-mod.dylib \
-output "$APP/Contents/libstfc-community-mod.dylib"

cp assets/launcher.icns \
"$APP/Contents/Resources/"

mv \
zig-out/staging/arm64/macOSLauncher.app \
"zig-out/staging/arm64/STFC Community Mod.app"

FINAL_APP="zig-out/staging/arm64/STFC Community Mod.app"

test -f "$FINAL_APP/Contents/Info.plist"
test -f "$FINAL_APP/Contents/Resources/Assets.car"
test -f "$FINAL_APP/Contents/Resources/launcher.icns"
test -f "$FINAL_APP/Contents/MacOS/macOSLauncher"
test -f "$FINAL_APP/Contents/stfc-community-mod-loader"
test -f "$FINAL_APP/Contents/libstfc-community-mod.dylib"
scripts/assemble-mac-app.sh "zig-out/staging/STFC Community Mod.app" \
zig-out/staging/arm64 zig-out/staging/x86_64

- name: Verify universal binaries
shell: bash
run: |
set -euo pipefail

APP="zig-out/staging/arm64/STFC Community Mod.app"

verify_universal() {
local file="$1"
local archs

archs="$(lipo -archs "$file")"
echo "$file: $archs"

[[ " $archs " == *" arm64 "* ]]
[[ " $archs " == *" x86_64 "* ]]
}

verify_universal \
"$APP/Contents/MacOS/macOSLauncher"

verify_universal \
"$APP/Contents/stfc-community-mod-loader"

verify_universal \
"$APP/Contents/libstfc-community-mod.dylib"

- name: Verify universal app linkage
shell: bash
run: |
set -euo pipefail

APP="zig-out/staging/arm64/STFC Community Mod.app"

check_linkage() {
local binary="$1"
local deps

echo
echo "==> $binary"
deps="$(otool -L "$binary")"
printf '%s\n' "$deps"

if printf '%s\n' "$deps" \
| grep -vE '^[[:space:]]+/usr/lib/libcurl(\.[0-9]+)*\.dylib[[:space:]]' \
| grep -E \
'/opt/homebrew/|/usr/local/(opt|Cellar)/|(^|/)libssl(\.[0-9]+)*\.dylib|(^|/)libcrypto(\.[0-9]+)*\.dylib|(^|/)libcurl(\.[0-9]+)*\.dylib'
then
echo "::error file=$binary::Forbidden dynamic dependency detected"
return 1
fi
}

check_linkage "$APP/Contents/MacOS/macOSLauncher"
check_linkage "$APP/Contents/stfc-community-mod-loader"
check_linkage "$APP/Contents/libstfc-community-mod.dylib"

- name: Sign App Bundle
shell: bash
run: |
set -euo pipefail

codesign \
--force \
--verify \
--verbose \
--deep \
--sign "-" \
"zig-out/staging/arm64/STFC Community Mod.app"
APP="zig-out/staging/STFC Community Mod.app"
for binary in MacOS/macOSLauncher stfc-community-mod-loader libstfc-community-mod.dylib; do
archs="$(lipo -archs "$APP/Contents/$binary")"
echo "$binary: $archs"
[[ " $archs " == *" arm64 "* && " $archs " == *" x86_64 "* ]]
done

- name: Install packaging tools
shell: bash
Expand All @@ -361,52 +251,17 @@ jobs:
command -v create-dmg >/dev/null 2>&1 || brew install create-dmg

- name: Package macOS mod archive
shell: bash
run: |
set -euo pipefail

mkdir -p stfc-community-mod-macos-universal

tar -cf - \
-C "zig-out/staging/arm64/STFC Community Mod.app/Contents" \
libstfc-community-mod.dylib \
| zstd -15 -T0 \
-o stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst

shasum -a 256 \
stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst \
tar -cf - -C "zig-out/staging/STFC Community Mod.app/Contents" libstfc-community-mod.dylib \
| zstd -15 -T0 -o stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst
shasum -a 256 stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst \
| awk '{print $1}' \
> stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst.sha256

- name: Create installer
shell: bash
run: |
set -euo pipefail

APP="zig-out/staging/arm64/STFC Community Mod.app"
DMG_SOURCE="zig-out/staging/arm64/dmg-source"

mkdir -p "$DMG_SOURCE"
mv "$APP" "$DMG_SOURCE/"

create_dmg_options=(
--filesystem APFS
--format ULFO
--volname "STFC Community Mod Installer"
--volicon assets/launcher.icns
--no-internet-enable
--background assets/mac_installer_background.png
--window-pos 200 120
--window-size 800 400
--icon-size 100
--icon "STFC Community Mod.app" 200 190
--app-drop-link 600 185
--applescript-sleep-duration 1
)

create-dmg "${create_dmg_options[@]}" \
stfc-community-mod-installer.dmg \
"$DMG_SOURCE/"
run: scripts/create-mac-dmg.sh "zig-out/staging/STFC Community Mod.app" stfc-community-mod-installer.dmg

- name: Upload installer
uses: actions/upload-artifact@v7.0.1
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/validate-example-configs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ permissions:

jobs:
validate-example-configs:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v7
Expand Down
43 changes: 37 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Before changing bindings or hooks, read the [IL2CPP reference](docs/IL2CPP.md).

## Build and check

The project uses **Zig 0.16.0** and **C++23**.
The project uses **Zig 0.16 or 0.17** and **C++23**. Keep build changes working on both; `zig/compat.zig` holds the differences.
Build definitions are in `build.zig` and `zig/`. Dependency URLs and hashes are pinned in `build.zig.zon`.
Windows uses the MSVC ABI and static CRT.

Expand Down Expand Up @@ -63,26 +63,57 @@ Dependency pins and notices are listed in [THIRD_PARTY.md](docs/THIRD_PARTY.md).

## Add or change a feature

1. Extend the domain file in `src/mod/features/`, or add one named after the feature's purpose.
1. Extend the domain file in `src/mod/features/`, or add one named after the feature's purpose. Merge micro-files.
2. Keep installation private. Export a descriptor such as `const Feature kXxx{"Xxx", &Config::Patches::group, InstallXxx};`.
3. Declare the descriptor in `features/features.h` and add it to `kFeatures` in `mod.cc`.
`InstallXxx` returns whether the feature went in, usually `return hooks.Commit();`. A feature that declines for a
reason of its own logs that reason with its tag first; the library reports missing methods and failed detours.
3. Declare the descriptor in `features/features.h` and add it to `kFeatures` in `mod.cc`, both grouped by file.
4. For a new hook group, add a `[patches]` member defaulting to `true` and read it in `Config::Read`.

Shortcut-only helpers need no descriptor.
Frame features supply their frame function as the descriptor's fourth member and return early when disabled.
`mod.cc` logs each feature as installed, skipped or not installed, and runs frame work only for installed features.
A feature that only one platform can install is left out of `features.h` and `kFeatures` elsewhere with `#if _WIN32`.

Only `[patches]` keys decide which hooks install. They are restart-only switches for conflicts and client drift.
Feature settings belong in sections such as `[ui]`, `[graphics]`, or `[audio]`.
Install hooks even when a feature is disabled. Each hook reads the current setting and cheaply calls `original` when off.

Every feature file has the same shape:

- A top comment naming the settings it owns, then includes: project headers, `game/`, libraries, then std.
- One anonymous namespace holding state, helpers, hooks and, last, `Install<Descriptor>()`. Sections use `// --- X ---`.
- Exported calls from `features.h`, then the descriptors at the end of the file.

macOS release builds compile feature files four to a translation unit. Give file-scope names a feature-specific
name (`recovery_installed`, `InstallPinnedShips`), never a generic one like `installed`, `Enabled` or `Install`.

## Hooks and frame work

- Use `IL2CPP_HOOK` or `IL2CPP_DETOUR` from `<il2cpp/hook.h>`. Name callbacks `Class_Method_Hook` and take `auto original` first.
- Each macro expansion owns one target. Prefer one detour per game method; chained detours depend on installation order.
- Before hooking a small wrapper or thunk, verify its native extent against SPUD's overwrite size on every supported architecture for the exact client. A build does not prove fit.
- Every installer stages its hooks in one `il2cpp::HookSet` named after its descriptor, then commits it. Use
`IL2CPP_STAGE` on a `Class::Method` lookup; `IL2CPP_STAGE_AT` is for native exports.
- Name callbacks `Class_Method_Hook`, take `auto original` first and the receiver as `_this`, typed with its binding.
- `_this` and `unity::Instances` items are never null; do not check them. Check other pointers from the game before
calling through them, since receivers must not be null. Do not cap counts or sizes the game reports.
- Use `Method<R(P...)>` or `StaticMethod<R(P...)>` when the hook reads value-type or enum parameters.
- A hook shared by two features goes in its own set, committed once from a function-local static that both call.
- SPUD measures each target from the image's unwind or function-start tables and refuses one too small for its patch;
the set reports it. Installers do not repeat a check that SPUD, the library or the dump checker already makes.
- Match game-visible signatures from `dump.cs`. Change hidden `MethodInfo*` handling only with runtime evidence.
- Keep the single `ScreenManager.Update` hook in `mod.cc`. Other hooks that read keys must call `unity::Key::Poll()` first.

The library reports missing members, signature drift and failed detours. Features do not log hook failures or
success. A set is one SPUD transaction: every hook or none, so a feature installs whole or not at all, and a hook
that runs can rely on the rest of its set. Do not pre-check fields or properties by default: a missing one is
reported and reads as zero, and the dump checker catches drift before a release. `HasField` and `HasProperty` are for
the rare hook that would take an in-game action on a value read as zero.

Bindings contain managed exceptions, so hooks need no catch-all. Catch only around `il2cpp::InvokeBound`, which
lets them through so the caller can fall back to the game's own path.

Feature log lines start with the descriptor name: `spdlog::info("[PinnedShips] ...")`. Log what the mod changed or
could not do, at most once for anything that repeats.

The frame tick polls input, checks for settings changes, runs installed feature frame functions, then dispatches shortcuts.
Hooks run in no fixed order within a frame. Reads before polling see the previous frame's input.

Expand Down
Loading
Loading