From f9837f15e99170a0bf5fa184e1c15e79215b5dee Mon Sep 17 00:00:00 2001 From: mleem97 Date: Thu, 13 Aug 2026 13:13:32 +0200 Subject: [PATCH 1/6] feat: consolidate GregCore integration and release flow Integrate the reviewed security and performance changes, establish bounded runtime services, add contributor and maintainer documentation, and enforce the dev to pre-release to main promotion model. --- .github/workflows/branch-policy.yml | 44 ++ .github/workflows/build.yml | 346 +++------- .github/workflows/docs.yml | 26 +- .github/workflows/release.yml | 62 ++ .gitignore | 1 + CHANGELOG.md | 71 +- README.md | 7 +- VERSION | 2 +- docs/codebase/ARCHITECTURE.md | 19 + docs/codebase/CONCERNS.md | 27 + docs/codebase/CONVENTIONS.md | 20 + docs/codebase/INTEGRATIONS.md | 21 + docs/codebase/STACK.md | 19 + docs/codebase/STRUCTURE.md | 23 + docs/codebase/TESTING.md | 18 + docs/maintainers/branch-protection.md | 72 ++ docs/maintainers/contracts.md | 13 + docs/maintainers/open-pr-audit-2026-08.md | 56 ++ docs/maintainers/release-smoke-test.md | 25 + docs/modding/api/coverage.md | 17 + docs/modding/api/events.md | 9 + docs/modding/getting-started.md | 17 + docs/troubleshooting/doctor.md | 7 + framework/greg_hooks.json | 61 +- gregCore.csproj | 6 +- scripts/generate_api_docs.py | 16 +- scripts/validate_contracts.py | 46 ++ scripts/validate_release_artifacts.py | 27 + scripts/validate_version.py | 26 + src/API/CustomEmployeeManager.cs | 43 +- src/API/GregAPI.cs | 15 +- src/Bridge/LuaFFI/LuaFFIBridge.cs | 638 +++++++++--------- src/Core/Diagnostics/GameFingerprint.cs | 67 ++ src/Core/Diagnostics/GregDoctor.cs | 64 ++ src/Core/Diagnostics/GregDoctorReport.cs | 25 + src/Core/GregCoreMod.cs | 78 ++- src/Core/Models/GregHooksManifest.cs | 47 +- src/Core/Models/ModManifest.cs | 9 +- src/Core/Models/PerformanceProfile.cs | 18 +- src/Core/Models/PluginInfo.cs | 14 +- src/GameLayer/Bootstrap/GregBootstrapper.cs | 27 +- src/GameLayer/Hooks/GregDynamicHookPatcher.cs | 81 +++ src/GameLayer/Hooks/GregNativeEventHooks.cs | 19 +- .../Patches/Networking/CablePositionsPatch.cs | 3 +- .../Performance/GregOperationQueue.cs | 84 ++- .../Performance/GregPerformanceGovernor.cs | 26 +- src/Infrastructure/Plugins/AssemblyScanner.cs | 65 +- .../Plugins/GregDependencyResolver.cs | 39 +- .../Plugins/GregPluginRegistry.cs | 104 ++- .../Scripting/Lua/LuaHotReload.cs | 2 + .../Lua/Modules/GregEventLuaModule.cs | 112 +-- .../Scripting/Lua/Modules/LuaServerModule.cs | 78 ++- .../Settings/GregModSettingsService.cs | 40 +- .../Services/GregNotificationService.cs | 90 +-- .../GregSettingsPersistenceService.cs | 6 +- src/PublicApi/GregApiContext.cs | 9 +- src/PublicApi/GregEventBusPublic.cs | 24 +- src/PublicApi/GregMainThreadDispatcher.cs | 24 + src/PublicApi/GregMod.cs | 56 +- src/PublicApi/GregResourceRegistry.cs | 25 + src/PublicApi/IGregMainThreadDispatcher.cs | 8 + .../Modules/GregPerformanceModule.cs | 39 +- src/PublicApi/Modules/GregUIModule.cs | 13 +- src/UI/GregNotificationManager.cs | 10 + templates/csharp/ExampleMod.cs | 24 + templates/csharp/GregMod.Template.csproj | 14 + templates/csharp/README.md | 9 + templates/lua/README.md | 12 + templates/lua/example-mod/main.lua | 11 + templates/lua/example-mod/mod.json | 9 + tests/Core/DependencyResolverTests.cs | 38 +- tests/Core/GregDoctorTests.cs | 17 + tests/PublicApi/GregResourceRegistryTests.cs | 27 + tests/gregCore.Tests.csproj | 25 +- .../GregCoverageScanner.csproj | 12 + tools/GregCoverageScanner/Program.cs | 93 +++ tools/GregCoverageScanner/README.md | 16 + 77 files changed, 2508 insertions(+), 905 deletions(-) create mode 100644 .github/workflows/branch-policy.yml create mode 100644 .github/workflows/release.yml create mode 100644 docs/codebase/ARCHITECTURE.md create mode 100644 docs/codebase/CONCERNS.md create mode 100644 docs/codebase/CONVENTIONS.md create mode 100644 docs/codebase/INTEGRATIONS.md create mode 100644 docs/codebase/STACK.md create mode 100644 docs/codebase/STRUCTURE.md create mode 100644 docs/codebase/TESTING.md create mode 100644 docs/maintainers/branch-protection.md create mode 100644 docs/maintainers/contracts.md create mode 100644 docs/maintainers/open-pr-audit-2026-08.md create mode 100644 docs/maintainers/release-smoke-test.md create mode 100644 docs/modding/api/coverage.md create mode 100644 docs/modding/api/events.md create mode 100644 docs/modding/getting-started.md create mode 100644 docs/troubleshooting/doctor.md create mode 100644 scripts/validate_contracts.py create mode 100644 scripts/validate_release_artifacts.py create mode 100644 scripts/validate_version.py create mode 100644 src/Core/Diagnostics/GameFingerprint.cs create mode 100644 src/Core/Diagnostics/GregDoctor.cs create mode 100644 src/Core/Diagnostics/GregDoctorReport.cs create mode 100644 src/PublicApi/GregMainThreadDispatcher.cs create mode 100644 src/PublicApi/GregResourceRegistry.cs create mode 100644 src/PublicApi/IGregMainThreadDispatcher.cs create mode 100644 templates/csharp/ExampleMod.cs create mode 100644 templates/csharp/GregMod.Template.csproj create mode 100644 templates/csharp/README.md create mode 100644 templates/lua/README.md create mode 100644 templates/lua/example-mod/main.lua create mode 100644 templates/lua/example-mod/mod.json create mode 100644 tests/Core/GregDoctorTests.cs create mode 100644 tests/PublicApi/GregResourceRegistryTests.cs create mode 100644 tools/GregCoverageScanner/GregCoverageScanner.csproj create mode 100644 tools/GregCoverageScanner/Program.cs create mode 100644 tools/GregCoverageScanner/README.md 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..4f51235f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,69 @@ -# 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. + +### 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. + +### 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. + +## Committed history since v1.2.1 + +### 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 +77,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..9e12ac3e 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)]() @@ -132,6 +132,11 @@ 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. + ## 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/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..a9a2e9f2 --- /dev/null +++ b/docs/codebase/CONCERNS.md @@ -0,0 +1,27 @@ +# 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, exact multiplayer semantics, and validation of optional compatibility modules against the installed game build. + +## 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..70107637 --- /dev/null +++ b/docs/codebase/INTEGRATIONS.md @@ -0,0 +1,21 @@ +# 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`. +- FishNet multiplayer integration is optional under `src/Compatibility/FishNet`. +- 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..f97121a8 --- /dev/null +++ b/docs/codebase/STRUCTURE.md @@ -0,0 +1,23 @@ +# 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 FishNet compatibility code. +- `src/UI`: UI Toolkit canvas, panels, overlays, themes and notifications. +- `src/greg.*`: feature modules such as SaveEngine, WallRack, QoL and Multiplayer. +- `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/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/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..cb001761 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 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/Core/Diagnostics/GameFingerprint.cs b/src/Core/Diagnostics/GameFingerprint.cs new file mode 100644 index 00000000..9df7a002 --- /dev/null +++ b/src/Core/Diagnostics/GameFingerprint.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using System.Diagnostics; + +namespace gregCore.Core.Diagnostics; + +public sealed record GameFingerprint +{ + public string GameVersion { get; init; } = "UNKNOWN"; + public string AssemblyCSharpSha256 { get; init; } = string.Empty; + public string GameAssemblySha256 { get; init; } = string.Empty; + public string MetadataSha256 { get; init; } = string.Empty; + public string UnityVersion { get; init; } = "UNKNOWN"; + public string MelonLoaderVersion { get; init; } = "UNKNOWN"; + public string Il2CppInteropVersion { get; init; } = "UNKNOWN"; + + public string CombinedSha256 => ComputeCombinedSha256(); + + public static GameFingerprint Capture(string gameRoot) + { + var assembly = Path.Combine(gameRoot, "MelonLoader", "Il2CppAssemblies", "Assembly-CSharp.dll"); + var gameAssembly = Path.Combine(gameRoot, "GameAssembly.dll"); + var metadata = Path.Combine(gameRoot, "Data", "Metadata", "global-metadata.dat"); + if (!File.Exists(metadata) && Directory.Exists(gameRoot)) + metadata = Directory.GetFiles(gameRoot, "global-metadata.dat", SearchOption.AllDirectories).OrderBy(x => x, StringComparer.Ordinal).FirstOrDefault() ?? metadata; + return new GameFingerprint + { + AssemblyCSharpSha256 = HashIfPresent(assembly), + GameAssemblySha256 = HashIfPresent(gameAssembly), + MetadataSha256 = HashIfPresent(metadata), + UnityVersion = ReadUnityVersion(gameRoot), + GameVersion = ReadGameVersion(gameRoot), + MelonLoaderVersion = ReadAssemblyVersion(gameRoot, "MelonLoader.dll"), + Il2CppInteropVersion = ReadAssemblyVersion(gameRoot, "Il2CppInterop.Runtime.dll") + }; + } + + private string ComputeCombinedSha256() { + using var sha = SHA256.Create(); + var value = string.Join("\n", GameVersion, AssemblyCSharpSha256, GameAssemblySha256, MetadataSha256, UnityVersion, MelonLoaderVersion, Il2CppInteropVersion); + return Convert.ToHexString(sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + } + + private static string ReadUnityVersion(string root) { + var path = Path.Combine(root, "UnityPlayer.dll"); + return File.Exists(path) ? FileVersionInfo.GetVersionInfo(path).ProductVersion ?? "UNKNOWN" : "UNKNOWN"; + } + + private static string ReadGameVersion(string root) { + var candidates = new[] { Path.Combine(root, "version.txt"), Path.Combine(root, "VERSION") }; + foreach (var path in candidates) if (File.Exists(path)) return File.ReadAllText(path).Trim(); + return "UNKNOWN"; + } + + private static string ReadAssemblyVersion(string root, string fileName) { + var path = Directory.Exists(root) ? Directory.GetFiles(root, fileName, SearchOption.AllDirectories).OrderBy(x => x, StringComparer.Ordinal).FirstOrDefault() : null; + try { return path is null ? "UNKNOWN" : System.Reflection.AssemblyName.GetAssemblyName(path).Version?.ToString() ?? "UNKNOWN"; } + catch { return "UNKNOWN"; } + } + + private static string HashIfPresent(string path) + { + if (!File.Exists(path)) return string.Empty; + using var stream = File.OpenRead(path); + using var sha = SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant(); + } +} diff --git a/src/Core/Diagnostics/GregDoctor.cs b/src/Core/Diagnostics/GregDoctor.cs new file mode 100644 index 00000000..9e7d3b9b --- /dev/null +++ b/src/Core/Diagnostics/GregDoctor.cs @@ -0,0 +1,64 @@ +using System.Text.Json; + +namespace gregCore.Core.Diagnostics; + +public static class GregDoctor +{ + public static GregDoctorReport Create(string gameRoot, string manifestPath, string logPath, + IEnumerable? loadedMods = null, IEnumerable? activeHosts = null) + { + var fingerprint = GameFingerprint.Capture(gameRoot); + var manifestVersion = "UNKNOWN"; + try + { + using var document = JsonDocument.Parse(File.ReadAllText(manifestPath)); + if (document.RootElement.TryGetProperty("manifestVersion", out var version)) + manifestVersion = version.ToString(); + } + catch { } + + var manifestFingerprint = ""; + var manifestUnity = "UNKNOWN"; + var manifestMelon = "UNKNOWN"; + var manifestInterop = "UNKNOWN"; + try + { + using var document = JsonDocument.Parse(File.ReadAllText(manifestPath)); + var root = document.RootElement; + if (root.TryGetProperty("assemblyFingerprint", out var fp)) manifestFingerprint = fp.GetString() ?? fp.ToString(); + if (root.TryGetProperty("unityVersion", out var unity)) manifestUnity = unity.GetString() ?? unity.ToString(); + if (root.TryGetProperty("melonLoaderVersion", out var melon)) manifestMelon = melon.GetString() ?? melon.ToString(); + if (root.TryGetProperty("il2CppInteropVersion", out var interop)) manifestInterop = interop.GetString() ?? interop.ToString(); + } + catch { } + var fingerprintMatch = manifestFingerprint is "" or "UNKNOWN" ? "unknown" : + string.Equals(manifestFingerprint, fingerprint.CombinedSha256, StringComparison.OrdinalIgnoreCase) ? "match" : "mismatch"; + var knownBuild = fingerprintMatch == "match"; + return new GregDoctorReport + { + Status = knownBuild ? "SUPPORTED_GAME_BUILD" : "UNSUPPORTED_GAME_BUILD", + SafeMode = !knownBuild, + ErrorCode = knownBuild ? "" : "UNSUPPORTED_GAME_BUILD", + GregCoreVersion = typeof(GregDoctor).Assembly.GetName().Version?.ToString() ?? "UNKNOWN", + ManifestVersion = manifestVersion, + FingerprintMatch = fingerprintMatch, + UnityVersion = fingerprint.UnityVersion == "UNKNOWN" ? manifestUnity : fingerprint.UnityVersion, + MelonLoaderVersion = fingerprint.MelonLoaderVersion == "UNKNOWN" ? manifestMelon : fingerprint.MelonLoaderVersion, + Il2CppInteropVersion = fingerprint.Il2CppInteropVersion == "UNKNOWN" ? manifestInterop : fingerprint.Il2CppInteropVersion, + Fingerprint = fingerprint, + LoadedMods = loadedMods?.OrderBy(x => x, StringComparer.Ordinal).ToArray() ?? Array.Empty(), + ActiveLanguageHosts = activeHosts?.OrderBy(x => x, StringComparer.Ordinal).ToArray() ?? Array.Empty(), + DisabledComponents = knownBuild ? Array.Empty() : new[] { "risk-bearing game hooks" }, + Recommendations = knownBuild + ? new[] { "Keep the committed manifest and runtime versions aligned." } + : new[] { "Install the supported game build or add this fingerprint to a reviewed manifest.", "Keep risky hooks disabled until compatibility is reviewed." }, + LogPath = logPath + }; + } + + public static void Write(string path, GregDoctorReport report) + { + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? "."); + File.WriteAllText(path, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); + } +} diff --git a/src/Core/Diagnostics/GregDoctorReport.cs b/src/Core/Diagnostics/GregDoctorReport.cs new file mode 100644 index 00000000..d9f311c8 --- /dev/null +++ b/src/Core/Diagnostics/GregDoctorReport.cs @@ -0,0 +1,25 @@ +namespace gregCore.Core.Diagnostics; + +public sealed record GregDoctorReport +{ + public string Status { get; init; } = "UNKNOWN"; + public bool SafeMode { get; init; } + public string ErrorCode { get; init; } = string.Empty; + public string GregCoreVersion { get; init; } = string.Empty; + public string ManifestVersion { get; init; } = string.Empty; + public string FingerprintMatch { get; init; } = "unknown"; + public string UnityVersion { get; init; } = "UNKNOWN"; + public string MelonLoaderVersion { get; init; } = "UNKNOWN"; + public string Il2CppInteropVersion { get; init; } = "UNKNOWN"; + public GameFingerprint Fingerprint { get; init; } = new(); + public IReadOnlyList LoadedMods { get; init; } = Array.Empty(); + public IReadOnlyList ActiveLanguageHosts { get; init; } = Array.Empty(); + public IReadOnlyList InstalledHooks { get; init; } = Array.Empty(); + public IReadOnlyList FailedHooks { get; init; } = Array.Empty(); + public IReadOnlyList DisabledComponents { get; init; } = Array.Empty(); + public IReadOnlyList ModLoadErrors { get; init; } = Array.Empty(); + public string SelfTestResult { get; init; } = "not-run"; + public IReadOnlyList Recommendations { get; init; } = Array.Empty(); + public string LogPath { get; init; } = string.Empty; + public DateTime GeneratedAtUtc { get; init; } = DateTime.UtcNow; +} diff --git a/src/Core/GregCoreMod.cs b/src/Core/GregCoreMod.cs index 6861f50e..288a7fa2 100644 --- a/src/Core/GregCoreMod.cs +++ b/src/Core/GregCoreMod.cs @@ -14,9 +14,14 @@ using gregCore.Sdk.Language; using gregCore.GameLayer.Hooks; using gregCore.Core.Abstractions; +using gregCore.Core.Exceptions; +using gregCore.Infrastructure.Plugins; +using gregCore.Infrastructure.Settings; +using gregCore.Core.Diagnostics; +using gregCore.GameLayer.Bootstrap; using Il2CppInterop.Runtime.Injection; -[assembly: MelonInfo(typeof(gregCore.Core.GregCoreMod), "gregCore", "1.2.1", "TeamGreg")] +[assembly: MelonInfo(typeof(gregCore.Core.GregCoreMod), "gregCore", "1.2.2-dev.0", "TeamGreg")] [assembly: MelonColor(255, 0, 191, 165)] // Teal [assembly: MelonPriority(-1000)] // Load first! @@ -40,7 +45,18 @@ public sealed class GregCoreMod : MelonMod public override void OnInitializeMelon() { Instance = this; - MelonLogger.Msg("--- Framework Boot v1.2.1-UI-Toolkit ---"); + MelonLogger.Msg("--- Framework Boot v1.2.2-dev.0-UI-Toolkit ---"); + + try + { + var doctorPath = Path.Combine(MelonLoader.Utils.MelonEnvironment.UserDataDirectory, "gregCore", "doctor.json"); + var manifestPath = Path.Combine(MelonLoader.Utils.MelonEnvironment.GameRootDirectory, "Mods", "framework", "greg_hooks.json"); + var report = GregDoctor.Create(MelonLoader.Utils.MelonEnvironment.GameRootDirectory, manifestPath, + MelonLoader.Utils.MelonEnvironment.UserDataDirectory); + GregDoctor.Write(doctorPath, report); + MelonLogger.Msg($"[gregCore] Doctor: {report.Status}{(string.IsNullOrEmpty(report.ErrorCode) ? "" : $" ({report.ErrorCode})")}"); + } + catch (Exception ex) { MelonLogger.Warning($"[gregCore] Doctor report failed: {ex.Message}"); } // Initialize Social Services try @@ -62,18 +78,35 @@ public override void OnInitializeMelon() MelonLogger.Error($"[gregCore] IL2CPP type registration failed: {ex.Message}"); } - // Initialize core event buses + // Build the shared service graph exactly once. This wires the public API, + // performance governor, settings, plugin registry and canonical hook bus. try { var logger = new gregCore.Infrastructure.Logging.ConsoleLogger(LoggerInstance); - EventBus = new GregEventBus(logger); - HookBus = new GregHookBus(logger); + GregBootstrapper.Build(LoggerInstance); + EventBus = GregServiceContainer.Get(); + HookBus = GregServiceContainer.Get(); API.GregAPI.Initialize(logger); - MelonLogger.Msg("[gregCore] Event buses initialized."); + if (EventBus == null || HookBus == null) + throw new GregInitException("Bootstrap did not register the shared event and hook buses."); + MelonLogger.Msg("[gregCore] Shared service graph initialized."); } catch (Exception ex) { - MelonLogger.Error($"[gregCore] Event bus initialization failed: {ex.Message}"); + MelonLogger.Error($"[gregCore] Bootstrap failed: {ex.Message}"); + // Keep lifecycle diagnostics alive if an optional game dependency is + // absent. The fallback is deliberately not registered as a service. + try + { + var logger = new gregCore.Infrastructure.Logging.ConsoleLogger(LoggerInstance); + EventBus ??= new GregEventBus(logger); + HookBus ??= new GregHookBus(logger); + API.GregAPI.Initialize(logger); + } + catch (Exception fallbackEx) + { + MelonLogger.Error($"[gregCore] Event bus fallback failed: {fallbackEx.Message}"); + } } // Initialize UI Toolkit root @@ -122,6 +155,14 @@ public override void OnUpdate() MelonLogger.Error($"[gregCore] gregExt discovery failed: {ex.Message}"); } + try + { + GregServiceContainer.Get()?.LoadAll(); + } + catch (Exception ex) + { + MelonLogger.Error($"[gregCore] Mod registry activation failed: {ex.Message}"); + } try { var modsDir = System.IO.Path.Combine(global::MelonLoader.Utils.MelonEnvironment.UserDataDirectory, "Mods", "Scripts"); @@ -147,7 +188,13 @@ public override void OnUpdate() try { + if (GregServiceContainer.Get() is { } governor) + governor.OnUpdate(); + GregServiceContainer.Get()?.FlushPendingSave(); GregLanguageRegistry.OnUpdate(Time.deltaTime); + global::gregCore.PublicApi.greg._context?.MainThread.Drain(); + if (GregServiceContainer.Get() is GregPluginRegistry registry) + registry.Update(Time.deltaTime); } catch (Exception ex) { @@ -192,18 +239,24 @@ public override void OnSceneWasLoaded(int buildIndex, string sceneName) Infrastructure.Social.DiscordService.UpdatePresence("Planning Next Build", "Main Menu"); } GregLanguageRegistry.OnSceneLoaded(sceneName); + (GregServiceContainer.Get() as GregPluginRegistry)?.SceneLoaded(sceneName); // Notify mods about scene change - HookBus?.Dispatch("OnSceneLoaded", new gregCore.Core.Models.EventPayload + var scenePayload = new gregCore.Core.Models.EventPayload { - HookName = "OnSceneLoaded", + HookName = "gregMod.lifecycle.sceneLoaded", OccurredAtUtc = DateTime.UtcNow, Data = new Dictionary { - { "BuildIndex", buildIndex }, - { "SceneName", sceneName } + { "buildIndex", buildIndex }, + { "sceneName", sceneName } } - }); + }; + HookBus?.Dispatch("gregMod.lifecycle.sceneLoaded", scenePayload); + EventBus?.Publish("gregMod.lifecycle.sceneLoaded", scenePayload); + // Deprecated compatibility bridge. + HookBus?.Dispatch("greg.lifecycle.SceneLoaded", scenePayload); + EventBus?.Publish("greg.lifecycle.SceneLoaded", scenePayload); } catch (Exception ex) { @@ -219,6 +272,7 @@ public override void OnApplicationQuit() try { GregLanguageRegistry.Shutdown(); + (GregServiceContainer.Get() as GregPluginRegistry)?.Shutdown(); GregUIManager.Shutdown(); Infrastructure.Social.DiscordService.Shutdown(); } diff --git a/src/Core/Models/GregHooksManifest.cs b/src/Core/Models/GregHooksManifest.cs index a7284780..6f895cd9 100644 --- a/src/Core/Models/GregHooksManifest.cs +++ b/src/Core/Models/GregHooksManifest.cs @@ -9,10 +9,23 @@ public class GregHookPayloadSchema public string Parameters { get; set; } = string.Empty; } -public class GregHookDef -{ - public string Name { get; set; } = string.Empty; - public string Legacy { get; set; } = string.Empty; +public class GregHookDef +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Legacy { get; set; } = string.Empty; + public string Assembly { get; set; } = string.Empty; + public string Namespace { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Member { get; set; } = string.Empty; + public string Signature { get; set; } = string.Empty; + public string Domain { get; set; } = string.Empty; + public string Threading { get; set; } = string.Empty; + public bool Cancellable { get; set; } + public string Risk { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public List SupportedLanguages { get; set; } = new(); + public string ApprovalReason { get; set; } = string.Empty; public string PatchTarget { get; set; } = string.Empty; public string Strategy { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; @@ -25,10 +38,26 @@ public class GregHookDef public GregHookPayloadSchema? PayloadSchema { get; set; } } -public class GregHooksManifest -{ - public int Version { get; set; } +public class GregHooksManifest +{ + public int ManifestVersion { get; set; } + public string SchemaVersion { get; set; } = string.Empty; + public string GameBuild { get; set; } = string.Empty; + public string AssemblyFingerprint { get; set; } = string.Empty; + public string UnityVersion { get; set; } = string.Empty; + public string MelonLoaderVersion { get; set; } = string.Empty; + public string Il2CppInteropVersion { get; set; } = string.Empty; + public int Version { get; set; } public string Description { get; set; } = string.Empty; public string GeneratedFrom { get; set; } = string.Empty; - public List Hooks { get; set; } = new(); -} + public List Hooks { get; set; } = new(); + public List ExcludedMembers { get; set; } = new(); +} + +public class GregExcludedMember +{ + public string Assembly { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Member { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; +} diff --git a/src/Core/Models/ModManifest.cs b/src/Core/Models/ModManifest.cs index b7dc5a3b..c4b1cd39 100644 --- a/src/Core/Models/ModManifest.cs +++ b/src/Core/Models/ModManifest.cs @@ -12,6 +12,9 @@ public record ModManifest public string Name { get; init; } = string.Empty; public string Version { get; init; } = "1.0.0"; public string PersistentId { get; init; } = string.Empty; - public string Author { get; init; } = string.Empty; - public IReadOnlyList Dependencies { get; init; } = Array.Empty(); -} + public string Author { get; init; } = string.Empty; + public string Entrypoint { get; init; } = string.Empty; + public string ApiVersion { get; init; } = "1.0.0"; + public string Loader { get; init; } = "MelonLoader"; + public IReadOnlyList Dependencies { get; init; } = Array.Empty(); +} diff --git a/src/Core/Models/PerformanceProfile.cs b/src/Core/Models/PerformanceProfile.cs index 85f2c248..f3897e5f 100644 --- a/src/Core/Models/PerformanceProfile.cs +++ b/src/Core/Models/PerformanceProfile.cs @@ -10,9 +10,10 @@ public sealed record PerformanceProfile public bool ThrottleWhenUnfocused { get; init; } = true; // Concurrency - public int MaxConcurrentOps { get; init; } = 3; - public int MaxConcurrentRequests { get; init; } = 4; - public int MaxEventsPerFrame { get; init; } = 20; + public int MaxConcurrentOps { get; init; } = 3; + public int MaxConcurrentRequests { get; init; } = 4; + public int MaxEventsPerFrame { get; init; } = 20; + public int MaxQueuedOperations { get; init; } = 256; // Memory public int RamWarningMb { get; init; } = 3072; @@ -53,7 +54,8 @@ public sealed record PerformanceProfile { TargetFps = 144, UnfocusedFps = 30, - MaxConcurrentOps = 8, + MaxConcurrentOps = 8, + MaxQueuedOperations = 512, EnableVSync = false, QualityLevel = 4, GcIntervalSeconds = 60, @@ -65,7 +67,8 @@ public sealed record PerformanceProfile TargetFps = 30, UnfocusedFps = 10, MaxConcurrentOps = 1, - MaxEventsPerFrame = 10, + MaxEventsPerFrame = 10, + MaxQueuedOperations = 64, RamWarningMb = 2048, RamCriticalMb = 3072, EnableVSync = true, @@ -79,7 +82,7 @@ public sealed record PerformanceProfile DisableContactShadows = true, DisableGlobalIllumination = true, DisableSSR = true, - MaxShadowRequests = 64, + MaxShadowRequests = 64, DisableDecals = true, }; public static PerformanceProfile DatacenterOptimal => new PerformanceProfile @@ -107,6 +110,7 @@ public sealed record PerformanceProfile EnableStreamingMipmaps = true, StreamingMipmapBudgetMb = 512f, RouteEvalCooldownSeconds = 2.0f, - AutoSaveIntervalMinutes = 10.0f, + AutoSaveIntervalMinutes = 10.0f, + MaxQueuedOperations = 128, }; } diff --git a/src/Core/Models/PluginInfo.cs b/src/Core/Models/PluginInfo.cs index 9161f803..dc3e8347 100644 --- a/src/Core/Models/PluginInfo.cs +++ b/src/Core/Models/PluginInfo.cs @@ -6,9 +6,15 @@ namespace gregCore.Core.Models; -public record PluginInfo -{ +public record PluginInfo +{ public string AssemblyPath { get; init; } = string.Empty; public ModManifest Manifest { get; init; } = new(); - public bool IsNative { get; init; } -} + public bool IsNative { get; init; } + public string AssemblyName { get; init; } = string.Empty; + public string AssemblyVersion { get; init; } = string.Empty; + public string Sha256 { get; init; } = string.Empty; + public IReadOnlyList DeclaredDependencies { get; init; } = Array.Empty(); + public string ScanStatus { get; init; } = "SCANNED"; + public string ModTypeName { get; init; } = string.Empty; +} diff --git a/src/GameLayer/Bootstrap/GregBootstrapper.cs b/src/GameLayer/Bootstrap/GregBootstrapper.cs index 27fda02e..8be5565a 100644 --- a/src/GameLayer/Bootstrap/GregBootstrapper.cs +++ b/src/GameLayer/Bootstrap/GregBootstrapper.cs @@ -65,8 +65,9 @@ public static GregServiceContainer Build(global::MelonLoader.MelonLogger.Instanc var validationService = new Core.Services.GregValidationService(logger); - container.Register(bus); - container.Register(hookBus); + container.Register(bus); + container.Register(bus); + container.Register(hookBus); container.Register(catalog); container.Register(catalogService); container.Register(validationService); @@ -114,13 +115,21 @@ public static GregServiceContainer Build(global::MelonLoader.MelonLogger.Instanc gregCore.API.GregAPI._modSettingsService = modSettingsService; // -------------------------- - var apiContext = new global::gregCore.PublicApi.GregApiContext { - Logger = logger, - EventBus = bus, - HookBus = hookBus, - Config = container.GetRequired(), - Persist = container.GetRequired() - }; + var lifetime = new CancellationTokenSource(); + var apiContext = new global::gregCore.PublicApi.GregApiContext { + Logger = logger, + EventBus = bus, + HookBus = hookBus, + Config = container.GetRequired(), + Persist = container.GetRequired(), + Events = new global::gregCore.PublicApi.GregEventBusPublic(bus), + MainThread = new global::gregCore.PublicApi.GregMainThreadDispatcher(), + Resources = new global::gregCore.PublicApi.GregResourceRegistry(), + CancellationToken = lifetime.Token, + LifetimeSource = lifetime + }; + + pluginRegistry.Configure(apiContext); var governor = new gregCore.Infrastructure.Performance.GregPerformanceGovernor(apiContext); container.Register(governor); diff --git a/src/GameLayer/Hooks/GregDynamicHookPatcher.cs b/src/GameLayer/Hooks/GregDynamicHookPatcher.cs index 20a9e634..072dc911 100644 --- a/src/GameLayer/Hooks/GregDynamicHookPatcher.cs +++ b/src/GameLayer/Hooks/GregDynamicHookPatcher.cs @@ -23,10 +23,12 @@ public sealed class GregDynamicHookPatcher private readonly IGregLogger _logger; private int _installedCount; private int _failedCount; + private readonly HookInstallReport _report = new(); public int InstalledCount => _installedCount; public int FailedCount => _failedCount; public int TotalHooks { get; private set; } + public HookInstallReport InstallReport => _report; public GregDynamicHookPatcher(HarmonyLib.Harmony harmony, GregEventBus eventBus, IGregLogger logger) { @@ -49,6 +51,20 @@ public void InstallFromFile(string hooksFilePath) try { var json = File.ReadAllText(hooksFilePath); + // The release manifest is deliberately an object. Refuse the legacy + // unbound inventory here: installing every discovered member is unsafe. + if (!json.TrimStart().StartsWith("{", StringComparison.Ordinal)) + { + _report.ManifestVersion = "legacy-rejected"; + _report.Skipped.Add(new HookInstallEntry { HookId = "legacy-inventory", Status = "skipped", ErrorClass = "ManifestNotBoundToBuild", TargetMember = hooksFilePath }); + _logger.Warning("Rejected legacy unbound hook inventory; use framework/greg_hooks.json."); + return; + } + var manifest = JsonConvert.DeserializeObject(json); + InstallFromManifest(manifest, Path.GetDirectoryName(hooksFilePath) ?? Directory.GetCurrentDirectory()); + return; + +#pragma warning disable CS0162 var hooks = JsonConvert.DeserializeObject>(json); if (hooks == null || hooks.Count == 0) @@ -119,6 +135,51 @@ public void InstallFromFile(string hooksFilePath) } } + public void InstallFromManifest(GregHooksManifest? manifest, string manifestDirectory) + { + if (manifest == null) { _report.Skipped.Add(new HookInstallEntry { Status="skipped", ErrorClass="InvalidManifest" }); return; } + _report.ManifestVersion = manifest.ManifestVersion > 0 ? manifest.ManifestVersion.ToString() : manifest.Version.ToString(); + TotalHooks = manifest.Hooks.Count; + var gameRoot = Directory.GetParent(manifestDirectory)?.Parent?.FullName ?? manifestDirectory; + var fingerprint = Core.Diagnostics.GameFingerprint.Capture(gameRoot); + var fingerprintKnown = !string.IsNullOrWhiteSpace(manifest.AssemblyFingerprint) && manifest.AssemblyFingerprint != "UNKNOWN"; + var fingerprintMatches = fingerprintKnown && string.Equals(manifest.AssemblyFingerprint, fingerprint.CombinedSha256, StringComparison.OrdinalIgnoreCase); + _report.FingerprintMatch = fingerprintMatches ? "match" : fingerprintKnown ? "mismatch" : "unknown"; + if (!fingerprintMatches) + { + _report.SafeMode = true; + foreach (var hook in manifest.Hooks) + _report.Disabled.Add(Entry(hook, "disabled", "UnknownOrMismatchedBuild", null)); + _logger.Warning($"Hook manifest fingerprint {_report.FingerprintMatch}; risky hooks disabled."); + return; + } + + foreach (var hook in manifest.Hooks) + { + if (!string.Equals(hook.Status, "implemented", StringComparison.OrdinalIgnoreCase)) { _report.Skipped.Add(Entry(hook, "skipped", "NotImplemented", null)); continue; } + try + { + var method = ResolveManifestMethod(hook); + if (method == null) { _report.Failed.Add(Entry(hook, "failed", "TargetNotFound", null)); continue; } + lock (_globalMethodToHookNames) _globalMethodToHookNames[method] = new List { hook.Name }; + _harmony.Patch(method, postfix: new HarmonyMethod(typeof(GregDynamicHookPatcher), nameof(GenericPostfix))); + _installedCount++; _report.Installed.Add(Entry(hook, "installed", "", method)); + } + catch (Exception ex) { _failedCount++; _report.Failed.Add(Entry(hook, "failed", ex.GetType().Name, null, ex)); _logger.Warning($"Hook {hook.Id} failed: {ex.Message}"); } + } + } + + private MethodBase? ResolveManifestMethod(GregHookDef hook) + { + var typeName = string.IsNullOrWhiteSpace(hook.Type) ? hook.PatchTarget : (string.IsNullOrWhiteSpace(hook.Namespace) ? hook.Type : hook.Namespace + "." + hook.Type); + var type = SafeTypeByName(typeName) ?? SafeTypeByName(hook.Type); + return type == null ? null : SafeGetMethod(type, string.IsNullOrWhiteSpace(hook.Member) ? hook.MethodName : hook.Member, null); + } + + private static HookInstallEntry Entry(GregHookDef hook, string status, string error, MethodBase? method, Exception? ex = null) => new() { + HookId=hook.Id, Status=status, ErrorClass=error, Exception=ex?.ToString() ?? "", TargetMember=method == null ? hook.Member : method.DeclaringType?.FullName + "." + method.Name + }; + private static string GetHookName(GameHookJsonDef hook) { return $"greg.{hook.Group}.{hook.MethodName}"; @@ -304,6 +365,26 @@ public static void GenericPostfix(MethodBase __originalMethod, object[] __args) } } + public sealed class HookInstallReport + { + public string ManifestVersion { get; set; } = "UNKNOWN"; + public string FingerprintMatch { get; set; } = "unknown"; + public bool SafeMode { get; set; } + public List Installed { get; } = new(); + public List Failed { get; } = new(); + public List Skipped { get; } = new(); + public List Disabled { get; } = new(); + } + + public sealed class HookInstallEntry + { + public string HookId { get; set; } = ""; + public string Status { get; set; } = ""; + public string ErrorClass { get; set; } = ""; + public string Exception { get; set; } = ""; + public string TargetMember { get; set; } = ""; + } + // ─── JSON Models ─────────────────────────────────────────────────── public class GameHookJsonDef diff --git a/src/GameLayer/Hooks/GregNativeEventHooks.cs b/src/GameLayer/Hooks/GregNativeEventHooks.cs index 74d3b5f4..2cc0df9d 100644 --- a/src/GameLayer/Hooks/GregNativeEventHooks.cs +++ b/src/GameLayer/Hooks/GregNativeEventHooks.cs @@ -23,14 +23,15 @@ public static void Install(IGregLogger logger, GregHookBus hookBus, GregEventBus try { - // Initialize dynamic patcher for all 1771+ hooks from game_hooks.json + // Load only the build-bound canonical manifest. The legacy static + // inventory is intentionally never a runtime patch source. _dynamicPatcher = new GregDynamicHookPatcher(harmony, eventBus, logger); GregDynamicHookPatcher.SetGlobalBus(eventBus); GregDynamicHookPatcher.SetGlobalLogger(logger); string hooksFile = System.IO.Path.Combine( global::MelonLoader.Utils.MelonEnvironment.ModsDirectory, - "game_hooks.json"); + "framework", "greg_hooks.json"); if (!System.IO.File.Exists(hooksFile)) { @@ -38,7 +39,7 @@ public static void Install(IGregLogger logger, GregHookBus hookBus, GregEventBus var asmDir = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); if (!string.IsNullOrEmpty(asmDir)) { - hooksFile = System.IO.Path.Combine(asmDir, "game_hooks.json"); + hooksFile = System.IO.Path.Combine(asmDir, "framework", "greg_hooks.json"); } } @@ -47,11 +48,21 @@ public static void Install(IGregLogger logger, GregHookBus hookBus, GregEventBus // Final fallback: project root hooksFile = System.IO.Path.Combine( global::MelonLoader.Utils.MelonEnvironment.GameRootDirectory, - "game_hooks.json"); + "Mods", "framework", "greg_hooks.json"); } _dynamicPatcher.InstallFromFile(hooksFile); + try + { + var reportPath = System.IO.Path.Combine(global::MelonLoader.Utils.MelonEnvironment.UserDataDirectory, + "gregCore", "hook-install-report.json"); + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(reportPath)!); + System.IO.File.WriteAllText(reportPath, System.Text.Json.JsonSerializer.Serialize(_dynamicPatcher.InstallReport, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); + } + catch (Exception reportEx) { _logger?.Warning($"Could not write hook-install-report.json: {reportEx.Message}"); } + _logger?.Success($"GregNativeEventHooks Harmony Bridge installiert. Patched {_dynamicPatcher.InstalledCount} methods."); } catch (Exception ex) diff --git a/src/GameLayer/Patches/Networking/CablePositionsPatch.cs b/src/GameLayer/Patches/Networking/CablePositionsPatch.cs index 84c3f50c..42968f10 100644 --- a/src/GameLayer/Patches/Networking/CablePositionsPatch.cs +++ b/src/GameLayer/Patches/Networking/CablePositionsPatch.cs @@ -67,7 +67,8 @@ public static void SetBaseId(int baseId) } while (Interlocked.CompareExchange(ref _nextCableId, baseId + 1, current) != current); - MelonLogger.Msg($"[CablePatch] Cable ID counter set to {baseId + 1}"); + try { MelonLogger.Msg($"[CablePatch] Cable ID counter set to {baseId + 1}"); } + catch { /* logging may not be initialized in static/unit-test contexts */ } } public static int PeekNextId() => _nextCableId; diff --git a/src/Infrastructure/Performance/GregOperationQueue.cs b/src/Infrastructure/Performance/GregOperationQueue.cs index 1879655d..8f9f2fb2 100644 --- a/src/Infrastructure/Performance/GregOperationQueue.cs +++ b/src/Infrastructure/Performance/GregOperationQueue.cs @@ -4,27 +4,57 @@ internal sealed class GregOperationQueue : IDisposable { private readonly GregRequestThrottler _throttler; private readonly IGregLogger _logger; - private readonly PriorityQueue _queue = new PriorityQueue(); - private readonly SemaphoreSlim _processLock = new SemaphoreSlim(1, 1); - private bool _isDisposed; - - internal GregOperationQueue(GregRequestThrottler throttler, IGregLogger logger) - { - _throttler = throttler; - _logger = logger.ForContext(nameof(GregOperationQueue)); + private readonly PriorityQueue _queue = new PriorityQueue(); + private readonly SemaphoreSlim _processLock = new SemaphoreSlim(1, 1); + private int _maxQueueSize; + private bool _isDisposed; + + internal GregOperationQueue(GregRequestThrottler throttler, IGregLogger logger, int maxQueueSize) + { + _throttler = throttler; + _logger = logger.ForContext(nameof(GregOperationQueue)); + _maxQueueSize = Math.Max(1, maxQueueSize); } internal async Task EnqueueAsync(string name, Func> operation, OperationPriority priority = OperationPriority.Normal, CancellationToken ct = default) { - var tcs = new TaskCompletionSource(); - var op = new QueuedOperation(name, async () => { - try { tcs.SetResult(await _throttler.ExecuteOperationAsync(name, operation, priority, ct)); } - catch (Exception ex) { tcs.SetException(ex); } - }, (int)priority); - - lock (_queue) { _queue.Enqueue(op, -(int)priority); } - _ = ProcessQueueAsync(ct); - return await tcs.Task; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (ct.IsCancellationRequested) + { + tcs.TrySetCanceled(ct); + return await tcs.Task.ConfigureAwait(false); + } + + var op = new QueuedOperation(name, async () => { + try { tcs.SetResult(await _throttler.ExecuteOperationAsync(name, operation, priority, ct)); } + catch (Exception ex) { tcs.SetException(ex); } + }, (int)priority, cancellationToken => tcs.TrySetCanceled(cancellationToken)); + + Exception? rejection = null; + lock (_queue) + { + if (_isDisposed) + { + rejection = new ObjectDisposedException(nameof(GregOperationQueue)); + } + else if (_queue.Count >= _maxQueueSize) + { + rejection = new InvalidOperationException($"Operation queue limit reached ({_maxQueueSize})."); + } + else + { + _queue.Enqueue(op, -(int)priority); + } + } + if (rejection != null) + { + tcs.TrySetException(rejection); + if (rejection is InvalidOperationException) + _logger.Warning($"[Queue] Rejected '{name}': queue limit {_maxQueueSize} reached."); + return await tcs.Task.ConfigureAwait(false); + } + _ = ProcessQueueAsync(ct); + return await tcs.Task.ConfigureAwait(false); } private async Task ProcessQueueAsync(CancellationToken ct) @@ -34,14 +64,22 @@ private async Task ProcessQueueAsync(CancellationToken ct) while (true) { QueuedOperation? op; lock (_queue) { if (!_queue.TryDequeue(out op, out _)) break; } - if (ct.IsCancellationRequested) break; + if (ct.IsCancellationRequested) + { + op.Cancel(ct); + continue; + } try { await op.Execute(); } catch (Exception ex) { _logger.Error($"[Queue] Fehlgeschlagen: {op.Name}", ex); } } } finally { _processLock.Release(); } } - internal int QueueDepth { get { lock (_queue) return _queue.Count; } } - public void Dispose() { if (!_isDisposed) { _isDisposed = true; _processLock.Dispose(); } } - - private record QueuedOperation(string Name, Func Execute, int Priority); -} + internal int QueueDepth { get { lock (_queue) return _queue.Count; } } + internal void UpdateLimit(int maxQueueSize) => Volatile.Write(ref _maxQueueSize, Math.Max(1, maxQueueSize)); + public void Dispose() { if (!_isDisposed) { _isDisposed = true; _processLock.Dispose(); } } + + private sealed record QueuedOperation(string Name, Func Execute, int Priority, Action CancelAction) + { + public void Cancel(CancellationToken cancellationToken) => CancelAction(cancellationToken); + } +} diff --git a/src/Infrastructure/Performance/GregPerformanceGovernor.cs b/src/Infrastructure/Performance/GregPerformanceGovernor.cs index d263b7d2..930cf914 100644 --- a/src/Infrastructure/Performance/GregPerformanceGovernor.cs +++ b/src/Infrastructure/Performance/GregPerformanceGovernor.cs @@ -22,7 +22,7 @@ internal GregPerformanceGovernor(GregApiContext ctx, PerformanceProfile? profile _throttler = new GregRequestThrottler(ctx.Logger, _profile); _monitor = new GregResourceMonitor(ctx.Logger, ctx.EventBus, _profile); _memHandler = new GregMemoryPressureHandler(ctx.Logger, ctx.EventBus, _profile); - _queue = new GregOperationQueue(_throttler, ctx.Logger); + _queue = new GregOperationQueue(_throttler, ctx.Logger, _profile.MaxQueuedOperations); // Performance-Patches initialisieren (Throttle, Cleanup, etc.) GregPerformancePatches.Initialize(); @@ -54,21 +54,23 @@ internal void Configure(PerformanceProfile profile) { _profile = profile; _fpsLimiter.Apply(profile); - _throttler.UpdateProfile(profile); - ApplyPatchSettings(profile); + _throttler.UpdateProfile(profile); + _queue.UpdateLimit(profile.MaxQueuedOperations); + ApplyPatchSettings(profile); } private void ApplyPatchSettings(PerformanceProfile profile) { - GregPerformancePatches.CanvasThrottleEnabled = true; - GregPerformancePatches.CanvasUpdateInterval = 0.1f; - GregPerformancePatches.IndicatorThrottleEnabled = true; - GregPerformancePatches.IndicatorUpdateInterval = 0.1f; - GregPerformancePatches.PulsatingThrottleEnabled = true; - GregPerformancePatches.PulsatingUpdateInterval = 0.05f; - GregPerformancePatches.NpcThrottleEnabled = true; - GregPerformancePatches.NpcThrottleDistance = 15f; - GregPerformancePatches.NpcThrottleInterval = 0.2f; + var quality = Math.Clamp(profile.QualityLevel, 0, 4); + GregPerformancePatches.CanvasThrottleEnabled = quality < 4; + GregPerformancePatches.CanvasUpdateInterval = quality switch { 0 => 0.25f, 1 => 0.15f, 2 => 0.1f, _ => 0.05f }; + GregPerformancePatches.IndicatorThrottleEnabled = true; + GregPerformancePatches.IndicatorUpdateInterval = quality <= 0 ? 0.2f : quality <= 2 ? 0.1f : 0.05f; + GregPerformancePatches.PulsatingThrottleEnabled = true; + GregPerformancePatches.PulsatingUpdateInterval = quality <= 0 ? 0.15f : quality <= 2 ? 0.05f : 0.025f; + GregPerformancePatches.NpcThrottleEnabled = true; + GregPerformancePatches.NpcThrottleDistance = quality <= 0 ? 10f : quality <= 2 ? 15f : 25f; + GregPerformancePatches.NpcThrottleInterval = quality <= 0 ? 0.35f : quality <= 2 ? 0.2f : 0.1f; GregPerformancePatches.AsyncRouteEvalEnabled = false; } diff --git a/src/Infrastructure/Plugins/AssemblyScanner.cs b/src/Infrastructure/Plugins/AssemblyScanner.cs index 12e4164a..4f44ec15 100644 --- a/src/Infrastructure/Plugins/AssemblyScanner.cs +++ b/src/Infrastructure/Plugins/AssemblyScanner.cs @@ -4,7 +4,9 @@ /// Maintainer: Nutzt Mono.Cecil für statische Analyse. Assembly.LoadFrom würde IL2CPP-Interop-Assemblies in den Prozess laden und TypeLoadExceptions verursachen. /// -using Mono.Cecil; +using Mono.Cecil; +using System.Security.Cryptography; +using gregCore.PublicApi.Attributes; namespace gregCore.Infrastructure.Plugins; @@ -16,12 +18,38 @@ public IReadOnlyList ScanDirectory(string path) var plugins = new List(); if (!Directory.Exists(path)) return plugins; - foreach (var file in Directory.GetFiles(path, "*.dll")) - { - try - { - using var module = ModuleDefinition.ReadModule(file); - plugins.Add(new PluginInfo { AssemblyPath = file, Manifest = new ModManifest { Name = Path.GetFileNameWithoutExtension(file) } }); + foreach (var file in Directory.GetFiles(path, "*.dll").OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + { + try + { + using var module = ModuleDefinition.ReadModule(file); + var modType = module.Types.SelectMany(AllTypes) + .FirstOrDefault(t => t.CustomAttributes.Any(a => a.AttributeType.FullName == typeof(GregModAttribute).FullName)); + var modAttribute = modType?.CustomAttributes.FirstOrDefault(a => a.AttributeType.FullName == typeof(GregModAttribute).FullName); + var id = GetString(modAttribute, 0); + var name = GetString(modAttribute, 1); + var version = GetString(modAttribute, 2); + var dependencies = modType?.CustomAttributes + .Where(a => a.AttributeType.FullName == typeof(GregDependsOnAttribute).FullName) + .Select(a => GetString(a, 0)).Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToArray() ?? Array.Empty(); + plugins.Add(new PluginInfo + { + AssemblyPath = file, + AssemblyName = module.Assembly?.Name.Name ?? Path.GetFileNameWithoutExtension(file), + AssemblyVersion = module.Assembly?.Name.Version?.ToString() ?? string.Empty, + ModTypeName = modType?.FullName ?? string.Empty, + Sha256 = ComputeSha256(file), + DeclaredDependencies = dependencies, + ScanStatus = modType == null ? "NO_MOD_ATTRIBUTE" : "SCANNED", + Manifest = new ModManifest + { + Id = id, + Name = string.IsNullOrWhiteSpace(name) ? Path.GetFileNameWithoutExtension(file) : name, + Version = string.IsNullOrWhiteSpace(version) ? (module.Assembly?.Name.Version?.ToString() ?? "0.0.0") : version, + Dependencies = dependencies + } + }); } catch { @@ -29,6 +57,23 @@ public IReadOnlyList ScanDirectory(string path) } } - return plugins; - } -} + return plugins; + } + + private static IEnumerable AllTypes(TypeDefinition type) + { + yield return type; + foreach (var nested in type.NestedTypes.SelectMany(AllTypes)) yield return nested; + } + + private static string GetString(CustomAttribute? attribute, int index) => + attribute != null && attribute.ConstructorArguments.Count > index + ? attribute.ConstructorArguments[index].Value?.ToString() ?? string.Empty : string.Empty; + + private static string ComputeSha256(string file) + { + using var stream = File.OpenRead(file); + using var sha256 = SHA256.Create(); + return Convert.ToHexString(sha256.ComputeHash(stream)).ToLowerInvariant(); + } +} diff --git a/src/Infrastructure/Plugins/GregDependencyResolver.cs b/src/Infrastructure/Plugins/GregDependencyResolver.cs index 47224a0a..1f1ad3a9 100644 --- a/src/Infrastructure/Plugins/GregDependencyResolver.cs +++ b/src/Infrastructure/Plugins/GregDependencyResolver.cs @@ -10,7 +10,42 @@ public sealed class GregDependencyResolver { public IReadOnlyList Resolve(IReadOnlyList plugins) { - // Topological Sort Placeholder - return plugins.ToList(); + ArgumentNullException.ThrowIfNull(plugins); + var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var plugin in plugins.OrderBy(p => p.Manifest.Id, StringComparer.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(plugin.Manifest.Id)) + throw new GregPluginLoadException($"Plugin '{plugin.AssemblyPath}' has no manifest id."); + if (!byId.TryAdd(plugin.Manifest.Id, plugin)) + throw new GregPluginLoadException($"Duplicate plugin id '{plugin.Manifest.Id}'."); + } + var state = new Dictionary(StringComparer.OrdinalIgnoreCase); + var result = new List(plugins.Count); + var stack = new Stack(); + foreach (var id in byId.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) Visit(id); + return result; + + void Visit(string id) + { + if (state.TryGetValue(id, out var value)) + { + if (value == 2) return; + throw new GregPluginLoadException($"Cyclic plugin dependency: {string.Join(" -> ", stack.Reverse().Append(id))}."); + } + if (!byId.TryGetValue(id, out var plugin)) + throw new GregPluginLoadException($"Missing plugin dependency '{id}'."); + state[id] = 1; + stack.Push(id); + foreach (var dependency in plugin.Manifest.Dependencies.Where(x => !string.IsNullOrWhiteSpace(x)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + { + var dependencyId = dependency.Split('@', 2)[0].Trim(); + if (!byId.ContainsKey(dependencyId)) + throw new GregPluginLoadException($"Plugin '{id}' requires missing dependency '{dependencyId}'."); + Visit(dependencyId); + } + stack.Pop(); + state[id] = 2; + result.Add(plugin); + } } } diff --git a/src/Infrastructure/Plugins/GregPluginRegistry.cs b/src/Infrastructure/Plugins/GregPluginRegistry.cs index d783f5a9..f7729031 100644 --- a/src/Infrastructure/Plugins/GregPluginRegistry.cs +++ b/src/Infrastructure/Plugins/GregPluginRegistry.cs @@ -8,7 +8,11 @@ using System.Security.Cryptography; using System.Text; using gregCore.Core.Abstractions; -using gregCore.Core.Models; +using gregCore.Core.Models; +using gregCore.PublicApi; +using gregCore.PublicApi.Attributes; +using gregCore.Core.Events; +using System.Reflection; namespace gregCore.Infrastructure.Plugins; @@ -18,7 +22,9 @@ public sealed class GregPluginRegistry : IGregPluginRegistry private readonly IGregLogger _logger; private readonly IGregEventBus _eventBus; private readonly List _loadedPlugins = new(); - private readonly Dictionary _registeredMods = new(); + private readonly Dictionary _registeredMods = new(); + private readonly Dictionary _runtimeMods = new(StringComparer.OrdinalIgnoreCase); + private GregApiContext? _context; public GregPluginRegistry(IAssemblyScanner scanner, IGregLogger logger, IGregEventBus eventBus) { @@ -27,7 +33,7 @@ public GregPluginRegistry(IAssemblyScanner scanner, IGregLogger logger, IGregEve _eventBus = eventBus; } - public void RegisterMod(ModMetadata metadata) + public void RegisterMod(ModMetadata metadata) { if (string.IsNullOrEmpty(metadata.ModId)) { @@ -45,7 +51,9 @@ public void RegisterMod(ModMetadata metadata) _registeredMods[metadata.ModId] = metadata; _logger.Info($"Mod registriert: {metadata.Name} ({metadata.Version}) [ID: {metadata.ModId}, PersistentID: {metadata.PersistentId}]"); - } + } + + public void Configure(GregApiContext context) => _context = context ?? throw new ArgumentNullException(nameof(context)); public ModMetadata? GetModMetadata(string modId) { @@ -55,13 +63,87 @@ public void RegisterMod(ModMetadata metadata) public IEnumerable GetAllRegisteredMods() => _registeredMods.Values; - public void LoadAll() - { - _logger.Info("Lade alle Plugins..."); - var plugins = _scanner.ScanDirectory("Mods"); - _loadedPlugins.AddRange(plugins); - _logger.Info($"{_loadedPlugins.Count} Plugins geladen."); - } + public void LoadAll() + { + _logger.Info("Lade alle Plugins..."); + var path = Path.Combine(global::MelonLoader.Utils.MelonEnvironment.ModsDirectory); + var plugins = new GregDependencyResolver().Resolve(_scanner.ScanDirectory(path) + .Where(p => !string.IsNullOrWhiteSpace(p.Manifest.Id)).ToArray()); + foreach (var plugin in plugins) + { + if (_loadedPlugins.Any(p => string.Equals(p.Manifest.Id, plugin.Manifest.Id, StringComparison.OrdinalIgnoreCase))) continue; + _loadedPlugins.Add(plugin); + if (_context != null) LoadRuntimeMod(plugin); + } + _logger.Info($"{_loadedPlugins.Count} Plugins geladen."); + } + + public void Update(float deltaTime) + { + foreach (var mod in _runtimeMods.Values.ToArray()) + Safe(mod, () => mod.Instance.OnUpdate(deltaTime), "OnUpdate"); + } + + public void SceneLoaded(string sceneName) + { + foreach (var mod in _runtimeMods.Values.ToArray()) + Safe(mod, () => mod.Instance.OnSceneLoaded(sceneName), "OnSceneLoaded"); + } + + public void Shutdown() + { + _context?.LifetimeSource?.Cancel(); + foreach (var id in _runtimeMods.Keys.ToArray()) Unload(id); + } + + public bool Unload(string modId) + { + if (!_runtimeMods.Remove(modId, out var mod)) return false; + Safe(mod, mod.Instance.OnShutdown, "OnShutdown"); + mod.Dispose(); + return true; + } + + private void LoadRuntimeMod(PluginInfo plugin) + { + if (_context == null || string.IsNullOrWhiteSpace(plugin.ModTypeName)) return; + try + { + var assembly = Assembly.LoadFrom(plugin.AssemblyPath); + var type = assembly.GetType(plugin.ModTypeName, throwOnError: true)!; + if (!typeof(GregMod).IsAssignableFrom(type)) + throw new GregPluginLoadException($"{plugin.Manifest.Id}: entry type does not derive from GregMod."); + var instance = (GregMod?)Activator.CreateInstance(type) ?? throw new GregPluginLoadException($"{plugin.Manifest.Id}: could not create entry type."); + instance.Initialize(_context); + var loaded = new LoadedMod(plugin.Manifest.Id, instance); + _runtimeMods.Add(plugin.Manifest.Id, loaded); + Safe(loaded, instance.OnLoad, "OnLoad"); + Safe(loaded, instance.OnReady, "OnReady"); + _logger.Info($"Mod ready: {plugin.Manifest.Id} ({plugin.Manifest.Version})"); + } + catch (Exception ex) + { + _logger.Error($"Mod load failed: {plugin.Manifest.Id}", ex); + } + } + + private void Safe(LoadedMod mod, Action action, string phase) + { + try { action(); } + catch (Exception ex) { _logger.Error($"Mod {mod.Id} {phase} failed", ex); } + } + + private sealed class LoadedMod : IDisposable + { + public string Id { get; } + public GregMod Instance { get; } + public LoadedMod(string id, GregMod instance) { Id = id; Instance = instance; } + public void Dispose() + { + Instance.DisposeSubscriptions(); + Instance.DisposeResources(); + } + } public IReadOnlyList GetLoadedPlugins() => _loadedPlugins.AsReadOnly(); } diff --git a/src/Infrastructure/Scripting/Lua/LuaHotReload.cs b/src/Infrastructure/Scripting/Lua/LuaHotReload.cs index 2445141a..9f87ad1c 100644 --- a/src/Infrastructure/Scripting/Lua/LuaHotReload.cs +++ b/src/Infrastructure/Scripting/Lua/LuaHotReload.cs @@ -122,6 +122,8 @@ private void ProcessPendingReloads() return null; string? current = Path.GetDirectoryName(fullPath); + if (current != null && current.Equals(rootPath, PathComparison) && Path.GetExtension(fullPath).Equals(".lua", StringComparison.OrdinalIgnoreCase)) + return Path.GetFileNameWithoutExtension(fullPath); while (current != null && IsPathWithin(current, rootPath)) { if (File.Exists(Path.Combine(current, "main.lua"))) diff --git a/src/Infrastructure/Scripting/Lua/Modules/GregEventLuaModule.cs b/src/Infrastructure/Scripting/Lua/Modules/GregEventLuaModule.cs index 2bfdb82c..f1374359 100644 --- a/src/Infrastructure/Scripting/Lua/Modules/GregEventLuaModule.cs +++ b/src/Infrastructure/Scripting/Lua/Modules/GregEventLuaModule.cs @@ -16,7 +16,14 @@ namespace gregCore.Infrastructure.Scripting.Lua.Modules; public static class GregEventLuaModule { - private static readonly Dictionary handler)>> _handlers = new(); + private sealed class Subscription + { + public string Token = ""; + public string HookName = ""; + public Action Handler = null!; + } + + private static readonly Dictionary> _handlers = new(); /// /// Registriert Event-Funktionen im greg-Table. @@ -24,11 +31,12 @@ public static class GregEventLuaModule public static void Register(Table greg, Script script, GregEventBus eventBus, string modId) { // greg.on(hookName, callback) – Subscribe to an event - greg["on"] = (Action)((hookName, callback) => - { - try - { - Action handler = payload => + greg["on"] = (Func)((hookName, callback) => + { + try + { + var token = $"{modId}:{Guid.NewGuid():N}"; + Action handler = payload => { try { @@ -41,31 +49,45 @@ public static void Register(Table greg, Script script, GregEventBus eventBus, st } }; - eventBus.Subscribe(hookName, handler); + eventBus.Subscribe(hookName, handler); // Track for cleanup if (!_handlers.TryGetValue(modId, out var list)) { - list = new List<(Closure, Action)>(); - _handlers[modId] = list; - } - list.Add((callback, handler)); - } - catch (Exception ex) - { - MelonLogger.Error($"[LuaMod:{modId}] greg.on('{hookName}') failed: {ex.Message}"); - } - }); + list = new List(); + _handlers[modId] = list; + } + list.Add(new Subscription { Token = token, HookName = hookName, Handler = handler }); + return token; + } + catch (Exception ex) + { + MelonLogger.Error($"[LuaMod:{modId}] greg.on('{hookName}') failed: {ex.Message}"); + return ""; + } + }); + + greg["off"] = (Action)(token => + { + if (!_handlers.TryGetValue(modId, out var list)) return; + var subscription = list.FirstOrDefault(x => x.Token == token); + if (subscription == null) return; + eventBus.Unsubscribe(subscription.HookName, subscription.Handler); + list.Remove(subscription); + }); // greg.once(hookName, callback) – Subscribe once, auto-unsubscribes after first call - greg["once"] = (Action)((hookName, callback) => - { - try - { - Action? handler = null; - handler = payload => - { - eventBus.Unsubscribe(hookName, handler!); + greg["once"] = (Func)((hookName, callback) => + { + try + { + var token = $"{modId}:{Guid.NewGuid():N}"; + Action? handler = null; + handler = payload => + { + eventBus.Unsubscribe(hookName, handler!); + if (_handlers.TryGetValue(modId, out var list)) + list.RemoveAll(x => x.Token == token); try { @@ -76,15 +98,20 @@ public static void Register(Table greg, Script script, GregEventBus eventBus, st { MelonLogger.Error($"[LuaMod:{modId}] greg.once handler error for '{hookName}': {ex.Message}"); } - }; - - eventBus.Subscribe(hookName, handler); - } - catch (Exception ex) - { - MelonLogger.Error($"[LuaMod:{modId}] greg.once('{hookName}') failed: {ex.Message}"); - } - }); + }; + + eventBus.Subscribe(hookName, handler); + if (!_handlers.TryGetValue(modId, out var subscriptions)) + _handlers[modId] = subscriptions = new List(); + subscriptions.Add(new Subscription { Token = token, HookName = hookName, Handler = handler }); + return token; + } + catch (Exception ex) + { + MelonLogger.Error($"[LuaMod:{modId}] greg.once('{hookName}') failed: {ex.Message}"); + return ""; + } + }); // greg.fire(hookName, dataTable) – Fire a custom event greg["fire"] = (Action)((hookName, dataTable) => @@ -152,10 +179,13 @@ public static Table PayloadToTable(Script script, EventPayload payload) /// /// Entfernt alle Handler eines Mods (für Shutdown/Hot-Reload). /// - public static void UnregisterAll(string modId, GregEventBus eventBus) - { - // Note: EventBus unsubscribe by handler reference would need to be tracked - // For now, clear the tracking list - _handlers.Remove(modId); - } -} + public static void UnregisterAll(string modId, GregEventBus eventBus) + { + if (!_handlers.Remove(modId, out var subscriptions)) return; + foreach (var subscription in subscriptions) + { + try { eventBus.Unsubscribe(subscription.HookName, subscription.Handler); } + catch (Exception ex) { MelonLogger.Error($"[LuaMod:{modId}] subscription cleanup failed: {ex.Message}"); } + } + } +} diff --git a/src/Infrastructure/Scripting/Lua/Modules/LuaServerModule.cs b/src/Infrastructure/Scripting/Lua/Modules/LuaServerModule.cs index 17bd8abb..f57e54bd 100644 --- a/src/Infrastructure/Scripting/Lua/Modules/LuaServerModule.cs +++ b/src/Infrastructure/Scripting/Lua/Modules/LuaServerModule.cs @@ -21,27 +21,32 @@ public static void Register(Table greg, Script script, string modId) { try { - var servers = UnityEngine.Object.FindObjectsOfType(); var result = new Table(script); int i = 1; - foreach (var s in servers) + var nm = Il2Cpp.NetworkMap.instance; + if (nm != null && nm.servers != null) { - try + foreach (var kvp in nm.servers) { - var info = new Table(script); - info["id"] = s.ServerID ?? s.GetHashCode().ToString(); - info["hash"] = s.GetHashCode(); - info["is_on"] = s.isOn; - info["is_broken"] = s.isBroken; - info["server_type"] = (int)s.serverType; - info["size_u"] = s.sizeInU; - var pos = s.transform?.position ?? UnityEngine.Vector3.zero; - info["x"] = (double)pos.x; - info["y"] = (double)pos.y; - info["z"] = (double)pos.z; - result[i++] = info; + var s = kvp.Value; + if (s == null) continue; + try + { + var info = new Table(script); + info["id"] = s.ServerID ?? s.GetHashCode().ToString(); + info["hash"] = s.GetHashCode(); + info["is_on"] = s.isOn; + info["is_broken"] = s.isBroken; + info["server_type"] = (int)s.serverType; + info["size_u"] = s.sizeInU; + var pos = s.transform?.position ?? UnityEngine.Vector3.zero; + info["x"] = (double)pos.x; + info["y"] = (double)pos.y; + info["z"] = (double)pos.z; + result[i++] = info; + } + catch { } } - catch { } } return result; } @@ -80,18 +85,23 @@ public static void Register(Table greg, Script script, string modId) { try { - var servers = UnityEngine.Object.FindObjectsOfType(); - foreach (var s in servers) + var nm = Il2Cpp.NetworkMap.instance; + if (nm != null && nm.brokenServers != null) { - try + foreach (var kvp in nm.brokenServers) { - if (s.GetHashCode() == hash && s.isBroken) + var s = kvp.Value; + if (s == null) continue; + try { - s.RepairDevice(); - return true; + if (s.GetHashCode() == hash && s.isBroken) + { + s.RepairDevice(); + return true; + } } + catch { } } - catch { } } return false; } @@ -104,18 +114,26 @@ public static void Register(Table greg, Script script, string modId) try { int repaired = 0; - var servers = UnityEngine.Object.FindObjectsOfType(); - foreach (var s in servers) + var nm = Il2Cpp.NetworkMap.instance; + if (nm != null && nm.brokenServers != null) { - try + var brokenServers = new System.Collections.Generic.List(); + foreach (var kvp in nm.brokenServers) + { + if (kvp.Value != null) brokenServers.Add(kvp.Value); + } + foreach (var s in brokenServers) { - if (s.isBroken) + try { - s.RepairDevice(); - repaired++; + if (s.isBroken) + { + s.RepairDevice(); + repaired++; + } } + catch { } } - catch { } } return repaired; } diff --git a/src/Infrastructure/Settings/GregModSettingsService.cs b/src/Infrastructure/Settings/GregModSettingsService.cs index fb0fc93a..666d6424 100644 --- a/src/Infrastructure/Settings/GregModSettingsService.cs +++ b/src/Infrastructure/Settings/GregModSettingsService.cs @@ -7,11 +7,14 @@ namespace gregCore.Infrastructure.Settings; -public class GregModSettingsService -{ +public class GregModSettingsService +{ private readonly Dictionary _settings = new(); private readonly IGregLogger _logger; - private GregSettingsPersistenceService? _persistence; + private GregSettingsPersistenceService? _persistence; + private bool _savePending; + private DateTime _lastChangeUtc; + private const double SaveDebounceSeconds = 0.5; public GregModSettingsService(IGregLogger logger) { @@ -64,10 +67,11 @@ public void UpdateSetting(string modId, string settingId, T newValue) var entry = Get(modId, settingId); if (entry != null) { - entry.Value = newValue; - entry.OnValueChanged?.Invoke(newValue); - _persistence?.SaveAll(); - _logger.Info($"Setting aktualisiert: {modId}.{settingId} -> {newValue}"); + entry.Value = newValue; + entry.OnValueChanged?.Invoke(newValue); + _savePending = true; + _lastChangeUtc = DateTime.UtcNow; + _logger.Info($"Setting aktualisiert: {modId}.{settingId} -> {newValue}"); } } @@ -93,8 +97,9 @@ public void ResetToDefault(string modId, string settingId) callback?.DynamicInvoke(defaultValue); } - _persistence?.SaveAll(); - _logger.Info($"Setting auf Default zurückgesetzt: {id}"); + _savePending = true; + _lastChangeUtc = DateTime.UtcNow; + _logger.Info($"Setting auf Default zurückgesetzt: {id}"); } } } @@ -103,7 +108,7 @@ public void ResetToDefault(string modId, string settingId) public IEnumerable GetByMod(string modId) => _settings.Values.Where(s => s.ModId == modId); - public IEnumerable Search(string query) + public IEnumerable Search(string query) { if (string.IsNullOrEmpty(query)) return _settings.Values; @@ -111,6 +116,15 @@ public IEnumerable Search(string query) return _settings.Values.Where(s => s.DisplayName.ToLowerInvariant().Contains(q) || s.ModId.ToLowerInvariant().Contains(q) || - (s.Category != null && s.Category.ToLowerInvariant().Contains(q))); - } -} + (s.Category != null && s.Category.ToLowerInvariant().Contains(q))); + } + + public void FlushPendingSave() + { + if (!_savePending || (DateTime.UtcNow - _lastChangeUtc).TotalSeconds < SaveDebounceSeconds) return; + _savePending = false; + _persistence?.SaveAll(); + } + + internal void MarkSaved() => _savePending = false; +} diff --git a/src/Infrastructure/Settings/Services/GregNotificationService.cs b/src/Infrastructure/Settings/Services/GregNotificationService.cs index e1e63abb..85c8d3bb 100644 --- a/src/Infrastructure/Settings/Services/GregNotificationService.cs +++ b/src/Infrastructure/Settings/Services/GregNotificationService.cs @@ -1,17 +1,13 @@ using System; -using System.Collections.Generic; -using UnityEngine; -using UnityEngine.UIElements; -using gregCore.Core.Abstractions; -using gregCore.UI; +using UnityEngine; +using gregCore.Core.Abstractions; +using gregCore.UI; namespace gregCore.Infrastructure.Settings.Services { public class GregNotificationService { private readonly IGregLogger _logger; - private readonly List _activeNotifications = new(); - public GregNotificationService(IGregLogger logger) { _logger = logger.ForContext("NotificationService"); @@ -19,82 +15,8 @@ public GregNotificationService(IGregLogger logger) public void Show(string title, string message, float duration = 5f) { - _activeNotifications.Add(new Notification - { - Title = title, - Message = message, - Expiration = Time.time + duration - }); - _logger.Info($"Notification: {title} - {message}"); - - BuildNotificationUI(title, message, duration); - } - - private void BuildNotificationUI(string title, string message, float duration) - { - var notification = new VisualElement - { - name = $"Notify_{Guid.NewGuid()}", - style = - { - width = 300, - backgroundColor = new Color(0.12f, 0.12f, 0.12f, 0.95f), - borderTopColor = GregUITheme.PrimaryAccent, - borderBottomColor = GregUITheme.PrimaryAccent, - borderLeftColor = GregUITheme.PrimaryAccent, - borderRightColor = GregUITheme.PrimaryAccent, - borderTopWidth = 2, - borderBottomWidth = 2, - borderLeftWidth = 2, - borderRightWidth = 2, - borderTopLeftRadius = 6, - borderTopRightRadius = 6, - borderBottomLeftRadius = 6, - borderBottomRightRadius = 6, - paddingTop = 8, - paddingBottom = 8, - paddingLeft = 10, - paddingRight = 10, - marginBottom = 8, - position = Position.Absolute, - right = 20, - bottom = 20 - } - }; - - var titleLabel = new Label(title) - { - style = - { - fontSize = 14, - unityFontStyleAndWeight = FontStyle.Bold, - color = GregUITheme.SecondaryColor, - marginBottom = 4 - } - }; - notification.Add(titleLabel); - - var messageLabel = new Label(message) - { - style = - { - fontSize = 12, - color = new Color(0.88f, 0.88f, 0.88f) - } - }; - notification.Add(messageLabel); - - GregUIManager.RegisterPanel(notification.name, notification); - - // Auto-remove after duration - _logger.Info($"Notification UI built: {notification.name}"); - } - - private class Notification - { - public string Title { get; set; } = null!; - public string Message { get; set; } = null!; - public float Expiration { get; set; } - } + _logger.Info($"Notification: {title} - {message}"); + GregNotificationManager.Show(string.IsNullOrWhiteSpace(title) ? message : $"{title}: {message}", duration); + } } } diff --git a/src/Infrastructure/Settings/Services/GregSettingsPersistenceService.cs b/src/Infrastructure/Settings/Services/GregSettingsPersistenceService.cs index 8b8c8098..f54539be 100644 --- a/src/Infrastructure/Settings/Services/GregSettingsPersistenceService.cs +++ b/src/Infrastructure/Settings/Services/GregSettingsPersistenceService.cs @@ -37,7 +37,11 @@ public GregSettingsPersistenceService( } } - public void SaveAll() => Save(); + public void SaveAll() + { + Save(); + _modSettingsService.MarkSaved(); + } public void Load() { diff --git a/src/PublicApi/GregApiContext.cs b/src/PublicApi/GregApiContext.cs index 67c3e0f7..b254983c 100644 --- a/src/PublicApi/GregApiContext.cs +++ b/src/PublicApi/GregApiContext.cs @@ -12,5 +12,10 @@ public sealed class GregApiContext public IGregEventBus EventBus { get; init; } = null!; public Core.Events.GregHookBus HookBus { get; init; } = null!; public IGregConfigService Config { get; init; } = null!; - public IGregPersistenceService Persist { get; init; } = null!; -} + public IGregPersistenceService Persist { get; init; } = null!; + public CancellationToken CancellationToken { get; init; } + public GregEventBusPublic Events { get; init; } = null!; + public IGregMainThreadDispatcher MainThread { get; init; } = null!; + public GregResourceRegistry Resources { get; init; } = null!; + internal CancellationTokenSource? LifetimeSource { get; init; } +} diff --git a/src/PublicApi/GregEventBusPublic.cs b/src/PublicApi/GregEventBusPublic.cs index be169894..f6efde80 100644 --- a/src/PublicApi/GregEventBusPublic.cs +++ b/src/PublicApi/GregEventBusPublic.cs @@ -15,6 +15,24 @@ public GregEventBusPublic(IGregEventBus internalBus) _internalBus = internalBus; } - public void Subscribe(string hookName, Action handler) => _internalBus.Subscribe(hookName, handler); - public void Unsubscribe(string hookName, Action handler) => _internalBus.Unsubscribe(hookName, handler); -} + public IDisposable On(string hookName, Action handler) + { + _internalBus.Subscribe(hookName, handler); + return new Subscription(() => _internalBus.Unsubscribe(hookName, handler)); + } + public IDisposable Once(string hookName, Action handler) + { + Action? wrapper = null; + wrapper = payload => { try { handler(payload); } finally { if (wrapper != null) _internalBus.Unsubscribe(hookName, wrapper); } }; + return On(hookName, wrapper); + } + public void Subscribe(string hookName, Action handler) => _ = On(hookName, handler); + public void Unsubscribe(string hookName, Action handler) => _internalBus.Unsubscribe(hookName, handler); + + private sealed class Subscription : IDisposable + { + private Action? _dispose; + public Subscription(Action dispose) => _dispose = dispose; + public void Dispose() => Interlocked.Exchange(ref _dispose, null)?.Invoke(); + } +} diff --git a/src/PublicApi/GregMainThreadDispatcher.cs b/src/PublicApi/GregMainThreadDispatcher.cs new file mode 100644 index 00000000..177760f1 --- /dev/null +++ b/src/PublicApi/GregMainThreadDispatcher.cs @@ -0,0 +1,24 @@ +using System.Collections.Concurrent; + +namespace gregCore.PublicApi; + +public sealed class GregMainThreadDispatcher : IGregMainThreadDispatcher +{ + private readonly int _mainThreadId = Environment.CurrentManagedThreadId; + private readonly ConcurrentQueue _queue = new(); + + public bool IsMainThread => Environment.CurrentManagedThreadId == _mainThreadId; + public void Enqueue(Action action) => _queue.Enqueue(action ?? throw new ArgumentNullException(nameof(action))); + + public int Drain(int maxItems = 256) + { + var count = 0; + while (count < maxItems && _queue.TryDequeue(out var action)) + { + try { action(); } + catch { /* callers receive isolation; runtime logger reports at the integration boundary */ } + count++; + } + return count; + } +} diff --git a/src/PublicApi/GregMod.cs b/src/PublicApi/GregMod.cs index bf6106ac..b8d4e381 100644 --- a/src/PublicApi/GregMod.cs +++ b/src/PublicApi/GregMod.cs @@ -6,20 +6,54 @@ namespace gregCore.PublicApi; -public abstract class GregMod -{ +public abstract class GregMod +{ protected IGregLogger Logger { get; private set; } = null!; protected IGregEventBus EventBus { get; private set; } = null!; - protected GregApiContext Api { get; private set; } = null!; + protected GregApiContext Api { get; private set; } = null!; + private readonly List _subscriptions = new(); + protected CancellationToken CancellationToken => Api.CancellationToken; + protected IGregMainThreadDispatcher MainThread => Api.MainThread; + protected GregResourceRegistry Resources => Api.Resources; public virtual void OnLoad() { } - public virtual void OnReady() { } - public virtual void OnUnload() { } + public virtual void OnReady() { } + public virtual void OnUpdate(float deltaTime) { } + public virtual void OnSceneLoaded(string sceneName) { } + public virtual void OnUnload() { } + public virtual void OnShutdown() => OnUnload(); - internal void Initialize(GregApiContext context) - { - Api = context; + internal void Initialize(GregApiContext context) + { + Api = new GregApiContext + { + Logger = context.Logger, + EventBus = context.EventBus, + HookBus = context.HookBus, + Config = context.Config, + Persist = context.Persist, + CancellationToken = context.CancellationToken, + Events = context.Events, + MainThread = context.MainThread, + Resources = new GregResourceRegistry(), + LifetimeSource = context.LifetimeSource + }; Logger = context.Logger.ForContext(GetType().Name); - EventBus = context.EventBus; - } -} + EventBus = context.EventBus; + } + + protected IDisposable On(string hookName, Action handler) + { + var subscription = Api.Events.On(hookName, handler); + _subscriptions.Add(subscription); + return subscription; + } + + internal void DisposeSubscriptions() + { + foreach (var subscription in _subscriptions.ToArray()) subscription.Dispose(); + _subscriptions.Clear(); + } + + internal void DisposeResources() => Api.Resources?.Dispose(); +} diff --git a/src/PublicApi/GregResourceRegistry.cs b/src/PublicApi/GregResourceRegistry.cs new file mode 100644 index 00000000..47d0f5fe --- /dev/null +++ b/src/PublicApi/GregResourceRegistry.cs @@ -0,0 +1,25 @@ +namespace gregCore.PublicApi; + +public sealed class GregResourceRegistry : IDisposable +{ + private readonly List _resources = new(); + private bool _disposed; + + public T Track(T resource) where T : IDisposable + { + if (_disposed) throw new ObjectDisposedException(nameof(GregResourceRegistry)); + _resources.Add(resource ?? throw new ArgumentNullException(nameof(resource))); + return resource; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + foreach (var resource in Enumerable.Reverse(_resources.ToArray())) + { + try { resource.Dispose(); } catch { } + } + _resources.Clear(); + } +} diff --git a/src/PublicApi/IGregMainThreadDispatcher.cs b/src/PublicApi/IGregMainThreadDispatcher.cs new file mode 100644 index 00000000..b357baa9 --- /dev/null +++ b/src/PublicApi/IGregMainThreadDispatcher.cs @@ -0,0 +1,8 @@ +namespace gregCore.PublicApi; + +public interface IGregMainThreadDispatcher +{ + bool IsMainThread { get; } + void Enqueue(Action action); + int Drain(int maxItems = 256); +} diff --git a/src/PublicApi/Modules/GregPerformanceModule.cs b/src/PublicApi/Modules/GregPerformanceModule.cs index 7df12ec0..760e0003 100644 --- a/src/PublicApi/Modules/GregPerformanceModule.cs +++ b/src/PublicApi/Modules/GregPerformanceModule.cs @@ -2,11 +2,13 @@ namespace gregCore.PublicApi.Modules; -public sealed class GregPerformanceModule -{ +public sealed class GregPerformanceModule +{ private readonly GregPerformanceGovernor _governor; private readonly IGregLogger _logger; - private readonly IGregEventBus _bus; + private readonly IGregEventBus _bus; + private readonly object _resourceEventGate = new(); + private readonly Dictionary, Action> _resourceHandlers = new(); internal GregPerformanceModule(GregApiContext ctx, GregPerformanceGovernor governor) { @@ -21,7 +23,8 @@ internal GregPerformanceModule(GregApiContext ctx, GregPerformanceGovernor gover public PerformanceStats GetStats() => _governor.GetStats(); public ResourceSnapshot GetResourceSnapshot() => _governor.GetStats().Resources; - public PerformanceProfile Balanced => PerformanceProfile.Balanced; + public PerformanceProfile Medium => PerformanceProfile.Balanced; + public PerformanceProfile Balanced => PerformanceProfile.Balanced; public PerformanceProfile HighPerformance => PerformanceProfile.HighPerformance; public PerformanceProfile LowEnd => PerformanceProfile.LowEnd; public PerformanceProfile DatacenterOptimal => PerformanceProfile.DatacenterOptimal; @@ -70,10 +73,30 @@ public Task QueueOperation(string name, Func> operation, Operation public void SetMaxConcurrentOperations(int max) => Configure(GetProfile() with { MaxConcurrentOps = max }); // ── Events ─────────────────────────────────────────────────────────────── - public event Action? OnResourceUpdate - { - add => _bus.Subscribe("greg.performance.ResourceSnapshot", p => value?.Invoke(_governor.GetStats().Resources)); - remove => _bus.Unsubscribe("greg.performance.ResourceSnapshot", p => value?.Invoke(_governor.GetStats().Resources)); + public event Action? OnResourceUpdate + { + add + { + if (value == null) return; + Action handler = _ => value(_governor.GetStats().Resources); + lock (_resourceEventGate) + { + _resourceHandlers[value] = handler; + } + _bus.Subscribe("greg.performance.ResourceSnapshot", handler); + } + remove + { + if (value == null) return; + Action? handler = null; + lock (_resourceEventGate) + { + if (_resourceHandlers.TryGetValue(value, out handler)) + _resourceHandlers.Remove(value); + } + if (handler != null) + _bus.Unsubscribe("greg.performance.ResourceSnapshot", handler); + } } private PerformanceProfile GetProfile() => _governor.GetStats().Profile; diff --git a/src/PublicApi/Modules/GregUIModule.cs b/src/PublicApi/Modules/GregUIModule.cs index f80c9e6c..59e83cd6 100644 --- a/src/PublicApi/Modules/GregUIModule.cs +++ b/src/PublicApi/Modules/GregUIModule.cs @@ -1,4 +1,5 @@ -using gregCore.UI; +using System; +using gregCore.UI; using UnityEngine; namespace gregCore.PublicApi.Modules @@ -10,10 +11,10 @@ public sealed class GregUIModule public GregUIBuilder CreateBuilder(string title) => GregUIBuilder.Create(title); - public void ShowNotification(string message, float duration = 3f) - { - // Integration with notification service - // GregServiceContainer.Get()?.Show(message, duration); - } + public void ShowNotification(string message, float duration = 3f) + { + if (string.IsNullOrWhiteSpace(message)) return; + GregNotificationManager.Show(message, Math.Max(0.25f, duration)); + } } } diff --git a/src/UI/GregNotificationManager.cs b/src/UI/GregNotificationManager.cs index 7f31eb68..f1705413 100644 --- a/src/UI/GregNotificationManager.cs +++ b/src/UI/GregNotificationManager.cs @@ -14,6 +14,8 @@ public static class GregNotificationManager private static VisualElement? _container; private static readonly Queue<(string message, float duration)> _pending = new(); private static readonly List<(VisualElement element, float expireTime)> _active = new(); + private const int MaxPending = 64; + private const int MaxActive = 16; private static bool _initialized; public static void Initialize() @@ -49,6 +51,7 @@ public static void Show(string message, float duration = 3f) if (_container == null) { + if (_pending.Count >= MaxPending) _pending.Dequeue(); _pending.Enqueue((message, duration)); return; } @@ -77,6 +80,13 @@ private static void CreateToast(string message, float duration) { if (_container == null) return; + while (_active.Count >= MaxActive) + { + var oldest = _active[0].element; + try { oldest?.RemoveFromHierarchy(); } catch { } + _active.RemoveAt(0); + } + var toast = new VisualElement(); toast.style.backgroundColor = GregUITheme.SurfaceDark; toast.style.borderLeftWidth = 4; diff --git a/templates/csharp/ExampleMod.cs b/templates/csharp/ExampleMod.cs new file mode 100644 index 00000000..970684b5 --- /dev/null +++ b/templates/csharp/ExampleMod.cs @@ -0,0 +1,24 @@ +using gregCore.PublicApi; +using gregCore.PublicApi.Attributes; +using gregCore.Core.Models; + +namespace ExampleMod; + +[GregMod("example.mod", "Example Mod", "1.0.0")] +public sealed class Example : GregMod +{ + private IDisposable? _subscription; + + public override void OnLoad() + { + Logger.Info("Example mod loaded."); + _subscription = On("gregMod.lifecycle.sceneLoaded", OnScene); + } + + private void OnScene(EventPayload payload) + { + MainThread.Enqueue(() => Logger.Info("Scene callback handled on the main thread.")); + } + + public override void OnShutdown() => _subscription?.Dispose(); +} diff --git a/templates/csharp/GregMod.Template.csproj b/templates/csharp/GregMod.Template.csproj new file mode 100644 index 00000000..2d2eaf5a --- /dev/null +++ b/templates/csharp/GregMod.Template.csproj @@ -0,0 +1,14 @@ + + + net6.0 + enable + enable + gregMod.Example + artifacts/ + false + ../../bin/Release/net6.0/gregCore.dll + + + + + diff --git a/templates/csharp/README.md b/templates/csharp/README.md new file mode 100644 index 00000000..d5a6d999 --- /dev/null +++ b/templates/csharp/README.md @@ -0,0 +1,9 @@ +# GregCore C# mod template + +Build from this directory: + +```bash +dotnet build GregMod.Template.csproj -c Release -p:GregCorePath=/absolute/path/to/gregCore.dll +``` + +The DLL is written to `artifacts/`. Copy it to the game's `Mods` directory. The entry type must use `[GregMod]` and derive from `GregMod`. `OnShutdown` must release resources created outside the automatic `GregMod` subscription registry. diff --git a/templates/lua/README.md b/templates/lua/README.md new file mode 100644 index 00000000..70e7d6b0 --- /dev/null +++ b/templates/lua/README.md @@ -0,0 +1,12 @@ +# GregCore Lua mod template + +Install the `example-mod` directory below the GregCore Lua mods directory. The manifest is the preferred format. Legacy single `.lua` files directly in the Lua directory remain supported and use the filename as their mod ID. + +The event API returns a subscription token: + +```lua +local handle = greg.on("gregMod.lifecycle.sceneLoaded", callback) +greg.off(handle) +``` + +All subscriptions are removed automatically during shutdown/reload. diff --git a/templates/lua/example-mod/main.lua b/templates/lua/example-mod/main.lua new file mode 100644 index 00000000..05d15cfd --- /dev/null +++ b/templates/lua/example-mod/main.lua @@ -0,0 +1,11 @@ +local subscription = greg.on("gregMod.lifecycle.sceneLoaded", function(payload) + greg.log("Loaded scene: " .. tostring(payload.data.sceneName or payload.data.SceneName)) +end) + +function on_update(delta_time) + -- Keep frame work small; use the documented main-thread API for game actions. +end + +function on_shutdown() + greg.off(subscription) +end diff --git a/templates/lua/example-mod/mod.json b/templates/lua/example-mod/mod.json new file mode 100644 index 00000000..eaa02867 --- /dev/null +++ b/templates/lua/example-mod/mod.json @@ -0,0 +1,9 @@ +{ + "Id": "example.lua", + "Name": "Example Lua Mod", + "Version": "1.0.0", + "ApiVersion": "1.0.0", + "Loader": "Lua", + "Entrypoint": "main.lua", + "Dependencies": [] +} diff --git a/tests/Core/DependencyResolverTests.cs b/tests/Core/DependencyResolverTests.cs index 67d1a64d..30e32db7 100644 --- a/tests/Core/DependencyResolverTests.cs +++ b/tests/Core/DependencyResolverTests.cs @@ -10,14 +10,15 @@ using Xunit; using FluentAssertions; using gregCore.Infrastructure.Plugins; -using gregCore.Core.Models; +using gregCore.Core.Models; +using gregCore.Core.Exceptions; namespace gregCore.Tests.Core; public class DependencyResolverTests { [Fact] - public void Resolve_WithLinearDependencies_ShouldReturnCorrectOrder() + public void Resolve_WithLinearDependencies_ShouldReturnCorrectOrder() { var resolver = new GregDependencyResolver(); var plugins = new List @@ -29,6 +30,33 @@ public void Resolve_WithLinearDependencies_ShouldReturnCorrectOrder() var result = resolver.Resolve(plugins); - result.Should().NotBeEmpty(); - } -} + result.Select(x => x.Manifest.Id).Should().Equal("A", "B", "C"); + } + + [Fact] + public void Resolve_WithCycle_ShouldFailClearly() + { + var resolver = new GregDependencyResolver(); + var plugins = new List + { + new() { AssemblyPath = "a.dll", Manifest = new ModManifest { Id = "A", Dependencies = new[] { "B" } } }, + new() { AssemblyPath = "b.dll", Manifest = new ModManifest { Id = "B", Dependencies = new[] { "A" } } } + }; + + var action = () => resolver.Resolve(plugins); + action.Should().Throw().WithMessage("*Cyclic plugin dependency*"); + } + + [Fact] + public void Resolve_WithMissingDependency_ShouldFailClearly() + { + var resolver = new GregDependencyResolver(); + var plugins = new List + { + new() { AssemblyPath = "a.dll", Manifest = new ModManifest { Id = "A", Dependencies = new[] { "missing" } } } + }; + + var action = () => resolver.Resolve(plugins); + action.Should().Throw().WithMessage("*missing dependency 'missing'*"); + } +} diff --git a/tests/Core/GregDoctorTests.cs b/tests/Core/GregDoctorTests.cs new file mode 100644 index 00000000..7211bb34 --- /dev/null +++ b/tests/Core/GregDoctorTests.cs @@ -0,0 +1,17 @@ +using gregCore.Core.Diagnostics; +using Xunit; +using FluentAssertions; + +namespace gregCore.Tests.Core; + +public sealed class GregDoctorTests +{ + [Fact] + public void CreateWithoutGameFiles_ShouldReportUnsupportedBuild() + { + var report = GregDoctor.Create(Path.Combine(Path.GetTempPath(), "gregcore-no-game"), "missing-manifest.json", "test.log"); + report.Status.Should().Be("UNSUPPORTED_GAME_BUILD"); + report.ErrorCode.Should().Be("UNSUPPORTED_GAME_BUILD"); + report.Recommendations.Should().NotBeEmpty(); + } +} diff --git a/tests/PublicApi/GregResourceRegistryTests.cs b/tests/PublicApi/GregResourceRegistryTests.cs new file mode 100644 index 00000000..2b3aa9d1 --- /dev/null +++ b/tests/PublicApi/GregResourceRegistryTests.cs @@ -0,0 +1,27 @@ +using gregCore.PublicApi; +using Xunit; +using FluentAssertions; + +namespace gregCore.Tests.PublicApi; + +public sealed class GregResourceRegistryTests +{ + [Fact] + public void Dispose_ShouldReleaseResourcesInReverseOrder() + { + var calls = new List(); + using (var registry = new GregResourceRegistry()) + { + registry.Track(new Disposable(() => calls.Add(1))); + registry.Track(new Disposable(() => calls.Add(2))); + } + calls.Should().Equal(2, 1); + } + + private sealed class Disposable : IDisposable + { + private readonly Action _action; + public Disposable(Action action) => _action = action; + public void Dispose() => _action(); + } +} diff --git a/tests/gregCore.Tests.csproj b/tests/gregCore.Tests.csproj index 10ff7c81..8f200088 100644 --- a/tests/gregCore.Tests.csproj +++ b/tests/gregCore.Tests.csproj @@ -1,15 +1,22 @@ - - net6.0 + + net6.0 10.0 enable enable - false - - - - - + false + + + + + + + + + + + + @@ -18,4 +25,4 @@ - \ No newline at end of file + diff --git a/tools/GregCoverageScanner/GregCoverageScanner.csproj b/tools/GregCoverageScanner/GregCoverageScanner.csproj new file mode 100644 index 00000000..3e5cff56 --- /dev/null +++ b/tools/GregCoverageScanner/GregCoverageScanner.csproj @@ -0,0 +1,12 @@ + + + Exe + net8.0 + enable + enable + true + + + + + diff --git a/tools/GregCoverageScanner/Program.cs b/tools/GregCoverageScanner/Program.cs new file mode 100644 index 00000000..95892b6e --- /dev/null +++ b/tools/GregCoverageScanner/Program.cs @@ -0,0 +1,93 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Diagnostics; +using Mono.Cecil; + +// Deterministic, offline scanner for a Data Center installation. +internal static class Program +{ +public static int Main(string[] args) +{ +var options = Args.Parse(args); +if (options.GameRoot is null) { Console.Error.WriteLine("Usage: --game-root --output "); return 2; } +var root = Path.GetFullPath(options.GameRoot); +var output = Path.GetFullPath(options.Output ?? Path.Combine("coverage", "build-UNKNOWN")); +Directory.CreateDirectory(output); + +var files = new[] { + Find(root, "Assembly-CSharp.dll", "MelonLoader/Il2CppAssemblies/Assembly-CSharp.dll"), + Find(root, "GameAssembly.dll", "GameAssembly.dll"), + Find(root, "global-metadata.dat", "Data/Metadata/global-metadata.dat") +}; +var fingerprint = new Fingerprint(root, files); +var inventory = new List(); +foreach (var assemblyPath in files.Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && File.Exists(p))) +{ + try { ScanAssembly(assemblyPath, inventory); } + catch (Exception ex) { Console.Error.WriteLine($"warning: {assemblyPath}: {ex.Message}"); } +} +inventory = inventory.OrderBy(x => x.Assembly, StringComparer.Ordinal).ThenBy(x => x.Type, StringComparer.Ordinal) + .ThenBy(x => x.Kind, StringComparer.Ordinal).ThenBy(x => x.Name, StringComparer.Ordinal).ThenBy(x => x.Signature, StringComparer.Ordinal).ToList(); +var relevant = inventory.Where(x => x.ModdingRelevant).ToList(); +var hooks = relevant.Where(x => x.Kind == "method").Select((x, i) => new HookRow { + Id = "scanner." + StableId(x), Name = "gregExt.scanned." + SafeName(x.Domain) + "." + SafeName(x.Name), + Assembly = x.Assembly, Namespace = x.Namespace, Type = x.Type, Member = x.Name, Signature = x.Signature, + Domain = x.Domain, Risk = x.Risk, Status = "review", SupportedLanguages = new[] { "CSharp", "Lua" }, + ApprovalReason = "Discovered as modding-relevant; requires maintainer review before implementation." +}).ToList(); +var excluded = inventory.Where(x => !x.ModdingRelevant).Select(x => new ExcludedRow { Assembly=x.Assembly, Type=x.Type, Member=x.Name, Reason=x.ExclusionReason }).ToList(); + +WriteJson(Path.Combine(output, "fingerprint.json"), fingerprint); +WriteJson(Path.Combine(output, "assembly-inventory.json"), inventory); +WriteJson(Path.Combine(output, "modding-manifest.json"), new Manifest { + ManifestVersion=2, SchemaVersion="2.0.0", GameBuild=fingerprint.GameBuild, AssemblyFingerprint=fingerprint.CombinedSha256, + UnityVersion=fingerprint.UnityVersion, MelonLoaderVersion=fingerprint.MelonLoaderVersion, Il2CppInteropVersion=fingerprint.Il2CppInteropVersion, + Hooks=hooks, ExcludedMembers=excluded +}); +WriteCsv(Path.Combine(output, "coverage.csv"), inventory); +WriteJson(Path.Combine(output, "coverage-diff.json"), new { added = relevant.Select(StableId).OrderBy(x=>x).ToArray(), removed = Array.Empty(), changed = Array.Empty() }); +Console.WriteLine($"scanned {inventory.Count} members, {relevant.Count} modding-relevant; fingerprint {fingerprint.CombinedSha256}"); +return 0; +} + +static void ScanAssembly(string path, List rows) { + using var asm = AssemblyDefinition.ReadAssembly(path, new ReaderParameters { ReadSymbols = false }); + var name = Path.GetFileName(path); + foreach (var type in asm.MainModule.Types.SelectMany(AllTypes).OrderBy(t=>t.FullName, StringComparer.Ordinal)) { + foreach (var m in type.Methods.OrderBy(x=>x.Name, StringComparer.Ordinal).ThenBy(x=>x.FullName, StringComparer.Ordinal)) + rows.Add(Row(name, type, "method", m.Name, m.FullName, m.IsStatic, m.IsPublic)); + foreach (var p in type.Properties.OrderBy(x=>x.Name, StringComparer.Ordinal)) rows.Add(Row(name, type, "property", p.Name, p.FullName, false, p.GetMethod?.IsPublic == true)); + foreach (var f in type.Fields.OrderBy(x=>x.Name, StringComparer.Ordinal)) rows.Add(Row(name, type, "field", f.Name, f.FullName, f.IsStatic, f.IsPublic)); + } +} +static IEnumerable AllTypes(TypeDefinition t) => new[] { t }.Concat(t.NestedTypes.SelectMany(AllTypes)); +static MemberRow Row(string assembly, TypeDefinition type, string kind, string name, string signature, bool isStatic, bool isPublic) { + var relevant = IsRelevant(type, name); + return new MemberRow { Assembly=assembly, Namespace=type.Namespace, Type=type.FullName, Kind=kind, Name=name, Signature=signature, + Static=isStatic, Visibility=isPublic ? "public" : "non-public", Domain=Domain(type.FullName), ModdingRelevant=relevant, + Risk=kind == "method" && name is "Update" or "LateUpdate" or "FixedUpdate" ? "high" : relevant ? "medium" : "low", + ExclusionReason=relevant ? "" : "Unity/third-party internals or compiler-generated member" }; +} +static bool IsRelevant(TypeDefinition t, string member) => !t.FullName.Contains("UnityEngine", StringComparison.OrdinalIgnoreCase) + && !t.FullName.Contains("System.", StringComparison.OrdinalIgnoreCase) && !member.StartsWith("<", StringComparison.Ordinal) + && (t.Namespace.StartsWith("Il2Cpp", StringComparison.OrdinalIgnoreCase) || t.Namespace.StartsWith("DataCenter", StringComparison.OrdinalIgnoreCase)); +static string Domain(string type) { var s=type.ToLowerInvariant(); return s.Contains("player") ? "Player" : s.Contains("network") || s.Contains("server") ? "Network" : s.Contains("save") ? "Save" : s.Contains("ui") ? "UI" : s.Contains("shop") || s.Contains("coin") ? "Economy" : "Gameplay"; } +static string StableId(MemberRow x) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|", x.Assembly,x.Type,x.Kind,x.Name,x.Signature)))).ToLowerInvariant()[..16]; +static string SafeName(string s) => new string(s.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant() is { Length: > 0 } v ? v : "unknown"; +static string Find(string root, string file, string preferred) { var p=Path.Combine(root, preferred); if(File.Exists(p)) return p; return Directory.Exists(root) ? Directory.GetFiles(root,file,SearchOption.AllDirectories).OrderBy(x=>x,StringComparer.Ordinal).FirstOrDefault() ?? p : p; } +static void WriteJson(string path,T value) => File.WriteAllText(path, JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented=true }), new UTF8Encoding(false)); +static void WriteCsv(string path,List rows) { using var w=new StreamWriter(path,false,new UTF8Encoding(false)); w.WriteLine("assembly,type,kind,name,signature,static,visibility,domain,moddingRelevant,risk,exclusionReason"); foreach(var x in rows) w.WriteLine(string.Join(",", new[]{x.Assembly,x.Type,x.Kind,x.Name,x.Signature,x.Static.ToString().ToLowerInvariant(),x.Visibility,x.Domain,x.ModdingRelevant.ToString().ToLowerInvariant(),x.Risk,x.ExclusionReason}.Select(Csv))); static string Csv(string x)=>"\""+x.Replace("\"","\"\"")+"\""; } + +record Args(string? GameRoot,string? Output) { public static Args Parse(string[] a) => new(a.SkipWhile(x=>x!="--game-root").Skip(1).FirstOrDefault(), a.SkipWhile(x=>x!="--output").Skip(1).FirstOrDefault()); } +record Fingerprint { public string GameBuild{get;init;}="UNKNOWN"; public string UnityVersion{get;init;}="UNKNOWN"; public string MelonLoaderVersion{get;init;}="UNKNOWN"; public string Il2CppInteropVersion{get;init;}="UNKNOWN"; public string AssemblyCSharpSha256{get;init;}=""; public string GameAssemblySha256{get;init;}=""; public string MetadataSha256{get;init;}=""; public string CombinedSha256{get;init;}=""; public Fingerprint(string root,string[] files) { GameBuild=ReadVersion(root); UnityVersion=ProductVersion(root,"UnityPlayer.dll"); MelonLoaderVersion=AssemblyVersion(root,"MelonLoader.dll"); Il2CppInteropVersion=AssemblyVersion(root,"Il2CppInterop.Runtime.dll"); AssemblyCSharpSha256=Hash(files[0]); GameAssemblySha256=Hash(files[1]); MetadataSha256=Hash(files[2]); CombinedSha256=HashText(string.Join("\n",GameBuild,AssemblyCSharpSha256,GameAssemblySha256,MetadataSha256,UnityVersion,MelonLoaderVersion,Il2CppInteropVersion)); } } +record MemberRow { public string Assembly{get;init;}=""; public string Namespace{get;init;}=""; public string Type{get;init;}=""; public string Kind{get;init;}=""; public string Name{get;init;}=""; public string Signature{get;init;}=""; public bool Static{get;init;} public string Visibility{get;init;}=""; public string Domain{get;init;}=""; public bool ModdingRelevant{get;init;} public string Risk{get;init;}=""; public string ExclusionReason{get;init;}=""; } +record HookRow { public string Id{get;init;}=""; public string Name{get;init;}=""; public string Assembly{get;init;}=""; public string Namespace{get;init;}=""; public string Type{get;init;}=""; public string Member{get;init;}=""; public string Signature{get;init;}=""; public string Domain{get;init;}=""; public string Risk{get;init;}=""; public string Status{get;init;}=""; public string[] SupportedLanguages{get;init;}=Array.Empty(); public string ApprovalReason{get;init;}=""; } +record ExcludedRow { public string Assembly{get;init;}=""; public string Type{get;init;}=""; public string Member{get;init;}=""; public string Reason{get;init;}=""; } +record Manifest { public int ManifestVersion{get;init;} public string SchemaVersion{get;init;}=""; public string GameBuild{get;init;}=""; public string AssemblyFingerprint{get;init;}=""; public string UnityVersion{get;init;}=""; public string MelonLoaderVersion{get;init;}=""; public string Il2CppInteropVersion{get;init;}=""; public List Hooks{get;init;}=new(); public List ExcludedMembers{get;init;}=new(); } +static string Hash(string path) => File.Exists(path) ? Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant() : ""; +static string HashText(string text) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant(); +static string ReadVersion(string root) => File.Exists(Path.Combine(root,"version.txt")) ? File.ReadAllText(Path.Combine(root,"version.txt")).Trim() : "UNKNOWN"; +static string ProductVersion(string root,string file) { var p=Find(root,file,file); return File.Exists(p) ? FileVersionInfo.GetVersionInfo(p).ProductVersion ?? "UNKNOWN" : "UNKNOWN"; } +static string AssemblyVersion(string root,string file) { var p=Find(root,file,file); try { return File.Exists(p) ? System.Reflection.AssemblyName.GetAssemblyName(p).Version?.ToString() ?? "UNKNOWN" : "UNKNOWN"; } catch { return "UNKNOWN"; } } +} diff --git a/tools/GregCoverageScanner/README.md b/tools/GregCoverageScanner/README.md new file mode 100644 index 00000000..6b744bee --- /dev/null +++ b/tools/GregCoverageScanner/README.md @@ -0,0 +1,16 @@ +# GregCoverageScanner + +Scans a Data Center installation without loading Unity or executing game code. +All inputs are sorted and all output collections are sorted, so identical input +files produce byte-identical artifacts. + +```bash +dotnet run --project tools/GregCoverageScanner -- \ + --game-root /path/to/DataCenter \ + --output coverage/build- +``` + +The output contains `fingerprint.json`, `assembly-inventory.json`, +`modding-manifest.json`, `coverage.csv`, and `coverage-diff.json`. Scanner +discoveries have status `review`; only explicitly reviewed `implemented` hooks +may be copied into `framework/greg_hooks.json`. From aed6b58c0cc73616ba5de125a2bee6a07386a4ea Mon Sep 17 00:00:00 2001 From: mleem97 Date: Thu, 13 Aug 2026 15:09:54 +0200 Subject: [PATCH 2/6] docs: record branch consolidation and cleanup Document the chronological branch audit, retained promotion branches, and rejected changes from redundant automation proposals. --- CHANGELOG.md | 4 ++ docs/maintainers/branch-cleanup-2026-08.md | 47 ++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 docs/maintainers/branch-cleanup-2026-08.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f51235f..f0a14693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ `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 @@ -27,6 +29,8 @@ - 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. ### Verification diff --git a/docs/maintainers/branch-cleanup-2026-08.md b/docs/maintainers/branch-cleanup-2026-08.md new file mode 100644 index 00000000..fe085cac --- /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 they add runtime + coupling and possible duplicate broadcasts; they need a dedicated + multiplayer contract review. +- 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. From 77d4de32f84029caa790e14b796eba8d221fc38a Mon Sep 17 00:00:00 2001 From: mleem97 Date: Thu, 13 Aug 2026 21:14:19 +0200 Subject: [PATCH 3/6] refactor(multiplayer): remove custom networking stack Keep the legacy Rust FFI v7 table layout stable while making its Steam and P2P slots inert. Document the native Unity/Data Center/Steamworks boundary from the local reference assemblies. --- CHANGELOG.md | 10 + README.md | 5 +- docs/CHANGELOG.md | 2 +- docs/SOURCE_LAYOUT.md | 2 +- docs/codebase/CONCERNS.md | 5 +- docs/codebase/INTEGRATIONS.md | 9 +- docs/codebase/STRUCTURE.md | 5 +- docs/codebase/native-coop-assembly-audit.md | 39 + docs/maintainers/branch-cleanup-2026-08.md | 6 +- docs/modding/native-coop.md | 38 + docs/multiplayer-architecture.md | 58 - gregCore.csproj | 1 - scripts/Deploy-Release-ToDataCenter.ps1 | 2 - src/CI_Stubs.cs | 20 +- src/Compatibility/DataCenterModLoader/Core.cs | 30 +- .../DataCenterModLoader/GameAPI.cs | 189 +- .../DataCenterModLoader/HarmonyPatches.cs | 2 +- .../MultiplayerBridge.UI.cs | 298 --- .../DataCenterModLoader/MultiplayerBridge.cs | 1977 ----------------- .../FishNet/GregNetworkCables.cs | 211 -- src/Compatibility/FishNet/GregNetworkRack.cs | 169 -- .../FishNet/GregNetworkServer.cs | 199 -- .../Networking/GregMultiplayerService.cs | 175 -- src/greg.Multiplayer/GregMultiplayerMod.cs | 56 - src/greg.Multiplayer/GregRelayService.cs | 153 -- src/greg.Multiplayer/MultiplayerConfig.cs | 39 - src/greg.Multiplayer/Patches/CablePatch.cs | 51 - src/greg.Multiplayer/Patches/EscMenuPatch.cs | 23 - src/greg.Multiplayer/Patches/RackPatch.cs | 79 - .../Payloads/CableSyncPayload.cs | 15 - .../Payloads/RackSyncPayload.cs | 28 - src/greg.Multiplayer/README.md | 50 - src/greg.Multiplayer/UI/MultiplayerHud.cs | 337 --- src/greg.Multiplayer/greg.Multiplayer.csproj | 54 - 34 files changed, 141 insertions(+), 4196 deletions(-) create mode 100644 docs/codebase/native-coop-assembly-audit.md create mode 100644 docs/modding/native-coop.md delete mode 100644 docs/multiplayer-architecture.md delete mode 100644 src/Compatibility/DataCenterModLoader/MultiplayerBridge.UI.cs delete mode 100644 src/Compatibility/DataCenterModLoader/MultiplayerBridge.cs delete mode 100644 src/Compatibility/FishNet/GregNetworkCables.cs delete mode 100644 src/Compatibility/FishNet/GregNetworkRack.cs delete mode 100644 src/Compatibility/FishNet/GregNetworkServer.cs delete mode 100644 src/Infrastructure/Networking/GregMultiplayerService.cs delete mode 100644 src/greg.Multiplayer/GregMultiplayerMod.cs delete mode 100644 src/greg.Multiplayer/GregRelayService.cs delete mode 100644 src/greg.Multiplayer/MultiplayerConfig.cs delete mode 100644 src/greg.Multiplayer/Patches/CablePatch.cs delete mode 100644 src/greg.Multiplayer/Patches/EscMenuPatch.cs delete mode 100644 src/greg.Multiplayer/Patches/RackPatch.cs delete mode 100644 src/greg.Multiplayer/Payloads/CableSyncPayload.cs delete mode 100644 src/greg.Multiplayer/Payloads/RackSyncPayload.cs delete mode 100644 src/greg.Multiplayer/README.md delete mode 100644 src/greg.Multiplayer/UI/MultiplayerHud.cs delete mode 100644 src/greg.Multiplayer/greg.Multiplayer.csproj diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a14693..d1e7d66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,14 @@ - 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 @@ -38,6 +46,8 @@ - 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 diff --git a/README.md b/README.md index 9e12ac3e..44d9dbcc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -136,6 +136,9 @@ 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 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/CONCERNS.md b/docs/codebase/CONCERNS.md index a9a2e9f2..601d78db 100644 --- a/docs/codebase/CONCERNS.md +++ b/docs/codebase/CONCERNS.md @@ -10,7 +10,9 @@ - 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, exact multiplayer semantics, and validation of optional compatibility modules against the installed game build. +- 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 @@ -24,4 +26,3 @@ - `src/Infrastructure/Performance/GregOperationQueue.cs` - `src/Infrastructure/Performance/GregPerformanceGovernor.cs` - `src/PublicApi/Modules/GregPerformanceModule.cs` - diff --git a/docs/codebase/INTEGRATIONS.md b/docs/codebase/INTEGRATIONS.md index 70107637..ecc0bb90 100644 --- a/docs/codebase/INTEGRATIONS.md +++ b/docs/codebase/INTEGRATIONS.md @@ -4,7 +4,13 @@ - 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`. -- FishNet multiplayer integration is optional under `src/Compatibility/FishNet`. +- 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. @@ -18,4 +24,3 @@ - `src/Bridge/` - `src/Infrastructure/Config/` - `src/Infrastructure/Settings/Services/` - diff --git a/docs/codebase/STRUCTURE.md b/docs/codebase/STRUCTURE.md index f97121a8..31607ee6 100644 --- a/docs/codebase/STRUCTURE.md +++ b/docs/codebase/STRUCTURE.md @@ -4,9 +4,9 @@ - `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 FishNet compatibility code. +- `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, QoL and Multiplayer. +- `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. @@ -20,4 +20,3 @@ The MelonLoader entry point is `src/Core/GregCoreMod.cs`; C# mods derive from `s - `src/Core/GregCoreMod.cs` - `src/PublicApi/GregMod.cs` - `tests/` - 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 index fe085cac..6bd0979e 100644 --- a/docs/maintainers/branch-cleanup-2026-08.md +++ b/docs/maintainers/branch-cleanup-2026-08.md @@ -34,9 +34,9 @@ these classes: - 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 they add runtime - coupling and possible duplicate broadcasts; they need a dedicated - multiplayer contract review. +- 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. 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/gregCore.csproj b/gregCore.csproj index cb001761..85a6cd0f 100644 --- a/gregCore.csproj +++ b/gregCore.csproj @@ -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/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