diff --git a/.gemini/agents/fishnet-sync-architect.md b/.gemini/agents/fishnet-sync-architect.md
deleted file mode 100644
index 30acd170..00000000
--- a/.gemini/agents/fishnet-sync-architect.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-name: fishnet-sync-architect
-description: Converts Unity classes and Harmony patches into FishNet NetworkBehaviour classes for server-authoritative multiplayer sync.
-kind: local
-tools:
- - read_file
- - grep_search
-model: inherit
-temperature: 0.2
-max_turns: 20
----
-
-You are the FishNet Sync Architect. Your job is to build the multiplayer networking layer for `GregCore` and `GregMods`.
-We use a Listen-Server (Host-Authority) model using the FishNet networking library within an IL2CPP Unity environment.
-
-**STRICT RULES:**
-1. Every networking script must inherit from `NetworkBehaviour`.
-2. Because this is an IL2CPP game, every class MUST have the `[RegisterTypeInIl2Cpp]` attribute.
-3. Use `[ServerRpc]` for client-to-host requests (e.g., placing a rack, connecting a cable).
-4. Use `[ObserversRpc]` or `[SyncVar]` for host-to-client state broadcasting.
-5. If you see original game logic (like `CablePositions.Connect`), wrap it in network logic so only the Server executes the raw logic, and clients just receive the visual update.
-
-Generate clean, highly optimized C# code. Avoid Unity's Netcode for GameObjects or Mirror syntax. Strictly FishNet.
diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml
new file mode 100644
index 00000000..8f12af99
--- /dev/null
+++ b/.github/workflows/branch-policy.yml
@@ -0,0 +1,44 @@
+name: Branch policy
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, edited]
+ branches: [dev, pre-release, main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: read
+
+jobs:
+ policy:
+ name: policy/branch-flow
+ runs-on: ubuntu-latest
+ steps:
+ - name: Enforce promotion direction
+ env:
+ BASE: ${{ github.event.pull_request.base.ref }}
+ HEAD: ${{ github.event.pull_request.head.ref }}
+ EVENT: ${{ github.event_name }}
+ run: |
+ if [[ "$EVENT" != "pull_request" ]]; then exit 0; fi
+ case "$BASE" in
+ dev)
+ [[ "$HEAD" != "main" && "$HEAD" != "pre-release" && "$HEAD" != release/* ]] || { echo "dev accepts feature/fix/integration branches only"; exit 1; }
+ ;;
+ pre-release)
+ [[ "$HEAD" == "dev" ]] || { echo "pre-release may only be promoted from dev"; exit 1; }
+ ;;
+ main)
+ [[ "$HEAD" == "pre-release" ]] || { echo "main may only be promoted from pre-release"; exit 1; }
+ ;;
+ esac
+
+ - name: Enforce release branch immutability
+ env:
+ BASE: ${{ github.event.pull_request.base.ref }}
+ run: |
+ if [[ "$BASE" == release/* ]]; then
+ echo "release branches are historical release snapshots and are not merge targets"
+ exit 1
+ fi
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3d23720c..e9da8f11 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,237 +1,109 @@
-name: gregCore CI
-
-on:
- push:
- branches: [ main ]
- pull_request:
- branches: [ main ]
-
-permissions:
- contents: write
-
-jobs:
- # ─── 1. Auto-bump version on every push to main ───────────────────────────
- version-bump:
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- runs-on: ubuntu-latest
- outputs:
- version: ${{ steps.bump.outputs.version }}
- tag: ${{ steps.bump.outputs.tag }}
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Bump patch version
- id: bump
- run: |
- CURRENT=$(cat VERSION | tr -d '[:space:]')
- MAJOR=$(echo "$CURRENT" | cut -d. -f1)
- MINOR=$(echo "$CURRENT" | cut -d. -f2)
- PATCH=$(echo "$CURRENT" | cut -d. -f3)
- # Strip any pre-release suffix from patch
- PATCH=$(echo "$PATCH" | grep -oE '^[0-9]+')
- NEW_PATCH=$((PATCH + 1))
- NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
- echo "$NEW_VERSION" > VERSION
- echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
- echo "tag=v${NEW_VERSION}" >> "$GITHUB_OUTPUT"
- echo "Bumped $CURRENT → $NEW_VERSION"
-
- - name: Update version in .csproj
- run: |
- V="${{ steps.bump.outputs.version }}"
- sed -i "s|.*|$V|g" gregCore.csproj
- sed -i "s|.*|$V|g" gregCore.csproj
- sed -i "s|.*|$V.0|g" gregCore.csproj
-
- - name: Update version in GregCoreMod.cs
- run: |
- V="${{ steps.bump.outputs.version }}"
- sed -i 's|"gregCore", "[^"]*"|"gregCore", "'"$V"'"|g' src/Core/GregCoreMod.cs
- sed -i 's|Framework Boot v[^-"]*|Framework Boot v'"$V"'|g' src/Core/GregCoreMod.cs
-
- - name: Update CHANGELOG
- run: |
- V="${{ steps.bump.outputs.version }}"
- DATE=$(date +%Y-%m-%d)
- PREV=$(git log --format="%s" HEAD~1 -1 2>/dev/null || echo "patch update")
- ENTRY="## [${V}] - ${DATE}\n\n### Changed\n\n- Auto-release: ${PREV}\n\n"
- # Insert after first line (# Changelog)
- awk -v entry="$ENTRY" 'NR==1{print; print ""; printf "%s", entry; next} /^## \[/{if(!done){done=1} print; next} {print}' CHANGELOG.md > CHANGELOG.tmp
- mv CHANGELOG.tmp CHANGELOG.md
-
- - name: Commit version bump
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git add VERSION gregCore.csproj src/Core/GregCoreMod.cs CHANGELOG.md
- git commit -m "chore(release): bump version to ${{ steps.bump.outputs.version }} [skip ci]" || echo "Nothing to commit"
- git tag "${{ steps.bump.outputs.tag }}"
- git push origin main --tags
-
- # ─── 2. Build for Windows & Linux ─────────────────────────────────────────
- build:
- needs: [ version-bump ]
- if: always() && (needs.version-bump.result == 'success' || github.event_name == 'pull_request')
- strategy:
- matrix:
- os: [ windows-latest, ubuntu-latest ]
- include:
- - os: windows-latest
- rid: win-x64
- label: windows
- - os: ubuntu-latest
- rid: linux-x64
- label: linux
- runs-on: ${{ matrix.os }}
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ needs.version-bump.outputs.tag || github.sha }}
- fetch-depth: 0
-
- - name: Setup .NET 6
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 6.0.x
-
- - name: Restore
- run: dotnet restore gregCore.csproj
-
- - name: Build Release
- run: dotnet build gregCore.csproj -c Release -p:CI=true
-
- - name: Stage MelonLoader artifact
- shell: bash
- run: |
- V=$(cat VERSION | tr -d '[:space:]')
- OUT="dist/melonloader-${{ matrix.label }}"
- mkdir -p "$OUT/Mods"
- cp bin/Release/net6.0/gregCore.dll "$OUT/Mods/"
- cp game_hooks.json "$OUT/Mods/"
- cp framework/greg_hooks.json "$OUT/Mods/"
- cp README.md "$OUT/"
- cp CHANGELOG.md "$OUT/"
- cd dist
- if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
- powershell -Command "Compress-Archive -Path 'melonloader-${{ matrix.label }}/*' -DestinationPath 'gregCore-v${V}-melonloader-${{ matrix.label }}.zip'"
- else
- zip -r "gregCore-v${V}-melonloader-${{ matrix.label }}.zip" "melonloader-${{ matrix.label }}"
- fi
-
- - name: Stage BepInEx artifact
- shell: bash
- run: |
- V=$(cat VERSION | tr -d '[:space:]')
- OUT="dist/bepinex-${{ matrix.label }}"
- mkdir -p "$OUT/BepInEx/plugins/gregCore"
- cp bin/Release/net6.0/gregCore.dll "$OUT/BepInEx/plugins/gregCore/"
- cp game_hooks.json "$OUT/BepInEx/plugins/gregCore/"
- cp framework/greg_hooks.json "$OUT/BepInEx/plugins/gregCore/"
- cp README.md "$OUT/"
- cp CHANGELOG.md "$OUT/"
- cd dist
- if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
- powershell -Command "Compress-Archive -Path 'bepinex-${{ matrix.label }}/*' -DestinationPath 'gregCore-v${V}-bepinex-${{ matrix.label }}.zip'"
- else
- zip -r "gregCore-v${V}-bepinex-${{ matrix.label }}.zip" "bepinex-${{ matrix.label }}"
- fi
-
- - name: Upload artifacts
- uses: actions/upload-artifact@v4
- with:
- name: gregCore-${{ matrix.label }}-zips
- path: dist/*.zip
-
- # ─── 3. Generate API docs from hook JSONs ─────────────────────────────────
- docs:
- needs: [ version-bump ]
- if: always() && needs.version-bump.result == 'success'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ needs.version-bump.outputs.tag }}
- fetch-depth: 2
-
- - name: Check if hook files changed
- id: changed
- run: |
- git diff HEAD~1 --name-only 2>/dev/null | grep -E '(game_hooks\.json|framework/greg_hooks\.json)' \
- && echo "changed=true" >> "$GITHUB_OUTPUT" \
- || echo "changed=false" >> "$GITHUB_OUTPUT"
-
- - name: Generate FrameworkAPI docs
- if: steps.changed.outputs.changed == 'true'
- run: |
- python3 scripts/generate_api_docs.py \
- --game-hooks game_hooks.json \
- --greg-hooks framework/greg_hooks.json \
- --output docs/FrameworkAPI.md \
- --version "$(cat VERSION)"
-
- - name: Upload docs artifact
- if: steps.changed.outputs.changed == 'true'
- uses: actions/upload-artifact@v4
- with:
- name: api-docs
- path: docs/FrameworkAPI.md
-
- # ─── 4. Publish release + create version branch ────────────────────────────
- release:
- needs: [ version-bump, build ]
- if: >
- github.event_name == 'push' &&
- github.ref == 'refs/heads/main' &&
- needs.build.result == 'success'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ needs.version-bump.outputs.tag }}
- fetch-depth: 0
-
- - name: Create version branch
- run: |
- TAG="${{ needs.version-bump.outputs.tag }}"
- BRANCH="release/${TAG}"
- git checkout -b "$BRANCH"
- git push origin "$BRANCH" || true
-
- - name: Download all artifacts
- uses: actions/download-artifact@v4
- with:
- pattern: gregCore-*-zips
- merge-multiple: true
- path: release-assets
-
- - name: Download API docs (if generated)
- uses: actions/download-artifact@v4
- with:
- name: api-docs
- path: release-assets
- continue-on-error: true
-
- - name: Extract changelog entry
- id: changelog
- run: |
- V="${{ needs.version-bump.outputs.version }}"
- # Extract the section for this version from CHANGELOG.md
- NOTES=$(awk "/^## \[${V}\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
- echo "notes<> "$GITHUB_OUTPUT"
- echo "$NOTES" >> "$GITHUB_OUTPUT"
- echo "EOF" >> "$GITHUB_OUTPUT"
-
- - name: Create GitHub Release
- uses: softprops/action-gh-release@v2
- with:
- tag_name: ${{ needs.version-bump.outputs.tag }}
- name: "gregCore ${{ needs.version-bump.outputs.tag }}"
- body: ${{ steps.changelog.outputs.notes }}
- prerelease: false
- files: release-assets/*
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
+name: gregCore CI
+
+on:
+ push:
+ branches: [dev, pre-release, main]
+ pull_request:
+ branches: [dev, pre-release, main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ contract-validation:
+ name: contracts
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Validate API contracts
+ run: python3 scripts/validate_contracts.py
+
+ build:
+ name: build-${{ matrix.label }}
+ needs: contract-validation
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ label: windows
+ - os: ubuntu-latest
+ label: linux
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET 6
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 6.0.x
+
+ - name: Restore
+ run: dotnet restore gregCore.csproj
+
+ - name: Build Release
+ run: dotnet build gregCore.csproj -c Release -p:CI=true
+
+ - name: Build C# mod template
+ if: matrix.os == 'ubuntu-latest'
+ run: dotnet build templates/csharp/GregMod.Template.csproj -c Release -p:GregCorePath="$PWD/bin/Release/net6.0/gregCore.dll"
+
+ - name: Validate Lua template
+ if: matrix.os == 'ubuntu-latest'
+ run: python3 -m json.tool templates/lua/example-mod/mod.json >/dev/null
+
+ - name: Validate release version
+ if: matrix.os == 'ubuntu-latest'
+ run: python3 scripts/validate_version.py "$(tr -d '[:space:]' < VERSION)"
+
+ - name: Stage MelonLoader artifact
+ shell: bash
+ run: |
+ V=$(tr -d '[:space:]' < VERSION)
+ OUT="dist/melonloader-${{ matrix.label }}"
+ mkdir -p "$OUT/Mods"
+ cp bin/Release/net6.0/gregCore.dll "$OUT/Mods/"
+ cp game_hooks.json "$OUT/Mods/"
+ cp framework/greg_hooks.json "$OUT/Mods/"
+ cp README.md CHANGELOG.md "$OUT/"
+ cd dist
+ if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
+ powershell -Command "Compress-Archive -Path 'melonloader-${{ matrix.label }}/*' -DestinationPath 'gregCore-v${V}-melonloader-${{ matrix.label }}.zip'"
+ else
+ zip -r "gregCore-v${V}-melonloader-${{ matrix.label }}.zip" "melonloader-${{ matrix.label }}"
+ fi
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: gregCore-${{ matrix.label }}-zips
+ path: dist/*.zip
+
+ tests:
+ name: tests
+ needs: contract-validation
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET 6
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 6.0.x
+ - name: Run tests
+ run: dotnet test tests/gregCore.Tests.csproj -c Release --no-restore
+
+ docs:
+ name: docs
+ needs: contract-validation
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Validate documentation links and examples
+ run: |
+ test -f docs/FrameworkAPI.md
+ test -f docs/modding/getting-started.md
+ test -f docs/maintainers/release-smoke-test.md
+ python3 -m json.tool templates/lua/example-mod/mod.json >/dev/null
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 0e94c48f..5d39604f 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -2,23 +2,20 @@ name: Generate FrameworkAPI Docs
on:
workflow_dispatch:
- push:
- branches: [ main ]
+ push:
+ branches: [ dev, pre-release, main ]
paths:
- 'game_hooks.json'
- 'framework/greg_hooks.json'
-permissions:
- contents: write
+permissions:
+ contents: read
jobs:
generate-docs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- token: ${{ secrets.GITHUB_TOKEN }}
+ - uses: actions/checkout@v4
- name: Generate FrameworkAPI.md
run: |
@@ -28,15 +25,6 @@ jobs:
--output docs/FrameworkAPI.md \
--version "$(cat VERSION | tr -d '[:space:]')"
- - name: Commit updated docs
- run: |
- if [[ -n "$(git status --porcelain docs/FrameworkAPI.md)" ]]; then
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git add docs/FrameworkAPI.md
- git commit -m "docs: regenerate FrameworkAPI from hook definitions [skip ci]"
- git push
- else
- echo "FrameworkAPI.md unchanged – skipping commit."
- fi
+ - name: Check generated docs are committed
+ run: git diff --exit-code -- docs/FrameworkAPI.md
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..c40010fe
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,62 @@
+name: Release
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Stable SemVer to publish (for example 1.2.2)"
+ required: true
+ type: string
+
+permissions:
+ contents: write
+
+jobs:
+ publish:
+ if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Resolve and validate stable version
+ id: version
+ env:
+ INPUT_VERSION: ${{ inputs.version }}
+ run: |
+ VERSION="${INPUT_VERSION:-$(tr -d '[:space:]' < VERSION)}"
+ python3 scripts/validate_version.py "$VERSION"
+ [[ "$VERSION" != *-* ]] || { echo "release workflow requires a stable SemVer without a prerelease suffix"; exit 1; }
+ if [[ "${GITHUB_EVENT_NAME}" == "push" ]] && [[ "$VERSION" != "$(tr -d '[:space:]' < VERSION)" ]]; then
+ echo "workflow input cannot override a push release"; exit 1
+ fi
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
+
+ - name: Create immutable release branch
+ env:
+ VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ BRANCH="release/v${VERSION}"
+ if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
+ echo "$BRANCH already exists; refusing to rewrite it"
+ exit 0
+ fi
+ git switch -c "$BRANCH"
+ git push origin "$BRANCH"
+
+ - name: Create immutable tag and GitHub release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ TAG="v${VERSION}"
+ if ! git rev-parse "$TAG" >/dev/null 2>&1; then
+ git tag -a "$TAG" -m "gregCore $TAG"
+ git push origin "$TAG"
+ fi
+ gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --title "gregCore $TAG" --generate-notes --verify-tag || true
diff --git a/.gitignore b/.gitignore
index f56384f2..5d649d0b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,7 @@ publish/
publish_out/
publish_full/
build/artifacts/
+templates/**/artifacts/
Releases/
*.zip
*.pdb
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c9465be..be52eba0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,104 @@
-# Changelog
+# Changelog
+
+## [Unreleased] — 1.2.2-dev.2026-08-13
+
+> Arbeitsstand `1.2.2-dev.0`; noch nicht als Release veröffentlicht. Die Einträge werden bis zur Freigabe commitweise ergänzt.
+
+### Added
+
+- GregCore-Codebase-Analyse unter `docs/codebase/` mit Stack, Struktur, Architektur, Integrationen, Tests und Befunden.
+- Aktivierter gemeinsamer Bootstrap für Plugin-Lifecycle, Settings, Performance-Governor, Main-Thread-Dispatcher und Ressourcenverwaltung.
+- Gebundene Plugin-Abhängigkeiten mit deterministischer Reihenfolge sowie Fehlern für fehlende und zyklische Dependencies.
+- Begrenzte Operation-Queues, Quality-abhängige Throttling-Intervalle und `Medium` als öffentliche Standardprofil-Bezeichnung.
+- Begrenzte Notification-Warteschlange und maximale Anzahl aktiver UI-Toasts.
+- Chronologischer Audit der 28 offenen PRs; redundante Sentinel-/Bolt-Varianten
+ werden in eine geprüfte Integrationsänderung zusammengeführt.
+- Security-Härtung der Portrait-Pfade und O(1)-Zugriff auf die autoritativen
+ `NetworkMap`-Serverregister.
+- Branch-Policy und Release-Dokumentation für `dev`, `pre-release`, `main` und
+ unveränderliche `release/vX.Y.Z`-Snapshots.
+- Open-PR- und Remote-Branch-Audit mit Konsolidierung auf einen Integrations-PR;
+ 222 redundante, nicht geschützte Branches wurden nach Prüfung entfernt.
+
+### Fixed
+
+- GregCore-Bootstrap wird jetzt vom tatsächlichen MelonLoader-Einstiegspunkt aufgerufen.
+- Performance-Governor wird im echten Update-Pfad ausgeführt.
+- `GregPerformanceModule.OnResourceUpdate` entfernt exakt den registrierten Handler.
+- Legacy- und Public-Notification-APIs verwenden nun den vorhandenen UI-Manager statt leerer Implementierungen.
+- Settings-Änderungen schreiben nicht mehr synchron bei jeder Änderung, sondern werden gedrosselt persistiert.
+- Testprojekt schließt generierte `bin/`-/`obj/`-Quellen aus.
+- Erzeugte Template-Artefakte werden nicht mehr als Quellcode eingecheckt.
+- Nicht übernommene Nebenänderungen (unbegrenzte Steam-Queue, reflektive
+ FishNet-RPCs und optionale Testvarianten) sind mit Begründung dokumentiert.
+- Die eigene FishNet-/Relay-/`greg.Multiplayer`-Schicht und die zugehörigen
+ Synchronisationswrapper wurden entfernt; Koop bleibt vollständig beim
+ nativen Data-Center-Spiel.
+- Die aktiven Steam-/P2P-Aufrufe aus dem alten FFI-Multiplayerpfad wurden
+ entfernt; die v7-ABI-Slots bleiben ausschließlich als stabile No-op-Slots.
+- Native Unity-/Steamworks-/Data-Center-Netzwerktypen wurden gegen die lokalen
+ Referenz-Assemblies geprüft und mit Hashes und reproduzierbaren Befehlen in
+ `docs/codebase/native-coop-assembly-audit.md` festgehalten.
+
+### Verification
+
+- Release-Build ohne Deployment: erfolgreich.
+- Hook-Vertragsprüfung: 2 kanonische Hooks aus Manifest v2 validiert.
+- Tests: 26/26 bestanden.
+- In-Game-Smoke-Test gegen eine reale Data-Center-Installation: noch offen.
+- Test-Build und VSTest-Lauf nach Entfernung der Eigenimplementierung:
+ 26/26 bestanden.
+
+## Committed history since v1.2.1
+
+### c6192214 — 2026-08-13 — `chore: remove obsolete FishNet agent instructions`
+
+- Veraltete `.gemini`-Anweisungen für den nicht mehr zulässigen FishNet-Eigenstack
+ entfernt.
+
+### 77d4de32 — 2026-08-13 — `refactor(multiplayer): remove custom networking stack`
+
+- Eigene FishNet-, Relay-, `dc_multiplayer.dll`- und Steam/P2P-Synchronisation
+ entfernt; native Data-Center-Co-op-Grenze gegen die lokalen Assemblies
+ dokumentiert.
+
+### aed6b58c — 2026-08-13 — `docs: record branch consolidation and cleanup`
+
+- Branch-, PR- und Release-Bereinigung sowie die Maintainer-Dokumentation
+ festgehalten.
+
+### f9837f15 — 2026-08-13 — `feat: consolidate GregCore integration and release flow`
+
+- GregCore-Integration, Release-Flows, Versionierung, CI-Gates und die
+ konsolidierte Integrationsbasis umgesetzt.
+
+### 9366b777 — 2026-07-28 — `docs: add macOS support notice`
+
+- Dokumentation um den macOS-Support-Hinweis ergänzt.
+
+### 4d9f222e — 2026-07-28 — `security: migrate Lua hot-reload sandbox PRs [skip ci]`
+
+- Sicherheitsänderungen aus den Lua-Hot-Reload-Sandbox-PRs übernommen.
+
+### 2539edb8 — 2026-07-28 — `security: migrate Lua module sandbox PRs [skip ci]`
+
+- Sicherheitsänderungen für die Lua-Modul-Sandbox übernommen.
+
+### 618fea58 — 2026-07-28 — `perf: optimize legacy GameHooks rack counting [skip ci]`
+
+- Rack-Zählung in den Legacy-GameHooks performance-optimiert.
+
+### e7c346f6 — 2026-07-28 — `perf: remove global scene scans from public and Lua APIs [skip ci]`
+
+- Globale Scene-Scans aus Public- und Lua-APIs entfernt.
+
+### b4d43682 — 2026-07-28 — `perf: use cached device counts for facility metrics [skip ci]`
+
+- Facility-Metriken auf gecachte Geräteanzahlen umgestellt.
+
+### 5916a2d7 — 2026-07-28 — `security: harden Lua sandbox and employee identifiers [skip ci]`
+
+- Lua-Sandbox und Employee-Identifier abgesichert.
## [1.2.1] - 2026-06-28
@@ -12,7 +112,9 @@
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
-Versioning follows `MAJOR.MINOR.PATCH` — patch is auto-incremented on every push to `main`.
+Versioning follows Semantic Versioning 2.0.0. Development uses
+`X.Y.Z-dev.N`, release candidates use `X.Y.Z-rc.N`, and stable releases use
+`X.Y.Z`; stable versions are never auto-incremented on ordinary pushes.
## [Unreleased]
diff --git a/README.md b/README.md
index 43f756ce..44d9dbcc 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
[](https://discord.gg/greg)
[](https://gregframework.eu)
[](./LICENSE)
-[]()
+[]()
[]()
[]()
@@ -38,7 +38,7 @@
- Custom shop and employee management APIs
- Logging and diagnostic infrastructure
- Lua, JS and Python scripting bridges
-- FishNet multiplayer sync layer (optional)
+- Native Data Center co-op compatibility without a replacement networking stack
## Installation
@@ -132,6 +132,14 @@ See [`docs/FrameworkAPI.md`](docs/FrameworkAPI.md) for the auto-generated hook r
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
+Development follows `dev -> pre-release -> main`. See
+[the branch and release policy](docs/maintainers/branch-protection.md) before
+opening a pull request. Downloads are published on the GitHub Releases page;
+development builds are intentionally not presented as stable releases.
+Native Data Center co-op remains game-owned; mod authors should follow the
+[native co-op boundary](docs/modding/native-coop.md) and avoid adding a second
+transport or lobby implementation.
+
## License
This project is licensed under the **Apache License 2.0**. See [`LICENSE`](./LICENSE).
diff --git a/VERSION b/VERSION
index 6085e946..28fa7968 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.2.1
+1.2.2-dev.0
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 2f67c1d8..a34ebd64 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -25,5 +25,5 @@ All notable changes to gregCore are documented here.
- Save engine with versioning (LiteDB)
- Multi-mod architecture with dependency resolution
- Lua, JS and Python scripting bridges
-- FishNet multiplayer sync layer
+- Native Data Center co-op compatibility boundary
- CI/CD pipeline with auto-versioning
diff --git a/docs/SOURCE_LAYOUT.md b/docs/SOURCE_LAYOUT.md
index 5a88fbf8..b5fd656f 100644
--- a/docs/SOURCE_LAYOUT.md
+++ b/docs/SOURCE_LAYOUT.md
@@ -13,7 +13,7 @@ gregCore.Framework/
│ ├── Shop/ # Custom shop system
│ ├── Employee/ # Employee management
│ ├── Rack/ # Wall rack and grid placement
-│ ├── Network/ # FishNet multiplayer sync (optional)
+│ ├── Network/ # Native game-network read-only adapters
│ ├── Diagnostics/ # Debug and logging infrastructure
│ └── ... # 27 modules total
├── framework/ # greg_hooks.json — canonical hook registry
diff --git a/docs/codebase/ARCHITECTURE.md b/docs/codebase/ARCHITECTURE.md
new file mode 100644
index 00000000..197dbd0f
--- /dev/null
+++ b/docs/codebase/ARCHITECTURE.md
@@ -0,0 +1,19 @@
+# GregCore Architecture
+
+`GregBootstrapper` builds the shared service graph: logger, event bus, hook bus, settings, persistence, plugin registry, notification service, public API, and performance governor. `GregApiContext` exposes the guarded subset to `GregMod` instances.
+
+`GregPluginRegistry` scans DLLs with Mono.Cecil, resolves dependencies, creates `GregMod` entrypoints, and isolates lifecycle failures. The main-thread dispatcher is drained from the Melon update callback. Resource registrations are disposed with the mod.
+
+The event bus caches handler arrays and defers events when the performance governor's per-frame budget is exhausted. The hook bus dispatches synchronous named hooks with handler isolation. The dynamic Harmony patcher only accepts the object-shaped reviewed manifest and enters safe mode for unknown or mismatched fingerprints.
+
+Performance is centralized in `GregPerformanceGovernor`, which controls frame settings, memory monitoring, event budgets, operation concurrency and bounded operation queues.
+
+## Evidence
+
+- `src/GameLayer/Bootstrap/GregBootstrapper.cs`
+- `src/PublicApi/GregApiContext.cs`
+- `src/Infrastructure/Plugins/GregPluginRegistry.cs`
+- `src/Core/Events/GregEventBus.cs`
+- `src/GameLayer/Hooks/GregDynamicHookPatcher.cs`
+- `src/Infrastructure/Performance/GregPerformanceGovernor.cs`
+
diff --git a/docs/codebase/CONCERNS.md b/docs/codebase/CONCERNS.md
new file mode 100644
index 00000000..601d78db
--- /dev/null
+++ b/docs/codebase/CONCERNS.md
@@ -0,0 +1,28 @@
+# GregCore Concerns and Implementation Findings
+
+- The active worktree contains pre-existing uncommitted changes; they were preserved and are not attributable to this analysis alone. Inspect `git status` before committing.
+- The canonical hook manifest currently has `assemblyFingerprint: UNKNOWN`; runtime therefore correctly enters safe mode until a reviewed game fingerprint is supplied.
+- The compatibility README advertises broad support, but the release smoke test is explicitly external and not passed by repository tests.
+- `GregDependencyResolver` was a placeholder in the base commit and is now implemented in the existing worktree changes; cycle and missing-dependency tests pass.
+- The GregCore bootstrap was present but not called by the Melon entry point; it is now wired into `GregCoreMod.OnInitializeMelon`.
+- Notification APIs had disconnected/empty paths; the public and legacy notification facades now use the bounded `GregNotificationManager`.
+- Settings updates are marked dirty and persisted after a short debounce instead of writing synchronously on every update.
+- Operation queues are bounded by `PerformanceProfile.MaxQueuedOperations`; event dispatch and UI notifications also have hard limits.
+- `GregPerformanceGovernor.OnUpdate` is now called from the real Melon update path, and quality profiles control throttle intervals.
+- `GregPerformanceModule.OnResourceUpdate` now removes the exact delegate registered by the caller.
+- Remaining TODOs include external in-game verification and validation of native
+ co-op callbacks against the installed game build. gregCore deliberately does
+ not define or synchronize multiplayer state itself.
+
+## Evidence
+
+- `git status --short --branch`
+- `src/Core/GregCoreMod.cs`
+- `src/GameLayer/Bootstrap/GregBootstrapper.cs`
+- `src/Infrastructure/Plugins/GregDependencyResolver.cs`
+- `src/PublicApi/Modules/GregUIModule.cs`
+- `src/API/GregAPI.cs`
+- `src/Infrastructure/Settings/GregModSettingsService.cs`
+- `src/Infrastructure/Performance/GregOperationQueue.cs`
+- `src/Infrastructure/Performance/GregPerformanceGovernor.cs`
+- `src/PublicApi/Modules/GregPerformanceModule.cs`
diff --git a/docs/codebase/CONVENTIONS.md b/docs/codebase/CONVENTIONS.md
new file mode 100644
index 00000000..44b4f7c5
--- /dev/null
+++ b/docs/codebase/CONVENTIONS.md
@@ -0,0 +1,20 @@
+# GregCore Conventions
+
+- Framework/public types use the `Greg` or `greg` prefix and are grouped by layer.
+- C# mods use `[GregMod]` and derive from `GregMod`.
+- Dependencies use `[GregDependsOn]`; plugin IDs and setting IDs are case-insensitive at registry boundaries.
+- Public mod subscriptions should use `GregMod.On(...)` so disposal removes them automatically.
+- Unity/Il2Cpp work must be dispatched through `IGregMainThreadDispatcher` when initiated from another thread.
+- Hook contracts use canonical `gregMod.*`, `gregExt.*` or `gregPlugin.*` names; legacy names are migration aliases.
+- Callback and patch failures are caught and logged at framework boundaries.
+
+[TODO] A repository-wide formatter/analyzer policy was not verified in the inspected files.
+
+## Evidence
+
+- `src/PublicApi/Attributes/`
+- `src/PublicApi/GregMod.cs`
+- `src/PublicApi/IGregMainThreadDispatcher.cs`
+- `framework/greg_hooks.json`
+- `src/Core/Events/`
+
diff --git a/docs/codebase/INTEGRATIONS.md b/docs/codebase/INTEGRATIONS.md
new file mode 100644
index 00000000..ecc0bb90
--- /dev/null
+++ b/docs/codebase/INTEGRATIONS.md
@@ -0,0 +1,26 @@
+# GregCore Integrations
+
+- MelonLoader 0.7.3 and Unity 6000.4.12f1 are the reviewed contract values in `framework/greg_hooks.json`.
+- Unity IL2CPP and Il2CppInterop are required runtime references.
+- Harmony provides Prefix/Postfix patching; the canonical manifest is fingerprint-gated.
+- Data Center integration is under `src/Compatibility/DataCenterModLoader` and `src/GameLayer`.
+- Native Data Center co-op is treated as a game-owned subsystem; gregCore does
+ not ship a relay, FishNet, lobby, or replacement synchronization layer.
+- Legacy Rust FFI v7 Steam/lobby/P2P slots remain ABI-compatible no-ops only;
+ they do not call Steam or create sessions.
+- The reviewed game references expose Unity multiplayer roles, game-owned
+ network-save/network-map types, SteamManager and Steamworks lobby/P2P APIs;
+ see `docs/codebase/native-coop-assembly-audit.md` for hashes and commands.
+- Lua, JavaScript, Python, C#, Go and Rust bridges are represented under `src/Bridge` and `src/Sdk/Language`.
+- Settings and framework persistence use JSON services; LiteDB is declared for the save-engine layer.
+
+[TODO] In-game Windows/Linux smoke-test artifacts were not available in this repository inspection.
+
+## Evidence
+
+- `framework/greg_hooks.json`
+- `gregCore.csproj`
+- `src/Compatibility/`
+- `src/Bridge/`
+- `src/Infrastructure/Config/`
+- `src/Infrastructure/Settings/Services/`
diff --git a/docs/codebase/STACK.md b/docs/codebase/STACK.md
new file mode 100644
index 00000000..96d1fd39
--- /dev/null
+++ b/docs/codebase/STACK.md
@@ -0,0 +1,19 @@
+# GregCore Stack
+
+## Runtime
+
+- C# / .NET 6 (`gregCore.csproj`, `TargetFramework=net6.0`).
+- Unity IL2CPP with MelonLoader and BepInEx compatibility targets.
+- Harmony and Il2CppInterop for runtime integration.
+- x64 build target with external game/reference assemblies under `references/`.
+
+## Packages
+
+Jint 4.8.0, LiteDB 5.0.21, Mono.Cecil 0.11.6, MoonSharp 2.0.0, Newtonsoft.Json 13.0.3 and pythonnet 3.0.5 are declared in `gregCore.csproj`.
+
+## Evidence
+
+- `gregCore.csproj`
+- `README.md`
+- `tests/gregCore.Tests.csproj`
+
diff --git a/docs/codebase/STRUCTURE.md b/docs/codebase/STRUCTURE.md
new file mode 100644
index 00000000..31607ee6
--- /dev/null
+++ b/docs/codebase/STRUCTURE.md
@@ -0,0 +1,22 @@
+# GregCore Structure
+
+- `src/Core`: Melon entry point, models, events, persistence and exceptions.
+- `src/PublicApi`: `GregMod`, public context, attributes, modules and facade.
+- `src/Infrastructure`: logging, settings, plugins, performance, scripting and UI services.
+- `src/GameLayer`: bootstrap, lifecycle integration, hooks and game patches.
+- `src/Compatibility`: DataCenterModLoader and native game compatibility code.
+- `src/UI`: UI Toolkit canvas, panels, overlays, themes and notifications.
+- `src/greg.*`: feature modules such as SaveEngine, WallRack and QoL; no custom multiplayer module.
+- `src/Bridge`: C#, Lua, JS, Python, Go and Rust bridges.
+- `framework/`: canonical reviewed hook manifest and Harmony hooks.
+- `tools/`, `templates/`, `examples/`: coverage scanner, mod templates and language examples.
+- `tests/`: unit tests for events, patches, registry, diagnostics and public resources.
+
+The MelonLoader entry point is `src/Core/GregCoreMod.cs`; C# mods derive from `src/PublicApi/GregMod.cs`.
+
+## Evidence
+
+- `README.md`
+- `src/Core/GregCoreMod.cs`
+- `src/PublicApi/GregMod.cs`
+- `tests/`
diff --git a/docs/codebase/TESTING.md b/docs/codebase/TESTING.md
new file mode 100644
index 00000000..ea4b711c
--- /dev/null
+++ b/docs/codebase/TESTING.md
@@ -0,0 +1,18 @@
+# GregCore Testing
+
+The test project is `tests/gregCore.Tests.csproj`. Current tests cover event dispatch and isolation, dependency ordering and failures, plugin persistent IDs, diagnostics, resource disposal, and rack/cable patch behavior.
+
+The release build was verified with temporary output directories and no game deployment. The final local run built `gregCore.dll` in Release and executed 26 xUnit tests successfully using .NET roll-forward to the installed runtime.
+
+[TODO] A real Data Center in-game smoke test with archived logs and runtime artifacts remains external work.
+
+## Evidence
+
+- `tests/gregCore.Tests.csproj`
+- `tests/Events/`
+- `tests/Core/`
+- `tests/Infrastructure/`
+- `tests/Patches/`
+- `tests/PublicApi/`
+- `docs/maintainers/release-smoke-test.md`
+
diff --git a/docs/codebase/native-coop-assembly-audit.md b/docs/codebase/native-coop-assembly-audit.md
new file mode 100644
index 00000000..f469fde4
--- /dev/null
+++ b/docs/codebase/native-coop-assembly-audit.md
@@ -0,0 +1,39 @@
+# Native co-op assembly audit
+
+Reviewed against the checked-in Data Center reference assemblies on 2026-08-13.
+The hashes below identify the exact files inspected; they are not a substitute
+for a runtime smoke test against the installed game.
+
+| Assembly | SHA-256 | Relevant metadata observed |
+|---|---|---|
+| `lib/references/MelonLoader/Il2CppAssemblies/Assembly-CSharp.dll` | `30b53b19a1ebaa61ae604de5cac206fea8fcae26765be5082bb2d9b1be19ca69` | `NetworkSaveData`, `WaypointInitializationSystem.LoadNetworkState`, `NetworkMap`, `NetworkSwitch`, `SteamManager` and `SteamAPIDebugTextHook` |
+| `lib/references/MelonLoader/Il2CppAssemblies/UnityEngine.MultiplayerModule.dll` | `6f555174ff18a3713a5548d2332c29506fb05a300195b34306cc356f9b311219` | `Unity.Multiplayer.PlayMode.CurrentPlayer`, `MultiplayerManager`, `MultiplayerRole`, `ClientAndServer` and active role-mask APIs |
+| `lib/references/MelonLoader/Il2CppAssemblies/Il2Cppcom.rlabrecque.steamworks.net.dll` | `a025963cb4433ae8840da96e8951cdeacfb9730be1bff9fd356c358549b12182` | `SteamMatchmaking`, lobby callbacks, `SteamNetworking`, `SteamNetworkingMessages`, `SendP2PPacket`, `ReadP2PPacket` and related session types |
+
+## Conclusion
+
+The references support the native-co-op boundary used by GregCore:
+
+1. Unity exposes a multiplayer role/player module.
+2. The game assembly owns network-save loading and network-map state and has a
+ native Steam manager.
+3. The bundled Steamworks Il2Cpp wrapper exposes the lobby/P2P and networking
+ primitives needed by the game.
+
+This is sufficient evidence that GregCore must not ship its former FishNet,
+relay or `dc_multiplayer.dll` replacement stack. It does not, by itself, prove
+which exact Unity/Steam callback path is active in every game scene. That
+remaining fact requires an in-game Windows/Linux smoke test with two native
+co-op players.
+
+## Reproduction
+
+The metadata was checked with:
+
+```bash
+monodis --typedef lib/references/MelonLoader/Il2CppAssemblies/UnityEngine.MultiplayerModule.dll
+monodis --typedef lib/references/MelonLoader/Il2CppAssemblies/Assembly-CSharp.dll
+monodis --typedef lib/references/MelonLoader/Il2CppAssemblies/Il2Cppcom.rlabrecque.steamworks.net.dll
+```
+
+Additional symbol checks used `strings` with the names listed in the table.
diff --git a/docs/maintainers/branch-cleanup-2026-08.md b/docs/maintainers/branch-cleanup-2026-08.md
new file mode 100644
index 00000000..6bd0979e
--- /dev/null
+++ b/docs/maintainers/branch-cleanup-2026-08.md
@@ -0,0 +1,47 @@
+# Branch cleanup — 2026-08-13
+
+The complete remote branch inventory was inspected in ascending author-date
+order. Before deletion there were 227 named remote refs including the newly
+created integration and promotion branches. The resulting repository keeps
+only:
+
+- `main` — published release line;
+- `dev` — current development integration line;
+- `pre-release` — gate-passed promotion candidate;
+- `release/v1.2.1` — existing historical release snapshot;
+- `agent/gregcore-integration` — the open consolidated PR #235.
+
+222 obsolete branches were deleted after the open PR audit. They fell into
+these classes:
+
+- 97 Sentinel path-traversal variants, including repeated `StartsWith` and
+ portrait proposals;
+- 86 Bolt rack/server lookup variants, many with repeated CI-only changes;
+- 15 Jules branches containing duplicate fixes, exploratory refactors, or
+ tests that were not compatible with the current test/reference setup;
+- 24 other stale feature, fix, performance, test, and automation branches;
+- the broad architecture PR #207, which was reviewed but intentionally not
+ merged wholesale because it changes project layout and compatibility scope.
+
+## Implementability decisions
+
+- The strongest Portrait path-validation and `NetworkMap` server lookup
+ changes were consolidated in PR #235.
+- Lua sandbox branches were not copied blindly: the current `GregIoLuaModule`,
+ `LuaModuleLoader`, and `LuaHotReload` already canonicalize paths and enforce
+ a separator-aware root boundary. The many variants differ mostly in CI
+ churn or duplicate validation.
+- The proposed Steam callback queue was not adopted because its unbounded
+ queue conflicts with the framework's bounded-work policy; it needs a
+ bounded/coalescing design and an integration test first.
+- FishNet reflection/RPC proposals were not adopted because Data Center now
+ owns co-op natively. The complete custom multiplayer layer was removed in a
+ follow-up commit rather than retaining a competing synchronization path.
+- The timezone fallback and isolated test proposals were reviewed but left out
+ of the integration commit because they are unrelated to the current
+ security/performance scope and some depend on optional runtime assemblies.
+ Their commits remain recoverable from the closed PR records.
+
+The deletion was intentionally limited to non-protected, non-release branches;
+the GitHub PR records preserve the review history and the release snapshot is
+unchanged.
diff --git a/docs/maintainers/branch-protection.md b/docs/maintainers/branch-protection.md
new file mode 100644
index 00000000..8c45d08e
--- /dev/null
+++ b/docs/maintainers/branch-protection.md
@@ -0,0 +1,72 @@
+# GregCore branch and release policy
+
+This repository uses a deliberately one-way promotion flow:
+
+```text
+feature/fix/integration -> dev -> pre-release -> main
+ |
+ +-> release/vX.Y.Z (immutable snapshot)
+```
+
+## Branch responsibilities
+
+| Branch | Meaning | Allowed incoming pull requests | Version form |
+| --- | --- | --- | --- |
+| `dev` | Current development integration line | Feature, fix, security, performance, and integration branches | `X.Y.Z-dev.N` |
+| `pre-release` | The latest `dev` state whose gates passed | `dev` only | `X.Y.Z-rc.N` while frozen, otherwise the promoted dev version |
+| `main` | Current published release | `pre-release` only | Stable `X.Y.Z` |
+| `release/vX.Y.Z` | Historical release snapshot | None | Stable `X.Y.Z` |
+
+`main` is not a development branch. A change reaches it only through a reviewed
+pull request whose base is `main` and whose head is `pre-release`. A direct push,
+feature-to-main PR, and auto-version-bump commit are all forbidden.
+
+## Required protection rules
+
+The GitHub ruleset/branch protection configuration must apply to `dev`,
+`pre-release`, `main`, and `release/v*`:
+
+- pull requests are required; direct pushes are disabled;
+- required status checks are `policy/branch-flow`, `contracts`, `tests`,
+ `build-windows`, `build-linux`, and `docs`;
+- at least one approving review is required, with stale approvals dismissed;
+- conversation resolution is required;
+- force pushes and branch deletion are disabled;
+- administrators are included in enforcement;
+- linear history is preferred; squash merge is the repository default;
+- `release/v*` is locked after creation and has no merge target;
+- only the release workflow may create `release/v*` branches.
+
+The `branch-policy.yml` check enforces the source-branch direction because
+ordinary branch protection does not express “only this exact source branch” on
+its own. The workflow is therefore a required check, not advisory documentation.
+
+## Promotion procedure
+
+1. Work is reviewed and merged into `dev`. Every development build uses a
+ monotonically increasing `X.Y.Z-dev.N` version.
+2. A maintainer opens `dev -> pre-release`. CI must pass on the exact merge
+ commit. This is the only route into `pre-release`.
+3. When the candidate is accepted, the version is changed in a separate,
+ reviewed commit to the stable `X.Y.Z` value and the changelog entry is
+ complete. That commit is promoted through `pre-release -> main`.
+4. The `Release` workflow runs from `main`, creates `release/vX.Y.Z` exactly
+ once, creates tag `vX.Y.Z`, and publishes the release assets. Re-running it
+ never rewrites an existing branch or tag.
+5. A released branch is retained as an audit snapshot. Fixes go to a new
+ development line and are never merged back into an old release branch.
+
+## Emergency handling
+
+An emergency fix still follows `feature/fix -> dev -> pre-release -> main`.
+If GitHub service failure requires an administrator bypass, the maintainer must
+record the reason, approving reviewer, commit, and resulting release in the
+changelog and release notes. The release snapshot remains immutable.
+
+## SemVer contract
+
+GregCore follows Semantic Versioning 2.0.0 and Keep a Changelog. `dev` and
+`pre-release` identifiers are prerelease metadata, not separate numeric release
+versions. The current development line after `v1.2.1` is `1.2.2-dev.0`; it is
+more meaningful than inventing a lower `0.x` line after a published `1.x`
+release. A stable `1.2.2` is created only when the candidate is ready.
diff --git a/docs/maintainers/contracts.md b/docs/maintainers/contracts.md
new file mode 100644
index 00000000..8a18403c
--- /dev/null
+++ b/docs/maintainers/contracts.md
@@ -0,0 +1,13 @@
+# Contract maintenance
+
+Audience: GregCore maintainers.
+
+Edit `framework/greg_hooks.json` only after reviewing the corresponding game member. Keep a stable `id`, canonical `gregMod.*`, `gregExt.*`, or `gregPlugin.*` name, payload schema, threading rule, and status. Preserve `legacy` aliases only as explicitly deprecated migration bridges.
+
+Run:
+
+```bash
+python3 scripts/validate_contracts.py
+dotnet build gregCore.csproj -c Release -p:CI=true --no-restore
+dotnet test tests/gregCore.Tests.csproj -c Release --no-restore
+```
diff --git a/docs/maintainers/open-pr-audit-2026-08.md b/docs/maintainers/open-pr-audit-2026-08.md
new file mode 100644
index 00000000..8606d75f
--- /dev/null
+++ b/docs/maintainers/open-pr-audit-2026-08.md
@@ -0,0 +1,56 @@
+# Open PR audit — 2026-08-13
+
+The open pull requests were inspected oldest to newest. The audit found two
+repeating proposal families, not 28 independent features:
+
+- **Sentinel:** path traversal checks for `CustomEmployeeManager.SetPortrait`.
+ The selected implementation validates logical IDs, rejects separators and
+ rooted paths, and canonicalizes the final path inside `ModAssets`.
+- **Bolt:** replacement of repeated `FindObjectsOfType()` calls with
+ `NetworkMap` collections. The selected implementation uses the authoritative
+ server/broken-server registries and does not allocate a global scene scan in
+ the hot API path.
+
+PR #207 is a broad architecture migration. It was reviewed separately and is
+not merged wholesale because it changes the project layout and release model;
+compatible ideas are documented in `docs/codebase/` and the branch policy. It
+must not be mixed into the smaller security/performance integration without a
+separate compatibility review.
+
+| PR | Created (UTC) | Branch | Decision |
+| ---: | --- | --- | --- |
+| #207 | 2026-07-28 | `refactor/il2cpp-version-neutral` | Close as superseded; retain architecture ideas for a separate reviewed migration. |
+| #208 | 2026-07-29 | `sentinel/fix-setportrait-traversal-*` | Close; duplicate Sentinel variant. |
+| #209 | 2026-07-29 | `sentinel/path-traversal-setportrait-*` | Close; duplicate Sentinel variant. |
+| #210 | 2026-07-30 | `sentinel/fix-setportrait-path-traversal-*` | Close; duplicate Sentinel variant. |
+| #211 | 2026-07-31 | `perf/lua-server-module-*` | Close; superseded by the selected NetworkMap implementation. |
+| #212 | 2026-07-31 | `sentinel/fix-path-traversal-setportrait-*` | Close; duplicate Sentinel variant. |
+| #213 | 2026-08-01 | `bolt/optimize-lua-server-lookup-*` | Close; superseded by the selected NetworkMap implementation. |
+| #214 | 2026-08-02 | `sentinel/fix-path-traversal-employee-manager-*` | Close; duplicate Sentinel variant. |
+| #215 | 2026-08-03 | `sentinel/fix-setportrait-path-traversal-*` | Close; duplicate Sentinel variant. |
+| #216 | 2026-08-03 | `bolt-optimize-findobjects-*` | Close; broader variant superseded by the selected implementation. |
+| #217 | 2026-08-03 | `sentinel-fix-path-traversal-*` | Close; duplicate Sentinel variant. |
+| #218 | 2026-08-04 | `bolt/optimize-lua-server-module-*` | Close; superseded by the selected NetworkMap implementation. |
+| #219 | 2026-08-04 | `sentinel/fix-path-traversal-portrait-*` | Close; duplicate and includes unrelated changes. |
+| #220 | 2026-08-05 | `bolt-optimize-server-lookups-*` | Close; superseded by the selected NetworkMap implementation. |
+| #221 | 2026-08-05 | `perf/optimize-findobjectsoftype-*` | Close; empty effective diff against current main. |
+| #222 | 2026-08-05 | `sentinel/fix-custom-employee-manager-*` | Close; duplicate Sentinel variant. |
+| #223 | 2026-08-07 | `sentinel/fix-path-traversal-setportrait-*` | Close; duplicate Sentinel variant. |
+| #224 | 2026-08-07 | `bolt-optimize-lua-server-queries-*` | Close; superseded by the selected NetworkMap implementation. |
+| #225 | 2026-08-08 | `sentinel-security-pathtraversal-*` | Close; duplicate Sentinel variant. |
+| #226 | 2026-08-08 | `bolt-optimize-luaserver-api-*` | Close; superseded by the selected NetworkMap implementation. |
+| #227 | 2026-08-09 | `sentinel/fix-path-traversal-portrait-*` | Close; duplicate Sentinel variant. |
+| #228 | 2026-08-09 | `bolt/optimize-lua-server-api-*` | Close; superseded by the selected NetworkMap implementation. |
+| #229 | 2026-08-09 | `sentinel-fix-setportrait-traversal-*` | Close; duplicate Sentinel variant. |
+| #230 | 2026-08-10 | `bolt-optimize-findobjectsoftype-*` | Close; superseded by the selected NetworkMap implementation. |
+| #231 | 2026-08-11 | `sentinel/fix-path-traversal-setportrait-*` | Close; duplicate Sentinel variant. |
+| #232 | 2026-08-11 | `bolt-lua-server-opt-*` | Close; superseded by the selected NetworkMap implementation. |
+| #233 | 2026-08-12 | `sentinel/fix-path-traversal-setportrait-*` | Close; duplicate Sentinel variant. |
+| #234 | 2026-08-12 | `bolt/optimize-lua-server-module-*` | Close; superseded by the selected NetworkMap implementation. |
+
+## Cleanup rule
+
+After the integration PR is opened, each listed PR is closed with a link to
+this audit. Its source branch is deleted only after GitHub confirms the PR is
+closed and only when it is not `main`, `dev`, `pre-release`, or `release/*`.
+The existing `release/v1.2.1` branch is preserved as a historical snapshot.
diff --git a/docs/maintainers/release-smoke-test.md b/docs/maintainers/release-smoke-test.md
new file mode 100644
index 00000000..a92a07a6
--- /dev/null
+++ b/docs/maintainers/release-smoke-test.md
@@ -0,0 +1,25 @@
+# Release smoke test
+
+The real Data Center installation is external to this repository. Run the
+following gate on Windows x64 and Linux/Proton using the verified versions:
+
+- Unity `6000.4.12f1`
+- MelonLoader `0.7.3`
+- Il2CppInterop `1.5.1`
+
+Install GregCore, the C# template mod, and the Lua manifest mod. Verify startup,
+scene changes, lifecycle events, config access, a main-thread action, Lua reload,
+C# unload, save/load, shutdown, and restart. After each run archive:
+`doctor.json`, `MelonLoader.log`, `gregCore.log`, `fingerprint.json`,
+`hook-install-report.json`, and `loaded-mod-report.json`.
+
+Validate the archive with:
+
+```bash
+python3 scripts/validate_release_artifacts.py path/to/archive
+```
+
+The gate passes only when both example mods load, reload/unload removes their
+subscriptions, no unexplained Critical/High errors remain, and an unknown build
+starts in safe mode without enabling risk-bearing hooks. An external game run is
+required; repository CI cannot claim this gate passed without the archived logs.
diff --git a/docs/modding/api/coverage.md b/docs/modding/api/coverage.md
new file mode 100644
index 00000000..ad7c87e4
--- /dev/null
+++ b/docs/modding/api/coverage.md
@@ -0,0 +1,17 @@
+# API coverage
+
+Coverage has two separate denominators:
+
+1. The complete static inventory in `game_hooks.json` and the assembly scanner output.
+2. The explicitly reviewed, modding-relevant members represented by `framework/greg_hooks.json`.
+
+Only the second denominator is eligible for a 100% support claim. Unknown game fingerprints are reported as unsupported; they are not silently treated as compatible. The scanner records the assembly version and SHA-256 fingerprint so a coverage diff can be reproduced for a new build.
+
+Run from the repository root:
+
+```bash
+python3 scripts/validate_contracts.py
+python3 ../docs/audit/generate_coverage.py
+```
+
+Expected artifacts are the validation summary and `docs/audit/coverage-matrix.csv`.
diff --git a/docs/modding/api/events.md b/docs/modding/api/events.md
new file mode 100644
index 00000000..7dbbe8c6
--- /dev/null
+++ b/docs/modding/api/events.md
@@ -0,0 +1,9 @@
+# GregCore events
+
+Audience: all supported mod languages.
+
+Canonical names use `gregMod..`. `greg.*` names are legacy compatibility aliases and are deprecated. Hook payloads are dictionaries with the fields declared in `framework/greg_hooks.json`; callbacks run on the documented thread.
+
+Subscriptions must be disposed during unload. C# mods can use the disposable returned by `context.Events.On(...)` or the protected `On(...)` helper on `GregMod`. A callback exception is logged and isolated from other subscribers.
+
+The committed manifest is the source of truth for IDs, payload fields, cancellation, and threading. An empty or unknown manifest entry is not an API guarantee.
diff --git a/docs/modding/getting-started.md b/docs/modding/getting-started.md
new file mode 100644
index 00000000..00a2ec15
--- /dev/null
+++ b/docs/modding/getting-started.md
@@ -0,0 +1,17 @@
+# Getting started with GregCore
+
+Audience: C# and Lua mod authors.
+
+Status: `STATIC_CONTRACT`; runtime verification requires the Data Center installation.
+
+Compatibility target: Unity `6000.4.12f1`, MelonLoader `0.7.3`, Il2CppInterop `1.5.1`.
+
+Install GregCore's MelonLoader artifact into the game's `Mods` directory. On the first start, inspect the MelonLoader log for `Framework initialization complete`. GregCore reports its diagnostics in the same log and does not claim runtime support for an unknown game build.
+
+Expected files:
+
+- `Data Center/Mods/gregCore.dll`
+- `Data Center/Mods/game_hooks.json`
+- `Data Center/Mods/framework/greg_hooks.json`
+
+If startup reports `UNSUPPORTED_GAME_BUILD`, keep the diagnostic report and do not enable hooks manually. The report must be reviewed against the committed manifest before updating the game.
diff --git a/docs/modding/native-coop.md b/docs/modding/native-coop.md
new file mode 100644
index 00000000..9b71a5b4
--- /dev/null
+++ b/docs/modding/native-coop.md
@@ -0,0 +1,38 @@
+# Native Data Center co-op boundary
+
+Data Center owns the multiplayer session, lobby lifecycle, transport and
+shared world state. GregCore must remain a framework around that game-owned
+authority; it must not create a second session or attempt to replicate game
+objects independently.
+
+## What GregCore may do
+
+- read local game state through the public API;
+- provide local UI, settings, diagnostics and performance helpers;
+- observe game-owned lifecycle or object events when the game exposes them;
+- make a local change only when the feature is explicitly safe for native
+ co-op and the game remains the authority.
+
+## What GregCore must not do
+
+- create or join Steam lobbies;
+- open Steam P2P or Steam Networking sessions;
+- install a FishNet/relay/network-manager replacement;
+- transfer saves or world objects through a custom multiplayer protocol;
+- make profile, unlock, money, tower or simulation changes on behalf of other
+ players.
+
+The legacy Rust FFI v7 Steam/lobby/P2P function-pointer positions are retained
+for ABI layout compatibility with older plugins. They are inert no-ops. New
+plugins must not depend on them; they are not an adapter to Data Center's
+native session.
+
+## Mod author rules
+
+Single-player-only features must declare or enforce their single-player scope.
+Features that are safe in native co-op should operate on local presentation or
+read-only diagnostics. Any shared-world mutation needs a documented game-owned
+authority path and an in-game test before it can be enabled.
+
+The assembly evidence for the current reference set is recorded in
+[`../codebase/native-coop-assembly-audit.md`](../codebase/native-coop-assembly-audit.md).
diff --git a/docs/multiplayer-architecture.md b/docs/multiplayer-architecture.md
deleted file mode 100644
index 32c36d0a..00000000
--- a/docs/multiplayer-architecture.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# greg.Multiplayer – Architecture & Design Decisions
-
-## Stack
-| Layer | Technology | Why |
-|-------|-----------|-----|
-| Mod Loader | MelonLoader 0.7.2 | IL2CPP injection, .NET 6 |
-| Networking | FishNet Latest | Free, IL2CPP/AOT-ready, server-authority |
-| Relay | FishBait (Docker) | Self-hosted, no Photon subscription |
-| Game Hooks | HarmonyLib 2.x | Prefix/Postfix without source access |
-| Serialisation | System.Text.Json | .NET 6 built-in, no extra DLL |
-
-## Connection Modes
-
-### Mode A – LAN Listen-Server
-```
-Host PC: ServerManager.StartConnection(:7777)
- ClientManager.StartConnection("localhost", 7777)
-Client: ClientManager.StartConnection("192.168.x.x", 7777)
-```
-The host runs server + client simultaneously.
-NAT-only problem if players are not on the same LAN.
-
-### Mode B – WAN via FishBait Relay
-```
-Host: ServerManager.StartConnection(:7777)
- Registers room on relay (fishbait://relay:7778)
-Client: Connects to relay → relayed to host
-```
-No port forwarding needed. Docker compose spins relay locally or on VPS.
-
-## State Synchronisation Strategy
-
-Only **deltas** are transmitted. `RackSyncPayload.ChangedSlots` contains only
-the slots that changed since last broadcast. IOPS is sent via
-unreliable channel (low priority, high frequency); structural changes
-(PlaceDevice, RemoveDevice, ConnectCable) use reliable ordered channel.
-
-## Server Authority
-All write operations (PlaceDevice, RemoveDevice, ConnectCable) are
-patched with a Harmony **Postfix** that checks `InstanceFinder.IsServerStarted`
-before broadcasting. Clients that try to trigger these via RPC are
-validated server-side before application.
-
-## IL2CPP Notes
-- Classes attached to GameObjects must use `ClassInjector.RegisterTypeInIl2Cpp()`
-- Generic types need explicit specialisation or `[Il2CppSetOption]` attributes
-- Reflection field access is a temporary workaround until IL2CppDumper
- provides exact field offsets for the game build
-
-## Roadmap
-```
-v0.0.1 Research + bare Listen-Server + HUD scaffold
-v0.1.0 RackSync + CableSync via FishNet ObserversRpc
-v0.2.0 QR invite + Approval Queue + Anti-Cheat validation
-v0.3.0 FishBait Docker relay + reconnect/heartbeat
-v1.0.0 Full IOPS LOD sync + ModSync hooks + Thunderstore release
-v2.0.0 Dedicated server mode + Admin panel
-```
diff --git a/docs/troubleshooting/doctor.md b/docs/troubleshooting/doctor.md
new file mode 100644
index 00000000..5f3e5f49
--- /dev/null
+++ b/docs/troubleshooting/doctor.md
@@ -0,0 +1,7 @@
+# Diagnostics and troubleshooting
+
+Audience: players and mod authors.
+
+The current runtime diagnostic baseline is Unity `6000.4.12f1`, MelonLoader `0.7.3`, Il2CppInterop `1.5.1`, with game version `UNKNOWN` until a verified game fingerprint is available. A healthy startup ends with `Framework initialization complete`.
+
+For hook failures, record the hook ID, exception, game fingerprint, and log path. GregCore isolates callback failures; restart the game after changing a mod. Do not copy legacy `FMF.HexLabelMod.dll` or `ModFramework/FMF` artifacts into a new installation. BepInEx is not a supported installation target until its adapter has a runtime verification record.
diff --git a/framework/greg_hooks.json b/framework/greg_hooks.json
index 0967ef42..6110047a 100644
--- a/framework/greg_hooks.json
+++ b/framework/greg_hooks.json
@@ -1 +1,60 @@
-{}
+{
+ "manifestVersion": 2,
+ "schemaVersion": "2.0.0",
+ "generatedFrom": "coverage/build-UNKNOWN",
+ "gameBuild": "UNKNOWN",
+ "assemblyFingerprint": "UNKNOWN",
+ "unityVersion": "6000.4.12f1",
+ "melonLoaderVersion": "0.7.3",
+ "il2cppInteropVersion": "1.5.1",
+ "hooks": [
+ {
+ "id": "greg.lifecycle.scene-loaded",
+ "name": "gregMod.lifecycle.sceneLoaded",
+ "legacy": "greg.lifecycle.SceneLoaded",
+ "assembly": "MelonLoader.dll",
+ "namespace": "MelonLoader",
+ "type": "MelonMod",
+ "member": "OnSceneWasLoaded",
+ "signature": "void OnSceneWasLoaded(int buildIndex, string sceneName)",
+ "domain": "System",
+ "patchTarget": "MelonLoader.MelonMod.OnSceneWasLoaded",
+ "strategy": "dispatcher",
+ "description": "A Unity scene has finished loading.",
+ "payloadSchema": {
+ "buildIndex": "int",
+ "sceneName": "string"
+ },
+ "threading": "main-thread",
+ "cancellable": false,
+ "status": "implemented",
+ "supportedLanguages": ["CSharp", "Lua"],
+ "risk": "low",
+ "approvalReason": "Stable MelonLoader lifecycle callback used by the framework dispatcher."
+ },
+ {
+ "id": "greg.lifecycle.update",
+ "name": "gregMod.lifecycle.update",
+ "legacy": "greg.lifecycle.Update",
+ "assembly": "MelonLoader.dll",
+ "namespace": "MelonLoader",
+ "type": "MelonMod",
+ "member": "OnUpdate",
+ "signature": "void OnUpdate()",
+ "domain": "System",
+ "patchTarget": "MelonLoader.MelonMod.OnUpdate",
+ "strategy": "dispatcher",
+ "description": "Per-frame update callback for ready mods.",
+ "payloadSchema": {
+ "deltaTime": "float"
+ },
+ "threading": "main-thread",
+ "cancellable": false,
+ "status": "implemented",
+ "supportedLanguages": ["CSharp", "Lua"],
+ "risk": "medium",
+ "approvalReason": "Stable MelonLoader lifecycle callback used by the framework dispatcher."
+ }
+ ],
+ "excludedMembers": []
+}
diff --git a/gregCore.csproj b/gregCore.csproj
index db867118..85a6cd0f 100644
--- a/gregCore.csproj
+++ b/gregCore.csproj
@@ -13,9 +13,9 @@
false
false
false
- 1.2.1
- 1.2.1
- 1.2.1.0
+ 1.2.2-dev.0
+ 1.2.2.0
+ 1.2.2.0
@@ -83,7 +83,6 @@
-
diff --git a/scripts/Deploy-Release-ToDataCenter.ps1 b/scripts/Deploy-Release-ToDataCenter.ps1
index e996938b..ea734439 100644
--- a/scripts/Deploy-Release-ToDataCenter.ps1
+++ b/scripts/Deploy-Release-ToDataCenter.ps1
@@ -43,7 +43,6 @@ if ([string]::IsNullOrWhiteSpace($GameDir) -or -not (Test-Path -LiteralPath $Gam
$GameProjects = @(
'framework\gregCore.csproj',
- 'plugins\greg.Plugin.Multiplayer\greg.Plugin.Multiplayer.csproj',
'plugins\greg.Plugin.Sysadmin\greg.Plugin.Sysadmin.csproj',
'plugins\greg.Plugin.AssetExporter\greg.Plugin.AssetExporter.csproj',
'plugins\greg.Plugin.WebUIBridge\greg.Plugin.WebUIBridge.csproj',
@@ -88,7 +87,6 @@ Copy-Item -LiteralPath $RedirectorDll -Destination (Join-Path $MlPlugins 'greg.M
Write-Host "[deploy] -> $MlPlugins\greg.ModPathRedirector.dll"
$pluginNames = @(
- 'greg.Plugin.Multiplayer',
'greg.Plugin.Sysadmin',
'greg.Plugin.AssetExporter',
'greg.Plugin.WebUIBridge',
diff --git a/scripts/generate_api_docs.py b/scripts/generate_api_docs.py
index d770878a..94455e55 100644
--- a/scripts/generate_api_docs.py
+++ b/scripts/generate_api_docs.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
generate_api_docs.py
-Generates docs/FrameworkAPI.md from game_hooks.json and framework/greg_hooks.json.
+Generates docs/FrameworkAPI.md from the reviewed framework manifest.
Usage:
python3 scripts/generate_api_docs.py \
@@ -79,17 +79,17 @@ def render_greg_hooks(data: dict) -> str:
lines.append("|-----------|-------------|----------|-------------|")
for h in sorted(groups[group_name], key=lambda x: x.get("name", "")):
hook_name = h.get("name", "")
- target = h.get("patchTarget", "")
- strategy = h.get("strategy", "")
- desc = h.get("description", "")
- lines.append(f"| `{hook_name}` | `{target}` | `{strategy}` | {desc} |")
+ target = h.get("signature") or h.get("patchTarget", "")
+ status = h.get("status", "review")
+ desc = h.get("approvalReason", h.get("description", ""))
+ lines.append(f"| `{hook_name}` | `{target}` | `{status}` | {desc} |")
lines.append("")
return "\n".join(lines)
def main() -> None:
- parser = argparse.ArgumentParser(description="Generate FrameworkAPI.md from hook JSON files.")
- parser.add_argument("--game-hooks", required=True, help="Path to game_hooks.json")
+ parser = argparse.ArgumentParser(description="Generate FrameworkAPI.md from the reviewed hook manifest.")
+ parser.add_argument("--game-hooks", required=False, help="Ignored legacy inventory path (compatibility option)")
parser.add_argument("--greg-hooks", required=True, help="Path to framework/greg_hooks.json")
parser.add_argument("--output", required=True, help="Output markdown file path")
parser.add_argument("--version", default="?", help="Current framework version")
@@ -101,7 +101,7 @@ def main() -> None:
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
today = date.today().isoformat()
- game_section = render_game_hooks(game_hooks if isinstance(game_hooks, list) else [])
+ game_section = "_The raw game inventory is scanner output and is not a supported API._\n"
greg_section = render_greg_hooks(greg_hooks if isinstance(greg_hooks, dict) else {})
doc = f"""# gregCore FrameworkAPI Reference
diff --git a/scripts/validate_contracts.py b/scripts/validate_contracts.py
new file mode 100644
index 00000000..1fdbeee0
--- /dev/null
+++ b/scripts/validate_contracts.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+"""Validate committed GregCore API and hook-contract invariants."""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+MANIFEST = ROOT / "framework" / "greg_hooks.json"
+
+
+def main() -> None:
+ manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
+ assert manifest["manifestVersion"] >= 1
+ assert manifest["schemaVersion"]
+ assert manifest.get("unityVersion") == "6000.4.12f1"
+ assert manifest.get("melonLoaderVersion") == "0.7.3"
+ assert manifest.get("il2cppInteropVersion") == "1.5.1"
+ assert manifest["gameBuild"] == "UNKNOWN" or re.fullmatch(r"[A-Za-z0-9._-]+", manifest["gameBuild"])
+
+ ids: set[str] = set()
+ names: set[str] = set()
+ for hook in manifest.get("hooks", []):
+ hook_id = hook["id"]
+ name = hook["name"]
+ assert hook_id not in ids, f"duplicate hook id: {hook_id}"
+ assert name not in names, f"duplicate hook name: {name}"
+ assert re.fullmatch(r"greg(?:Mod|Ext|Plugin)\.[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+", name), name
+ assert hook.get("threading") in {"main-thread", "any-thread"}
+ assert isinstance(hook.get("payloadSchema", {}), dict)
+ assert hook.get("status") in {"implemented", "review", "deprecated"}
+ for field in ("assembly", "namespace", "type", "member", "signature", "domain", "risk", "approvalReason"):
+ assert hook.get(field), f"missing manifest field: {field}"
+ assert hook.get("supportedLanguages", []) and set(hook["supportedLanguages"]).issubset({"CSharp", "Lua"})
+ assert not name.startswith("greg.")
+ if hook.get("legacy"):
+ assert hook["legacy"].startswith("greg.")
+ ids.add(hook_id)
+ names.add(name)
+
+ print(f"validated {len(ids)} canonical hooks from manifest v{manifest['manifestVersion']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/validate_release_artifacts.py b/scripts/validate_release_artifacts.py
new file mode 100644
index 00000000..ca4a97da
--- /dev/null
+++ b/scripts/validate_release_artifacts.py
@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+"""Validate the portable artifacts collected by the external game smoke test."""
+import json
+import sys
+from pathlib import Path
+
+REQUIRED = ("doctor.json", "fingerprint.json", "hook-install-report.json", "loaded-mod-report.json")
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print("usage: validate_release_artifacts.py ", file=sys.stderr)
+ return 2
+ root = Path(sys.argv[1])
+ missing = [name for name in REQUIRED if not (root / name).is_file()]
+ if missing:
+ print("missing: " + ", ".join(missing), file=sys.stderr)
+ return 1
+ doctor = json.loads((root / "doctor.json").read_text(encoding="utf-8"))
+ hooks = json.loads((root / "hook-install-report.json").read_text(encoding="utf-8"))
+ if doctor.get("Status") == "SELF_TEST_FAILED" or hooks.get("SafeMode") and doctor.get("Status") == "SUPPORTED_GAME_BUILD":
+ print("release gate failed: self-test or inconsistent safe mode", file=sys.stderr)
+ return 1
+ print(f"validated smoke-test artifacts for {doctor.get('Status', 'UNKNOWN')}")
+ return 0
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/validate_version.py b/scripts/validate_version.py
new file mode 100644
index 00000000..aace3429
--- /dev/null
+++ b/scripts/validate_version.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+"""Validate the SemVer contract used by GregCore CI and release automation."""
+
+from __future__ import annotations
+
+import re
+import sys
+
+
+SEMVER = re.compile(
+ r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
+ r"(?:-((?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?"
+ r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
+)
+
+
+def main() -> int:
+ if len(sys.argv) != 2 or not SEMVER.fullmatch(sys.argv[1]):
+ print("invalid SemVer; expected MAJOR.MINOR.PATCH[-prerelease][+build]", file=sys.stderr)
+ return 2
+ print(f"valid SemVer: {sys.argv[1]}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/API/CustomEmployeeManager.cs b/src/API/CustomEmployeeManager.cs
index 9b4512b6..9363bd2f 100644
--- a/src/API/CustomEmployeeManager.cs
+++ b/src/API/CustomEmployeeManager.cs
@@ -889,12 +889,11 @@ private static void SetPortrait(Transform card, string employeeId)
var portraitTransform = card.Find("Image");
if (portraitTransform == null) return;
- string assetsDir = Path.Combine(MelonEnvironment.UserDataDirectory, "ModAssets");
string? imagePath = null;
- foreach (var ext in new[] { ".jpg", ".png" })
+ if (!TryResolvePortraitPath(employeeId, out imagePath))
{
- string candidate = Path.Combine(assetsDir, employeeId + ext);
- if (File.Exists(candidate)) { imagePath = candidate; break; }
+ CrashLog.Log($"[Security] CustomEmployee: rejected portrait id='{employeeId}'");
+ return;
}
if (imagePath != null)
@@ -962,6 +961,42 @@ private static void SetPortrait(Transform card, string employeeId)
}
}
+ private static bool TryResolvePortraitPath(string employeeId, out string? imagePath)
+ {
+ imagePath = null;
+ if (string.IsNullOrWhiteSpace(employeeId) || Path.IsPathRooted(employeeId)) return false;
+
+ // IDs are logical names, never paths. Reject both separators so the
+ // same validation is safe on Windows and Linux.
+ if (employeeId.IndexOfAny(new[] { '/', '\\', '\0' }) >= 0 || employeeId.Contains("..", StringComparison.Ordinal))
+ return false;
+
+ try
+ {
+ var assetsRoot = Path.GetFullPath(Path.Combine(MelonEnvironment.UserDataDirectory, "ModAssets"));
+ var rootPrefix = assetsRoot.EndsWith(Path.DirectorySeparatorChar)
+ ? assetsRoot
+ : assetsRoot + Path.DirectorySeparatorChar;
+
+ foreach (var extension in new[] { ".jpg", ".png" })
+ {
+ var candidate = Path.GetFullPath(Path.Combine(assetsRoot, employeeId + extension));
+ if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) return false;
+ if (File.Exists(candidate))
+ {
+ imagePath = candidate;
+ return true;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ CrashLog.LogException($"ResolvePortraitPath for '{employeeId}'", ex);
+ }
+
+ return true;
+ }
+
private static void RefreshAllCards()
{
try
diff --git a/src/API/GregAPI.cs b/src/API/GregAPI.cs
index 75331360..3ac325a5 100644
--- a/src/API/GregAPI.cs
+++ b/src/API/GregAPI.cs
@@ -52,8 +52,19 @@ internal static void Initialize(IGregLogger logger)
public static void LogWarning(string msg) => Log(msg, "WARN");
public static void LogError(string msg) => Log(msg, "ERROR");
- public static void ShowNotification(string msg) { }
- public static void ShowNotification(string msg, float duration) { }
+ public static void ShowNotification(string msg) => ShowNotification(msg, 3f);
+ public static void ShowNotification(string msg, float duration)
+ {
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(msg))
+ GregNotificationManager.Show(msg, Math.Max(0.25f, duration));
+ }
+ catch (Exception ex)
+ {
+ MelonLogger.Warning($"[GregAPI] Notification failed: {ex.Message}");
+ }
+ }
internal static GregHookBus? HookBus { get; set; }
diff --git a/src/Bridge/LuaFFI/LuaFFIBridge.cs b/src/Bridge/LuaFFI/LuaFFIBridge.cs
index 08bdd779..cebb6099 100644
--- a/src/Bridge/LuaFFI/LuaFFIBridge.cs
+++ b/src/Bridge/LuaFFI/LuaFFIBridge.cs
@@ -1,307 +1,331 @@
-///
-/// Schicht: Bridge
-/// Zweck: Zentraler Orchestrator für die Lua-Modding-Umgebung.
-/// Maintainer: Initialisiert Loader, Scheduler, Hot-Reload und Dev-Tools.
-/// Verbindet C#-Hooks mit der Lua-VM.
-///
-
-using System;
-using System.Collections.Generic;
-using System.IO;
-using MoonSharp.Interpreter;
-using MelonLoader;
-using gregCore.API;
-using gregCore.Infrastructure.Scripting.Lua;
-using gregCore.Infrastructure.Scripting.Lua.Modules;
-using gregCore.Infrastructure.Scripting.Lua.Dev;
-
-namespace gregCore.Bridge.LuaFFI;
-
-public sealed class LuaFFIBridge
-{
- private static readonly List _plugins = new();
- private static LuaHotReload? _hotReload;
- private static LuaHookBindingGenerator? _hookGenerator;
- private static LuaRepl? _repl;
- private static LuaProfiler? _profiler;
- private static LuaErrorOverlay? _errorOverlay;
- private static bool _initialized;
-
- public static void Initialize()
- {
- if (_initialized) return;
-
- MelonLogger.Msg("[LuaFFI] Initializing modernized Lua environment...");
-
- UserData.RegisterType();
-
- string gameRoot = global::MelonLoader.Utils.MelonEnvironment.GameRootDirectory;
- string luaDir = Path.Combine(gameRoot, "UserData", "gregCore", "Mods", "Lua");
- string sharedDir = Path.Combine(luaDir, "@shared");
- string hooksFile = Path.Combine(gameRoot, "UserData", "gregCore", "game_hooks.json");
-
- if (!Directory.Exists(luaDir)) Directory.CreateDirectory(luaDir);
- if (!Directory.Exists(sharedDir)) Directory.CreateDirectory(sharedDir);
-
- // Infrastructure
- _profiler = new LuaProfiler(2.0f); // 2ms per frame budget
- _errorOverlay = new LuaErrorOverlay();
- _repl = new LuaRepl();
- _repl.Initialize();
-
- // Hook Generator
- _hookGenerator = new LuaHookBindingGenerator(API.GregAPI.EventBus!, hooksFile);
- _hookGenerator.LoadHooks();
-
- // Hot Reload
- _hotReload = new LuaHotReload(luaDir, OnPluginNeedsReload);
- _hotReload.Start();
-
- LoadPlugins(luaDir);
- _initialized = true;
- }
-
- private static void LoadPlugins(string luaDir)
- {
- foreach (string dir in Directory.GetDirectories(luaDir))
- {
- if (Path.GetFileName(dir).StartsWith("@")) continue; // Skip @shared and others
-
- string mainFile = Path.Combine(dir, "main.lua");
- string manifestFile = Path.Combine(dir, "mod.json");
-
- if (!File.Exists(mainFile)) continue;
-
- try
- {
- string id = Path.GetFileName(dir);
- var script = new Script(CoreModules.Preset_SoftSandbox);
-
- // 1. Module Loader (require support)
- var loader = new LuaModuleLoader(script, dir, Path.Combine(luaDir, "@shared"));
- loader.Register();
-
- // 2. Global greg table
- var gregTable = new Table(script);
- script.Globals["greg"] = gregTable;
-
- // 3. Register Core Modules
- GregEventLuaModule.Register(gregTable, script, API.GregAPI.EventBus!, id);
- GregIoLuaModule.Register(gregTable, script, id, Path.Combine(dir, "data"));
-
- // 4. Register Domain Modules
- LuaPlayerModule.Register(gregTable, script, id);
- LuaWorldModule.Register(gregTable, script, id);
- LuaRackModule.Register(gregTable, script, id);
- LuaServerModule.Register(gregTable, script, id);
- LuaCableModule.Register(gregTable, script, id);
- LuaUiModule.Register(gregTable, script, id);
-
- // 5. Register Auto-Hooks
- _hookGenerator?.RegisterInScript(script, gregTable, id);
-
- // 6. Scheduler
- var scheduler = new LuaCoroutineScheduler(script);
- scheduler.Register(gregTable);
-
- // 7. Load file
- script.DoFile(mainFile);
-
- var plugin = new LuaPlugin
- {
- Id = id,
- Script = script,
- MainFile = mainFile,
- Scheduler = scheduler,
- OnInit = script.Globals.Get("on_init").Type == DataType.Function ? script.Globals.Get("on_init").Function : null,
- OnUpdate = script.Globals.Get("on_update").Type == DataType.Function ? script.Globals.Get("on_update").Function : null,
- OnSceneLoaded = script.Globals.Get("on_scene_loaded").Type == DataType.Function ? script.Globals.Get("on_scene_loaded").Function : null,
- OnShutdown = script.Globals.Get("on_shutdown").Type == DataType.Function ? script.Globals.Get("on_shutdown").Function : null,
- OnReload = script.Globals.Get("on_reload").Type == DataType.Function ? script.Globals.Get("on_reload").Function : null
- };
-
- SafeCall(plugin, plugin.OnInit);
- _plugins.Add(plugin);
-
- // Hot-reload registration
- _hotReload?.RegisterPlugin(id, script, mainFile);
-
- MelonLogger.Msg($"[LuaFFI] Mod loaded: {id} ({_hookGenerator?.TotalHookCount} hooks available)");
- }
- catch (Exception ex)
- {
- MelonLogger.Error($"[LuaFFI] Error loading mod {dir}: {ex.Message}");
- _errorOverlay?.ReportError(Path.GetFileName(dir), ex.Message);
- }
- }
- }
-
- public static void OnUpdate(float dt)
- {
- if (!_initialized) return;
-
- _repl?.Update();
-
- foreach (var plugin in _plugins)
- {
- using (_profiler?.BeginScope(plugin.Id))
- {
- try
- {
- plugin.Scheduler.OnUpdate(dt);
- if (plugin.OnUpdate != null)
- {
- plugin.OnUpdate.Call(dt);
- }
- }
- catch (Exception ex)
- {
- _errorOverlay?.ReportError(plugin.Id, ex.Message);
- }
- }
- }
-
- _profiler?.EndFrame();
- }
-
- public static void OnSceneLoaded(string name)
- {
- if (!_initialized) return;
- foreach (var plugin in _plugins)
- {
- try { plugin.OnSceneLoaded?.Call(name); } catch { }
- }
- }
-
- public static void Shutdown()
- {
- if (!_initialized) return;
- foreach (var plugin in _plugins)
- {
- try { plugin.OnShutdown?.Call(); } catch { }
- }
- _plugins.Clear();
- _hotReload?.Stop();
- _initialized = false;
- }
-
- private static void OnPluginNeedsReload(LuaPluginReloadInfo info)
- {
- MelonLogger.Msg($"[LuaFFI] Hot-reloading mod: {info.ModId}");
-
- // Find existing plugin
- var existing = _plugins.Find(p => p.Id == info.ModId);
- if (existing != null)
- {
- try { existing.OnShutdown?.Call(); } catch { }
- _plugins.Remove(existing);
- }
-
- // Re-load using the provided new Script instance from LuaHotReload.
- try
- {
- LoadSpecificPlugin(info);
- }
- catch (Exception ex)
- {
- MelonLogger.Error($"[LuaFFI] Hot-reload failed for {info.ModId}: {ex.Message}");
- }
- }
-
- private static void LoadSpecificPlugin(LuaPluginReloadInfo info)
- {
- // Use the NewScript provided by the hot-reload infrastructure and wire up
- // the same modules / scheduler / hooks as in initial LoadPlugins.
- var newScript = info.NewScript;
- string id = info.ModId;
- string mainFile = info.MainFilePath;
-
- try
- {
- // Ensure shared folder exists
- string gameRoot = global::MelonLoader.Utils.MelonEnvironment.GameRootDirectory;
- string luaDir = Path.Combine(gameRoot, "UserData", "gregCore", "Mods", "Lua");
- string sharedDir = Path.Combine(luaDir, "@shared");
-
- // 1. Module Loader (require support)
- var loader = new LuaModuleLoader(newScript, Path.GetDirectoryName(mainFile)!, sharedDir);
- loader.Register();
-
- // 2. Global greg table
- var gregTable = new Table(newScript);
- newScript.Globals["greg"] = gregTable;
-
- // 3. Register Core Modules
- GregEventLuaModule.Register(gregTable, newScript, API.GregAPI.EventBus!, id);
- GregIoLuaModule.Register(gregTable, newScript, id, Path.Combine(Path.GetDirectoryName(mainFile)!, "data"));
-
- // 4. Register Domain Modules
- LuaPlayerModule.Register(gregTable, newScript, id);
- LuaWorldModule.Register(gregTable, newScript, id);
- LuaRackModule.Register(gregTable, newScript, id);
- LuaServerModule.Register(gregTable, newScript, id);
- LuaCableModule.Register(gregTable, newScript, id);
- LuaUiModule.Register(gregTable, newScript, id);
-
- // 5. Register Auto-Hooks
- _hookGenerator?.RegisterInScript(newScript, gregTable, id);
-
- // 6. Scheduler
- var scheduler = new LuaCoroutineScheduler(newScript);
- scheduler.Register(gregTable);
-
- // 7. Load file
- newScript.DoFile(mainFile);
-
- var plugin = new LuaPlugin
- {
- Id = id,
- Script = newScript,
- MainFile = mainFile,
- Scheduler = scheduler,
- OnInit = newScript.Globals.Get("on_init").Type == DataType.Function ? newScript.Globals.Get("on_init").Function : null,
- OnUpdate = newScript.Globals.Get("on_update").Type == DataType.Function ? newScript.Globals.Get("on_update").Function : null,
- OnSceneLoaded = newScript.Globals.Get("on_scene_loaded").Type == DataType.Function ? newScript.Globals.Get("on_scene_loaded").Function : null,
- OnShutdown = newScript.Globals.Get("on_shutdown").Type == DataType.Function ? newScript.Globals.Get("on_shutdown").Function : null,
- OnReload = newScript.Globals.Get("on_reload").Type == DataType.Function ? newScript.Globals.Get("on_reload").Function : null
- };
-
- SafeCall(plugin, plugin.OnInit);
- _plugins.Add(plugin);
-
- // Hot-reload registration (update the watcher map)
- _hotReload?.RegisterPlugin(id, newScript, mainFile);
-
- MelonLogger.Msg($"[LuaFFI] Mod reloaded: {id} ({_hookGenerator?.TotalHookCount} hooks available)");
- }
- catch (Exception ex)
- {
- MelonLogger.Error($"[LuaFFI] Error reloading mod {info.ModId}: {ex.Message}");
- _errorOverlay?.ReportError(info.ModId, ex.Message);
- }
- }
-
- private static void SafeCall(LuaPlugin plugin, Closure? closure, params object[] args)
- {
- if (closure == null) return;
- try { closure.Call(args); }
- catch (Exception ex)
- {
- MelonLogger.Error($"[LuaMod:{plugin.Id}] Runtime error: {ex.Message}");
- _errorOverlay?.ReportError(plugin.Id, ex.Message);
- }
- }
-}
-
-public class LuaPlugin
-{
- public string Id = "";
- public Script Script = null!;
- public string MainFile = "";
- public LuaCoroutineScheduler Scheduler = null!;
- public Closure? OnInit;
- public Closure? OnUpdate;
- public Closure? OnSceneLoaded;
- public Closure? OnShutdown;
- public Closure? OnReload;
-}
+///
+/// Schicht: Bridge
+/// Zweck: Zentraler Orchestrator für die Lua-Modding-Umgebung.
+/// Maintainer: Initialisiert Loader, Scheduler, Hot-Reload und Dev-Tools.
+/// Verbindet C#-Hooks mit der Lua-VM.
+///
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using MoonSharp.Interpreter;
+using MelonLoader;
+using gregCore.API;
+using gregCore.Infrastructure.Scripting.Lua;
+using gregCore.Infrastructure.Scripting.Lua.Modules;
+using gregCore.Infrastructure.Scripting.Lua.Dev;
+
+namespace gregCore.Bridge.LuaFFI;
+
+public sealed class LuaFFIBridge
+{
+ private static readonly List _plugins = new();
+ private static LuaHotReload? _hotReload;
+ private static LuaHookBindingGenerator? _hookGenerator;
+ private static LuaRepl? _repl;
+ private static LuaProfiler? _profiler;
+ private static LuaErrorOverlay? _errorOverlay;
+ private static bool _initialized;
+
+ public static void Initialize()
+ {
+ if (_initialized) return;
+
+ MelonLogger.Msg("[LuaFFI] Initializing modernized Lua environment...");
+
+ UserData.RegisterType();
+
+ string gameRoot = global::MelonLoader.Utils.MelonEnvironment.GameRootDirectory;
+ string luaDir = Path.Combine(gameRoot, "UserData", "gregCore", "Mods", "Lua");
+ string sharedDir = Path.Combine(luaDir, "@shared");
+ string hooksFile = Path.Combine(gameRoot, "UserData", "gregCore", "game_hooks.json");
+
+ if (!Directory.Exists(luaDir)) Directory.CreateDirectory(luaDir);
+ if (!Directory.Exists(sharedDir)) Directory.CreateDirectory(sharedDir);
+
+ // Infrastructure
+ _profiler = new LuaProfiler(2.0f); // 2ms per frame budget
+ _errorOverlay = new LuaErrorOverlay();
+ _repl = new LuaRepl();
+ _repl.Initialize();
+
+ // Hook Generator
+ _hookGenerator = new LuaHookBindingGenerator(API.GregAPI.EventBus!, hooksFile);
+ _hookGenerator.LoadHooks();
+
+ // Hot Reload
+ _hotReload = new LuaHotReload(luaDir, OnPluginNeedsReload);
+ _hotReload.Start();
+
+ LoadPlugins(luaDir);
+ _initialized = true;
+ }
+
+ private static void LoadPlugins(string luaDir)
+ {
+ foreach (string source in Directory.GetDirectories(luaDir).Concat(Directory.GetFiles(luaDir, "*.lua")))
+ {
+ var isLegacyFile = File.Exists(source);
+ string dir = isLegacyFile ? Path.GetDirectoryName(source)! : source;
+ if (!isLegacyFile && Path.GetFileName(dir).StartsWith("@")) continue; // Skip @shared and others
+
+ string mainFile = isLegacyFile ? source : Path.Combine(dir, "main.lua");
+ string manifestFile = Path.Combine(dir, "mod.json");
+
+ if (!isLegacyFile && !File.Exists(mainFile)) continue;
+
+ try
+ {
+ var manifest = isLegacyFile
+ ? new gregCore.Core.Models.ModManifest { Id = Path.GetFileNameWithoutExtension(source), Name = Path.GetFileNameWithoutExtension(source), Entrypoint = Path.GetFileName(source), Loader = "Lua" }
+ : ReadManifest(manifestFile, Path.GetFileName(dir));
+ string id = manifest.Id;
+ mainFile = Path.Combine(dir, string.IsNullOrWhiteSpace(manifest.Entrypoint) ? "main.lua" : manifest.Entrypoint);
+ var script = new Script(CoreModules.Preset_SoftSandbox);
+
+ // 1. Module Loader (require support)
+ var loader = new LuaModuleLoader(script, dir, Path.Combine(luaDir, "@shared"));
+ loader.Register();
+
+ // 2. Global greg table
+ var gregTable = new Table(script);
+ script.Globals["greg"] = gregTable;
+
+ // 3. Register Core Modules
+ GregEventLuaModule.Register(gregTable, script, API.GregAPI.EventBus!, id);
+ GregIoLuaModule.Register(gregTable, script, id, Path.Combine(dir, "data"));
+
+ // 4. Register Domain Modules
+ LuaPlayerModule.Register(gregTable, script, id);
+ LuaWorldModule.Register(gregTable, script, id);
+ LuaRackModule.Register(gregTable, script, id);
+ LuaServerModule.Register(gregTable, script, id);
+ LuaCableModule.Register(gregTable, script, id);
+ LuaUiModule.Register(gregTable, script, id);
+
+ // 5. Register Auto-Hooks
+ _hookGenerator?.RegisterInScript(script, gregTable, id);
+
+ // 6. Scheduler
+ var scheduler = new LuaCoroutineScheduler(script);
+ scheduler.Register(gregTable);
+
+ // 7. Load file
+ if (!File.Exists(mainFile)) throw new FileNotFoundException($"Lua entrypoint not found: {mainFile}");
+ script.DoFile(mainFile);
+
+ var plugin = new LuaPlugin
+ {
+ Id = id,
+ Manifest = manifest,
+ Script = script,
+ MainFile = mainFile,
+ Scheduler = scheduler,
+ OnInit = script.Globals.Get("on_init").Type == DataType.Function ? script.Globals.Get("on_init").Function : null,
+ OnUpdate = script.Globals.Get("on_update").Type == DataType.Function ? script.Globals.Get("on_update").Function : null,
+ OnSceneLoaded = script.Globals.Get("on_scene_loaded").Type == DataType.Function ? script.Globals.Get("on_scene_loaded").Function : null,
+ OnShutdown = script.Globals.Get("on_shutdown").Type == DataType.Function ? script.Globals.Get("on_shutdown").Function : null,
+ OnReload = script.Globals.Get("on_reload").Type == DataType.Function ? script.Globals.Get("on_reload").Function : null
+ };
+
+ SafeCall(plugin, plugin.OnInit);
+ _plugins.Add(plugin);
+
+ // Hot-reload registration
+ _hotReload?.RegisterPlugin(id, script, mainFile);
+
+ MelonLogger.Msg($"[LuaFFI] Mod loaded: {id} ({_hookGenerator?.TotalHookCount} hooks available)");
+ }
+ catch (Exception ex)
+ {
+ MelonLogger.Error($"[LuaFFI] Error loading mod {dir}: {ex.Message}");
+ _errorOverlay?.ReportError(Path.GetFileName(dir), ex.Message);
+ }
+ }
+ }
+
+ public static void OnUpdate(float dt)
+ {
+ if (!_initialized) return;
+
+ _repl?.Update();
+
+ foreach (var plugin in _plugins)
+ {
+ using (_profiler?.BeginScope(plugin.Id))
+ {
+ try
+ {
+ plugin.Scheduler.OnUpdate(dt);
+ if (plugin.OnUpdate != null)
+ {
+ plugin.OnUpdate.Call(dt);
+ }
+ }
+ catch (Exception ex)
+ {
+ _errorOverlay?.ReportError(plugin.Id, ex.Message);
+ }
+ }
+ }
+
+ _profiler?.EndFrame();
+ }
+
+ public static void OnSceneLoaded(string name)
+ {
+ if (!_initialized) return;
+ foreach (var plugin in _plugins)
+ {
+ try { plugin.OnSceneLoaded?.Call(name); } catch { }
+ }
+ }
+
+ public static void Shutdown()
+ {
+ if (!_initialized) return;
+ foreach (var plugin in _plugins)
+ {
+ GregEventLuaModule.UnregisterAll(plugin.Id, API.GregAPI.EventBus!);
+ try { plugin.OnShutdown?.Call(); } catch { }
+ }
+ _plugins.Clear();
+ _hotReload?.Stop();
+ _initialized = false;
+ }
+
+ private static void OnPluginNeedsReload(LuaPluginReloadInfo info)
+ {
+ MelonLogger.Msg($"[LuaFFI] Hot-reloading mod: {info.ModId}");
+
+ // Find existing plugin
+ var existing = _plugins.Find(p => p.Id == info.ModId);
+ if (existing != null)
+ {
+ GregEventLuaModule.UnregisterAll(existing.Id, API.GregAPI.EventBus!);
+ try { existing.OnShutdown?.Call(); } catch { }
+ _plugins.Remove(existing);
+ }
+
+ // Re-load using the provided new Script instance from LuaHotReload.
+ try
+ {
+ LoadSpecificPlugin(info);
+ }
+ catch (Exception ex)
+ {
+ MelonLogger.Error($"[LuaFFI] Hot-reload failed for {info.ModId}: {ex.Message}");
+ }
+ }
+
+ private static void LoadSpecificPlugin(LuaPluginReloadInfo info)
+ {
+ // Use the NewScript provided by the hot-reload infrastructure and wire up
+ // the same modules / scheduler / hooks as in initial LoadPlugins.
+ var newScript = info.NewScript;
+ string id = info.ModId;
+ string mainFile = info.MainFilePath;
+
+ try
+ {
+ // Ensure shared folder exists
+ string gameRoot = global::MelonLoader.Utils.MelonEnvironment.GameRootDirectory;
+ string luaDir = Path.Combine(gameRoot, "UserData", "gregCore", "Mods", "Lua");
+ string sharedDir = Path.Combine(luaDir, "@shared");
+
+ // 1. Module Loader (require support)
+ var loader = new LuaModuleLoader(newScript, Path.GetDirectoryName(mainFile)!, sharedDir);
+ loader.Register();
+
+ // 2. Global greg table
+ var gregTable = new Table(newScript);
+ newScript.Globals["greg"] = gregTable;
+
+ // 3. Register Core Modules
+ GregEventLuaModule.Register(gregTable, newScript, API.GregAPI.EventBus!, id);
+ GregIoLuaModule.Register(gregTable, newScript, id, Path.Combine(Path.GetDirectoryName(mainFile)!, "data"));
+
+ // 4. Register Domain Modules
+ LuaPlayerModule.Register(gregTable, newScript, id);
+ LuaWorldModule.Register(gregTable, newScript, id);
+ LuaRackModule.Register(gregTable, newScript, id);
+ LuaServerModule.Register(gregTable, newScript, id);
+ LuaCableModule.Register(gregTable, newScript, id);
+ LuaUiModule.Register(gregTable, newScript, id);
+
+ // 5. Register Auto-Hooks
+ _hookGenerator?.RegisterInScript(newScript, gregTable, id);
+
+ // 6. Scheduler
+ var scheduler = new LuaCoroutineScheduler(newScript);
+ scheduler.Register(gregTable);
+
+ // 7. Load file
+ newScript.DoFile(mainFile);
+
+ var plugin = new LuaPlugin
+ {
+ Id = id,
+ Manifest = ReadManifest(Path.Combine(Path.GetDirectoryName(mainFile)!, "mod.json"), id),
+ Script = newScript,
+ MainFile = mainFile,
+ Scheduler = scheduler,
+ OnInit = newScript.Globals.Get("on_init").Type == DataType.Function ? newScript.Globals.Get("on_init").Function : null,
+ OnUpdate = newScript.Globals.Get("on_update").Type == DataType.Function ? newScript.Globals.Get("on_update").Function : null,
+ OnSceneLoaded = newScript.Globals.Get("on_scene_loaded").Type == DataType.Function ? newScript.Globals.Get("on_scene_loaded").Function : null,
+ OnShutdown = newScript.Globals.Get("on_shutdown").Type == DataType.Function ? newScript.Globals.Get("on_shutdown").Function : null,
+ OnReload = newScript.Globals.Get("on_reload").Type == DataType.Function ? newScript.Globals.Get("on_reload").Function : null
+ };
+
+ SafeCall(plugin, plugin.OnInit);
+ _plugins.Add(plugin);
+
+ // Hot-reload registration (update the watcher map)
+ _hotReload?.RegisterPlugin(id, newScript, mainFile);
+
+ MelonLogger.Msg($"[LuaFFI] Mod reloaded: {id} ({_hookGenerator?.TotalHookCount} hooks available)");
+ }
+ catch (Exception ex)
+ {
+ MelonLogger.Error($"[LuaFFI] Error reloading mod {info.ModId}: {ex.Message}");
+ _errorOverlay?.ReportError(info.ModId, ex.Message);
+ }
+ }
+
+ private static void SafeCall(LuaPlugin plugin, Closure? closure, params object[] args)
+ {
+ if (closure == null) return;
+ try { closure.Call(args); }
+ catch (Exception ex)
+ {
+ MelonLogger.Error($"[LuaMod:{plugin.Id}] Runtime error: {ex.Message}");
+ _errorOverlay?.ReportError(plugin.Id, ex.Message);
+ }
+ }
+
+ private static gregCore.Core.Models.ModManifest ReadManifest(string path, string fallbackId)
+ {
+ if (!File.Exists(path))
+ return new gregCore.Core.Models.ModManifest { Id = fallbackId, Name = fallbackId, Entrypoint = "main.lua", Loader = "Lua" };
+ var manifest = JsonSerializer.Deserialize(File.ReadAllText(path));
+ if (manifest == null || string.IsNullOrWhiteSpace(manifest.Id))
+ throw new InvalidDataException($"Lua manifest '{path}' has no id.");
+ if (!string.IsNullOrWhiteSpace(manifest.Entrypoint) && !File.Exists(Path.Combine(Path.GetDirectoryName(path)!, manifest.Entrypoint)))
+ throw new FileNotFoundException($"Lua entrypoint not found: {manifest.Entrypoint}");
+ return manifest;
+ }
+}
+
+public class LuaPlugin
+{
+ public string Id = "";
+ public Script Script = null!;
+ public string MainFile = "";
+ public gregCore.Core.Models.ModManifest Manifest = new();
+ public LuaCoroutineScheduler Scheduler = null!;
+ public Closure? OnInit;
+ public Closure? OnUpdate;
+ public Closure? OnSceneLoaded;
+ public Closure? OnShutdown;
+ public Closure? OnReload;
+}
diff --git a/src/CI_Stubs.cs b/src/CI_Stubs.cs
index 8a4267d1..e0fc616a 100644
--- a/src/CI_Stubs.cs
+++ b/src/CI_Stubs.cs
@@ -42,25 +42,7 @@ public void OnUpdate(float dt) { }
}
}
-namespace gregCore.Compatibility.FishNet { }
-
-namespace gregCore.Infrastructure.Networking {
- public class GregNetworkRack : IDisposable {
- public GregNetworkRack(GregEventBus bus, IGregLogger logger) { }
- public void Dispose() { }
- }
- public class GregNetworkServer : IDisposable {
- public GregNetworkServer(GregEventBus bus, IGregLogger logger) { }
- public void Dispose() { }
- }
- public class GregNetworkCables : IDisposable {
- public int CableCount = 0;
- public GregNetworkCables(GregEventBus bus, IGregLogger logger) { }
- public void Dispose() { }
- }
-}
-
-namespace gregCore.Infrastructure.Scripting.Lua.Modules {
+namespace gregCore.Infrastructure.Scripting.Lua.Modules {
public class LuaModuleLoader {
public LuaModuleLoader(Script script, string dir, string shared) { }
public void Register() { }
diff --git a/src/Compatibility/DataCenterModLoader/Core.cs b/src/Compatibility/DataCenterModLoader/Core.cs
index 3b99d301..6d4e36bf 100644
--- a/src/Compatibility/DataCenterModLoader/Core.cs
+++ b/src/Compatibility/DataCenterModLoader/Core.cs
@@ -85,9 +85,8 @@ public class Core
public MelonLogger.Instance LoggerInstance { get; }
- private FFIBridge? _ffiBridge;
- private MultiplayerBridge? _mpBridge;
- private string _modsPath = string.Empty;
+ private FFIBridge? _ffiBridge;
+ private string _modsPath = string.Empty;
private HarmonyLib.Harmony? _harmony;
private float _queueDrainTimer;
@@ -134,13 +133,7 @@ public void Initialize()
CrashLog.Log("step: loading all mods");
_ffiBridge.LoadAllMods();
- var mpDllPath = Path.Combine(_modsPath, "dc_multiplayer.dll");
- if (File.Exists(mpDllPath))
- {
- _mpBridge = new MultiplayerBridge(LoggerInstance);
- }
-
- LoggerInstance.Msg("Integrated Rust bridge initialization complete.");
+ LoggerInstance.Msg("Integrated Rust bridge initialization complete. Native co-op remains owned by Data Center.");
CrashLog.Log("step: Initialize complete");
}
catch (Exception ex)
@@ -154,9 +147,8 @@ public void OnSceneWasLoaded(int buildIndex, string sceneName)
{
try
{
- _ffiBridge?.OnSceneLoaded(sceneName);
- _mpBridge?.OnSceneLoaded(sceneName);
- ModConfigSystem.OnSceneLoaded(sceneName);
+ _ffiBridge?.OnSceneLoaded(sceneName);
+ ModConfigSystem.OnSceneLoaded(sceneName);
CustomEmployeeManager.ResetInjectionState();
}
catch (Exception ex)
@@ -169,9 +161,8 @@ public void OnUpdate()
{
try
{
- _ffiBridge?.OnUpdate(Time.deltaTime);
- _mpBridge?.OnUpdate(Time.deltaTime);
- ModConfigSystem.OnUpdate(Time.deltaTime);
+ _ffiBridge?.OnUpdate(Time.deltaTime);
+ ModConfigSystem.OnUpdate(Time.deltaTime);
CustomEmployeeManager.ReregisterSalariesIfNeeded();
EntityManager.Update();
CarryStateMonitor.Update();
@@ -216,10 +207,9 @@ public void OnApplicationQuit()
try
{
LoggerInstance.Msg("Shutting down integrated Rust bridge...");
- CrashLog.Log("step: OnApplicationQuit starting");
- EntityManager.DestroyAll();
- _mpBridge?.Shutdown();
- ModConfigSystem.Shutdown();
+ CrashLog.Log("step: OnApplicationQuit starting");
+ EntityManager.DestroyAll();
+ ModConfigSystem.Shutdown();
_ffiBridge?.Shutdown();
_ffiBridge?.Dispose();
_harmony?.UnpatchSelf();
diff --git a/src/Compatibility/DataCenterModLoader/GameAPI.cs b/src/Compatibility/DataCenterModLoader/GameAPI.cs
index 42c6a5cc..242d315c 100644
--- a/src/Compatibility/DataCenterModLoader/GameAPI.cs
+++ b/src/Compatibility/DataCenterModLoader/GameAPI.cs
@@ -70,7 +70,9 @@ public struct GameAPITable
public IntPtr GetDifficulty;
public IntPtr TriggerSave;
- // v7 - Steam / Multiplayer
+ // v7 - Legacy ABI slots. Native Data Center co-op owns lobby/session state;
+ // these positions remain stable for older Rust plugins and are no-op where
+ // the old custom implementation had no native backing.
public IntPtr SteamGetMyId;
public IntPtr SteamGetFriendName;
public IntPtr SteamCreateLobby;
@@ -309,37 +311,6 @@ public partial class GameAPIManager : IDisposable
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int RackGameUninstallDelegate(ulong objHandle, byte objectType);
- [DllImport("steam_api64", CallingConvention = CallingConvention.Cdecl)]
- private static extern IntPtr SteamAPI_SteamNetworking_v006();
-
- [DllImport("steam_api64", CallingConvention = CallingConvention.Cdecl)]
- private static extern IntPtr SteamAPI_SteamUser_v023();
-
- [DllImport("steam_api64", CallingConvention = CallingConvention.Cdecl)]
- private static extern IntPtr SteamAPI_SteamFriends_v018();
-
- [DllImport("steam_api64", CallingConvention = CallingConvention.Cdecl)]
- private static extern ulong SteamAPI_ISteamUser_GetSteamID(IntPtr self);
-
- [DllImport("steam_api64", CallingConvention = CallingConvention.Cdecl)]
- private static extern IntPtr SteamAPI_ISteamFriends_GetFriendPersonaName(IntPtr self, ulong steamId);
-
- [DllImport("steam_api64", CallingConvention = CallingConvention.Cdecl)]
- [return: MarshalAs(UnmanagedType.I1)]
- private static extern bool SteamAPI_ISteamNetworking_SendP2PPacket(IntPtr self, ulong steamIDRemote, IntPtr pubData, uint cubData, int eP2PSendType, int nChannel);
-
- [DllImport("steam_api64", CallingConvention = CallingConvention.Cdecl)]
- [return: MarshalAs(UnmanagedType.I1)]
- private static extern bool SteamAPI_ISteamNetworking_IsP2PPacketAvailable(IntPtr self, out uint pcubMsgSize, int nChannel);
-
- [DllImport("steam_api64", CallingConvention = CallingConvention.Cdecl)]
- [return: MarshalAs(UnmanagedType.I1)]
- private static extern bool SteamAPI_ISteamNetworking_ReadP2PPacket(IntPtr self, IntPtr pubDest, uint cubDest, out uint pcubMsgSize, out ulong psteamIDRemote, int nChannel);
-
- [DllImport("steam_api64", CallingConvention = CallingConvention.Cdecl)]
- [return: MarshalAs(UnmanagedType.I1)]
- private static extern bool SteamAPI_ISteamNetworking_AcceptP2PSessionWithUser(IntPtr instancePtr, ulong steamIDRemote);
-
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int RegisterCustomEmployeeDelegate(IntPtr employeeId, IntPtr name, IntPtr description, float salary, float requiredReputation, uint confirmDialogs);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
@@ -467,14 +438,7 @@ public partial class GameAPIManager : IDisposable
private readonly MelonLogger.Instance _logger;
private IntPtr _currentScenePtr = IntPtr.Zero;
- private IntPtr _friendNamePtr = IntPtr.Zero;
- private IntPtr _lobbyDataPtr = IntPtr.Zero;
-
- private IntPtr _steamNetworking = IntPtr.Zero;
- private IntPtr _steamUser = IntPtr.Zero;
- private IntPtr _steamFriends = IntPtr.Zero;
-
- public GameAPIManager(MelonLogger.Instance logger)
+ public GameAPIManager(MelonLogger.Instance logger)
{
_logger = logger;
@@ -981,50 +945,11 @@ private int TriggerSaveImpl()
}
- private IntPtr GetSteamNetworking()
- {
- if (_steamNetworking == IntPtr.Zero)
- _steamNetworking = SteamAPI_SteamNetworking_v006();
- return _steamNetworking;
- }
-
- private IntPtr GetSteamUser()
- {
- if (_steamUser == IntPtr.Zero)
- _steamUser = SteamAPI_SteamUser_v023();
- return _steamUser;
- }
-
- private IntPtr GetSteamFriends()
- {
- if (_steamFriends == IntPtr.Zero)
- _steamFriends = SteamAPI_SteamFriends_v018();
- return _steamFriends;
- }
-
- private ulong SteamGetMyIdImpl()
- {
- try
- {
- var user = GetSteamUser();
- if (user == IntPtr.Zero) return 0;
- return SteamAPI_ISteamUser_GetSteamID(user);
- }
- catch (Exception ex) { CrashLog.LogException("SteamGetMyId", ex); return 0; }
- }
-
- private IntPtr SteamGetFriendNameImpl(ulong steamId)
- {
- try
- {
- var friends = GetSteamFriends();
- if (friends == IntPtr.Zero) return IntPtr.Zero;
- return SteamAPI_ISteamFriends_GetFriendPersonaName(friends, steamId);
- }
- catch (Exception ex) { CrashLog.LogException("SteamGetFriendName", ex); return IntPtr.Zero; }
- }
-
- private int SteamCreateLobbyImpl(uint lobbyType, uint maxPlayers) { return 0; }
+ // Legacy v7 ABI slots are intentionally inert. Data Center owns Steam,
+ // lobby and co-op lifecycle now; gregCore must not open a second session.
+ private ulong SteamGetMyIdImpl() => 0;
+ private IntPtr SteamGetFriendNameImpl(ulong steamId) => IntPtr.Zero;
+ private int SteamCreateLobbyImpl(uint lobbyType, uint maxPlayers) { return 0; }
private int SteamJoinLobbyImpl(ulong lobbyId) { return 0; }
private void SteamLeaveLobbyImpl() { }
private ulong SteamGetLobbyIdImpl() { return 0; }
@@ -1034,85 +959,15 @@ private void SteamLeaveLobbyImpl() { }
private int SteamSetLobbyDataImpl(IntPtr key, IntPtr value) { return 0; }
private IntPtr SteamGetLobbyDataImpl(IntPtr key) { return IntPtr.Zero; }
- private int SteamSendP2PImpl(ulong target, IntPtr data, uint len, uint reliable)
- {
- try
- {
- var networking = GetSteamNetworking();
- if (networking == IntPtr.Zero)
- {
- CrashLog.Log("[Steam] SendP2P: ISteamNetworking not available");
- return 0;
- }
-
- // k_EP2PSendUnreliable=0, k_EP2PSendReliable=2
- int sendType = reliable != 0 ? 2 : 0;
- bool ok = SteamAPI_ISteamNetworking_SendP2PPacket(networking, target, data, len, sendType, 0);
- if (!ok)
- CrashLog.Log($"[Steam] SendP2PPacket failed: target={target}, len={len}, reliable={reliable}");
- return ok ? 1 : 0;
- }
- catch (Exception ex) { CrashLog.LogException("SteamSendP2P", ex); return 0; }
- }
-
- private uint SteamIsP2PAvailableImpl(IntPtr outSize)
- {
- try
- {
- var networking = GetSteamNetworking();
- if (networking == IntPtr.Zero) return 0;
-
- bool available = SteamAPI_ISteamNetworking_IsP2PPacketAvailable(networking, out uint msgSize, 0);
- if (available && msgSize > 0)
- {
- if (outSize != IntPtr.Zero)
- Marshal.WriteInt32(outSize, (int)msgSize);
- return 1;
- }
- return 0;
- }
- catch (Exception ex) { CrashLog.LogException("SteamIsP2PAvailable", ex); return 0; }
- }
-
- private uint SteamReadP2PImpl(IntPtr buf, uint bufLen, IntPtr outSender)
- {
- try
- {
- var networking = GetSteamNetworking();
- if (networking == IntPtr.Zero) return 0;
-
- bool ok = SteamAPI_ISteamNetworking_ReadP2PPacket(
- networking, buf, bufLen, out uint bytesRead, out ulong sender, 0);
-
- if (ok && bytesRead > 0)
- {
- if (outSender != IntPtr.Zero)
- Marshal.WriteInt64(outSender, (long)sender);
- return bytesRead;
- }
- return 0;
- }
- catch (Exception ex) { CrashLog.LogException("SteamReadP2P", ex); return 0; }
- }
-
- private void SteamAcceptP2PImpl(ulong remote)
- {
- try
- {
- var networking = GetSteamNetworking();
- if (networking == IntPtr.Zero) return;
-
- bool ok = SteamAPI_ISteamNetworking_AcceptP2PSessionWithUser(networking, remote);
- CrashLog.Log($"[Steam] AcceptP2PSessionWithUser({remote}): {ok}");
- }
- catch (Exception ex) { CrashLog.LogException("SteamAcceptP2P", ex); }
- }
-
- private uint SteamPollEventImpl(IntPtr outType, IntPtr outData)
- {
- // TODO: implement event queue for lobby callbacks
- return 0;
- }
+ private int SteamSendP2PImpl(ulong target, IntPtr data, uint len, uint reliable) => 0;
+ private uint SteamIsP2PAvailableImpl(IntPtr outSize) => 0;
+ private uint SteamReadP2PImpl(IntPtr buf, uint bufLen, IntPtr outSender) => 0;
+ private void SteamAcceptP2PImpl(ulong remote) { }
+ private uint SteamPollEventImpl(IntPtr outType, IntPtr outData)
+ {
+ // Intentionally inert: native Data Center owns lobby callbacks.
+ return 0;
+ }
private void GetPlayerPositionImpl(IntPtr outX, IntPtr outY, IntPtr outZ, IntPtr outRy)
{
@@ -2962,10 +2817,8 @@ int RackGameUninstallImpl(ulong objHandle, byte objectType)
public void Dispose()
{
- if (_tablePtr != IntPtr.Zero) { Marshal.FreeHGlobal(_tablePtr); _tablePtr = IntPtr.Zero; }
- if (_currentScenePtr != IntPtr.Zero) { Marshal.FreeHGlobal(_currentScenePtr); _currentScenePtr = IntPtr.Zero; }
- if (_friendNamePtr != IntPtr.Zero) { Marshal.FreeHGlobal(_friendNamePtr); _friendNamePtr = IntPtr.Zero; }
- if (_lobbyDataPtr != IntPtr.Zero) { Marshal.FreeHGlobal(_lobbyDataPtr); _lobbyDataPtr = IntPtr.Zero; }
- GC.SuppressFinalize(this);
+ if (_tablePtr != IntPtr.Zero) { Marshal.FreeHGlobal(_tablePtr); _tablePtr = IntPtr.Zero; }
+ if (_currentScenePtr != IntPtr.Zero) { Marshal.FreeHGlobal(_currentScenePtr); _currentScenePtr = IntPtr.Zero; }
+ GC.SuppressFinalize(this);
}
}
diff --git a/src/Compatibility/DataCenterModLoader/HarmonyPatches.cs b/src/Compatibility/DataCenterModLoader/HarmonyPatches.cs
index 8dcdead8..d0e07db4 100644
--- a/src/Compatibility/DataCenterModLoader/HarmonyPatches.cs
+++ b/src/Compatibility/DataCenterModLoader/HarmonyPatches.cs
@@ -934,7 +934,7 @@ internal static void Postfix(HRSystem __instance)
///
/// Detects when the local player picks up or drops a UsableObject (Server, Switch, etc.)
/// by comparing PlayerManager state before/after InteractOnClick.
-/// Fires ObjectPickedUp / ObjectDropped events for multiplayer synchronization.
+/// Fires ObjectPickedUp / ObjectDropped events for native co-op-aware mods.
///
[HarmonyPatch(typeof(UsableObject), nameof(UsableObject.InteractOnClick))]
internal static class Patch_UsableObject_InteractOnClick
diff --git a/src/Compatibility/DataCenterModLoader/MultiplayerBridge.UI.cs b/src/Compatibility/DataCenterModLoader/MultiplayerBridge.UI.cs
deleted file mode 100644
index 9a3887ab..00000000
--- a/src/Compatibility/DataCenterModLoader/MultiplayerBridge.UI.cs
+++ /dev/null
@@ -1,298 +0,0 @@
-using System;
-using UnityEngine;
-using UnityEngine.UIElements;
-
-namespace DataCenterModLoader
-{
- public partial class MultiplayerBridge
- {
- private void BuildUI()
- {
- _panelRoot = new VisualElement
- {
- name = "MultiplayerPanel",
- style =
- {
- position = Position.Absolute,
- top = (Screen.height - 420) / 2,
- left = (Screen.width - 400) / 2,
- width = 400,
- height = 420,
- backgroundColor = new Color(0.12f, 0.12f, 0.15f, 0.95f),
- borderTopColor = new Color(0f, 0.6f, 0.7f),
- borderBottomColor = new Color(0f, 0.6f, 0.7f),
- borderLeftColor = new Color(0f, 0.6f, 0.7f),
- borderRightColor = new Color(0f, 0.6f, 0.7f),
- borderTopWidth = 2,
- borderBottomWidth = 2,
- borderLeftWidth = 2,
- borderRightWidth = 2,
- borderTopLeftRadius = 8,
- borderTopRightRadius = 8,
- borderBottomLeftRadius = 8,
- borderBottomRightRadius = 8,
- flexDirection = FlexDirection.Column,
- paddingTop = 15,
- paddingBottom = 15,
- paddingLeft = 25,
- paddingRight = 25,
- display = DisplayStyle.None
- }
- };
-
- // Title
- var title = new Label("MULTIPLAYER")
- {
- style =
- {
- fontSize = 22,
- unityFontStyleAndWeight = FontStyle.Bold,
- color = Color.white,
- unityTextAlign = TextAnchor.MiddleLeft,
- marginBottom = 30
- }
- };
- _panelRoot.Add(title);
-
- // Room code section
- var codeLabel = new Label("ROOM CODE (UPPERCASE)")
- {
- style = { fontSize = 16, color = new Color(0.8f, 0.8f, 0.8f), marginBottom = 8 }
- };
- _panelRoot.Add(codeLabel);
-
- var codeField = new TextField
- {
- value = _roomCode ?? "",
- style =
- {
- backgroundColor = new Color(0.2f, 0.2f, 0.25f),
- color = Color.white,
- fontSize = 16,
- height = 40,
- marginBottom = 20
- }
- };
- codeField.RegisterValueChangedCallback(evt =>
- {
- _roomCode = evt.newValue;
- _roomCodeFieldFocused = !string.IsNullOrEmpty(evt.newValue);
- });
- _panelRoot.Add(codeField);
-
- // Join button
- var joinBtn = new Button(() =>
- {
- _roomCodeFieldFocused = false;
- DoConnect();
- })
- {
- text = "JOIN GAME",
- style =
- {
- backgroundColor = new Color(0f, 0.6f, 0.7f),
- color = Color.white,
- unityFontStyleAndWeight = FontStyle.Bold,
- height = 50,
- fontSize = 18,
- marginBottom = 20,
- borderTopLeftRadius = 4,
- borderTopRightRadius = 4,
- borderBottomLeftRadius = 4,
- borderBottomRightRadius = 4,
- }
- };
- _panelRoot.Add(joinBtn);
-
- // Separator
- var separator = new VisualElement
- {
- style =
- {
- backgroundColor = new Color(0.2f, 0.2f, 0.2f),
- height = 1,
- marginBottom = 20
- }
- };
- _panelRoot.Add(separator);
-
- // Host button
- var hostBtn = new Button(() =>
- {
- _roomCodeFieldFocused = false;
- DoHost();
- })
- {
- text = "HOST GAME",
- style =
- {
- backgroundColor = new Color(0f, 0.6f, 0.7f),
- color = Color.white,
- unityFontStyleAndWeight = FontStyle.Bold,
- height = 50,
- fontSize = 18,
- borderTopLeftRadius = 4,
- borderTopRightRadius = 4,
- borderBottomLeftRadius = 4,
- borderBottomRightRadius = 4,
- }
- };
- _panelRoot.Add(hostBtn);
-
- // Close button
- var closeBtn = new Button(() => HideMultiplayerPanel())
- {
- text = "X",
- style =
- {
- position = Position.Absolute,
- top = 10,
- right = 10,
- width = 25,
- height = 25,
- backgroundColor = Color.clear,
- color = Color.white,
- unityFontStyleAndWeight = FontStyle.Bold,
- borderTopWidth = 0,
- borderBottomWidth = 0,
- borderLeftWidth = 0,
- borderRightWidth = 0
- }
- };
- _panelRoot.Add(closeBtn);
-
- // Register with GregUIManager
- GregUIManager.RegisterPanel("MultiplayerPanel", _panelRoot);
- }
-
- private void UpdateUI()
- {
- if (_panelRoot == null) return;
-
- bool connected = _isConnected() != 0;
- _panelRoot.Clear();
-
- if (!connected)
- {
- BuildUI();
- }
- else
- {
- BuildConnectedUI();
- }
- }
-
- private void BuildConnectedUI()
- {
- if (_panelRoot == null) return;
-
- _panelRoot.Clear();
-
- // Status
- var status = new Label(_isHosting ? "HOSTING SESSION" : "CONNECTED TO SESSION")
- {
- style =
- {
- fontSize = 18,
- unityFontStyleAndWeight = FontStyle.Bold,
- color = new Color(0f, 0.9f, 0.6f),
- marginBottom = 20
- }
- };
- _panelRoot.Add(status);
-
- // Room code display
- string codeToDisplay = _isHosting ? _displayRoomCode : _roomCode;
- var codeLabel = new Label($"ROOM: {codeToDisplay}")
- {
- style = { fontSize = 16, color = new Color(0.8f, 0.8f, 0.8f), marginBottom = 20 }
- };
- _panelRoot.Add(codeLabel);
-
- // Player count
- uint players = _getPlayerCount != null ? _getPlayerCount() : 1;
- var playerLabel = new Label($"Players: {players}")
- {
- style = { fontSize = 16, color = new Color(0.8f, 0.8f, 0.8f), marginBottom = 30 }
- };
- _panelRoot.Add(playerLabel);
-
- // Stop/Disconnect button
- var actionBtn = new Button(() =>
- {
- if (_isHosting) DoStopHosting();
- else DoDisconnect();
- })
- {
- text = _isHosting ? "STOP HOSTING" : "DISCONNECT",
- style =
- {
- backgroundColor = new Color(0.7f, 0.2f, 0.2f),
- color = Color.white,
- unityFontStyleAndWeight = FontStyle.Bold,
- height = 50,
- fontSize = 18,
- borderTopLeftRadius = 4,
- borderTopRightRadius = 4,
- borderBottomLeftRadius = 4,
- borderBottomRightRadius = 4,
- }
- };
- _panelRoot.Add(actionBtn);
-
- // Close button
- var closeBtn = new Button(() => HideMultiplayerPanel())
- {
- text = "X",
- style =
- {
- position = Position.Absolute,
- top = 10,
- right = 10,
- width = 25,
- height = 25,
- backgroundColor = Color.clear,
- color = Color.white,
- unityFontStyleAndWeight = FontStyle.Bold,
- borderTopWidth = 0,
- borderBottomWidth = 0,
- borderLeftWidth = 0,
- borderRightWidth = 0
- }
- };
- _panelRoot.Add(closeBtn);
- }
-
- public void DrawGUI()
- {
- if (!_showPanel) return;
-
- if (!_stylesInitialized)
- {
- BuildUI();
- _stylesInitialized = true;
- }
-
- _panelRoot!.style.display = DisplayStyle.Flex;
- }
-
- private void HideMultiplayerPanel()
- {
- _showPanel = false;
- if (_panelRoot != null)
- _panelRoot.style.display = DisplayStyle.None;
- }
-
- private void ShowMultiplayerPanel()
- {
- _showPanel = true;
- if (_panelRoot == null)
- {
- BuildUI();
- }
- _panelRoot!.style.display = DisplayStyle.Flex;
- }
-
- private void InitStyles() { } // No longer needed - UI Toolkit handles styling
- }
-}
diff --git a/src/Compatibility/DataCenterModLoader/MultiplayerBridge.cs b/src/Compatibility/DataCenterModLoader/MultiplayerBridge.cs
deleted file mode 100644
index f102819c..00000000
--- a/src/Compatibility/DataCenterModLoader/MultiplayerBridge.cs
+++ /dev/null
@@ -1,1977 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Runtime.InteropServices;
-using MelonLoader;
-using UnityEngine;
-using UnityEngine.InputSystem;
-using Il2Cpp;
-using Il2CppTMPro;
-using Il2CppUMA;
-using Il2CppUMA.CharacterSystem;
-using UnityEngine.AI;
-using UnityEngine.UIElements;
-
-
-namespace DataCenterModLoader;
-
-///
-/// Manages the multiplayer bridge between C# (MelonLoader) and the Rust DLL (dc_multiplayer.dll).
-/// Handles relay-based networking, UI panel, and main menu button injection.
-///
-using UnityEngine.SceneManagement;
-
-public partial class MultiplayerBridge
-{
- [DllImport("kernel32.dll")]
- private static extern IntPtr GetModuleHandle(string lpModuleName);
-
- [DllImport("kernel32.dll")]
- private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);
-
- // ═══════════════════════════════════════════════════════════════════════
- // FFI Delegates (dc_multiplayer.dll exports)
- // ═══════════════════════════════════════════════════════════════════════
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate uint MpIsConnectedDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate uint MpIsRelayActiveDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate uint MpGetPlayerCountDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate ulong MpGetMySteamIdDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate int MpHostDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate int MpConnectDelegate(IntPtr roomCode, uint roomCodeLen);
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate int MpDisconnectDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate IntPtr MpGetRoomCodeDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate uint MpShouldSendSaveDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate int MpSendSaveDataDelegate(IntPtr data, uint len);
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate uint MpHasPendingSaveDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate uint MpGetSaveDataSizeDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate uint MpGetSaveDataDelegate(IntPtr buf, uint maxLen);
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate int MpSaveLoadCompleteDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate void MpSkipNextSaveRequestDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate void MpSetLocalSaveHashDelegate(IntPtr data, uint len);
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate float MpGetSaveTransferProgressDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate uint MpGetSaveTransferTotalBytesDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate uint MpIsSaveUpToDateDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate uint MpGetJoinStateDelegate();
-
- [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
- private delegate void MpSetJoinStateDelegate(uint state);
-
- private readonly MelonLogger.Instance _logger;
- private MpIsConnectedDelegate _isConnected = null!;
- private MpIsRelayActiveDelegate? _isRelayActive;
- private MpGetPlayerCountDelegate? _getPlayerCount;
- private MpGetMySteamIdDelegate? _getMySteamId;
- private MpHostDelegate? _host;
- private MpConnectDelegate? _connect;
- private MpDisconnectDelegate? _disconnect;
- private MpGetRoomCodeDelegate? _getRoomCode;
- private MpShouldSendSaveDelegate? _shouldSendSave;
- private MpSendSaveDataDelegate? _sendSaveData;
- private MpHasPendingSaveDelegate? _hasPendingSave;
- private MpGetSaveDataSizeDelegate? _getSaveDataSize;
- private MpGetSaveDataDelegate? _getSaveData;
- private MpSaveLoadCompleteDelegate? _saveLoadComplete;
- private MpSkipNextSaveRequestDelegate? _skipNextSaveRequest;
- private MpSetLocalSaveHashDelegate? _setLocalSaveHash;
- private MpGetSaveTransferProgressDelegate? _getSaveTransferProgress;
- private MpGetSaveTransferTotalBytesDelegate? _getSaveTransferTotalBytes;
- private MpIsSaveUpToDateDelegate? _isSaveUpToDate;
- private MpGetJoinStateDelegate? _getJoinState;
- private MpSetJoinStateDelegate? _setJoinState;
- private bool _initialized = false;
- private float _initTimer = 0f;
- private bool _isHosting = false;
- private bool _isConnectedState = false;
- private string? _discoveredSavePath = null;
-
- // Join state constants (must match Rust dc_multiplayer JOIN_* constants)
- private const uint JOIN_IDLE = 0;
- private const uint JOIN_WAITING_FOR_SAVE = 1;
- private const uint JOIN_SAVE_READY = 2;
- private const uint JOIN_SAVE_UP_TO_DATE = 3;
- private const uint JOIN_LOADING_SCENE = 4;
- private const uint JOIN_LOADED = 5;
- private string _currentSceneName = "";
- private byte[]? _pendingSaveBytes = null;
- private string? _pendingSaveName = null; // save name (without extension) written to disk
- private string? _pendingSaveFullPath = null; // full path to the written save file
- private float _deferredLoadDelay = 0f; // small delay after scene load before triggering Load()
- private string? _reconnectRoomCode = null; // room code to auto-reconnect after scene transition
- private bool _skipSaveOnReconnect = false; // skip save processing when reconnecting after load
- private float _reconnectCooldown = 0f; // cooldown to prevent rapid-fire reconnect attempts
- private int _mpReenableCountdown = 0;
- private UnityEngine.EventSystems.EventSystem? _mpDisabledEventSystem = null;
- private bool _pendingMenuInjection = false;
- private float _menuInjectionTimer = 0f;
- private GameObject? _menuButton = null;
- private bool _gameHandledSaveLoad = false; // true when MainMenu.Continue() handles the load
-
- // Host: cached save bytes so multiple client joins don't re-trigger SaveGame()
- private byte[]? _cachedSaveData = null;
- private float _cachedSaveAge = 0f;
- private const float SAVE_CACHE_LIFETIME = 30f; // seconds before cache expires
-
- // ═══════════════════════════════════════════════════════════════════════
- // Fields: Relay / Room Code
- // ═══════════════════════════════════════════════════════════════════════
-
- private string _roomCode = ""; // room code for joining
- private string _displayRoomCode = ""; // room code received after hosting
-
- private bool _showPanel;
- private VisualElement? _panelRoot;
-
- // Custom text field state (manual input handling for new Input System)
- private bool _roomCodeFieldFocused;
- private float _cursorBlinkTimer;
- private bool _cursorVisible = true;
- private float _keyRepeatTimer;
- private Key _lastHeldKey = Key.None;
- private const float KEY_REPEAT_DELAY = 0.4f;
- private const float KEY_REPEAT_RATE = 0.05f;
-
-
-
- public MultiplayerBridge(MelonLogger.Instance logger)
- {
- _logger = logger;
- }
-
- public bool TryInitialize()
- {
- if (_initialized) return true;
-
- // Match how Windows registers the module (with or without .dll).
- var handle = GetModuleHandle("dc_multiplayer.dll");
- if (handle == IntPtr.Zero)
- handle = GetModuleHandle("dc_multiplayer");
- if (handle == IntPtr.Zero) return false;
-
- var isConnectedPtr = GetProcAddress(handle, "mp_is_connected");
- var isRelayActivePtr = GetProcAddress(handle, "mp_is_relay_active");
- var playerCountPtr = GetProcAddress(handle, "mp_get_player_count");
- var steamIdPtr = GetProcAddress(handle, "mp_get_my_steam_id");
- var hostPtr = GetProcAddress(handle, "mp_host");
- var connectPtr = GetProcAddress(handle, "mp_connect");
- var disconnectPtr = GetProcAddress(handle, "mp_disconnect");
- var roomCodePtr = GetProcAddress(handle, "mp_get_room_code");
- var shouldSendSavePtr = GetProcAddress(handle, "mp_should_send_save");
- var sendSaveDataPtr = GetProcAddress(handle, "mp_send_save_data");
- var hasPendingSavePtr = GetProcAddress(handle, "mp_has_pending_save");
- var getSaveDataSizePtr = GetProcAddress(handle, "mp_get_save_data_size");
- var getSaveDataPtr = GetProcAddress(handle, "mp_get_save_data");
- var saveLoadCompletePtr = GetProcAddress(handle, "mp_save_load_complete");
-
- if (isConnectedPtr == IntPtr.Zero) return false;
-
- _isConnected = Marshal.GetDelegateForFunctionPointer(isConnectedPtr);
- _isRelayActive = isRelayActivePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(isRelayActivePtr) : null;
- _getPlayerCount = playerCountPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(playerCountPtr) : null;
- _getMySteamId = steamIdPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(steamIdPtr) : null;
- _host = hostPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(hostPtr) : null;
- _connect = connectPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(connectPtr) : null;
- _disconnect = disconnectPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(disconnectPtr) : null;
- _getRoomCode = roomCodePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(roomCodePtr) : null;
- _shouldSendSave = shouldSendSavePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(shouldSendSavePtr) : null;
- _sendSaveData = sendSaveDataPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(sendSaveDataPtr) : null;
- _hasPendingSave = hasPendingSavePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(hasPendingSavePtr) : null;
- _getSaveDataSize = getSaveDataSizePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(getSaveDataSizePtr) : null;
- _getSaveData = getSaveDataPtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(getSaveDataPtr) : null;
- _saveLoadComplete = saveLoadCompletePtr != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(saveLoadCompletePtr) : null;
-
- // Optional: may not exist in older DLLs — fail gracefully
- var skipNextSaveRequestPtr = GetProcAddress(handle, "mp_skip_next_save_request");
- _skipNextSaveRequest = skipNextSaveRequestPtr != IntPtr.Zero
- ? Marshal.GetDelegateForFunctionPointer(skipNextSaveRequestPtr)
- : null;
-
- var setLocalSaveHashPtr = GetProcAddress(handle, "mp_set_local_save_hash");
- _setLocalSaveHash = setLocalSaveHashPtr != IntPtr.Zero
- ? Marshal.GetDelegateForFunctionPointer(setLocalSaveHashPtr)
- : null;
-
- var getSaveTransferProgressPtr = GetProcAddress(handle, "mp_get_save_transfer_progress");
- _getSaveTransferProgress = getSaveTransferProgressPtr != IntPtr.Zero
- ? Marshal.GetDelegateForFunctionPointer(getSaveTransferProgressPtr)
- : null;
-
- var getSaveTransferTotalBytesPtr = GetProcAddress(handle, "mp_get_save_transfer_total_bytes");
- _getSaveTransferTotalBytes = getSaveTransferTotalBytesPtr != IntPtr.Zero
- ? Marshal.GetDelegateForFunctionPointer(getSaveTransferTotalBytesPtr)
- : null;
-
- var isSaveUpToDatePtr = GetProcAddress(handle, "mp_is_save_up_to_date");
- _isSaveUpToDate = isSaveUpToDatePtr != IntPtr.Zero
- ? Marshal.GetDelegateForFunctionPointer(isSaveUpToDatePtr)
- : null;
-
- var getJoinStatePtr = GetProcAddress(handle, "mp_get_join_state");
- _getJoinState = getJoinStatePtr != IntPtr.Zero
- ? Marshal.GetDelegateForFunctionPointer(getJoinStatePtr)
- : null;
-
- var setJoinStatePtr = GetProcAddress(handle, "mp_set_join_state");
- _setJoinState = setJoinStatePtr != IntPtr.Zero
- ? Marshal.GetDelegateForFunctionPointer(setJoinStatePtr)
- : null;
-
- _initialized = true;
- _logger.Msg("[MP Bridge] dc_multiplayer detected, bridge active.");
- _logger.Msg("[MP Bridge] Keybinds: F9=Host, F10=Multiplayer Panel, F11=Disconnect");
-
- return true;
- }
-
- public void OnUpdate(float dt)
- {
- if (_mpReenableCountdown > 0)
- {
- _mpReenableCountdown--;
- if (_mpReenableCountdown <= 0 && _mpDisabledEventSystem != null)
- {
- _mpDisabledEventSystem.enabled = true;
- _mpDisabledEventSystem = null;
- }
- }
-
- if (_reconnectCooldown > 0f) _reconnectCooldown -= dt;
-
-
- if (_pendingMenuInjection)
- {
- _menuInjectionTimer -= dt;
- if (_menuInjectionTimer <= 0f)
- {
- _pendingMenuInjection = false;
- if (_initialized)
- InjectMainMenuButton();
- }
- }
-
- // --- Retry DLL detection until initialized ---
- if (!_initialized)
- {
- _initTimer += dt;
- if (_initTimer >= 2f)
- {
- _initTimer = 0f;
- if (TryInitialize())
- {
- CrashLog.Log("[MP Bridge] dc_multiplayer.dll detected and initialized.");
-
- if (_currentSceneName == "MainMenu" && _menuButton == null)
- {
- InjectMainMenuButton();
- }
- }
- else
- {
- CrashLog.Log("[MP Bridge] dc_multiplayer.dll not found yet, will retry...");
- }
- }
-
- // Give feedback if the user presses keybinds before DLL is loaded
- var kb = Keyboard.current;
- if (kb != null && (kb.f9Key.wasPressedThisFrame || kb.f10Key.wasPressedThisFrame || kb.f11Key.wasPressedThisFrame))
- {
- _logger.Warning("[MP Bridge] dc_multiplayer.dll is not loaded — multiplayer keybinds (F9/F10/F11) are unavailable.");
- _logger.Warning("[MP Bridge] Make sure dc_multiplayer.dll is in your Mods/native folder and has been loaded.");
-
- try
- {
- var ui = StaticUIElements.instance;
- if (ui != null)
- ui.AddMeesageInField("Multiplayer: dc_multiplayer.dll not loaded! Check Mods/native folder.");
- }
- catch { }
- }
-
- return;
- }
-
- // --- Main update loop (only when initialized) ---
- try
- {
- HandleKeybinds();
-
- // Check for room code when hosting and we don't have one yet
- if (_isHosting && string.IsNullOrEmpty(_displayRoomCode) && _getRoomCode != null)
- {
- IntPtr codePtr = _getRoomCode();
- if (codePtr != IntPtr.Zero)
- {
- string? code = Marshal.PtrToStringAnsi(codePtr);
- if (!string.IsNullOrEmpty(code))
- {
- _displayRoomCode = code;
- CrashLog.Log($"[MP Bridge] Room code: {_displayRoomCode}");
- _logger.Msg($"[MP Bridge] Room code: {_displayRoomCode}");
- try
- {
- var ui = StaticUIElements.instance;
- if (ui != null) ui.AddMeesageInField($"Multiplayer: Room code: {_displayRoomCode}");
- }
- catch { }
- }
- }
- }
-
- bool connected = _isConnected() != 0;
-
- // Log state transitions and show in-game notifications
- if (connected && !_isConnectedState)
- {
- _isConnectedState = true;
- _logger.Msg("[MP Bridge] Connected! Remote players will now be rendered.");
- try
- {
- uint playerCount = _getPlayerCount != null ? _getPlayerCount() : 0;
- var ui = StaticUIElements.instance;
- if (ui != null)
- {
- if (_isHosting)
- ui.AddMeesageInField($"Multiplayer: A player connected! ({playerCount} player(s) in session)");
- else
- ui.AddMeesageInField("Multiplayer: Connected to host!");
- }
- }
- catch { }
- }
- else if (!connected && _isConnectedState)
- {
- _isConnectedState = false;
- _logger.Msg("[MP Bridge] Disconnected.");
- try
- {
- var ui = StaticUIElements.instance;
- if (ui != null)
- ui.AddMeesageInField("Multiplayer: Player disconnected.");
- }
- catch { }
- }
-
-
- bool relayAlive = _isRelayActive != null ? _isRelayActive() != 0 : connected;
-
- if (!relayAlive && (_isHosting || _isConnectedState))
- {
- // Only reset once on transition
- if (_isHosting)
- {
- _isHosting = false;
- _displayRoomCode = "";
- _logger.Msg("[MP Bridge] Relay disconnected while hosting, state reset.");
- }
- if (_isConnectedState)
- {
- _isConnectedState = false;
- _logger.Msg("[MP Bridge] Relay disconnected while connected, state reset.");
- }
- }
-
- if (!connected)
- {
- CleanupAll();
- return;
- }
-
- // Save sync: Host sends save when requested
- if (_isHosting && _shouldSendSave != null && _shouldSendSave() != 0)
- {
- SendSaveToClients();
- }
-
- if (_isHosting && _cachedSaveData != null)
- {
- _cachedSaveAge += dt;
- if (_cachedSaveAge >= SAVE_CACHE_LIFETIME)
- {
- _cachedSaveData = null;
- _cachedSaveAge = 0f;
- CrashLog.Log("[MP Save] Save cache expired");
- }
- }
-
- if (!_isHosting)
- {
- uint joinState = GetJoinState();
- switch (joinState)
- {
- case JOIN_WAITING_FOR_SAVE:
- // Rust automatically transitions to SaveReady or SaveUpToDate.
- // Nothing to do here — just wait.
- break;
-
- case JOIN_SAVE_READY:
- CrashLog.Log("[MP Join] Save data ready from Rust — fetching and processing");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Received save from host, loading..."); } catch { }
- FetchAndProcessSave();
- break;
-
- case JOIN_SAVE_UP_TO_DATE:
- {
- CrashLog.Log("[MP Join] Save is up to date — no download needed!");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Save is up to date!"); } catch { }
-
- string? savePath = DiscoverSaveFile();
- if (savePath != null)
- {
- _pendingSaveName = Path.GetFileNameWithoutExtension(savePath);
- _pendingSaveFullPath = savePath;
-
- if (IsInMainMenu())
- {
- CrashLog.Log("[MP Join] In MainMenu with up-to-date save — initiating scene transition");
- InitiateSceneTransition();
- }
- else
- {
- CrashLog.Log("[MP Join] Ingame with up-to-date save transitioning to Loaded");
- SetJoinState(JOIN_LOADED);
- _logger.Msg("[MP Join] Save up to date, already ingame!");
- }
- }
- else
- {
- CrashLog.Log("[MP Join] Save up to date but couldn't find local file staying in state");
- }
- break;
- }
-
- case JOIN_LOADING_SCENE:
- if (_deferredLoadDelay > 0f)
- {
- _deferredLoadDelay -= dt;
- }
- else if (!IsInMainMenu())
- {
- if (_gameHandledSaveLoad)
- {
- CrashLog.Log("[MP Join] Game scene loaded via MainMenu.Continue() — transitioning to Loaded");
- SetJoinState(JOIN_LOADED);
- _gameHandledSaveLoad = false;
- _pendingSaveBytes = null;
- _pendingSaveName = null;
- _logger.Msg("[MP Join] Save loaded from host (via game Continue)!");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Save loaded from host!"); } catch { }
- }
- else
- {
- CrashLog.Log("[MP Join] Game scene detected after deferred wait, attempting load...");
- AttemptSaveLoad();
- }
- }
- break;
-
- case JOIN_LOADED:
- if (_hasPendingSave != null && _hasPendingSave() != 0)
- {
- CrashLog.Log("[MP Join] Discarding late save data (already loaded)");
- if (_saveLoadComplete != null) _saveLoadComplete();
- }
- if (_reconnectRoomCode != null && !relayAlive && _reconnectCooldown <= 0f)
- {
- _reconnectCooldown = 5f;
- CrashLog.Log($"[MP Join] Relay not alive in Loaded state — auto-reconnecting to {_reconnectRoomCode}");
- AutoReconnect();
- }
- break;
-
- case JOIN_IDLE:
- default:
- break;
- }
- }
-
-
- }
- catch (Exception ex)
- {
- CrashLog.LogException("MultiplayerBridge.OnUpdate", ex);
- }
- }
-
-
- private uint GetJoinState()
- {
- if (_getJoinState != null) return _getJoinState();
- return JOIN_IDLE;
- }
-
- private void SetJoinState(uint state)
- {
- if (_setJoinState != null) _setJoinState(state);
- }
-
- public void OnSceneLoaded(string sceneName)
- {
- _currentSceneName = sceneName ?? "";
- CrashLog.Log($"[MP Join] OnSceneLoaded: \"{_currentSceneName}\" (joinState={GetJoinState()})");
-
- if (sceneName == "MainMenu" && _initialized)
- {
- _pendingMenuInjection = true;
- _menuInjectionTimer = 0.5f;
- }
- else
- {
- _menuButton = null;
-
- if (GetJoinState() == JOIN_LOADING_SCENE)
- {
- if (_gameHandledSaveLoad)
- {
- CrashLog.Log($"[MP Join] Game scene \"{sceneName}\" loaded (via Continue) — waiting for initialization");
- _deferredLoadDelay = 2.0f;
- }
- else if (_pendingSaveName != null)
- {
- CrashLog.Log($"[MP Join] Game scene \"{sceneName}\" loaded — will attempt save load after short delay");
- _deferredLoadDelay = 1.0f;
- }
- }
- }
- }
-
- private void HandleKeybinds()
- {
- var kb = Keyboard.current;
- if (kb == null) return;
-
- // F9 = Host game
- if (kb.f9Key.wasPressedThisFrame)
- {
- DoHost();
- }
-
- // F10 = Toggle multiplayer panel
- if (kb.f10Key.wasPressedThisFrame)
- {
- if (_showPanel)
- HideMultiplayerPanel();
- else
- ShowMultiplayerPanel();
- }
-
- // F11 = Disconnect
- if (kb.f11Key.wasPressedThisFrame)
- {
- DoDisconnect();
- }
-
- // Handle custom text field input when focused
- if (_showPanel && _roomCodeFieldFocused)
- {
- HandleTextFieldInput(kb);
- }
- }
-
- ///
- /// Manually handles keyboard input for the room code text field
- /// since the game uses the new Input System exclusively.
- ///
- private void HandleTextFieldInput(Keyboard kb)
- {
- bool ctrl = kb.leftCtrlKey.isPressed || kb.rightCtrlKey.isPressed;
-
- int maxLen = 16;
-
- // Ctrl+V = Paste
- if (ctrl && kb.vKey.wasPressedThisFrame)
- {
- string clip = GUIUtility.systemCopyBuffer;
- if (!string.IsNullOrEmpty(clip))
- {
- // Room codes: alphanumeric only, uppercase
- var filtered = new System.Text.StringBuilder();
- foreach (char c in clip)
- {
- if (char.IsLetterOrDigit(c)) filtered.Append(char.ToUpper(c));
- }
- _roomCode = (_roomCode ?? "") + filtered.ToString();
- if (_roomCode.Length > maxLen) _roomCode = _roomCode.Substring(0, maxLen);
- }
- return;
- }
-
- // Ctrl+A = Select all (clear for simplicity)
- if (ctrl && kb.aKey.wasPressedThisFrame)
- {
- _roomCode = "";
- return;
- }
-
- // Escape = unfocus
- if (kb.escapeKey.wasPressedThisFrame)
- {
- _roomCodeFieldFocused = false;
- return;
- }
-
- // Enter = trigger join
- if (kb.enterKey.wasPressedThisFrame || kb.numpadEnterKey.wasPressedThisFrame)
- {
- _roomCodeFieldFocused = false;
- DoConnect();
- return;
- }
-
- // Room code field: alphanumeric, auto-uppercase
- var alphaKeys = new (Key key, char ch)[]
- {
- (Key.A, 'A'), (Key.B, 'B'), (Key.C, 'C'), (Key.D, 'D'),
- (Key.E, 'E'), (Key.F, 'F'), (Key.G, 'G'), (Key.H, 'H'),
- (Key.I, 'I'), (Key.J, 'J'), (Key.K, 'K'), (Key.L, 'L'),
- (Key.M, 'M'), (Key.N, 'N'), (Key.O, 'O'), (Key.P, 'P'),
- (Key.Q, 'Q'), (Key.R, 'R'), (Key.S, 'S'), (Key.T, 'T'),
- (Key.U, 'U'), (Key.V, 'V'), (Key.W, 'W'), (Key.X, 'X'),
- (Key.Y, 'Y'), (Key.Z, 'Z'),
- (Key.Digit0, '0'), (Key.Digit1, '1'), (Key.Digit2, '2'),
- (Key.Digit3, '3'), (Key.Digit4, '4'), (Key.Digit5, '5'),
- (Key.Digit6, '6'), (Key.Digit7, '7'), (Key.Digit8, '8'),
- (Key.Digit9, '9'),
- (Key.Numpad0, '0'), (Key.Numpad1, '1'), (Key.Numpad2, '2'),
- (Key.Numpad3, '3'), (Key.Numpad4, '4'), (Key.Numpad5, '5'),
- (Key.Numpad6, '6'), (Key.Numpad7, '7'), (Key.Numpad8, '8'),
- (Key.Numpad9, '9'),
- };
-
- foreach (var (key, ch) in alphaKeys)
- {
- if (ShouldProcessKey(kb, key))
- {
- if ((_roomCode ?? "").Length < maxLen)
- _roomCode = (_roomCode ?? "") + ch;
- return;
- }
- }
-
- // Backspace
- if (ShouldProcessKey(kb, Key.Backspace))
- {
- if (!string.IsNullOrEmpty(_roomCode))
- _roomCode = _roomCode.Substring(0, _roomCode.Length - 1);
- return;
- }
-
- // Delete = clear all
- if (kb.deleteKey.wasPressedThisFrame)
- {
- _roomCode = "";
- return;
- }
- }
-
- ///
- /// Returns true if a key should be processed this frame (initial press or held-repeat).
- ///
- private bool ShouldProcessKey(Keyboard kb, Key key)
- {
- var control = kb[key];
- if (control.wasPressedThisFrame)
- {
- _lastHeldKey = key;
- _keyRepeatTimer = KEY_REPEAT_DELAY;
- return true;
- }
-
- if (control.isPressed && _lastHeldKey == key)
- {
- _keyRepeatTimer -= Time.deltaTime;
- if (_keyRepeatTimer <= 0f)
- {
- _keyRepeatTimer = KEY_REPEAT_RATE;
- return true;
- }
- }
- else if (_lastHeldKey == key && !control.isPressed)
- {
- _lastHeldKey = Key.None;
- }
-
- return false;
- }
-
- // ═══════════════════════════════════════════════════════════════════════
- // Actions (shared by keybinds and UI buttons)
- // ═══════════════════════════════════════════════════════════════════════
-
- private void DoHost()
- {
- if (_host == null)
- {
- _logger.Warning("[MP Bridge] mp_host export not available.");
- return;
- }
-
- if (_isHosting)
- {
- _logger.Msg("[MP Bridge] Already hosting.");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Already hosting!"); } catch { }
- return;
- }
-
- CrashLog.Log("[MP Bridge] DoHost: calling mp_host()");
- int result = _host();
- CrashLog.Log($"[MP Bridge] DoHost: mp_host returned {result}");
-
- if (result == 1)
- {
- _isHosting = true;
- _displayRoomCode = ""; // Reset — will be polled in OnUpdate
- _logger.Msg("[MP Bridge] Connecting to relay for hosting...");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Connecting to relay..."); } catch { }
- }
- else
- {
- _logger.Warning("[MP Bridge] Failed to connect to relay server.");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Failed to connect to relay!"); } catch { }
- }
- }
-
- private void DoConnect()
- {
- if (_connect == null)
- {
- _logger.Warning("[MP Bridge] mp_connect export not available.");
- return;
- }
-
- string code = _roomCode != null ? _roomCode.Trim().ToUpper() : "";
- if (string.IsNullOrEmpty(code))
- {
- _logger.Warning("[MP Bridge] No room code entered.");
- CrashLog.Log("[MP Bridge] DoConnect: empty room code");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Enter a room code!"); } catch { }
- return;
- }
-
- CrashLog.Log($"[MP Bridge] DoConnect: room={code}");
-
- // Prevent joining when already busy
- uint currentJoinState = GetJoinState();
- if (currentJoinState != JOIN_IDLE && currentJoinState != JOIN_LOADED)
- {
- CrashLog.Log($"[MP Bridge] DoConnect: blocked already in state {currentJoinState}");
- _logger.Msg("[MP Bridge] Already joining, please wait...");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Already joining, please wait..."); } catch { }
- return;
- }
-
- // Reset join state for a fresh attempt
- ResetJoinState();
-
- // Compute local save hash for save versioning
- if (_setLocalSaveHash != null)
- {
- try
- {
- string? savePath = DiscoverSaveFile();
- if (savePath != null && File.Exists(savePath))
- {
- byte[] localSave = File.ReadAllBytes(savePath);
- IntPtr hashPtr = Marshal.AllocHGlobal(localSave.Length);
- try
- {
- Marshal.Copy(localSave, 0, hashPtr, localSave.Length);
- _setLocalSaveHash(hashPtr, (uint)localSave.Length);
- CrashLog.Log($"[MP Join] Local save hash computed from {savePath} ({localSave.Length} bytes)");
- }
- finally
- {
- Marshal.FreeHGlobal(hashPtr);
- }
- }
- else
- {
- CrashLog.Log("[MP Join] No local save found — hash not set");
- }
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Join] Error computing local save hash: {ex.Message}");
- }
- }
-
- byte[] codeBytes = System.Text.Encoding.UTF8.GetBytes(code);
- IntPtr codePtr = Marshal.AllocHGlobal(codeBytes.Length);
- try
- {
- Marshal.Copy(codeBytes, 0, codePtr, codeBytes.Length);
- int result = _connect(codePtr, (uint)codeBytes.Length);
- CrashLog.Log($"[MP Bridge] DoConnect: mp_connect returned {result}");
-
- if (result == 1)
- {
- SetJoinState(JOIN_WAITING_FOR_SAVE);
- _logger.Msg($"[MP Bridge] Joining room {code}...");
- CrashLog.Log($"[MP Join] State → WaitingForSave");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField($"Multiplayer: Joining room {code}..."); } catch { }
- HideMultiplayerPanel();
- }
- else
- {
- _logger.Warning("[MP Bridge] Failed to connect to relay server.");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Failed to connect!"); } catch { }
- }
- }
- finally
- {
- Marshal.FreeHGlobal(codePtr);
- }
- }
-
- private void DoDisconnect()
- {
- ResetJoinState();
- if (_disconnect == null)
- {
- _logger.Warning("[MP Bridge] mp_disconnect export not available.");
- return;
- }
-
- _disconnect();
- _isHosting = false;
- _displayRoomCode = "";
- _discoveredSavePath = null;
- _cachedSaveData = null;
- _cachedSaveAge = 0f;
-
- // Clean up MP save files and restore originals
- CleanupMpSaveFiles();
-
- _logger.Msg("[MP Bridge] Disconnected.");
-
- try
- {
- var ui = StaticUIElements.instance;
- if (ui != null)
- ui.AddMeesageInField("Multiplayer: Disconnected.");
- }
- catch { }
- }
-
- private void DoStopHosting()
- {
- ResetJoinState();
- if (_disconnect == null)
- {
- _logger.Warning("[MP Bridge] mp_disconnect export not available.");
- return;
- }
-
- _disconnect();
- _isHosting = false;
- _displayRoomCode = "";
- _discoveredSavePath = null;
- _cachedSaveData = null;
- _cachedSaveAge = 0f;
-
- // Clean up MP save files (host might have _mp_temp leftovers)
- CleanupMpSaveFiles();
- _logger.Msg("[MP Bridge] Stopped hosting.");
-
- try
- {
- var ui = StaticUIElements.instance;
- if (ui != null)
- ui.AddMeesageInField("Multiplayer: Stopped hosting.");
- }
- catch { }
- }
-
-
-
- private void SendSaveToClients()
- {
- try
- {
- CrashLog.Log("[MP Save] Host: sending save to clients...");
-
- byte[]? saveData;
-
- // Check if we have a recent cached save (avoids re-saving when multiple clients join)
- if (_cachedSaveData != null && _cachedSaveAge < SAVE_CACHE_LIFETIME)
- {
- saveData = _cachedSaveData;
- CrashLog.Log($"[MP Save] Using cached save data ({saveData.Length} bytes, {_cachedSaveAge:F1}s old)");
- }
- else
- {
- // Save to a temp name so we don't pollute the save directory
- string tempSaveName = "_mp_temp";
-
- try
- {
- SaveSystem.SaveGame(tempSaveName, tempSaveName);
- CrashLog.Log("[MP Save] SaveGame(\"_mp_temp\", \"_mp_temp\") OK");
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Save] SaveGame with temp name failed: {ex.Message} — falling back to parameterless SaveGame");
- try { SaveSystem.SaveGame(); }
- catch (Exception ex2) { CrashLog.LogException("MP Save: SaveGame()", ex2); return; }
- }
-
- // Give the save a moment to flush to disk
- System.Threading.Thread.Sleep(300);
-
- // Try to find the temp save file first; fall back to newest save
- string? saveDirPath = null;
- try { saveDirPath = SaveSystem.saveDirPath; }
- catch { }
-
- string? savePath = null;
- bool isTempFile = false;
-
- if (!string.IsNullOrEmpty(saveDirPath))
- {
- string tempPath = Path.Combine(saveDirPath, tempSaveName + ".save");
- if (File.Exists(tempPath))
- {
- savePath = tempPath;
- isTempFile = true;
- CrashLog.Log($"[MP Save] Found temp save: {tempPath}");
- }
- }
-
- if (savePath == null)
- savePath = DiscoverSaveFile();
-
- if (savePath == null)
- {
- CrashLog.Log("[MP Save] ERROR: Could not find any save file!");
- _logger.Error("[MP Save] Could not locate save file to send.");
- return;
- }
-
- saveData = File.ReadAllBytes(savePath);
- CrashLog.Log($"[MP Save] Read {saveData.Length} bytes from {savePath}");
-
- if (saveData.Length == 0)
- {
- CrashLog.Log("[MP Save] ERROR: Save file is empty!");
- if (isTempFile) TryDeleteFile(savePath);
- return;
- }
-
- // Clean up temp file
- if (isTempFile) TryDeleteFile(savePath);
- try { SaveSystem.DeleteSaveFile(tempSaveName); } catch { }
-
- // Cache for future requests
- _cachedSaveData = saveData;
- _cachedSaveAge = 0f;
- CrashLog.Log($"[MP Save] Cached {saveData.Length} bytes for {SAVE_CACHE_LIFETIME}s");
- }
-
- // Pass to Rust for chunked transfer
- if (_sendSaveData != null)
- {
- IntPtr ptr = Marshal.AllocHGlobal(saveData.Length);
- try
- {
- Marshal.Copy(saveData, 0, ptr, saveData.Length);
- int result = _sendSaveData(ptr, (uint)saveData.Length);
- CrashLog.Log($"[MP Save] mp_send_save_data returned {result}");
-
- if (result == 1)
- {
- _logger.Msg("[MP Save] Save data queued for transfer.");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Sending save to client..."); } catch { }
- }
- }
- finally
- {
- Marshal.FreeHGlobal(ptr);
- }
- }
- }
- catch (Exception ex)
- {
- CrashLog.LogException("SendSaveToClients", ex);
- }
- }
-
- private void TryDeleteFile(string path)
- {
- try
- {
- File.Delete(path);
- CrashLog.Log($"[MP Save] Deleted temp file: {path}");
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Save] Could not delete temp file: {ex.Message}");
- }
- }
-
- /// Cleans up multiplayer save artifacts: _mp_sync files, .mp_backup files.
- /// Restores original saves from backups so the player's own world is intact after disconnect.
- private void CleanupMpSaveFiles()
- {
- string? saveDir = DiscoverSaveDirectory();
- if (saveDir == null)
- {
- CrashLog.Log("[MP Cleanup] No save directory found — skipping cleanup");
- return;
- }
-
- int cleaned = 0;
-
- try
- {
- // Delete _mp_sync.* and _mp_temp.* files
- foreach (var file in Directory.GetFiles(saveDir))
- {
- string name = Path.GetFileNameWithoutExtension(file).ToLower();
- if (name == "_mp_sync" || name == "_mp_temp")
- {
- TryDeleteFile(file);
- cleaned++;
- }
- }
-
- // Restore .mp_backup files → undo the overwrite from WriteSaveToDisk
- foreach (var backupFile in Directory.GetFiles(saveDir, "*.mp_backup"))
- {
- string originalPath = backupFile.Substring(0, backupFile.Length - ".mp_backup".Length);
- try
- {
- File.Copy(backupFile, originalPath, true);
- File.Delete(backupFile);
- CrashLog.Log($"[MP Cleanup] Restored original save: {Path.GetFileName(originalPath)}");
- cleaned++;
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Cleanup] Failed to restore {Path.GetFileName(backupFile)}: {ex.Message}");
- }
- }
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Cleanup] Error during cleanup: {ex.Message}");
- }
-
- if (cleaned > 0)
- CrashLog.Log($"[MP Cleanup] Cleaned up {cleaned} multiplayer save file(s)");
- else
- CrashLog.Log("[MP Cleanup] No multiplayer save files to clean up");
- }
-
- // ═══════════════════════════════════════════════════════════════════════
- // Client Join: State Machine Helpers
- // ═══════════════════════════════════════════════════════════════════════
-
- private bool IsInMainMenu()
- {
- if (!string.IsNullOrEmpty(_currentSceneName))
- return _currentSceneName.Equals("MainMenu", StringComparison.OrdinalIgnoreCase);
-
- // Fallback: query scene manager directly
- try
- {
- string scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name ?? "";
- _currentSceneName = scene;
- return scene.Equals("MainMenu", StringComparison.OrdinalIgnoreCase);
- }
- catch { return false; }
- }
-
- ///
- /// Called from OnUpdate when joinState == SaveReceived.
- /// Fetches bytes from Rust, writes them to disk, then decides how to load.
- ///
- private void FetchAndProcessSave()
- {
- try
- {
- // ── 1. Grab the raw bytes from Rust ──
- uint size = _getSaveDataSize != null ? _getSaveDataSize() : 0;
- if (size == 0)
- {
- CrashLog.Log("[MP Join] Pending save has 0 bytes — aborting");
- ResetJoinState();
- return;
- }
-
- CrashLog.Log($"[MP Join] Fetching {size} bytes from Rust...");
- byte[] saveData = new byte[size];
- IntPtr ptr = Marshal.AllocHGlobal((int)size);
- try
- {
- uint copied = _getSaveData != null ? _getSaveData(ptr, size) : 0;
- Marshal.Copy(ptr, saveData, 0, (int)copied);
- CrashLog.Log($"[MP Join] Got {copied} bytes");
- }
- finally
- {
- Marshal.FreeHGlobal(ptr);
- }
-
- // Peek first bytes for diagnostics
- if (saveData.Length > 0)
- {
- int peekLen = Math.Min(saveData.Length, 200);
- string peekText = System.Text.Encoding.UTF8.GetString(saveData, 0, peekLen).Replace("\r", "").Replace("\n", "\\n");
- CrashLog.Log($"[MP Join] First {peekLen} bytes: {peekText}");
- }
-
- _pendingSaveBytes = saveData;
-
- // Tell Rust we consumed the buffer
- if (_saveLoadComplete != null) _saveLoadComplete();
-
- // ── 2. Write to disk ──
- WriteSaveToDisk();
-
- // ── 3. Attempt load (scene-aware) ──
- if (_pendingSaveName == null)
- {
- CrashLog.Log("[MP Join] ERROR: WriteSaveToDisk failed to produce a save name");
- _logger.Error("[MP Join] Failed to write save to disk.");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Failed to write host save!"); } catch { }
- ResetJoinState();
- return;
- }
-
- // Decide how to load based on current scene
- CrashLog.Log($"[MP Join] Current scene: \"{_currentSceneName}\"");
-
- if (IsInMainMenu())
- {
- // From MainMenu: SaveSystem.Load() does NOT trigger a scene transition
- // (onLoadingData callbacks aren't registered yet).
- // We must: set loadSaveName, then manually load the game scene.
- CrashLog.Log("[MP Join] In MainMenu — initiating manual scene transition");
- InitiateSceneTransition();
- }
- else
- {
- // Already in-game: SaveSystem.Load() should work (callbacks are registered)
- AttemptSaveLoad();
- }
- }
- catch (Exception ex)
- {
- CrashLog.LogException("FetchAndProcessSave", ex);
- _logger.Error($"[MP Join] Exception during save processing: {ex.Message}");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Error processing host save!"); } catch { }
- ResetJoinState();
- }
- }
-
- ///
- /// Writes _pendingSaveBytes to disk. Handles both "overwrite existing" and "fresh install" cases.
- /// Sets _pendingSaveName and _pendingSaveFullPath on success.
- ///
- private void WriteSaveToDisk()
- {
- DumpSaveSystemMethods();
-
- if (_pendingSaveBytes == null) return;
-
- string? saveDir = DiscoverSaveDirectory();
- if (saveDir == null)
- {
- CrashLog.Log("[MP Join] ERROR: Could not find save directory!");
- // Last resort: use persistentDataPath directly
- saveDir = Application.persistentDataPath;
- try { Directory.CreateDirectory(saveDir); } catch { }
- }
-
- // ── Scan existing saves ──
- string? existingSaveName = null;
- string? existingSavePath = null;
- string ext = ".save";
-
- try
- {
- var existingFiles = Directory.GetFiles(saveDir);
- CrashLog.Log($"[MP Join] Save directory has {existingFiles.Length} files:");
- foreach (var f in existingFiles)
- {
- string fname = Path.GetFileName(f);
- string fext = Path.GetExtension(f).ToLower();
- var finfo = new FileInfo(f);
- CrashLog.Log($"[MP Join] {fname} ({finfo.Length} bytes, {finfo.LastWriteTime:HH:mm:ss})");
-
- if (fext == ".save" || fext == ".json" || fext == ".sav" || fext == ".dat")
- {
- ext = fext;
- string nameNoExt = Path.GetFileNameWithoutExtension(f);
- if (nameNoExt.StartsWith("_mp_")) continue;
- if (fext == ".vdf") continue;
-
- if (existingSavePath == null || finfo.LastWriteTime > new FileInfo(existingSavePath).LastWriteTime)
- {
- existingSaveName = nameNoExt;
- existingSavePath = f;
- }
- }
- }
- }
- catch (Exception ex) { CrashLog.Log($"[MP Join] Error scanning save dir: {ex.Message}"); }
-
- // ── Always write a debug/_mp_sync copy ──
- string tempPath = Path.Combine(saveDir, "_mp_sync" + ext);
- File.WriteAllBytes(tempPath, _pendingSaveBytes);
- CrashLog.Log($"[MP Join] Wrote debug copy: {tempPath}");
-
- // ── Strategy A: Overwrite an existing save (game already knows about it) ──
- if (existingSaveName != null && existingSavePath != null)
- {
- // Backup the original
- string backupPath = existingSavePath + ".mp_backup";
- try
- {
- File.Copy(existingSavePath, backupPath, true);
- CrashLog.Log($"[MP Join] Backed up: {existingSavePath} -> {backupPath}");
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Join] Backup warning: {ex.Message}");
- }
-
- File.WriteAllBytes(existingSavePath, _pendingSaveBytes);
- _pendingSaveName = existingSaveName;
- _pendingSaveFullPath = existingSavePath;
- CrashLog.Log($"[MP Join] Overwrote existing save: \"{existingSaveName}\" at {existingSavePath} ({_pendingSaveBytes.Length} bytes)");
- return;
- }
-
- // ── Strategy B: No existing save (fresh install) — create one with a timestamp name ──
- CrashLog.Log("[MP Join] No existing save found — creating new save file for fresh install");
- string timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
- string newPath = Path.Combine(saveDir, timestamp + ext);
- File.WriteAllBytes(newPath, _pendingSaveBytes);
- _pendingSaveName = timestamp;
- _pendingSaveFullPath = newPath;
- CrashLog.Log($"[MP Join] Created new save: \"{timestamp}\" at {newPath} ({_pendingSaveBytes.Length} bytes)");
- }
-
- ///
- /// Called when client is in MainMenu: sets loadSaveName on SaveSystem and
- /// triggers a scene transition to the game scene. After the scene loads,
- /// OnSceneLoaded → deferred delay → AttemptSaveLoad() will apply the save.
- ///
- private void InitiateSceneTransition()
- {
- // Store room code for auto-reconnect after scene transition
- _reconnectRoomCode = _roomCode?.Trim().ToUpper();
- if (string.IsNullOrEmpty(_reconnectRoomCode))
- {
- // Try to get it from Rust state
- try
- {
- IntPtr codePtr = _getRoomCode != null ? _getRoomCode() : IntPtr.Zero;
- if (codePtr != IntPtr.Zero)
- {
- string? code = Marshal.PtrToStringAnsi(codePtr);
- if (!string.IsNullOrEmpty(code)) _reconnectRoomCode = code;
- }
- }
- catch { }
- }
- CrashLog.Log($"[MP Join] Stored room code for reconnect: \"{_reconnectRoomCode}\"");
-
- // ── Approach 1: Use the game's own MainMenu.Continue() ──
- // This replicates what happens when the player presses "Continue" on the
- // main menu. The game handles isQuitting, scene transitions, save loading,
- // callbacks, and all internal state setup. Much safer than manual scene load.
- try
- {
- var menus = Resources.FindObjectsOfTypeAll();
- if (menus != null && menus.Count > 0)
- {
- var mainMenu = menus[0];
- CrashLog.Log("[MP Join] Found MainMenu instance — using game's Continue() flow");
-
- // The save we overwrote in WriteSaveToDisk is the newest save,
- // so Continue() will load it through the normal game path.
- _gameHandledSaveLoad = true;
- SetJoinState(JOIN_LOADING_SCENE);
- _pendingSaveBytes = null; // free memory
-
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Loading host's game..."); } catch { }
-
- mainMenu.Continue();
- CrashLog.Log("[MP Join] MainMenu.Continue() called — game will handle scene transition and save load");
- return;
- }
- else
- {
- CrashLog.Log("[MP Join] No MainMenu instance found — falling back to manual approach");
- }
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Join] MainMenu.Continue() failed: {ex.GetType().Name}: {ex.Message} — falling back to manual approach");
- _gameHandledSaveLoad = false;
- }
-
- // ── Approach 2: Manual scene transition (fallback) ──
- CrashLog.Log("[MP Join] Using manual scene transition fallback");
-
- // Reset isQuitting flag — the game sets this when quitting to MainMenu
- // and never resets it, which causes crashes during save load
- try
- {
- bool wasQuitting = SaveSystem.isQuitting;
- if (wasQuitting)
- {
- SaveSystem.isQuitting = false;
- CrashLog.Log($"[MP Join] Reset SaveSystem.isQuitting (was {wasQuitting})");
- }
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Join] Could not reset isQuitting: {ex.Message}");
- }
-
- // Set SaveSystem.loadSaveName so the game knows which save to load
- try
- {
- SaveSystem.loadSaveName = _pendingSaveName;
- CrashLog.Log($"[MP Join] Set SaveSystem.loadSaveName = \"{_pendingSaveName}\"");
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Join] Failed to set loadSaveName: {ex.Message}");
- }
-
- // Enumerate available scenes and find the game scene
- try
- {
- int sceneCount = SceneManager.sceneCountInBuildSettings;
- CrashLog.Log($"[MP Join] Build has {sceneCount} scenes:");
- string? gameSceneName = null;
- int gameSceneIndex = -1;
-
- for (int i = 0; i < sceneCount; i++)
- {
- string path = SceneUtility.GetScenePathByBuildIndex(i);
- string name = System.IO.Path.GetFileNameWithoutExtension(path);
- CrashLog.Log($"[MP Join] [{i}] \"{name}\" ({path})");
-
- if (!name.Equals("MainMenu", StringComparison.OrdinalIgnoreCase)
- && !name.Equals("Init", StringComparison.OrdinalIgnoreCase)
- && !name.Equals("Splash", StringComparison.OrdinalIgnoreCase)
- && !name.Equals("Loading", StringComparison.OrdinalIgnoreCase))
- {
- if (gameSceneName == null)
- {
- gameSceneName = name;
- gameSceneIndex = i;
- }
- }
- }
-
- if (gameSceneIndex >= 0)
- {
- CrashLog.Log($"[MP Join] Loading game scene: [{gameSceneIndex}] \"{gameSceneName}\"");
- SetJoinState(JOIN_LOADING_SCENE);
- _pendingSaveBytes = null;
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Loading host's game..."); } catch { }
- SceneManager.LoadScene(gameSceneIndex);
- return;
- }
- else
- {
- CrashLog.Log("[MP Join] Could not identify game scene — trying build index 1");
- SetJoinState(JOIN_LOADING_SCENE);
- _pendingSaveBytes = null;
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Loading host's game..."); } catch { }
- SceneManager.LoadScene(1);
- return;
- }
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Join] Scene enumeration failed: {ex.GetType().Name}: {ex.Message}");
- CrashLog.Log("[MP Join] Falling back to SceneManager.LoadScene(1)");
- SetJoinState(JOIN_LOADING_SCENE);
- _pendingSaveBytes = null;
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Loading host's game..."); } catch { }
- try { SceneManager.LoadScene(1); }
- catch (Exception ex2) { CrashLog.Log($"[MP Join] LoadScene(1) failed: {ex2.Message}"); }
- }
- }
-
- ///
- /// Applies the save via SaveSystem.Load().
- /// Then triggers auto-reconnect to the relay.
- ///
- private void AttemptSaveLoad()
- {
- if (_pendingSaveName == null)
- {
- CrashLog.Log("[MP Join] AttemptSaveLoad: no pending save name — aborting");
- ResetJoinState();
- return;
- }
-
- bool loaded = false;
- CrashLog.Log($"[MP Join] AttemptSaveLoad: name=\"{_pendingSaveName}\", scene=\"{_currentSceneName}\"");
-
- // Reset isQuitting flag
- try
- {
- bool wasQuitting = SaveSystem.isQuitting;
- SaveSystem.isQuitting = false;
- CrashLog.Log($"[MP Join] SaveSystem.isQuitting = false (was {wasQuitting})");
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Join] Could not reset isQuitting: {ex.Message}");
- }
-
- // ── Approach A: Load(name, false) — standard load path ──
- CrashLog.Log($"[MP Join] Approach A: SaveSystem.Load(\"{_pendingSaveName}\", false)...");
- try
- {
- SaveSystem.Load(_pendingSaveName, false);
- CrashLog.Log("[MP Join] Approach A returned OK");
- loaded = true;
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Join] Approach A threw: {ex.GetType().Name}: {ex.Message}");
- }
-
- // ── Approach B: Load(name, true) — "from pause menu" path ──
- if (!loaded)
- {
- CrashLog.Log($"[MP Join] Approach B: SaveSystem.Load(\"{_pendingSaveName}\", true)...");
- try
- {
- SaveSystem.Load(_pendingSaveName, true);
- CrashLog.Log("[MP Join] Approach B returned OK");
- loaded = true;
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Join] Approach B threw: {ex.GetType().Name}: {ex.Message}");
- }
- }
-
- // ── Approach C: Try _mp_sync name directly ──
- if (!loaded)
- {
- CrashLog.Log("[MP Join] Approach C: SaveSystem.Load(\"_mp_sync\", false)...");
- try
- {
- SaveSystem.Load("_mp_sync", false);
- CrashLog.Log("[MP Join] Approach C returned OK");
- loaded = true;
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Join] Approach C threw: {ex.GetType().Name}: {ex.Message}");
- }
- }
-
- // ── Approach D: Reflection — LoadGame(string) + LoadGameData() ──
- if (!loaded)
- {
- CrashLog.Log("[MP Join] Approach D: Trying reflection-based load...");
- try
- {
- var ssType = typeof(SaveSystem);
- var loadGame = ssType.GetMethod("LoadGame",
- System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static,
- null, new Type[] { typeof(string) }, null);
- if (loadGame != null)
- {
- CrashLog.Log($"[MP Join] Found LoadGame(string), invoking with \"{_pendingSaveName}\"...");
- loadGame.Invoke(null, new object?[] { _pendingSaveName });
- CrashLog.Log("[MP Join] Approach D (LoadGame) returned OK — now calling LoadGameData()...");
- try { SaveSystem.LoadGameData(); CrashLog.Log("[MP Join] LoadGameData() OK"); }
- catch (Exception ex3) { CrashLog.Log($"[MP Join] LoadGameData() threw: {ex3.Message}"); }
- loaded = true;
- }
- else
- {
- CrashLog.Log("[MP Join] LoadGame(string) not found via reflection");
- }
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Join] Approach D threw: {ex.GetType().Name}: {ex.Message}");
- }
- }
-
- CrashLog.Log($"[MP Join] AttemptSaveLoad finished: loaded={loaded}");
-
- if (loaded)
- {
- SetJoinState(JOIN_LOADED);
- _pendingSaveBytes = null; // free memory
- _logger.Msg("[MP Join] Save loaded from host!");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Save loaded from host!"); } catch { }
-
- // Only reconnect if the relay actually died during scene transition
- if (_reconnectRoomCode != null)
- {
- bool relayStillAlive = _isRelayActive != null && _isRelayActive() != 0;
- bool stillConnected = _isConnected != null && _isConnected() != 0;
-
- if (relayStillAlive && stillConnected)
- {
- CrashLog.Log($"[MP Join] Relay still alive after save load (alive={relayStillAlive}, connected={stillConnected}) — no reconnect needed");
- }
- else
- {
- CrashLog.Log($"[MP Join] Relay died during save load (alive={relayStillAlive}, connected={stillConnected}) — auto-reconnecting to {_reconnectRoomCode}");
- AutoReconnect();
- }
- }
- }
- else
- {
- CrashLog.Log("[MP Join] All load approaches failed — giving up");
- _logger.Warning("[MP Join] Could not load save — check dc_modloader_debug.log for details.");
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Failed to load host save! Check logs."); } catch { }
- ResetJoinState();
- }
- }
-
- ///
- /// Auto-reconnects to the relay after a scene transition, skipping save re-request.
- ///
- private void AutoReconnect()
- {
- if (_connect == null || string.IsNullOrEmpty(_reconnectRoomCode))
- {
- CrashLog.Log("[MP Join] AutoReconnect: no connect delegate or room code");
- return;
- }
-
- CrashLog.Log($"[MP Join] AutoReconnect: joining room {_reconnectRoomCode} (skip save = true)");
- _skipSaveOnReconnect = true;
- _reconnectCooldown = 5f;
-
- // Tell Rust not to request save on reconnect
- if (_skipNextSaveRequest != null)
- {
- _skipNextSaveRequest();
- }
-
- byte[] codeBytes = System.Text.Encoding.UTF8.GetBytes(_reconnectRoomCode);
- IntPtr codePtr = Marshal.AllocHGlobal(codeBytes.Length);
- try
- {
- Marshal.Copy(codeBytes, 0, codePtr, codeBytes.Length);
- int result = _connect(codePtr, (uint)codeBytes.Length);
- CrashLog.Log($"[MP Join] AutoReconnect: mp_connect returned {result}");
-
- if (result == 1)
- {
- SetJoinState(JOIN_WAITING_FOR_SAVE);
- try { var ui = StaticUIElements.instance; if (ui != null) ui.AddMeesageInField("Multiplayer: Reconnecting..."); } catch { }
- }
- else
- {
- CrashLog.Log("[MP Join] AutoReconnect failed");
- _skipSaveOnReconnect = false;
- }
- }
- finally
- {
- Marshal.FreeHGlobal(codePtr);
- }
- }
-
- ///
- /// Resets join state back to Idle and clears pending data.
- ///
- private void ResetJoinState()
- {
- SetJoinState(JOIN_IDLE);
- _pendingSaveBytes = null;
- _pendingSaveName = null;
- _pendingSaveFullPath = null;
- _deferredLoadDelay = 0f;
- _skipSaveOnReconnect = false;
- _reconnectCooldown = 0f;
- _gameHandledSaveLoad = false;
- }
-
- private void DumpSaveSystemMethods()
- {
- try
- {
- CrashLog.Log("[MP Save] === SaveSystem method dump ===");
- var ssType = typeof(SaveSystem);
- var methods = ssType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Instance);
- foreach (var m in methods)
- {
- var parms = m.GetParameters();
- var parmStr = string.Join(", ", Array.ConvertAll(parms, p => $"{p.ParameterType.Name} {p.Name}"));
- CrashLog.Log($"[MP Save] {(m.IsStatic ? "static " : "")}{m.ReturnType.Name} {m.Name}({parmStr})");
- }
- CrashLog.Log("[MP Save] === end SaveSystem dump ===");
-
- // Also dump static fields/properties
- var fields = ssType.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
- if (fields.Length > 0)
- {
- CrashLog.Log("[MP Save] === SaveSystem fields ===");
- foreach (var f in fields)
- {
- try
- {
- var val = f.GetValue(null);
- CrashLog.Log($"[MP Save] {(f.IsStatic ? "static " : "")}{f.FieldType.Name} {f.Name} = {val}");
- }
- catch
- {
- CrashLog.Log($"[MP Save] {(f.IsStatic ? "static " : "")}{f.FieldType.Name} {f.Name} = ");
- }
- }
- CrashLog.Log("[MP Save] === end SaveSystem fields ===");
- }
-
- var props = ssType.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
- if (props.Length > 0)
- {
- CrashLog.Log("[MP Save] === SaveSystem properties ===");
- foreach (var p in props)
- {
- try
- {
- var val = p.GetValue(null);
- CrashLog.Log($"[MP Save] {p.PropertyType.Name} {p.Name} = {val}");
- }
- catch
- {
- CrashLog.Log($"[MP Save] {p.PropertyType.Name} {p.Name} = ");
- }
- }
- CrashLog.Log("[MP Save] === end SaveSystem properties ===");
- }
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Save] SaveSystem reflection failed: {ex.GetType().Name}: {ex.Message}");
- }
- }
-
- private string? DiscoverSaveDirectory()
- {
- if (_discoveredSavePath != null)
- {
- string? dir = Path.GetDirectoryName(_discoveredSavePath);
- if (dir != null && Directory.Exists(dir)) return dir;
- }
-
- string basePath = Application.persistentDataPath;
- CrashLog.Log($"[MP Save] persistentDataPath = {basePath}");
-
- // Check common save subdirectories
- string[] subDirs = { "Saves", "SaveGames", "Save", "" };
- foreach (var sub in subDirs)
- {
- string candidate = string.IsNullOrEmpty(sub) ? basePath : Path.Combine(basePath, sub);
- if (Directory.Exists(candidate))
- {
- // Check if it has any save-looking files
- try
- {
- var files = Directory.GetFiles(candidate);
- if (files.Length > 0)
- {
- CrashLog.Log($"[MP Save] Found save directory: {candidate} ({files.Length} files)");
- return candidate;
- }
- }
- catch { }
- }
- }
-
- // Fallback: use persistentDataPath directly
- CrashLog.Log($"[MP Save] Using persistentDataPath as save directory: {basePath}");
- return basePath;
- }
-
- private string? DiscoverSaveFile()
- {
- if (_discoveredSavePath != null && File.Exists(_discoveredSavePath))
- return _discoveredSavePath;
-
- string basePath = Application.persistentDataPath;
- CrashLog.Log($"[MP Save] Searching for save files in: {basePath}");
-
- // Log directory contents for debugging
- try
- {
- LogDirectoryContents(basePath, 0);
- }
- catch (Exception ex) { CrashLog.Log($"[MP Save] Error listing dir: {ex.Message}"); }
-
- // Strategy: find the most recently modified save file
- string? bestFile = null;
- DateTime bestTime = DateTime.MinValue;
-
- string[] searchDirs = { basePath };
- try
- {
- // Also search subdirectories
- var subDirs = Directory.GetDirectories(basePath);
- var allDirs = new List(subDirs);
- allDirs.Insert(0, basePath);
- searchDirs = allDirs.ToArray();
- }
- catch { }
-
- string[] saveExtensions = { ".json", ".sav", ".save", ".dat" };
-
- foreach (var dir in searchDirs)
- {
- try
- {
- foreach (var file in Directory.GetFiles(dir))
- {
- string ext = Path.GetExtension(file).ToLower();
- if (Array.IndexOf(saveExtensions, ext) < 0) continue;
-
- var info = new FileInfo(file);
- CrashLog.Log($"[MP Save] Candidate: {file} (size={info.Length}, modified={info.LastWriteTime:HH:mm:ss})");
-
- if (info.LastWriteTime > bestTime)
- {
- bestTime = info.LastWriteTime;
- bestFile = file;
- }
- }
- }
- catch { }
- }
-
- if (bestFile != null)
- {
- CrashLog.Log($"[MP Save] Selected save file: {bestFile}");
- _discoveredSavePath = bestFile;
- }
-
- return bestFile;
- }
-
- private void LogDirectoryContents(string path, int depth)
- {
- if (depth > 2) return; // don't recurse too deep
- string indent = new string(' ', depth * 2);
-
- try
- {
- foreach (var file in Directory.GetFiles(path))
- {
- var info = new FileInfo(file);
- CrashLog.Log($"[MP Save] {indent}FILE: {Path.GetFileName(file)} ({info.Length} bytes, {info.LastWriteTime:yyyy-MM-dd HH:mm:ss})");
- }
- foreach (var dir in Directory.GetDirectories(path))
- {
- CrashLog.Log($"[MP Save] {indent}DIR: {Path.GetFileName(dir)}/");
- LogDirectoryContents(dir, depth + 1);
- }
- }
- catch (Exception ex)
- {
- CrashLog.Log($"[MP Save] {indent}Error: {ex.Message}");
- }
- }
-
- // ═══════════════════════════════════════════════════════════════════════
- // Main Menu Button Injection
- // ═══════════════════════════════════════════════════════════════════════
-
- private void InjectMainMenuButton()
- {
- try
- {
- if (!_initialized) return;
- if (_menuButton != null) return;
-
- var mpCheck = GetModuleHandle("dc_multiplayer.dll");
- if (mpCheck == IntPtr.Zero)
- mpCheck = GetModuleHandle("dc_multiplayer");
- if (mpCheck == IntPtr.Zero)
- {
- CrashLog.Log("[MP Bridge] InjectMainMenuButton aborted — dc_multiplayer.dll is not loaded.");
- _initialized = false;
- return;
- }
-
- Transform? templateButton = ModConfigSystem.SettingsButtonTransform;
-
-
- if (templateButton == null)
- {
- var allButtons = Resources.FindObjectsOfTypeAll();
- if (allButtons != null)
- {
- foreach (var btn in allButtons)
- {
- try
- {
- var onClick = btn.onClick;
- if (onClick == null) continue;
- int count = onClick.GetPersistentEventCount();
- for (int i = 0; i < count; i++)
- {
- if (onClick.GetPersistentMethodName(i) == "Settings")
- {
- templateButton = btn.transform;
- break;
- }
- }
- if (templateButton != null) break;
- }
- catch { }
- }
- }
- }
-
- if (templateButton == null)
- {
- _logger.Warning("[MP Bridge] Could not find Settings button (not cached by ModConfigSystem and no persistent 'Settings' listener found).");
- return;
- }
-
- var buttonPanel = templateButton.parent;
- int siblingIndex = templateButton.GetSiblingIndex();
-
- // Clone the Settings button into the same panel
- var clone = UnityEngine.Object.Instantiate(templateButton.gameObject, buttonPanel);
- // Place it BEFORE Settings (i.e. after Load Game)
- clone.transform.SetSiblingIndex(siblingIndex);
- clone.name = "MultiplayerButton";
-
- // ── Step 1: Destroy LocalisedText components ──
- var locTexts = clone.GetComponentsInChildren(true);
- if (locTexts != null)
- {
- foreach (var lt in locTexts)
- {
- UnityEngine.Object.Destroy(lt);
- }
- _logger.Msg($"[MP Bridge] Destroyed {locTexts.Count} LocalisedText component(s) on cloned button.");
- }
-
- // ── Step 2: Change the label text to "Multiplayer" ──
- var cloneTexts = clone.GetComponentsInChildren(true);
- if (cloneTexts != null)
- {
- foreach (var t in cloneTexts)
- {
- t.text = "Multiplayer";
- try { t.SetText("Multiplayer"); } catch { }
- try { t.ForceMeshUpdate(); } catch { }
- }
- }
- _logger.Msg($"[MP Bridge] Found {(cloneTexts != null ? cloneTexts.Count : 0)} TMP component(s) in cloned button.");
-
- // ── Step 3: Rewire onClick ──
- var btnExt = clone.GetComponent();
- if (btnExt != null)
- {
- try
- {
- btnExt.onClick = new ButtonExtended.ButtonClickedEvent();
- btnExt.onClick.AddListener((System.Action)(() => ShowMultiplayerPanel()));
- _logger.Msg("[MP Bridge] Wired ButtonExtended.onClick to ShowMultiplayerPanel.");
- }
- catch (Exception ex2)
- {
- _logger.Warning($"[MP Bridge] Failed to replace ButtonExtended.onClick: {ex2.Message}");
- // Fallback: try removing listeners and adding ours
- try
- {
- btnExt.onClick.RemoveAllListeners();
- btnExt.onClick.AddListener((System.Action)(() => ShowMultiplayerPanel()));
- }
- catch { }
- }
- }
- else
- {
- _logger.Warning("[MP Bridge] ButtonExtended not found on clone, trying Unity Button fallback.");
- // Fallback: try standard Unity Button
- var btn = clone.GetComponent