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 @@ [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/greg) [![gregFramework](https://img.shields.io/badge/gregFramework-Website-blue?style=for-the-badge)](https://gregframework.eu) [![License](https://img.shields.io/badge/License-Apache%202.0-green?style=for-the-badge)](./LICENSE) -[![Version](https://img.shields.io/badge/Version-1.1.0-orange?style=for-the-badge)]() +[![Version](https://img.shields.io/badge/Version-1.2.2--dev.0-orange?style=for-the-badge)]() [![GameVersion](https://img.shields.io/badge/Game%20Version-1.0.50.15-yellow?style=for-the-badge)]() [![Unity](https://img.shields.io/badge/Unity-6000.5-black?style=for-the-badge&logo=unity&logoColor=white)]() @@ -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