diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d23720c..57132395 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,237 +1,126 @@ -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: + - main + - 'refactor/**' + - 'compat/**' + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gregcore-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + profiles: + name: Validate compatibility profiles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python scripts/validate_compat_profiles.py + + hooks-v2: + name: Validate hook manifest v2 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python scripts/validate_hook_manifest.py framework/game_hooks.v2.json + + hooks-legacy: + name: Validate legacy hook JSON syntax + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m json.tool game_hooks.json > /dev/null + + build: + name: Build and test (${{ matrix.label }}) + needs: [profiles, hooks-v2, hooks-legacy] + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + label: windows-x64 + - os: ubuntu-latest + label: linux-x64 + runs-on: ${{ matrix.os }} + env: + CI: 'true' + DeployToGameOnBuild: 'false' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 6.0.x + - name: Restore solution + run: dotnet restore gregCore.sln --verbosity minimal + - name: Build solution + run: dotnet build gregCore.sln -c Release --no-restore -p:CI=true -p:DeployToGameOnBuild=false --verbosity minimal + - name: Run managed tests + run: dotnet test tests/gregCore.Tests.csproj -c Release --no-build --logger "trx;LogFileName=gregCore-${{ matrix.label }}.trx" + - name: Stage MelonLoader artifact + shell: bash + run: | + set -euo pipefail + VERSION="$(tr -d '[:space:]' < VERSION)" + OUT="dist/gregCore-${VERSION}-melonloader-${{ matrix.label }}" + mkdir -p "$OUT/Mods/gregCore" + cp bin/Release/net6.0/gregCore.dll "$OUT/Mods/gregCore/" + cp game_hooks.json "$OUT/Mods/gregCore/" + cp framework/game_hooks.v2.json "$OUT/Mods/gregCore/" + cp framework/game_hooks.schema.v2.json "$OUT/Mods/gregCore/" + cp -R compat "$OUT/Mods/gregCore/compat" + cp README.md CHANGELOG.md "$OUT/" + cd dist + if [[ "${{ runner.os }}" == "Windows" ]]; then + powershell -NoProfile -Command "Compress-Archive -Path 'gregCore-${VERSION}-melonloader-${{ matrix.label }}/*' -DestinationPath 'gregCore-${VERSION}-melonloader-${{ matrix.label }}.zip'" + else + zip -qr "gregCore-${VERSION}-melonloader-${{ matrix.label }}.zip" "gregCore-${VERSION}-melonloader-${{ matrix.label }}" + fi + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: gregCore-${{ matrix.label }} + path: dist/*.zip + if-no-files-found: error + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.label }} + path: '**/*.trx' + if-no-files-found: ignore + + public-api: + name: Public API compatibility baseline + needs: [profiles, hooks-v2] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 6.0.x + - name: Ensure shipped API baseline exists + run: test -s eng/PublicApi.Shipped.txt + - name: Build stable contract projects + run: | + dotnet build src/gregCore.Abstractions/gregCore.Abstractions.csproj -c Release -p:CI=true --verbosity minimal + dotnet build src/gregCore.SDK/gregCore.SDK.csproj -c Release -p:CI=true --verbosity minimal diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..2e79e246 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,214 @@ +name: gregCore Release + +on: + workflow_dispatch: + inputs: + version: + description: Exact gregCore version already committed to VERSION and project files + required: true + type: string + profile: + description: Compatibility profile path + required: true + default: compat/profiles/datacenter-1.0.50.15-unity6000.5.json + type: string + prerelease: + description: Mark the GitHub release as prerelease + required: true + default: false + type: boolean + +permissions: + contents: write + +concurrency: + group: gregcore-release-${{ inputs.version }}-${{ inputs.profile }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + profile_id: ${{ steps.profile.outputs.profile_id }} + unity: ${{ steps.profile.outputs.unity }} + game: ${{ steps.profile.outputs.game }} + tag: ${{ steps.profile.outputs.tag }} + maintenance_branch: ${{ steps.profile.outputs.maintenance_branch }} + archive_branch: ${{ steps.profile.outputs.archive_branch }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Validate repository metadata + run: | + python scripts/validate_compat_profiles.py + python scripts/validate_hook_manifest.py framework/game_hooks.v2.json + python -m json.tool game_hooks.json > /dev/null + - name: Require committed version + shell: bash + run: | + set -euo pipefail + REPOSITORY_VERSION="$(tr -d '[:space:]' < VERSION)" + test "$REPOSITORY_VERSION" = "${{ inputs.version }}" || { + echo "VERSION contains $REPOSITORY_VERSION, requested ${{ inputs.version }}" >&2 + exit 1 + } + grep -Fq "${{ inputs.version }}" gregCore.csproj + grep -Fq "${{ inputs.version }}.0" gregCore.csproj + grep -Fq '"gregCore", "${{ inputs.version }}", "TeamGreg"' src/Core/GregCoreMod.cs + grep -Fq "Framework Boot v${{ inputs.version }}" src/Core/GregCoreMod.cs + - name: Require exact verified profile and resolve refs + id: profile + env: + PROFILE_PATH: ${{ inputs.profile }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + python - <<'PY' + import hashlib + import json + import os + import re + from pathlib import Path + + profile_path = Path(os.environ['PROFILE_PATH']).resolve() + repository = Path.cwd().resolve() + if repository not in profile_path.parents: + raise SystemExit('profile path escapes the repository') + + profile = json.loads(profile_path.read_text(encoding='utf-8')) + version = os.environ['RELEASE_VERSION'] + if not re.fullmatch(r'\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', version): + raise SystemExit(f'invalid semantic version: {version}') + if profile.get('status') not in {'current', 'supported'}: + raise SystemExit('release profiles must have current or supported status') + if profile.get('unity', {}).get('exactVersionKnown') is not True: + raise SystemExit('release blocked: exact Unity patch version is not verified') + + missing_hashes = [ + ref.get('path', '') + for ref in profile.get('referenceFiles', []) + if ref.get('required') and not ref.get('sha256') + ] + if missing_hashes: + raise SystemExit('release blocked: missing required SHA-256 values: ' + ', '.join(missing_hashes)) + + hook = profile.get('hookManifest') or {} + hook_path = repository / hook.get('path', '') + expected_hook_hash = hook.get('sha256') + if not expected_hook_hash: + raise SystemExit('release blocked: hook manifest SHA-256 is missing') + actual_hook_hash = hashlib.sha256(hook_path.read_bytes()).hexdigest() + if actual_hook_hash.lower() != expected_hook_hash.lower(): + raise SystemExit('release blocked: hook manifest SHA-256 mismatch') + + unity = profile['unity']['version'] + game = profile['game']['version'] + profile_id = profile['profileId'] + major, minor = version.split('.', 2)[:2] + tag = f'u{unity}-game{game}-gc{version}' + maintenance = f'compat/u{unity}/game-{game}/gc-{major}.{minor}.x' + archive = f'archive/u{unity}/game-{game}/gc-{version}' + output = Path(os.environ['GITHUB_OUTPUT']) + with output.open('a', encoding='utf-8') as stream: + stream.write(f'profile_id={profile_id}\n') + stream.write(f'unity={unity}\n') + stream.write(f'game={game}\n') + stream.write(f'tag={tag}\n') + stream.write(f'maintenance_branch={maintenance}\n') + stream.write(f'archive_branch={archive}\n') + PY + + build: + needs: validate + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + label: windows-x64 + - os: ubuntu-latest + label: linux-x64 + runs-on: ${{ matrix.os }} + env: + CI: 'true' + DeployToGameOnBuild: 'false' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 6.0.x + - run: dotnet restore gregCore.sln --verbosity minimal + - run: dotnet build gregCore.sln -c Release --no-restore -p:CI=true -p:DeployToGameOnBuild=false --verbosity minimal + - run: dotnet test tests/gregCore.Tests.csproj -c Release --no-build + - name: Package verified MelonLoader build + shell: bash + run: | + set -euo pipefail + OUT="dist/gregCore-${{ inputs.version }}-${{ needs.validate.outputs.profile_id }}-melonloader-${{ matrix.label }}" + NAME="$(basename "$OUT")" + mkdir -p "$OUT/Mods/gregCore" + cp bin/Release/net6.0/gregCore.dll "$OUT/Mods/gregCore/" + cp game_hooks.json "$OUT/Mods/gregCore/" + cp framework/game_hooks.v2.json "$OUT/Mods/gregCore/" + cp framework/game_hooks.schema.v2.json "$OUT/Mods/gregCore/" + cp -R compat "$OUT/Mods/gregCore/compat" + cp README.md CHANGELOG.md "$OUT/" + cd dist + if [[ "${{ runner.os }}" == "Windows" ]]; then + powershell -NoProfile -Command "Compress-Archive -Path '${NAME}/*' -DestinationPath '${NAME}.zip'" + else + zip -qr "${NAME}.zip" "$NAME" + fi + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.label }} + path: dist/*.zip + if-no-files-found: error + + publish: + needs: [validate, build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/download-artifact@v4 + with: + pattern: release-* + merge-multiple: true + path: release-assets + - name: Create immutable tag and compatibility branches atomically + env: + TAG: ${{ needs.validate.outputs.tag }} + MAINTENANCE: ${{ needs.validate.outputs.maintenance_branch }} + ARCHIVE: ${{ needs.validate.outputs.archive_branch }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + echo "Tag already exists: $TAG" >&2 + exit 1 + fi + if git ls-remote --exit-code --heads origin "refs/heads/$ARCHIVE" >/dev/null 2>&1; then + echo "Immutable archive branch already exists: $ARCHIVE" >&2 + exit 1 + fi + + git tag --annotate "$TAG" --message "gregCore ${{ inputs.version }} for ${{ needs.validate.outputs.profile_id }}" + git push --atomic origin \ + "$TAG" \ + "HEAD:refs/heads/$MAINTENANCE" \ + "HEAD:refs/heads/$ARCHIVE" + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.validate.outputs.tag }} + name: gregCore ${{ inputs.version }} (${{ needs.validate.outputs.profile_id }}) + prerelease: ${{ inputs.prerelease }} + generate_release_notes: true + files: release-assets/*.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c9465be..82140fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,50 +1,76 @@ -# Changelog +# Changelog + + + +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 semantic versioning. Releases are created explicitly from a verified compatibility profile; normal pushes do not modify versions or publish artifacts. + +## [Unreleased] + +### Added + +- Machine-readable compatibility profiles for game, Unity, IL2CPP, loader, platform, architecture and reference fingerprints. +- Runtime compatibility reports and managed-only safe mode. +- Versioned full-signature hook manifest schema with profile-specific candidates. +- Centralized idempotent IL2CPP class-injection registry. +- Loader-neutral contracts and separately buildable migration project boundaries. +- Profile, hook, fingerprint and compatibility-branch tooling. +- Manual profile-driven release workflow with maintenance branches, immutable archive branches and profile-specific tags. +- Managed compatibility-verification tests and public API baseline. + +### Changed + +- Dynamic hooks resolve complete method signatures and reject unresolved parameter types instead of shortening signatures. +- High-frequency hooks can remain deferred until a subscriber exists. +- Several greg hook IDs mapped to one game method share a single Harmony patch. +- `AssemblyVersion` remains stable at `1.0.0.0` for binary-compatible 1.x releases. +- Game reference packs are selected through `GREG_REFERENCE_ROOT`/`GregReferenceRoot`. +- Local deployment is opt-in and no longer contains a hardcoded Steam installation path. +- CI validates and builds only; it no longer bumps versions, creates tags or publishes releases on every push. +- BepInEx IL2CPP is no longer packaged by relabeling the MelonLoader host and remains unsupported until a dedicated adapter is verified. +- Legacy assembly resolution is restricted to exact legacy assembly names. + +### Fixed + +- Deferred events are no longer discarded when the performance governor budget is exhausted. +- Duplicate automated security and performance pull requests were consolidated before the architecture branch was created. ## [1.2.1] - 2026-06-28 ### Changed -- Auto-release: chore: sync all versions to 1.2.0 - - - - -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`. - -## [Unreleased] - -### Changed - -- Initial unreleased section. - -## [1.1.0] - 2026-04-28 - -### Added -- Full CI/CD pipeline: auto version bump, win+linux × MelonLoader+BepInEx releases -- API docs auto-generation from `game_hooks.json` and `framework/greg_hooks.json` -- `scripts/generate_api_docs.py` — generates `docs/FrameworkAPI.md` -- Grid placement system (`greg.GridPlacement`) -- Multiplayer HUD (`src/UI`) -- Lua REPL integration -- Rust FFI host bridge - -### Fixed -- Resolved merge conflict in `GregPersistenceService.cs` -- Fixed `sponsor-tier-sync.yml` (`core` identifier conflict in github-script) -- Fixed workflow failures (incorrect project paths, hardcoded version strings) - -## [1.0.0] - 2026-01-01 - -### Added -- gregCore mod framework initial release -- Multiple mods: WallRack, GridPlacement, UI, CommonShop, etc. -- Harmony hooking system (Prefix/Postfix) -- Save engine with versioning (LiteDB) -- Unit tests - -[Unreleased]: https://github.com/mleem97/gregCore/compare/v1.1.0...HEAD -[1.1.0]: https://github.com/mleem97/gregCore/compare/v1.0.0...v1.1.0 -[1.0.0]: https://github.com/mleem97/gregCore/releases/tag/v1.0.0 +- Synchronized framework versions to 1.2.1. + +## [1.1.0] - 2026-04-28 + +### Added + +- Initial CI/CD release pipeline. +- API docs generation from hook manifests. +- Grid placement system (`greg.GridPlacement`). +- Multiplayer HUD. +- Lua REPL integration. +- Rust FFI host bridge. + +### Fixed + +- Resolved merge conflict in `GregPersistenceService.cs`. +- Fixed sponsor workflow identifier conflict. +- Fixed workflow failures caused by project paths and hardcoded version strings. + +## [1.0.0] - 2026-01-01 + +### Added + +- Initial gregCore mod framework release. +- WallRack, GridPlacement, UI and CommonShop modules. +- Harmony Prefix/Postfix hook infrastructure. +- LiteDB save engine with schema versioning. +- Unit tests. + +[Unreleased]: https://github.com/mleem97/gregCore/compare/v1.2.1...HEAD +[1.2.1]: https://github.com/mleem97/gregCore/compare/v1.1.0...v1.2.1 +[1.1.0]: https://github.com/mleem97/gregCore/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/mleem97/gregCore/releases/tag/v1.0.0 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..fbf15153 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + 10.0 + enable + true + true + portable + true + true + $(NoWarn);CS1701;CS1702;MSB3243;MSB3245 + + diff --git a/README.md b/README.md index 7c29bd35..92436a87 100644 --- a/README.md +++ b/README.md @@ -1,160 +1,202 @@ # gregCore -> Modular .NET 6 IL2CPP mod framework for **Data Center** — Harmony patching, UI overlays, save engine, scripting, and multi-mod architecture. +> Profile-driven .NET 6 IL2CPP mod framework for **Data Center** with Harmony hooks, UI, persistence, scripting and stable mod APIs. [![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)]() -[![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)]() +[![Version](https://img.shields.io/badge/Version-1.2.1-orange?style=for-the-badge)](./VERSION) +[![GameVersion](https://img.shields.io/badge/Game%20Version-1.0.50.15-yellow?style=for-the-badge)](./compat/current.json) +[![Unity](https://img.shields.io/badge/Unity-6000.5%20profile-black?style=for-the-badge&logo=unity&logoColor=white)](./compat/current.json) -## Links +## Status -- **Repository:** [github.com/mleem97/gregCore](https://github.com/mleem97/gregCore) -- **Discord / Support:** [discord.gg/greg](https://discord.gg/greg) -- **Website:** [gregframework.eu](https://gregframework.eu) +`main` represents the newest tested reference profile. Compatibility is not inferred from the Unity major/minor version alone. gregCore records and verifies the complete runtime tuple: -## Overview +```text +gregCore version ++ game build ++ Unity version ++ IL2CPP/reference fingerprint ++ loader and Il2CppInterop version ++ platform and architecture +``` + +The current development profile is: + +```text +Data Center 1.0.50.15 +Unity 6000.5 line +MelonLoader 0.7.x +Windows/Linux x64 +``` -**gregCore** is a modular .NET 6 framework for **Data Center** that provides Harmony-based runtime patching, UI overlay management, save engine with versioning, multi-mod architecture with dependency resolution, scripting bridges (Lua, JS, Python), and more. +The exact Unity patch version and SHA-256 values must still be captured from a verified local installation before the profile can be promoted to hash-verified/runtime-verified status. An unknown or mismatched runtime starts in **safe mode**: managed services, logging, configuration and diagnostics remain available while class injection and game-specific Harmony adapters are disabled. -## Compatibility +## Loader support -| Loader | Platform | Status | -|--------|----------|--------| -| MelonLoader 0.7+ | Windows x64 | Supported | -| MelonLoader 0.7+ | Linux x64 | Supported | -| BepInEx 6+ | Windows x64 | Supported | -| BepInEx 6+ | Linux x64 | Supported | +| Loader | Status | Notes | +|---|---|---| +| MelonLoader 0.7.x | Current reference | Dedicated host currently shipped and tested by CI | +| BepInEx IL2CPP | Adapter planned | Not packaged or advertised as supported until a dedicated pinned host passes the same profile matrix | + +A MelonLoader DLL placed under a BepInEx directory is not considered BepInEx support. ## Features -- Harmony-based runtime patching system (Prefix / Postfix) -- UI overlay and widget management (UI Toolkit / UGUI) -- Save engine with versioning and migration (LiteDB) -- Multi-mod architecture with dependency resolution -- Wall rack and grid placement systems -- Custom shop and employee management APIs -- Logging and diagnostic infrastructure -- Lua, JS and Python scripting bridges -- FishNet multiplayer sync layer (optional) +- Versioned compatibility profiles and runtime safe mode +- Full-signature, profile-aware Harmony hook resolution +- Lazy activation for high-frequency hooks +- Stable greg hook IDs independent of changing game method signatures +- Centralized, idempotent IL2CPP class injection +- UI Toolkit and UGUI integration +- Save engine with migrations +- Multi-mod dependency and event architecture +- Lua, JavaScript and Python bridges +- Custom shop, employee, rack and grid APIs +- Logging, diagnostics and performance governance +- Optional multiplayer integration ## Installation ### MelonLoader -1. Download `gregCore-vX.Y.Z-melonloader-windows.zip` (or `-linux.zip`) -2. Extract into your game's root folder -3. Your `Mods/` folder will contain `gregCore.dll` - -### BepInEx +1. Install the MelonLoader version declared by the selected compatibility profile. +2. Download the matching `gregCore---melonloader-.zip` release. +3. Extract it into the game root. +4. Keep `gregCore.dll`, `game_hooks*.json` and `compat/` together under `Mods/gregCore/`. -1. Download `gregCore-vX.Y.Z-bepinex-windows.zip` (or `-linux.zip`) -2. Extract into your game's root folder -3. `BepInEx/plugins/gregCore/gregCore.dll` is placed automatically +Do not mix a DLL from one profile with hook manifests or compatibility files from another profile. -## Dependencies +## Build from source -### Runtime - -- **MelonLoader** (v0.7.2+) or **BepInEx** (v6+) +Requirements: -### NuGet packages (bundled in release) +- .NET 6 SDK +- a legal local Data Center installation +- generated MelonLoader/Il2CppInterop reference assemblies -- Jint 4.8.0, LiteDB 5.0.21, Mono.Cecil 0.11.6, MoonSharp 2.0.0, Newtonsoft.Json 13.0.3, pythonnet 3.0.5 +Set the reference root explicitly: -### Build only +```bash +export GREG_REFERENCE_ROOT="/path/to/reference-pack" +dotnet restore gregCore.sln +dotnet build gregCore.sln -c Release -p:DeployToGameOnBuild=false +``` -- .NET 6 SDK -- Game reference assemblies in `lib/references/MelonLoader/` +PowerShell: -## Build from Source +```powershell +$env:GREG_REFERENCE_ROOT = "C:\path\to\reference-pack" +dotnet restore gregCore.sln +dotnet build gregCore.sln -c Release -p:DeployToGameOnBuild=false +``` -Requirements: +The reference root must contain the assemblies named in `gregCore.csproj`, including `MelonLoader.dll`, `Il2CppInterop.Runtime.dll`, `Assembly-CSharp.dll` and the required Unity modules. -- .NET 6 SDK -- local Data Center / MelonLoader installation +To capture sizes and SHA-256 hashes into a profile: -> **Note:** This framework was built on Linux using Proton-GE 10-34. Populate `lib/references/MelonLoader/` from your local game install (run the game once with MelonLoader, then copy `MelonLoader/Il2CppAssemblies/` and `MelonLoader/net6/`). +```bash +python scripts/capture_compat_profile.py \ + compat/profiles/datacenter-1.0.50.15-unity6000.5.json \ + --root /path/to/MelonLoader/Il2CppAssemblies \ + --root /path/to/MelonLoader/net6 +``` -Build: +Validate metadata without starting the game: ```bash -git clone https://github.com/mleem97/gregCore.git -cd gregCore -dotnet build -c Release +python scripts/validate_compat_profiles.py +python scripts/validate_hook_manifest.py game_hooks.json framework/game_hooks.v2.json +dotnet test tests/gregCore.Tests.csproj -c Release ``` -Release output: +## Architecture -``` -bin/Release/net6.0/gregCore.dll -``` +```text +stable contracts + gregCore.Abstractions (netstandard2.0) + gregCore.SDK (netstandard2.0) + +managed framework + gregCore.Core (netstandard2.0 migration boundary) + gregCore.Shared (netstandard2.0 migration boundary) -## Repository Layout +runtime adapters + gregCore.Mod + gregCore.Hooks + gregCore.Patches + gregCore.Compatibility + gregCore.Bridge + gregCore.UI +legacy host + gregCore.dll (net6.0, retained during staged extraction) ``` -gregCore.Framework/ -├── src/ # Framework + mod source code -│ ├── Core/ # GregCoreMod.cs — entry point -│ ├── Infrastructure/ # Config, logging, persistence -│ ├── GameLayer/ # Harmony patches for game classes -│ ├── UI/ # UI Toolkit overlay -│ ├── API/ # Public API surface -│ └── ... # 27 modules total -├── framework/ # greg_hooks.json — canonical hook registry -├── game_hooks.json # Patchable methods from IL2CPP dump -├── lib/ # Reference assemblies (game stubs, MelonLoader) -├── docs/ # Auto-generated API docs -├── scripts/ # Build and code-generation helpers -├── tests/ # Unit tests -├── sdk/ # SDK packs -├── examples/ # Example mods (C#, Go, JS, Lua, Python, Rust) -├── .github/workflows/ # CI pipeline -├── VERSION # Single source of truth for version -├── gregCore.csproj # Project file -├── LICENSE # Apache 2.0 -└── README.md + +The existing `gregCore.dll` remains the executable MelonLoader host while production types are moved gradually into the new assemblies. This avoids a flag-day namespace or binary break. + +## Hook manifests + +`framework/game_hooks.v2.json` maps stable greg hook IDs to one or more complete IL2CPP method candidates. Candidates include assembly, full type, method name, generic arity, static/instance state, return type and every parameter type. Unresolvable parameters invalidate the complete candidate; they are never silently removed. + +The legacy `game_hooks.json` array remains readable during migration. Convert it deterministically with: + +```bash +python scripts/convert_hook_manifest_v2.py \ + game_hooks.json framework/game_hooks.v2.generated.json \ + --profile-id datacenter-1.0.50.15-unity6000.5 ``` -## API Documentation +## Version and branch policy -See [`docs/FrameworkAPI.md`](docs/FrameworkAPI.md) for the auto-generated hook reference. +Normal pushes do not bump versions, create tags, publish releases or generate branches. Releases use the manual, profile-driven workflow. -## Credits +- Current development: `main` +- Maintained line: `compat/u/game-/gc-..x` +- Exact archive branch: `archive/u/game-/gc-` +- Immutable tag: `u-game-gc` -| Role | Contributor | -|------|-------------| -| **Codebase** | [mleem97](https://github.com/mleem97) ([TeamGreg Modding](https://github.com/teamGregModding)) | +See: -## Contributing +- [`docs/VERSIONING_AND_BRANCHES.md`](docs/VERSIONING_AND_BRANCHES.md) +- [`docs/BACKWARD_COMPATIBILITY.md`](docs/BACKWARD_COMPATIBILITY.md) +- [`compat/README.md`](compat/README.md) -See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. +## Backward compatibility -## License +The 1.x line keeps `AssemblyVersion` at `1.0.0.0`, treats the public API baseline as append-only, keeps stable hook IDs, uses additive payload/DTO changes and replaces broad assembly redirects with exact legacy facades or type forwarding. -This project is licensed under the **Apache License 2.0**. See [`LICENSE`](./LICENSE). +## Repository layout -## 🚀 Join the gregFramework Team! +```text +compat/ compatibility profiles and schema +framework/ hook manifests and schemas +src/Core/ current managed core and MelonLoader host +src/GameLayer/ IL2CPP/Harmony adapters +src/gregCore.*/ staged assembly boundaries +eng/PublicApi.Shipped.txt 1.x API baseline +scripts/ profile, hook, release and branch tools +tests/ managed compatibility and framework tests +.github/workflows/build.yml validation/build CI only +.github/workflows/release.yml manual profile-driven release +``` -Building the ultimate modding framework for Data Center is a massive undertaking. gregFramework is currently maintained by a passionate core team of three, and we are looking for fellow creators to help us scale this mission! +## API documentation -**Your place in the team:** We won't throw you into the deep end. Depending on your individual strengths and skills, we will match you with the right areas of the project so you can contribute exactly where you have the most fun. +See [`docs/FrameworkAPI.md`](docs/FrameworkAPI.md) for the generated hook reference. -**🌍 Language Requirement:** A solid grasp of written English is required (without relying on machine translation). Being comfortable speaking English in voice chats is a huge plus, but we completely respect those who prefer to stick to text! +## Contributing -**We are looking for motivated volunteers to join our crew across several roles:** +See [`CONTRIBUTING.md`](CONTRIBUTING.md). Changes that affect game types or hooks must update or add a compatibility profile and pass the metadata validation jobs. -- 💻 **Code Wizards** (C#, Rust, Lua, TS, GO) — Build and expand the core framework and mod packages -- 🎨 **Asset Creators** (3D Models, hardware assets) — Bring the framework to life visually -- 📚 **Technical Writers** — Craft wiki entries, maintain documentation, and write user guides -- 🎮 **Alpha Testers** — Hunt down bugs, stress-test the framework, and provide critical feedback -- ⚙️ **System Guardians** — Maintain our Linux servers, Docker containers, and infrastructure -- 🤝 **Community Managers** — Foster our Discord community, gather feedback, and keep the energy high +## License -Interested in joining the project? Everyone is absolutely welcome! Send us an email at **apply@gregframework.eu**, shoot a quick DM, or drop a message on [Discord](https://discord.gg/greg). +Apache License 2.0. See [`LICENSE`](LICENSE). ---- +## Contact -**gregFramework — powered by the community.** +- Repository: [github.com/mleem97/gregCore](https://github.com/mleem97/gregCore) +- Discord: [discord.gg/greg](https://discord.gg/greg) +- Website: [gregframework.eu](https://gregframework.eu) +- Team applications: **apply@gregframework.eu** diff --git a/build/ProjectScaffolding/AssemblyMarker.cs b/build/ProjectScaffolding/AssemblyMarker.cs new file mode 100644 index 00000000..0420daf9 --- /dev/null +++ b/build/ProjectScaffolding/AssemblyMarker.cs @@ -0,0 +1,10 @@ +namespace GregCore.Build.ProjectScaffolding; + +/// +/// Marks migration assemblies until production types are moved behind their +/// final project boundaries. Keeping the projects buildable prevents the +/// solution from advertising modules that do not exist on disk. +/// +internal static class AssemblyMarker +{ +} diff --git a/compat/README.md b/compat/README.md new file mode 100644 index 00000000..59f1373b --- /dev/null +++ b/compat/README.md @@ -0,0 +1,35 @@ +# Compatibility profiles + +`main` is tied to the profile referenced by `compat/current.json`. A profile describes the complete tested runtime tuple rather than only a Unity marketing version: + +- gregCore version line +- game and game build +- exact or partial Unity version +- IL2CPP metadata version when known +- loader and Il2CppInterop versions +- platform and architecture +- required reference assemblies, sizes and SHA-256 hashes +- supported runtime capabilities +- hook manifest version + +## Verification levels + +1. **Declared** — profile JSON is valid. +2. **Size verified** — required binaries exist and match the recorded sizes. +3. **Hash verified** — all recorded SHA-256 values match. +4. **Runtime verified** — class injection and required hooks pass a game smoke test. + +A profile without exact Unity patch information or hashes is not allowed to claim universal compatibility. It may remain the current development reference, but gregCore must enter safe mode when the runtime fingerprint differs. + +## Safe mode + +Safe mode keeps managed services, logging, configuration, the public API and diagnostics available while disabling game-specific class injection and critical Harmony patches. Optional hooks are enabled only after their complete signatures resolve unambiguously. + +## Adding a version + +1. Copy the closest profile under `compat/profiles/`. +2. Record exact Unity, game, loader and interop versions. +3. Run `scripts/capture_compat_profile.py` against the legal local installation to populate sizes and hashes. +4. Generate and validate the hook manifest. +5. Run the compatibility CI and an in-game smoke test. +6. Create a maintenance branch only after the profile is verified. diff --git a/compat/current.json b/compat/current.json new file mode 100644 index 00000000..1a3969ff --- /dev/null +++ b/compat/current.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "profile": "profiles/datacenter-1.0.50.15-unity6000.5.json", + "policy": { + "unknownRuntime": "safe-mode", + "missingRequiredReference": "disable-game-adapters", + "hashMismatch": "disable-game-adapters", + "sizeMismatch": "warn-and-disable-critical-hooks" + } +} diff --git a/compat/profiles/datacenter-1.0.50.15-unity6000.5.json b/compat/profiles/datacenter-1.0.50.15-unity6000.5.json new file mode 100644 index 00000000..414887b3 --- /dev/null +++ b/compat/profiles/datacenter-1.0.50.15-unity6000.5.json @@ -0,0 +1,78 @@ +{ + "$schema": "../schema/greg-compat-profile.schema.json", + "schemaVersion": 2, + "profileId": "datacenter-1.0.50.15-unity6000.5", + "status": "current", + "framework": { + "versionLine": "1.2.x", + "minimumVersion": "1.2.1", + "maximumVersionExclusive": "2.0.0" + }, + "game": { + "id": "data-center", + "version": "1.0.50.15", + "buildId": null + }, + "unity": { + "version": "6000.5", + "exactVersionKnown": false, + "backend": "IL2CPP", + "metadataVersion": null + }, + "runtime": { + "loader": "MelonLoader", + "loaderVersion": "0.7.x", + "interop": "Il2CppInterop", + "interopVersion": null, + "architectures": ["x64"], + "platforms": ["windows", "linux"] + }, + "referenceFiles": [ + { + "path": "Assembly-CSharp.dll", + "required": true, + "size": 1759744, + "sha256": null, + "assemblyVersion": null + }, + { + "path": "UnityEngine.CoreModule.dll", + "required": true, + "size": 4641792, + "sha256": null, + "assemblyVersion": null + }, + { + "path": "Il2CppInterop.Runtime.dll", + "required": true, + "size": 285184, + "sha256": null, + "assemblyVersion": null + }, + { + "path": "MelonLoader.dll", + "required": true, + "size": 2010624, + "sha256": null, + "assemblyVersion": null + } + ], + "features": { + "classInjection": true, + "dynamicHarmonyHooks": true, + "uiToolkit": true, + "entities": true, + "hdrp": true, + "fishNet": false, + "bepInExHost": false + }, + "hookManifest": { + "schemaVersion": 2, + "path": "framework/game_hooks.v2.json", + "sha256": null + }, + "notes": [ + "Unity patch version and binary SHA-256 values must be filled from a verified local installation before this profile can be promoted from size-verified to hash-verified.", + "BepInEx packaging is not considered supported until a dedicated IL2CPP host assembly passes the compatibility matrix." + ] +} diff --git a/compat/schema/greg-compat-profile.schema.json b/compat/schema/greg-compat-profile.schema.json new file mode 100644 index 00000000..3884cbf1 --- /dev/null +++ b/compat/schema/greg-compat-profile.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gregframework.eu/schemas/greg-compat-profile.schema.json", + "title": "gregCore compatibility profile", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "profileId", "framework", "game", "unity", "runtime", "referenceFiles", "features"], + "properties": { + "schemaVersion": { "const": 2 }, + "profileId": { "type": "string", "minLength": 1 }, + "status": { "enum": ["current", "supported", "legacy", "experimental", "retired"] }, + "framework": { + "type": "object", + "additionalProperties": false, + "required": ["versionLine", "minimumVersion"], + "properties": { + "versionLine": { "type": "string" }, + "minimumVersion": { "type": "string" }, + "maximumVersionExclusive": { "type": ["string", "null"] } + } + }, + "game": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version"], + "properties": { + "id": { "type": "string" }, + "version": { "type": "string" }, + "buildId": { "type": ["string", "null"] } + } + }, + "unity": { + "type": "object", + "additionalProperties": false, + "required": ["version", "exactVersionKnown", "backend"], + "properties": { + "version": { "type": "string" }, + "exactVersionKnown": { "type": "boolean" }, + "backend": { "const": "IL2CPP" }, + "metadataVersion": { "type": ["integer", "null"], "minimum": 0 } + } + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "required": ["loader", "loaderVersion", "interop", "interopVersion", "architectures"], + "properties": { + "loader": { "enum": ["MelonLoader", "BepInEx.IL2CPP"] }, + "loaderVersion": { "type": "string" }, + "interop": { "type": "string" }, + "interopVersion": { "type": ["string", "null"] }, + "architectures": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["x64", "arm64"] } + }, + "platforms": { + "type": "array", + "uniqueItems": true, + "items": { "enum": ["windows", "linux", "macos"] } + } + } + }, + "referenceFiles": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "required"], + "properties": { + "path": { "type": "string" }, + "required": { "type": "boolean" }, + "size": { "type": ["integer", "null"], "minimum": 0 }, + "sha256": { "type": ["string", "null"], "pattern": "^[0-9a-fA-F]{64}$" }, + "assemblyVersion": { "type": ["string", "null"] } + } + } + }, + "features": { + "type": "object", + "additionalProperties": { "type": "boolean" } + }, + "hookManifest": { + "type": "object", + "additionalProperties": false, + "properties": { + "schemaVersion": { "type": "integer", "minimum": 1 }, + "path": { "type": "string" }, + "sha256": { "type": ["string", "null"], "pattern": "^[0-9a-fA-F]{64}$" } + } + }, + "notes": { "type": "array", "items": { "type": "string" } } + } +} diff --git a/docs/BACKWARD_COMPATIBILITY.md b/docs/BACKWARD_COMPATIBILITY.md new file mode 100644 index 00000000..93a84977 --- /dev/null +++ b/docs/BACKWARD_COMPATIBILITY.md @@ -0,0 +1,46 @@ +# Backward compatibility policy + +## Public API + +Within the gregCore 1.x line: + +- public types are not removed or moved without a forwarding facade; +- existing public methods are not removed or changed incompatibly; +- new optional parameters must not replace existing overloads; +- DTO changes are additive; +- hook IDs remain stable and renamed hooks keep aliases; +- old behavior is marked `[Obsolete]` for at least one minor release before removal in a new major version; +- `eng/PublicApi.Shipped.txt` is append-only. + +`AssemblyVersion` remains `1.0.0.0` for binary-compatible 1.x releases. Package, file and informational versions continue to follow semantic versioning. + +## Legacy assemblies + +Broad `AppDomain.AssemblyResolve` redirects are prohibited. Compatibility assemblies must use one of: + +- `TypeForwardedTo` for types that retain binary-compatible signatures; +- explicit facade types that delegate to the new API; +- isolated conversion adapters for legacy DTOs. + +Only exact legacy assembly simple names may be redirected. A request for an unknown `gregCore.*` version must fail visibly instead of being silently mapped to an incompatible assembly. + +## Hook contracts + +A hook contract consists of its stable greg hook ID and payload schema. The underlying game method may change per compatibility profile. Profile-specific candidates map the stable hook ID to the current IL2CPP signature. + +Payload fields are additive. A field cannot change type inside the same major version. Removed game capabilities remain registered as unsupported capabilities instead of disappearing from the public API. + +## Save data + +Every persisted record includes: + +- schema identifier; +- schema version; +- producing gregCore version; +- migration history where applicable. + +Migrations are forward-only, deterministic and tested from every still-supported schema. Unknown future schemas are never rewritten by an older framework. + +## Compatibility tests + +Each supported release line should retain fixture mods compiled against previous SDK releases. CI verifies that they load against the current 1.x binaries and that public API baselines do not regress. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md new file mode 100644 index 00000000..39dd34a5 --- /dev/null +++ b/docs/IMPLEMENTATION_STATUS.md @@ -0,0 +1,47 @@ +# Implementation status + +## Implemented on `refactor/il2cpp-version-neutral` + +- Compatibility profile schema and current Data Center profile. +- Runtime fingerprint checks for Unity line, platform, architecture, required file sizes and SHA-256 hashes. +- Managed-only safe mode before class injection, UI adapters and game Harmony patches. +- Full-signature hook manifest v2 with profile-specific candidates. +- Strict type/signature resolution, ambiguity reporting and legacy-manifest conversion. +- Lazy activation for high-frequency hooks and subscriber-aware payload creation. +- Single Harmony patch per game method/patch kind with fan-out to stable greg hook IDs. +- Centralized, idempotent IL2CPP type registration with constructor validation. +- Restricted legacy assembly redirect behavior. +- Stable 1.x assembly version and append-only API policy. +- Loader-neutral contracts and real, separately buildable project boundaries. +- Configurable local reference packs and opt-in deployment. +- Explicit release workflow and Unity/game/framework maintenance/archive branch naming. +- Compatibility/profile/hook validation tools and managed verifier tests. +- Consolidated security and performance PR changes on `main` before branch creation. + +## Staged migration + +The new `gregCore.*` projects are real buildable project boundaries, but most production types still compile into the legacy `gregCore.dll` host. Source extraction is intentionally incremental so existing namespaces, assembly loading and third-party mods are not broken in one commit. + +The intended order is: + +1. Move pure contracts into `gregCore.Abstractions` and `gregCore.SDK` while retaining forwarding facades. +2. Move managed services into `gregCore.Core` and `gregCore.Shared`. +3. Move IL2CPP/Harmony code into `gregCore.Hooks`, `gregCore.Patches` and `gregCore.Compatibility`. +4. Reduce `gregCore.Mod` to the MelonLoader lifecycle host. +5. Add a separately compiled BepInEx IL2CPP host only after a pinned runtime is tested. + +## External verification still required + +The repository cannot manufacture or infer these values safely: + +- exact Unity patch version used by the installed game; +- SHA-256 of the local `Assembly-CSharp.dll`, Unity modules, MelonLoader and Il2CppInterop binaries; +- `global-metadata.dat` fingerprint and metadata version; +- successful in-game class injection and required hook smoke tests; +- a pinned, independently built BepInEx IL2CPP host. + +Use `scripts/capture_compat_profile.py` against a legal local installation, update the profile and hook-manifest hashes, then run the game smoke test. The release workflow blocks verified releases until the exact Unity version and required hashes are present. + +## CI infrastructure state + +The draft PR's GitHub Actions validation jobs currently terminate before checkout or any command step. GitHub exposes no executed steps and no downloadable job logs for those failures. This means the branch has not received a trustworthy hosted build result yet. The PR remains draft until runners execute normally and the Windows/Linux build and test matrix completes. diff --git a/docs/VERSIONING_AND_BRANCHES.md b/docs/VERSIONING_AND_BRANCHES.md new file mode 100644 index 00000000..754896d3 --- /dev/null +++ b/docs/VERSIONING_AND_BRANCHES.md @@ -0,0 +1,90 @@ +# Versioning and branch policy + +## Source of truth + +`main` always represents the newest tested gregCore release candidate for the profile referenced by `compat/current.json`. It must not claim support for a Unity or game build that has not passed profile validation and an in-game smoke test. + +Compatibility is identified by the complete tuple: + +```text +framework version ++ game version/build ++ Unity version ++ IL2CPP metadata/reference fingerprint ++ loader version ++ Il2CppInterop version ++ platform/architecture +``` + +Unity version alone is not a sufficient compatibility key. + +## Branches + +### Current development + +```text +main +``` + +### Framework work + +```text +feature/ +fix/ +refactor/ +``` + +### Maintained compatibility lines + +```text +compat/u/game-/gc-..x +``` + +Example: + +```text +compat/u6000.5/game-1.0.50.15/gc-1.2.x +``` + +The branch advances only with compatible patch releases for that exact profile line. + +### Exact archive branches + +```text +archive/u/game-/gc- +``` + +Example: + +```text +archive/u6000.5/game-1.0.50.15/gc-1.2.1 +``` + +Archive branches are immutable and protected after creation. They exist because the repository policy requires every exact framework/profile combination to remain independently addressable. Tags remain the canonical immutable release identity. + +## Tags + +```text +u-game-gc +``` + +Example: + +```text +u6000.5-game1.0.50.15-gc1.2.1 +``` + +## Release process + +1. Update code, `VERSION`, project metadata and changelog in a release pull request. +2. Validate the selected compatibility profile and hook manifest. +3. Run CI on Windows and Linux. +4. Run the profile's game smoke test. +5. Trigger `gregCore Release` manually with the exact version and profile path. +6. The workflow creates the tag, updates the maintenance branch and creates the immutable archive branch. + +Normal pushes never bump versions, create tags, publish releases or create branches. + +## Backports + +Backports are cherry-picked from `main` into the relevant `compat/...` branch. A backport must not replace the compatibility profile or introduce APIs that require a newer Unity/game runtime. Each backport receives a new patch release and exact archive branch. diff --git a/eng/PublicApi.Shipped.txt b/eng/PublicApi.Shipped.txt new file mode 100644 index 00000000..cc05cb50 --- /dev/null +++ b/eng/PublicApi.Shipped.txt @@ -0,0 +1,11 @@ +# gregCore public API baseline +# +# This file is append-only inside the 1.x release line. Public API removals, +# namespace moves and signature changes require a 2.0 release or a legacy shim. +# The initial detailed baseline is generated by the API compatibility job once +# production types are moved into gregCore.Abstractions and gregCore.SDK. + +gregCore.Abstractions.ILoaderHost +gregCore.Abstractions.IGregLogSink +gregCore.Abstractions.ICompatibilityContext +gregCore.Abstractions.LoaderRuntimeInfo diff --git a/framework/game_hooks.schema.v2.json b/framework/game_hooks.schema.v2.json new file mode 100644 index 00000000..c61c7eb2 --- /dev/null +++ b/framework/game_hooks.schema.v2.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gregframework.eu/schemas/game-hooks.v2.json", + "title": "gregCore game hook manifest v2", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "profileId", "hooks"], + "properties": { + "schemaVersion": { "const": 2 }, + "profileId": { "type": "string", "minLength": 1 }, + "generatedFrom": { + "type": "object", + "additionalProperties": false, + "properties": { + "assemblyCSharpSha256": { "type": ["string", "null"] }, + "metadataSha256": { "type": ["string", "null"] }, + "generatorVersion": { "type": ["string", "null"] } + } + }, + "hooks": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "group", "required", "patchKind", "candidates"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "group": { "type": "string", "minLength": 1 }, + "required": { "type": "boolean" }, + "highFrequency": { "type": "boolean" }, + "captureArguments": { "type": "boolean" }, + "patchKind": { "enum": ["prefix", "postfix"] }, + "candidates": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["assembly", "type", "method", "genericArity", "static", "returnType", "parameterTypes"], + "properties": { + "profiles": { "type": "array", "items": { "type": "string" } }, + "assembly": { "type": "string" }, + "type": { "type": "string" }, + "method": { "type": "string" }, + "genericArity": { "type": "integer", "minimum": 0 }, + "static": { "type": ["boolean", "null"] }, + "returnType": { "type": "string" }, + "parameterTypes": { "type": "array", "items": { "type": "string" } } + } + } + } + } + } + } + } +} diff --git a/framework/game_hooks.v2.json b/framework/game_hooks.v2.json new file mode 100644 index 00000000..352c5859 --- /dev/null +++ b/framework/game_hooks.v2.json @@ -0,0 +1,72 @@ +{ + "$schema": "game_hooks.schema.v2.json", + "schemaVersion": 2, + "profileId": "datacenter-1.0.50.15-unity6000.5", + "generatedFrom": { + "assemblyCSharpSha256": null, + "metadataSha256": null, + "generatorVersion": "2.0.0" + }, + "hooks": [ + { + "id": "greg.PLAYER.CoinChanged", + "group": "Player", + "required": false, + "highFrequency": false, + "captureArguments": true, + "patchKind": "postfix", + "candidates": [ + { + "profiles": ["datacenter-1.0.50.15-unity6000.5"], + "assembly": "Assembly-CSharp", + "type": "Il2Cpp.Player", + "method": "UpdateCoin", + "genericArity": 0, + "static": false, + "returnType": "System.Void", + "parameterTypes": ["System.Single"] + } + ] + }, + { + "id": "greg.SYSTEM.GameSaved", + "group": "System", + "required": false, + "highFrequency": false, + "captureArguments": false, + "patchKind": "postfix", + "candidates": [ + { + "profiles": ["datacenter-1.0.50.15-unity6000.5"], + "assembly": "Assembly-CSharp", + "type": "Il2Cpp.SaveSystem", + "method": "SaveGame", + "genericArity": 0, + "static": false, + "returnType": "System.Void", + "parameterTypes": [] + } + ] + }, + { + "id": "greg.UI.PauseMenu.Opened", + "group": "Ui", + "required": false, + "highFrequency": false, + "captureArguments": false, + "patchKind": "postfix", + "candidates": [ + { + "profiles": ["datacenter-1.0.50.15-unity6000.5"], + "assembly": "Assembly-CSharp", + "type": "Il2Cpp.PauseMenu", + "method": "OnEnable", + "genericArity": 0, + "static": false, + "returnType": "System.Void", + "parameterTypes": [] + } + ] + } + ] +} diff --git a/global.json b/global.json new file mode 100644 index 00000000..a4fd9b9d --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "6.0.428", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/gregCore.csproj b/gregCore.csproj index db867118..eab12f38 100644 --- a/gregCore.csproj +++ b/gregCore.csproj @@ -1,90 +1,109 @@ - - - - net6.0 - enable - true - latest - x64 - gregCore - CS1701;CS1702;MSB3243;MSB3245 - false - false - false - false - false - 1.2.1 - 1.2.1 - 1.2.1.0 - - - - - - - - - references/MelonLoader.dllfalse - references/0Harmony.dllfalse - references/Il2CppInterop.Runtime.dllfalse - references/Il2CppInterop.Common.dllfalse - references/Il2Cppmscorlib.dllfalse - references/Il2CppSystem.dllfalse - references/Il2CppSystem.Core.dllfalse - references/Assembly-CSharp.dllfalse - references/UnityEngine.CoreModule.dllfalse - references/UnityEngine.UI.dllfalse - references/UnityEngine.UIModule.dllfalse - references/UnityEngine.AIModule.dllfalse - references/UnityEngine.AnimationModule.dllfalse - references/UnityEngine.ImageConversionModule.dllfalse - references/UnityEngine.PhysicsModule.dllfalse - references/UnityEngine.TextRenderingModule.dllfalse - references/UnityEngine.UIElementsModule.dllfalse - references/UnityEngine.AssetBundleModule.dllfalse - references/UnityEngine.TextCoreTextEngineModule.dllfalse - references/UnityEngine.TextCoreFontEngineModule.dllfalse - references/Unity.InputSystem.dllfalse - references/Unity.TextMeshPro.dllfalse - references/Unity.Entities.dllfalse - references/Unity.RenderPipelines.HighDefinition.Runtime.dllfalse - references/Unity.RenderPipelines.Core.Runtime.dllfalse - references/Il2CppUMA_Core.dllfalse - - - - - - - - - - - - - - $(GREG_GAME_MODS) - C:\Program Files (x86)\Steam\steamapps\common\Data Center\Mods - - - - - - - - - - - - - - - - - - - + + + + net6.0 + true + x64 + gregCore + true + false + false + false + false + + 1.2.1 + 1.2.1.0 + 1.2.1 + + 1.0.0.0 + + $(GREG_REFERENCE_ROOT) + $(MSBuildProjectDirectory)\references + false + $(GREG_GAME_MODS) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $(GregReferenceRoot)\MelonLoader.dllfalse + $(GregReferenceRoot)\0Harmony.dllfalse + $(GregReferenceRoot)\Il2CppInterop.Runtime.dllfalse + $(GregReferenceRoot)\Il2CppInterop.Common.dllfalse + $(GregReferenceRoot)\Il2Cppmscorlib.dllfalse + $(GregReferenceRoot)\Il2CppSystem.dllfalse + $(GregReferenceRoot)\Il2CppSystem.Core.dllfalse + $(GregReferenceRoot)\Assembly-CSharp.dllfalse + $(GregReferenceRoot)\UnityEngine.CoreModule.dllfalse + $(GregReferenceRoot)\UnityEngine.UI.dllfalse + $(GregReferenceRoot)\UnityEngine.UIModule.dllfalse + $(GregReferenceRoot)\UnityEngine.AIModule.dllfalse + $(GregReferenceRoot)\UnityEngine.AnimationModule.dllfalse + $(GregReferenceRoot)\UnityEngine.ImageConversionModule.dllfalse + $(GregReferenceRoot)\UnityEngine.PhysicsModule.dllfalse + $(GregReferenceRoot)\UnityEngine.TextRenderingModule.dllfalse + $(GregReferenceRoot)\UnityEngine.UIElementsModule.dllfalse + $(GregReferenceRoot)\UnityEngine.AssetBundleModule.dllfalse + $(GregReferenceRoot)\UnityEngine.TextCoreTextEngineModule.dllfalse + $(GregReferenceRoot)\UnityEngine.TextCoreFontEngineModule.dllfalse + $(GregReferenceRoot)\Unity.InputSystem.dllfalse + $(GregReferenceRoot)\Unity.TextMeshPro.dllfalse + $(GregReferenceRoot)\Unity.Entities.dllfalse + $(GregReferenceRoot)\Unity.RenderPipelines.HighDefinition.Runtime.dllfalse + $(GregReferenceRoot)\Unity.RenderPipelines.Core.Runtime.dllfalse + $(GregReferenceRoot)\Il2CppUMA_Core.dllfalse + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/New-CompatibilityBranch.ps1 b/scripts/New-CompatibilityBranch.ps1 new file mode 100644 index 00000000..658794a0 --- /dev/null +++ b/scripts/New-CompatibilityBranch.ps1 @@ -0,0 +1,63 @@ +#!/usr/bin/env pwsh +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory = $true)] + [string]$Profile, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$')] + [string]$FrameworkVersion, + + [string]$StartPoint = 'HEAD', + [switch]$Push +) + +$ErrorActionPreference = 'Stop' +$profileData = Get-Content -Raw -LiteralPath $Profile | ConvertFrom-Json +$unity = [string]$profileData.unity.version +$game = [string]$profileData.game.version +if ([string]::IsNullOrWhiteSpace($unity) -or [string]::IsNullOrWhiteSpace($game)) { + throw 'Profile must contain unity.version and game.version.' +} + +$parts = $FrameworkVersion.Split('.') +$line = "$($parts[0]).$($parts[1]).x" +$maintenance = "compat/u$unity/game-$game/gc-$line" +$archive = "archive/u$unity/game-$game/gc-$FrameworkVersion" +$tag = "u$unity-game$game-gc$FrameworkVersion" + +& git rev-parse --verify $StartPoint | Out-Null +if ($LASTEXITCODE -ne 0) { throw "Unknown start point: $StartPoint" } + +foreach ($ref in @($maintenance, $archive)) { + & git show-ref --verify --quiet "refs/heads/$ref" + if ($LASTEXITCODE -eq 0) { + if ($ref -eq $archive) { throw "Immutable archive branch already exists: $archive" } + Write-Host "Maintenance branch already exists: $maintenance" + continue + } + + if ($PSCmdlet.ShouldProcess($ref, "Create from $StartPoint")) { + & git branch $ref $StartPoint + if ($LASTEXITCODE -ne 0) { throw "Failed to create $ref" } + } +} + +& git show-ref --verify --quiet "refs/tags/$tag" +if ($LASTEXITCODE -eq 0) { throw "Release tag already exists: $tag" } +if ($PSCmdlet.ShouldProcess($tag, "Create annotated tag from $StartPoint")) { + & git tag -a $tag $StartPoint -m "gregCore $FrameworkVersion for $($profileData.profileId)" + if ($LASTEXITCODE -ne 0) { throw "Failed to create tag $tag" } +} + +if ($Push) { + & git push origin $maintenance $archive $tag + if ($LASTEXITCODE -ne 0) { throw 'Push failed.' } +} + +[pscustomobject]@{ + Profile = $profileData.profileId + MaintenanceBranch = $maintenance + ArchiveBranch = $archive + Tag = $tag +} diff --git a/scripts/capture_compat_profile.py b/scripts/capture_compat_profile.py new file mode 100644 index 00000000..085d5388 --- /dev/null +++ b/scripts/capture_compat_profile.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Populate reference sizes and SHA-256 hashes in a compatibility profile.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def resolve_reference(name: str, roots: list[Path]) -> Path | None: + relative = Path(name) + candidates = [root / relative for root in roots] + candidates.extend(root / relative.name for root in roots) + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + return None + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("profile", type=Path) + parser.add_argument("--root", action="append", type=Path, required=True, + help="Reference search root; may be specified multiple times") + parser.add_argument("--output", type=Path) + parser.add_argument("--allow-missing-optional", action="store_true") + args = parser.parse_args() + + profile: dict[str, Any] = json.loads(args.profile.read_text(encoding="utf-8")) + roots = [root.resolve() for root in args.root] + errors: list[str] = [] + + for reference in profile.get("referenceFiles", []): + path = resolve_reference(reference["path"], roots) + if path is None: + if reference.get("required") or not args.allow_missing_optional: + errors.append(f"reference not found: {reference['path']}") + continue + reference["size"] = path.stat().st_size + reference["sha256"] = sha256(path) + + if errors: + for error in errors: + print(error, file=sys.stderr) + return 1 + + output = args.output or args.profile + output.write_text(json.dumps(profile, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"Wrote verified profile: {output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/convert_hook_manifest_v2.py b/scripts/convert_hook_manifest_v2.py new file mode 100644 index 00000000..dd9e7549 --- /dev/null +++ b/scripts/convert_hook_manifest_v2.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Convert the legacy game_hooks.json array to the v2 manifest shape.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +ALIASES = { + "Void": "System.Void", + "Boolean": "System.Boolean", + "Bool": "System.Boolean", + "Int16": "System.Int16", + "Int32": "System.Int32", + "Int": "System.Int32", + "Int64": "System.Int64", + "Long": "System.Int64", + "UInt16": "System.UInt16", + "UInt32": "System.UInt32", + "UInt": "System.UInt32", + "UInt64": "System.UInt64", + "ULong": "System.UInt64", + "Single": "System.Single", + "Float": "System.Single", + "Double": "System.Double", + "String": "System.String", + "Object": "System.Object", +} +HIGH_FREQUENCY = {"Update", "FixedUpdate", "LateUpdate", "OnUpdate"} + + +def normalize_type(name: str) -> str: + value = name.strip() + return ALIASES.get(value, value) + + +def convert_hook(hook: dict[str, Any]) -> dict[str, Any]: + namespace = hook.get("Namespace", "") + class_name = hook.get("ClassName", "") + method_name = hook.get("MethodName", "") + group = hook.get("Group", "System") + type_name = f"{namespace}.{class_name}" if namespace else class_name + + return { + "id": f"greg.{group}.{method_name}", + "group": group, + "required": False, + "highFrequency": method_name in HIGH_FREQUENCY, + "captureArguments": True, + "patchKind": "postfix", + "candidates": [ + { + "profiles": [], + "assembly": "Assembly-CSharp", + "type": type_name, + "method": method_name, + "genericArity": 0, + "static": None, + "returnType": normalize_type(hook.get("ReturnType", "System.Void")), + "parameterTypes": [ + normalize_type(parameter.get("Type", "")) + for parameter in hook.get("Parameters", []) + ], + } + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("input", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--profile-id", required=True) + parser.add_argument("--assembly-sha256") + parser.add_argument("--metadata-sha256") + args = parser.parse_args() + + legacy = json.loads(args.input.read_text(encoding="utf-8")) + if not isinstance(legacy, list): + raise SystemExit("legacy manifest root must be an array") + + hooks = [convert_hook(hook) for hook in legacy] + hooks.sort(key=lambda hook: (hook["id"], hook["candidates"][0]["type"])) + + manifest = { + "$schema": "game_hooks.schema.v2.json", + "schemaVersion": 2, + "profileId": args.profile_id, + "generatedFrom": { + "assemblyCSharpSha256": args.assembly_sha256, + "metadataSha256": args.metadata_sha256, + "generatorVersion": "2.0.0", + }, + "hooks": hooks, + } + args.output.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"Converted {len(hooks)} hooks to {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/new-compatibility-branch.sh b/scripts/new-compatibility-branch.sh new file mode 100644 index 00000000..76855745 --- /dev/null +++ b/scripts/new-compatibility-branch.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: $0 [start-point] [--push]" >&2 +} + +[[ $# -ge 2 ]] || { usage; exit 2; } +PROFILE=$1 +VERSION=$2 +START_POINT=${3:-HEAD} +PUSH=false +[[ ${4:-} == "--push" || ${3:-} == "--push" ]] && PUSH=true + +[[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] || { + echo "Invalid semantic version: $VERSION" >&2 + exit 2 +} + +readarray -t VALUES < <(python3 - "$PROFILE" <<'PY' +import json, sys +profile = json.load(open(sys.argv[1], encoding='utf-8')) +print(profile['profileId']) +print(profile['unity']['version']) +print(profile['game']['version']) +PY +) +PROFILE_ID=${VALUES[0]} +UNITY=${VALUES[1]} +GAME=${VALUES[2]} +IFS=. read -r MAJOR MINOR _ <<< "$VERSION" + +MAINTENANCE="compat/u${UNITY}/game-${GAME}/gc-${MAJOR}.${MINOR}.x" +ARCHIVE="archive/u${UNITY}/game-${GAME}/gc-${VERSION}" +TAG="u${UNITY}-game${GAME}-gc${VERSION}" + +git rev-parse --verify "$START_POINT" >/dev/null +if git show-ref --verify --quiet "refs/heads/$ARCHIVE"; then + echo "Immutable archive branch already exists: $ARCHIVE" >&2 + exit 1 +fi +if git show-ref --verify --quiet "refs/tags/$TAG"; then + echo "Release tag already exists: $TAG" >&2 + exit 1 +fi + +if ! git show-ref --verify --quiet "refs/heads/$MAINTENANCE"; then + git branch "$MAINTENANCE" "$START_POINT" +fi +git branch "$ARCHIVE" "$START_POINT" +git tag -a "$TAG" "$START_POINT" -m "gregCore $VERSION for $PROFILE_ID" + +if $PUSH; then + git push origin "$MAINTENANCE" "$ARCHIVE" "$TAG" +fi + +printf 'Profile: %s\nMaintenance: %s\nArchive: %s\nTag: %s\n' \ + "$PROFILE_ID" "$MAINTENANCE" "$ARCHIVE" "$TAG" diff --git a/scripts/validate_compat_profiles.py b/scripts/validate_compat_profiles.py new file mode 100644 index 00000000..2688192e --- /dev/null +++ b/scripts/validate_compat_profiles.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Validate gregCore compatibility profile files using only the Python stdlib.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") +REQUIRED_TOP_LEVEL = { + "schemaVersion", + "profileId", + "framework", + "game", + "unity", + "runtime", + "referenceFiles", + "features", +} + + +def load_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"{path}: {exc}") from exc + + +def validate_profile(path: Path) -> tuple[str, list[str]]: + data = load_json(path) + errors: list[str] = [] + if not isinstance(data, dict): + return "", [f"{path}: profile root must be an object"] + + missing = REQUIRED_TOP_LEVEL - data.keys() + if missing: + errors.append(f"{path}: missing fields: {', '.join(sorted(missing))}") + + if data.get("schemaVersion") != 2: + errors.append(f"{path}: schemaVersion must be 2") + + profile_id = data.get("profileId") + if not isinstance(profile_id, str) or not profile_id.strip(): + errors.append(f"{path}: profileId must be a non-empty string") + profile_id = "" + + unity = data.get("unity", {}) + if not isinstance(unity, dict) or unity.get("backend") != "IL2CPP": + errors.append(f"{path}: unity.backend must be IL2CPP") + if unity.get("exactVersionKnown") is True and not unity.get("version"): + errors.append(f"{path}: exact Unity profiles require unity.version") + + runtime = data.get("runtime", {}) + if not isinstance(runtime, dict): + errors.append(f"{path}: runtime must be an object") + else: + if runtime.get("loader") not in {"MelonLoader", "BepInEx.IL2CPP"}: + errors.append(f"{path}: unsupported runtime.loader") + architectures = runtime.get("architectures", []) + if not architectures: + errors.append(f"{path}: at least one runtime architecture is required") + + references = data.get("referenceFiles", []) + if not isinstance(references, list): + errors.append(f"{path}: referenceFiles must be an array") + else: + seen_paths: set[str] = set() + for index, reference in enumerate(references): + label = f"{path}: referenceFiles[{index}]" + if not isinstance(reference, dict): + errors.append(f"{label} must be an object") + continue + ref_path = reference.get("path") + if not isinstance(ref_path, str) or not ref_path: + errors.append(f"{label}.path must be non-empty") + elif ref_path.lower() in seen_paths: + errors.append(f"{label}.path is duplicated: {ref_path}") + else: + seen_paths.add(ref_path.lower()) + sha256 = reference.get("sha256") + if sha256 is not None and (not isinstance(sha256, str) or not SHA256_RE.fullmatch(sha256)): + errors.append(f"{label}.sha256 must be null or 64 hexadecimal characters") + size = reference.get("size") + if size is not None and (not isinstance(size, int) or size < 0): + errors.append(f"{label}.size must be null or a non-negative integer") + + return profile_id, errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--compat-root", type=Path, default=Path("compat")) + args = parser.parse_args() + + compat_root: Path = args.compat_root + profile_paths = sorted((compat_root / "profiles").glob("*.json")) + if not profile_paths: + print("No compatibility profiles found", file=sys.stderr) + return 1 + + errors: list[str] = [] + profile_ids: dict[str, Path] = {} + for path in profile_paths: + profile_id, profile_errors = validate_profile(path) + errors.extend(profile_errors) + if profile_id: + if profile_id in profile_ids: + errors.append(f"duplicate profileId {profile_id}: {profile_ids[profile_id]} and {path}") + profile_ids[profile_id] = path + + current_path = compat_root / "current.json" + try: + current = load_json(current_path) + profile_ref = current.get("profile") if isinstance(current, dict) else None + if not isinstance(profile_ref, str) or not profile_ref: + errors.append(f"{current_path}: profile pointer is required") + else: + resolved = (compat_root / profile_ref).resolve() + if not resolved.is_file(): + errors.append(f"{current_path}: referenced profile does not exist: {profile_ref}") + if compat_root.resolve() not in resolved.parents: + errors.append(f"{current_path}: profile pointer escapes compat root") + except ValueError as exc: + errors.append(str(exc)) + + if errors: + print("Compatibility validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print(f"Validated {len(profile_paths)} compatibility profile(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_hook_manifest.py b/scripts/validate_hook_manifest.py new file mode 100644 index 00000000..ac5e6b34 --- /dev/null +++ b/scripts/validate_hook_manifest.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Validate legacy and v2 gregCore hook manifests.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + + +def fail(errors: list[str], message: str) -> None: + errors.append(message) + + +def validate_v2(data: dict[str, Any], path: Path) -> list[str]: + errors: list[str] = [] + if data.get("schemaVersion") != 2: + fail(errors, f"{path}: schemaVersion must be 2") + if not data.get("profileId"): + fail(errors, f"{path}: profileId is required") + + hooks = data.get("hooks") + if not isinstance(hooks, list): + return [f"{path}: hooks must be an array"] + + ids: set[str] = set() + signatures: dict[tuple[Any, ...], str] = {} + for hook_index, hook in enumerate(hooks): + label = f"{path}: hooks[{hook_index}]" + if not isinstance(hook, dict): + fail(errors, f"{label} must be an object") + continue + + hook_id = hook.get("id") + if not isinstance(hook_id, str) or not hook_id: + fail(errors, f"{label}.id is required") + hook_id = f"" + elif hook_id in ids: + fail(errors, f"{label}.id is duplicated: {hook_id}") + ids.add(hook_id) + + if hook.get("patchKind") not in {"prefix", "postfix"}: + fail(errors, f"{label}.patchKind must be prefix or postfix") + + candidates = hook.get("candidates") + if not isinstance(candidates, list) or not candidates: + fail(errors, f"{label}.candidates must contain at least one candidate") + continue + + for candidate_index, candidate in enumerate(candidates): + candidate_label = f"{label}.candidates[{candidate_index}]" + if not isinstance(candidate, dict): + fail(errors, f"{candidate_label} must be an object") + continue + for key in ("assembly", "type", "method", "genericArity", "returnType", "parameterTypes"): + if key not in candidate: + fail(errors, f"{candidate_label}.{key} is required") + if candidate.get("static") not in (True, False, None): + fail(errors, f"{candidate_label}.static must be true, false or null") + parameter_types = candidate.get("parameterTypes") + if not isinstance(parameter_types, list) or not all(isinstance(item, str) and item for item in parameter_types): + fail(errors, f"{candidate_label}.parameterTypes must contain non-empty strings") + parameter_types = [] + + signature = ( + candidate.get("assembly"), + candidate.get("type"), + candidate.get("method"), + candidate.get("genericArity"), + candidate.get("static"), + tuple(parameter_types), + hook.get("patchKind"), + ) + previous = signatures.get(signature) + if previous and previous == hook_id: + fail(errors, f"{candidate_label}: duplicate candidate signature in {hook_id}") + signatures[signature] = hook_id + + return errors + + +def validate_legacy(data: list[Any], path: Path) -> list[str]: + errors: list[str] = [] + for index, hook in enumerate(data): + label = f"{path}: hooks[{index}]" + if not isinstance(hook, dict): + fail(errors, f"{label} must be an object") + continue + for key in ("Group", "ClassName", "MethodName", "Parameters"): + if key not in hook: + fail(errors, f"{label}.{key} is required") + parameters = hook.get("Parameters") + if not isinstance(parameters, list): + fail(errors, f"{label}.Parameters must be an array") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("paths", nargs="+", type=Path) + args = parser.parse_args() + + errors: list[str] = [] + for path in args.paths: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"{path}: {exc}") + continue + + if isinstance(data, dict): + errors.extend(validate_v2(data, path)) + elif isinstance(data, list): + errors.extend(validate_legacy(data, path)) + else: + errors.append(f"{path}: root must be an object or array") + + if errors: + print("Hook manifest validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print(f"Validated {len(args.paths)} hook manifest(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/Core/Abstractions/IGregEventBus.cs b/src/Core/Abstractions/IGregEventBus.cs index 03a55ff0..5fad1b40 100644 --- a/src/Core/Abstractions/IGregEventBus.cs +++ b/src/Core/Abstractions/IGregEventBus.cs @@ -1,14 +1,15 @@ -/// -/// Schicht: Core -/// Zweck: Interface für das Event-Bus System. -/// Maintainer: Zentraler Message-Broker für alle Mods und Hooks. -/// - -namespace gregCore.Core.Abstractions; - -public interface IGregEventBus -{ - void Subscribe(string hookName, Action handler); - void Unsubscribe(string hookName, Action handler); - bool Publish(string hookName, EventPayload payload); -} +/// +/// Schicht: Core +/// Zweck: Interface für das Event-Bus System. +/// Maintainer: Zentraler Message-Broker für alle Mods und Hooks. +/// + +namespace gregCore.Core.Abstractions; + +public interface IGregEventBus +{ + void Subscribe(string hookName, Action handler); + void Unsubscribe(string hookName, Action handler); + bool HasSubscribers(string hookName); + bool Publish(string hookName, EventPayload payload); +} diff --git a/src/Core/Compatibility/CompatibilityProfiles.cs b/src/Core/Compatibility/CompatibilityProfiles.cs new file mode 100644 index 00000000..e515850e --- /dev/null +++ b/src/Core/Compatibility/CompatibilityProfiles.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using Newtonsoft.Json; + +namespace gregCore.Core.Compatibility; + +public enum CompatibilityLevel +{ + Unknown = 0, + Declared = 1, + SizeVerified = 2, + HashVerified = 3, + RuntimeVerified = 4, + Incompatible = 100 +} + +public sealed class CompatibilityProfile +{ + [JsonProperty("schemaVersion")] + public int SchemaVersion { get; set; } + + [JsonProperty("profileId")] + public string ProfileId { get; set; } = string.Empty; + + [JsonProperty("status")] + public string Status { get; set; } = "experimental"; + + [JsonProperty("framework")] + public FrameworkCompatibility Framework { get; set; } = new(); + + [JsonProperty("game")] + public GameCompatibility Game { get; set; } = new(); + + [JsonProperty("unity")] + public UnityCompatibility Unity { get; set; } = new(); + + [JsonProperty("runtime")] + public RuntimeCompatibility Runtime { get; set; } = new(); + + [JsonProperty("referenceFiles")] + public List ReferenceFiles { get; set; } = new(); + + [JsonProperty("features")] + public Dictionary Features { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + [JsonProperty("hookManifest")] + public HookManifestCompatibility? HookManifest { get; set; } + + [JsonProperty("notes")] + public List Notes { get; set; } = new(); + + public bool Supports(string capability) => + Features.TryGetValue(capability, out bool supported) && supported; +} + +public sealed class FrameworkCompatibility +{ + [JsonProperty("versionLine")] + public string VersionLine { get; set; } = string.Empty; + + [JsonProperty("minimumVersion")] + public string MinimumVersion { get; set; } = string.Empty; + + [JsonProperty("maximumVersionExclusive")] + public string? MaximumVersionExclusive { get; set; } +} + +public sealed class GameCompatibility +{ + [JsonProperty("id")] + public string Id { get; set; } = string.Empty; + + [JsonProperty("version")] + public string Version { get; set; } = string.Empty; + + [JsonProperty("buildId")] + public string? BuildId { get; set; } +} + +public sealed class UnityCompatibility +{ + [JsonProperty("version")] + public string Version { get; set; } = string.Empty; + + [JsonProperty("exactVersionKnown")] + public bool ExactVersionKnown { get; set; } + + [JsonProperty("backend")] + public string Backend { get; set; } = "IL2CPP"; + + [JsonProperty("metadataVersion")] + public int? MetadataVersion { get; set; } +} + +public sealed class RuntimeCompatibility +{ + [JsonProperty("loader")] + public string Loader { get; set; } = string.Empty; + + [JsonProperty("loaderVersion")] + public string LoaderVersion { get; set; } = string.Empty; + + [JsonProperty("interop")] + public string Interop { get; set; } = string.Empty; + + [JsonProperty("interopVersion")] + public string? InteropVersion { get; set; } + + [JsonProperty("architectures")] + public List Architectures { get; set; } = new(); + + [JsonProperty("platforms")] + public List Platforms { get; set; } = new(); +} + +public sealed class ReferenceCompatibility +{ + [JsonProperty("path")] + public string Path { get; set; } = string.Empty; + + [JsonProperty("required")] + public bool Required { get; set; } + + [JsonProperty("size")] + public long? Size { get; set; } + + [JsonProperty("sha256")] + public string? Sha256 { get; set; } + + [JsonProperty("assemblyVersion")] + public string? AssemblyVersion { get; set; } +} + +public sealed class HookManifestCompatibility +{ + [JsonProperty("schemaVersion")] + public int SchemaVersion { get; set; } + + [JsonProperty("path")] + public string Path { get; set; } = string.Empty; + + [JsonProperty("sha256")] + public string? Sha256 { get; set; } +} + +public sealed record CompatibilityIssue( + string Code, + string Message, + bool IsFatal, + string? Path = null); + +public sealed class CompatibilityReport +{ + public string ProfileId { get; init; } = string.Empty; + public CompatibilityLevel Level { get; init; } + public bool SafeMode { get; init; } + public bool CanLoadGameAdapters => !SafeMode && Level != CompatibilityLevel.Incompatible; + public IReadOnlyList Issues { get; init; } = Array.Empty(); + + public string ToDiagnosticText() + { + var lines = new List + { + $"Profile: {ProfileId}", + $"Level: {Level}", + $"SafeMode: {SafeMode}" + }; + lines.AddRange(Issues.Select(issue => + $"[{(issue.IsFatal ? "FATAL" : "WARN")}] {issue.Code}: {issue.Message}")); + return string.Join(Environment.NewLine, lines); + } +} + +public static class CompatibilityProfileLoader +{ + public static CompatibilityProfile Load(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("A profile path is required.", nameof(path)); + + string json = File.ReadAllText(path); + var profile = JsonConvert.DeserializeObject(json) + ?? throw new InvalidDataException($"Compatibility profile is empty: {path}"); + + Validate(profile, path); + return profile; + } + + public static void Validate(CompatibilityProfile profile, string? source = null) + { + if (profile.SchemaVersion != 2) + throw new InvalidDataException($"Unsupported compatibility schema {profile.SchemaVersion} in {source ?? profile.ProfileId}."); + if (string.IsNullOrWhiteSpace(profile.ProfileId)) + throw new InvalidDataException("Compatibility profileId is required."); + if (!string.Equals(profile.Unity.Backend, "IL2CPP", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("gregCore compatibility profiles currently require the IL2CPP backend."); + if (profile.ReferenceFiles.Any(reference => string.IsNullOrWhiteSpace(reference.Path))) + throw new InvalidDataException("Every compatibility reference requires a path."); + } +} + +public static class CompatibilityVerifier +{ + public static CompatibilityReport Verify( + CompatibilityProfile profile, + Func resolveReference, + string? detectedUnityVersion = null, + string? detectedArchitecture = null, + string? detectedPlatform = null) + { + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(resolveReference); + + var issues = new List(); + bool allSizesVerified = true; + bool allHashesVerified = true; + bool anyHashDeclared = false; + + if (!string.IsNullOrWhiteSpace(detectedUnityVersion) && + !VersionMatches(profile.Unity, detectedUnityVersion)) + { + issues.Add(new CompatibilityIssue( + "UNITY_VERSION_MISMATCH", + $"Expected Unity {profile.Unity.Version}, detected {detectedUnityVersion}.", + IsFatal: true)); + } + + if (!string.IsNullOrWhiteSpace(detectedArchitecture) && + profile.Runtime.Architectures.Count > 0 && + !profile.Runtime.Architectures.Contains(detectedArchitecture, StringComparer.OrdinalIgnoreCase)) + { + issues.Add(new CompatibilityIssue( + "ARCHITECTURE_MISMATCH", + $"Architecture {detectedArchitecture} is not declared by the profile.", + IsFatal: true)); + } + + if (!string.IsNullOrWhiteSpace(detectedPlatform) && + profile.Runtime.Platforms.Count > 0 && + !profile.Runtime.Platforms.Contains(detectedPlatform, StringComparer.OrdinalIgnoreCase)) + { + issues.Add(new CompatibilityIssue( + "PLATFORM_MISMATCH", + $"Platform {detectedPlatform} is not declared by the profile.", + IsFatal: true)); + } + + foreach (ReferenceCompatibility reference in profile.ReferenceFiles) + { + string? resolvedPath = resolveReference(reference); + if (string.IsNullOrWhiteSpace(resolvedPath) || !File.Exists(resolvedPath)) + { + allSizesVerified = false; + allHashesVerified = false; + issues.Add(new CompatibilityIssue( + "REFERENCE_MISSING", + $"Reference file is missing: {reference.Path}", + reference.Required, + resolvedPath ?? reference.Path)); + continue; + } + + var file = new FileInfo(resolvedPath); + if (reference.Size.HasValue && file.Length != reference.Size.Value) + { + allSizesVerified = false; + allHashesVerified = false; + issues.Add(new CompatibilityIssue( + "REFERENCE_SIZE_MISMATCH", + $"{reference.Path}: expected {reference.Size.Value} bytes, found {file.Length} bytes.", + reference.Required, + resolvedPath)); + } + + if (!string.IsNullOrWhiteSpace(reference.Sha256)) + { + anyHashDeclared = true; + string actualHash = ComputeSha256(resolvedPath); + if (!actualHash.Equals(reference.Sha256, StringComparison.OrdinalIgnoreCase)) + { + allHashesVerified = false; + issues.Add(new CompatibilityIssue( + "REFERENCE_HASH_MISMATCH", + $"SHA-256 mismatch for {reference.Path}.", + reference.Required, + resolvedPath)); + } + } + else + { + allHashesVerified = false; + } + } + + bool fatal = issues.Any(issue => issue.IsFatal); + CompatibilityLevel level = fatal + ? CompatibilityLevel.Incompatible + : anyHashDeclared && allHashesVerified + ? CompatibilityLevel.HashVerified + : allSizesVerified + ? CompatibilityLevel.SizeVerified + : CompatibilityLevel.Declared; + + return new CompatibilityReport + { + ProfileId = profile.ProfileId, + Level = level, + SafeMode = fatal, + Issues = issues + }; + } + + private static bool VersionMatches(UnityCompatibility expected, string detected) + { + if (expected.ExactVersionKnown) + return expected.Version.Equals(detected, StringComparison.OrdinalIgnoreCase); + + return detected.StartsWith(expected.Version, StringComparison.OrdinalIgnoreCase); + } + + private static string ComputeSha256(string path) + { + using FileStream stream = File.OpenRead(path); + using SHA256 algorithm = SHA256.Create(); + return Convert.ToHexString(algorithm.ComputeHash(stream)).ToLowerInvariant(); + } +} diff --git a/src/Core/Events/GregEventBus.cs b/src/Core/Events/GregEventBus.cs index 79cf18eb..b9160ac3 100644 --- a/src/Core/Events/GregEventBus.cs +++ b/src/Core/Events/GregEventBus.cs @@ -86,9 +86,7 @@ public void Unsubscribe(string hookName, Action handler) { list.Remove(handler); if (list.Count == 0) - { _handlers.Remove(hookName); - } _isDirty = true; } } @@ -98,11 +96,29 @@ public void Unsubscribe(string hookName, Action handler) } } + public bool HasSubscribers(string hookName) + { + if (_disposed || string.IsNullOrWhiteSpace(hookName)) return false; + + _rwLock.EnterReadLock(); + try + { + return _handlers.TryGetValue(hookName, out var handlers) && handlers.Count > 0; + } + finally + { + _rwLock.ExitReadLock(); + } + } + public bool Publish(string hookName, EventPayload payload) { if (_disposed) return true; ArgumentNullException.ThrowIfNull(hookName); + if (!HasSubscribers(hookName)) + return true; + if (_governor != null && !_governor.CanDispatchEvent()) { _deferredEvents.Enqueue((hookName, payload)); @@ -119,9 +135,15 @@ internal void FlushDeferredEvents() int flushed = 0; const int maxFlushPerFrame = 50; - while (_deferredEvents.TryDequeue(out var ev) && flushed < maxFlushPerFrame) + while (flushed < maxFlushPerFrame) { - if (_governor != null && !_governor.CanDispatchEvent()) break; + // Check the budget before dequeuing so a denied dispatch does not + // silently discard the oldest deferred event. + if (_governor != null && !_governor.CanDispatchEvent()) + break; + if (!_deferredEvents.TryDequeue(out var ev)) + break; + PublishDirect(ev.hookName, ev.payload); flushed++; } @@ -145,9 +167,7 @@ private bool PublishDirect(string hookName, EventPayload payload) { _cachedHandlers.Clear(); foreach (var kvp in _handlers) - { _cachedHandlers[kvp.Key] = kvp.Value.ToArray(); - } _isDirty = false; } } @@ -168,18 +188,14 @@ private bool PublishDirect(string hookName, EventPayload payload) if (handlersToInvoke == null || handlersToInvoke.Length == 0) return true; var currentPayload = payload with { HookName = hookName }; - int handlersExecuted = 0; foreach (var handler in handlersToInvoke) { try { handler(currentPayload); - handlersExecuted++; if (currentPayload.IsCancelable && currentPayload.IsCancelled) - { return false; - } } catch (Exception ex) { @@ -191,9 +207,6 @@ private bool PublishDirect(string hookName, EventPayload payload) return true; } - /// - /// Returns statistics about event processing. - /// public (long processed, long deferred, int handlerCount) GetStats() { _rwLock.EnterReadLock(); @@ -201,9 +214,7 @@ private bool PublishDirect(string hookName, EventPayload payload) { int handlerCount = 0; foreach (var kvp in _handlers) - { handlerCount += kvp.Value.Count; - } return (_totalEventsProcessed, _totalEventsDeferred, handlerCount); } finally @@ -223,7 +234,7 @@ private void Dispose(bool disposing) if (_disposed) return; if (disposing) { - _disposed = true; // Set flag BEFORE disposing lock + _disposed = true; _rwLock.Dispose(); } _disposed = true; diff --git a/src/Core/GregCoreMod.cs b/src/Core/GregCoreMod.cs index 6861f50e..a408505a 100644 --- a/src/Core/GregCoreMod.cs +++ b/src/Core/GregCoreMod.cs @@ -1,287 +1,409 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.InteropServices; using HarmonyLib; using MelonLoader; +using Newtonsoft.Json; using UnityEngine; using gregCore.UI; using greg.UI.Settings; using gregCore.Infrastructure.UI; +using gregCore.Core.Compatibility; using gregCore.Core.Events; using gregCore.Core.Persistence; using gregCore.Sdk; using gregCore.Sdk.Language; using gregCore.GameLayer.Hooks; +using gregCore.GameLayer.Interop; using gregCore.Core.Abstractions; -using Il2CppInterop.Runtime.Injection; [assembly: MelonInfo(typeof(gregCore.Core.GregCoreMod), "gregCore", "1.2.1", "TeamGreg")] -[assembly: MelonColor(255, 0, 191, 165)] // Teal -[assembly: MelonPriority(-1000)] // Load first! +[assembly: MelonColor(255, 0, 191, 165)] +[assembly: MelonPriority(-1000)] -namespace gregCore.Core +namespace gregCore.Core; + +/// +/// MelonLoader host for gregCore. Loader-specific lifecycle work remains here; +/// managed framework services are initialized before IL2CPP-dependent adapters. +/// +public sealed class GregCoreMod : MelonMod { - /// - /// Central MelonMod entry point for gregCore. - /// Provides the modding framework backbone, IL2CPP type registration, - /// UI Toolkit initialization, dynamic hook patching, and gregExt discovery. - /// - public sealed class GregCoreMod : MelonMod + public static GregCoreMod Instance { get; private set; } = null!; + public static IGregAPI? PublicAPI { get; private set; } + public static new HarmonyLib.Harmony? HarmonyInstance { get; private set; } + public static GregEventBus? EventBus { get; private set; } + public static GregHookBus? HookBus { get; private set; } + public static CompatibilityProfile? ActiveCompatibilityProfile { get; private set; } + public static CompatibilityReport? CompatibilityReport { get; private set; } + public static bool SafeMode => CompatibilityReport?.SafeMode ?? true; + + private static bool _lateInitCompleted; + private static bool _shutdownRequested; + private Il2CppTypeRegistry? _typeRegistry; + + public override void OnInitializeMelon() { - public static GregCoreMod Instance { get; private set; } = null!; - public static IGregAPI? PublicAPI { get; private set; } - public static new HarmonyLib.Harmony? HarmonyInstance { get; private set; } - public static GregEventBus? EventBus { get; private set; } - public static GregHookBus? HookBus { get; private set; } - private static bool _lateInitCompleted; - private static bool _shutdownRequested; - - public override void OnInitializeMelon() - { - Instance = this; - MelonLogger.Msg("--- Framework Boot v1.2.1-UI-Toolkit ---"); + Instance = this; + MelonLogger.Msg("--- gregCore Framework Boot v1.2.1 ---"); - // Initialize Social Services - try - { - Infrastructure.Social.DiscordService.Initialize(); - } - catch (Exception ex) { MelonLogger.Error($"[Discord] Init error: {ex.Message}"); } + var logger = new gregCore.Infrastructure.Logging.ConsoleLogger(LoggerInstance); - // Register persistent IL2CPP components - try - { - ClassInjector.RegisterTypeInIl2Cpp(); - ClassInjector.RegisterTypeInIl2Cpp(); - ClassInjector.RegisterTypeInIl2Cpp(); - MelonLogger.Msg("[gregCore] IL2CPP types registered."); - } - catch (Exception ex) - { - MelonLogger.Error($"[gregCore] IL2CPP type registration failed: {ex.Message}"); - } + InitializeManagedCore(logger); + VerifyCompatibility(logger); + InitializeIl2CppAdapters(logger); + InitializeOptionalServices(); + } - // Initialize core event buses - try - { - var logger = new gregCore.Infrastructure.Logging.ConsoleLogger(LoggerInstance); - EventBus = new GregEventBus(logger); - HookBus = new GregHookBus(logger); - API.GregAPI.Initialize(logger); - MelonLogger.Msg("[gregCore] Event buses initialized."); - } - catch (Exception ex) - { - MelonLogger.Error($"[gregCore] Event bus initialization failed: {ex.Message}"); - } + private static void InitializeManagedCore(IGregLogger logger) + { + try + { + EventBus = new GregEventBus(logger); + HookBus = new GregHookBus(logger); + API.GregAPI.Initialize(logger); + MelonLogger.Msg("[gregCore] Managed event buses and public API initialized."); + } + catch (Exception ex) + { + MelonLogger.Error($"[gregCore] Managed core initialization failed: {ex.Message}"); + } + } - // Initialize UI Toolkit root - try - { - GregUIManager.Initialize(); - GregDevConsole.Initialize(); - GregSettingsHub.Initialize(); - MelonLogger.Msg("[gregCore] UI Toolkit root initialized."); - } - catch (Exception ex) + private static void VerifyCompatibility(IGregLogger logger) + { + try + { + string? currentPath = FindCompatibilityCurrentFile(); + if (currentPath == null) { - MelonLogger.Error($"[gregCore] UI initialization failed: {ex.Message}"); + CompatibilityReport = new CompatibilityReport + { + ProfileId = "unresolved", + Level = CompatibilityLevel.Unknown, + SafeMode = true, + Issues = new[] + { + new CompatibilityIssue( + "PROFILE_MISSING", + "compat/current.json was not found. Game adapters are disabled.", + IsFatal: true) + } + }; + logger.Warning(CompatibilityReport.ToDiagnosticText()); + return; } - // Initialize Harmony and dynamic hook patcher - try + var pointer = JsonConvert.DeserializeObject(File.ReadAllText(currentPath)) + ?? throw new InvalidDataException($"Invalid compatibility pointer: {currentPath}"); + string profilePath = Path.GetFullPath(Path.Combine( + Path.GetDirectoryName(currentPath)!, pointer.Profile)); + + ActiveCompatibilityProfile = CompatibilityProfileLoader.Load(profilePath); + CompatibilityReport = CompatibilityVerifier.Verify( + ActiveCompatibilityProfile, + ResolveReferenceFile, + detectedUnityVersion: Application.unityVersion, + detectedArchitecture: RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), + detectedPlatform: DetectPlatform()); + + if (CompatibilityReport.SafeMode) + logger.Warning(CompatibilityReport.ToDiagnosticText()); + else + logger.Info(CompatibilityReport.ToDiagnosticText()); + } + catch (Exception ex) + { + CompatibilityReport = new CompatibilityReport { - HarmonyInstance = new HarmonyLib.Harmony("gregCore.dynamic.hooks"); - if (EventBus != null && HookBus != null) + ProfileId = "invalid", + Level = CompatibilityLevel.Incompatible, + SafeMode = true, + Issues = new[] { - var logger = new gregCore.Infrastructure.Logging.ConsoleLogger(LoggerInstance); - GregNativeEventHooks.Install(logger, HookBus, EventBus, HarmonyInstance); + new CompatibilityIssue("PROFILE_ERROR", ex.Message, IsFatal: true) } - } - catch (Exception ex) - { - MelonLogger.Error($"[gregCore] Dynamic hook initialization failed: {ex.Message}"); - } + }; + logger.Error("Compatibility verification failed; safe mode enabled.", ex); } + } - public override void OnUpdate() - { - if (_shutdownRequested) return; + private void InitializeIl2CppAdapters(IGregLogger logger) + { + bool allowsInjection = !SafeMode && + (ActiveCompatibilityProfile?.Supports("classInjection") ?? false); - // Deferred late initialization (Load-Order Safety) - if (!_lateInitCompleted) - { - _lateInitCompleted = true; - try - { - DiscoverGregExtHosts(); - } - catch (Exception ex) - { - MelonLogger.Error($"[gregCore] gregExt discovery failed: {ex.Message}"); - } + _typeRegistry = new Il2CppTypeRegistry(logger); + _typeRegistry.Register(required: true, profileAllowsInjection: allowsInjection); + _typeRegistry.Register(required: false, profileAllowsInjection: allowsInjection); + _typeRegistry.Register(required: false, profileAllowsInjection: allowsInjection); - try - { - var modsDir = System.IO.Path.Combine(global::MelonLoader.Utils.MelonEnvironment.UserDataDirectory, "Mods", "Scripts"); - GregLanguageRegistry.ScanAndActivate(modsDir); - } - catch (Exception ex) - { - MelonLogger.Error($"[gregCore] Language host activation failed: {ex.Message}"); - } + bool adaptersReady = !SafeMode && _typeRegistry.RequiredRegistrationsSucceeded(); + if (!adaptersReady) + { + MelonLogger.Warning("[gregCore] Running managed-only safe mode; IL2CPP UI and Harmony adapters are disabled."); + return; + } - MelonLogger.Msg("[gregCore] Framework initialization complete."); - } + try + { + GregUIManager.Initialize(); + GregDevConsole.Initialize(); + GregSettingsHub.Initialize(); + MelonLogger.Msg("[gregCore] UI Toolkit adapters initialized."); + } + catch (Exception ex) + { + MelonLogger.Error($"[gregCore] UI adapter initialization failed: {ex.Message}"); + } - // Update UI systems - try - { - GregNotificationManager.Update(); - } - catch (Exception ex) + try + { + HarmonyInstance = new HarmonyLib.Harmony("gregCore.dynamic.hooks"); + if (EventBus != null && HookBus != null) { - MelonLogger.Error($"[gregCore] Notification update failed: {ex.Message}"); + GregNativeEventHooks.Install( + logger, + HookBus, + EventBus, + HarmonyInstance, + ActiveCompatibilityProfile?.ProfileId, + safeMode: SafeMode); } + } + catch (Exception ex) + { + MelonLogger.Error($"[gregCore] Dynamic hook initialization failed: {ex.Message}"); + } + } - try - { - GregLanguageRegistry.OnUpdate(Time.deltaTime); - } - catch (Exception ex) - { - MelonLogger.Error($"[gregCore] Update callback failed: {ex.Message}"); - } + private static void InitializeOptionalServices() + { + try + { + Infrastructure.Social.DiscordService.Initialize(); + } + catch (Exception ex) + { + MelonLogger.Warning($"[Discord] Optional service initialization failed: {ex.Message}"); + } + } + + public override void OnUpdate() + { + if (_shutdownRequested) return; - // Font search: retries periodically until game fonts are found + if (!_lateInitCompleted) + { + _lateInitCompleted = true; try { - GregFontLoader.Tick(); + DiscoverGregExtHosts(); } catch (Exception ex) { - MelonLogger.Error($"[gregCore] Font search tick failed: {ex.Message}"); + MelonLogger.Error($"[gregCore] gregExt discovery failed: {ex.Message}"); } - // Flush deferred events try { - EventBus?.FlushDeferredEvents(); + string modsDir = Path.Combine( + global::MelonLoader.Utils.MelonEnvironment.UserDataDirectory, + "Mods", "Scripts"); + GregLanguageRegistry.ScanAndActivate(modsDir); } catch (Exception ex) { - MelonLogger.Error($"[gregCore] Event flush failed: {ex.Message}"); + MelonLogger.Error($"[gregCore] Language host activation failed: {ex.Message}"); } + + MelonLogger.Msg($"[gregCore] Framework initialization complete. SafeMode={SafeMode}."); } - public override void OnSceneWasLoaded(int buildIndex, string sceneName) + if (!SafeMode) { - try + try { GregNotificationManager.Update(); } + catch (Exception ex) { MelonLogger.Error($"[gregCore] Notification update failed: {ex.Message}"); } + + try { GregFontLoader.Tick(); } + catch (Exception ex) { MelonLogger.Error($"[gregCore] Font search tick failed: {ex.Message}"); } + } + + try { GregLanguageRegistry.OnUpdate(Time.deltaTime); } + catch (Exception ex) { MelonLogger.Error($"[gregCore] Update callback failed: {ex.Message}"); } + + try { EventBus?.FlushDeferredEvents(); } + catch (Exception ex) { MelonLogger.Error($"[gregCore] Event flush failed: {ex.Message}"); } + } + + public override void OnSceneWasLoaded(int buildIndex, string sceneName) + { + try + { + if (!SafeMode) { - // Lazy font search — fonts are only available after scene load GregFontLoader.SearchFonts(); - - if (sceneName != "MainMenu") - { + if (!sceneName.Equals("MainMenu", StringComparison.Ordinal)) GregUIOverrideManager.HideVanillaUI(); - Infrastructure.Social.DiscordService.UpdatePresence("Managing Infrastructure", $"Scene: {sceneName}"); - } - else + } + + Infrastructure.Social.DiscordService.UpdatePresence( + sceneName.Equals("MainMenu", StringComparison.Ordinal) + ? "Planning Next Build" + : "Managing Infrastructure", + sceneName.Equals("MainMenu", StringComparison.Ordinal) + ? "Main Menu" + : $"Scene: {sceneName}"); + + GregLanguageRegistry.OnSceneLoaded(sceneName); + HookBus?.Dispatch("OnSceneLoaded", new gregCore.Core.Models.EventPayload + { + HookName = "OnSceneLoaded", + OccurredAtUtc = DateTime.UtcNow, + Data = new Dictionary { - Infrastructure.Social.DiscordService.UpdatePresence("Planning Next Build", "Main Menu"); + ["BuildIndex"] = buildIndex, + ["SceneName"] = sceneName, + ["SafeMode"] = SafeMode } - GregLanguageRegistry.OnSceneLoaded(sceneName); + }); + } + catch (Exception ex) + { + MelonLogger.Error($"[gregCore] Scene load callback failed: {ex.Message}"); + } + } - // Notify mods about scene change - HookBus?.Dispatch("OnSceneLoaded", new gregCore.Core.Models.EventPayload - { - HookName = "OnSceneLoaded", - OccurredAtUtc = DateTime.UtcNow, - Data = new Dictionary - { - { "BuildIndex", buildIndex }, - { "SceneName", sceneName } - } - }); - } - catch (Exception ex) - { - MelonLogger.Error($"[gregCore] Scene load callback failed: {ex.Message}"); - } + public override void OnApplicationQuit() + { + if (_shutdownRequested) return; + _shutdownRequested = true; + + try + { + GregLanguageRegistry.Shutdown(); + if (!SafeMode) GregUIManager.Shutdown(); + Infrastructure.Social.DiscordService.Shutdown(); + EventBus?.Dispose(); + } + catch (Exception ex) + { + MelonLogger.Error($"[gregCore] Shutdown failed: {ex.Message}"); } - public override void OnApplicationQuit() + base.OnApplicationQuit(); + } + + private static string? FindCompatibilityCurrentFile() + { + string? assemblyDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string gameRoot = global::MelonLoader.Utils.MelonEnvironment.GameRootDirectory; + string modsDirectory = global::MelonLoader.Utils.MelonEnvironment.ModsDirectory; + + string?[] candidates = { - if (_shutdownRequested) return; - _shutdownRequested = true; + assemblyDirectory == null ? null : Path.Combine(assemblyDirectory, "compat", "current.json"), + Path.Combine(modsDirectory, "gregCore", "compat", "current.json"), + Path.Combine(modsDirectory, "compat", "current.json"), + Path.Combine(gameRoot, "compat", "current.json") + }; - try - { - GregLanguageRegistry.Shutdown(); - GregUIManager.Shutdown(); - Infrastructure.Social.DiscordService.Shutdown(); - } - catch (Exception ex) - { - MelonLogger.Error($"[gregCore] Shutdown failed: {ex.Message}"); - } - base.OnApplicationQuit(); - } + return candidates.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path)); + } + + private static string? ResolveReferenceFile(ReferenceCompatibility reference) + { + string gameRoot = global::MelonLoader.Utils.MelonEnvironment.GameRootDirectory; + string? assemblyDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string fileName = reference.Path.Replace('/', Path.DirectorySeparatorChar); - /// - /// Discovers IGregLanguageHost implementations in assemblies named gregExt.* - /// or marked with [GregExtension] and registers them dynamically. - /// - private static void DiscoverGregExtHosts() + string?[] candidates = { - var extAssemblies = AppDomain.CurrentDomain.GetAssemblies() - .Where(a => a.GetName().Name?.StartsWith("gregExt.") == true); + Path.IsPathRooted(fileName) ? fileName : null, + Path.Combine(gameRoot, "MelonLoader", "Il2CppAssemblies", fileName), + Path.Combine(gameRoot, "MelonLoader", "net6", fileName), + Path.Combine(gameRoot, "BepInEx", "interop", fileName), + assemblyDirectory == null ? null : Path.Combine(assemblyDirectory, fileName), + assemblyDirectory == null ? null : Path.Combine(assemblyDirectory, "references", fileName) + }; + + return candidates.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path)); + } - foreach (var asm in extAssemblies) + private static string DetectPlatform() + { + if (OperatingSystem.IsWindows()) return "windows"; + if (OperatingSystem.IsLinux()) return "linux"; + if (OperatingSystem.IsMacOS()) return "macos"; + return "unknown"; + } + + private static void DiscoverGregExtHosts() + { + IEnumerable extAssemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => assembly.GetName().Name?.StartsWith("gregExt.", StringComparison.Ordinal) == true); + + foreach (Assembly assembly in extAssemblies) + { + try { - try - { - var hostTypes = asm.GetTypes() - .Where(t => typeof(IGregLanguageHost).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract); + IEnumerable hostTypes = assembly.GetTypes() + .Where(type => typeof(IGregLanguageHost).IsAssignableFrom(type) && + !type.IsInterface && !type.IsAbstract); - foreach (var hostType in hostTypes) - { - var instance = (IGregLanguageHost?)Activator.CreateInstance(hostType); - if (instance != null) - { - GregLanguageRegistry.RegisterHost(instance.HostId, instance); - MelonLogger.Msg($"[gregCore] gregExt host registered: {instance.HostId} ({instance.HostName})"); - } - } - } - catch (ReflectionTypeLoadException ex) - { - MelonLogger.Warning($"[gregCore] Could not load types from {asm.GetName().Name}: {ex.Message}"); - } - catch (Exception ex) + foreach (Type hostType in hostTypes) { - MelonLogger.Error($"[gregCore] gregExt discovery error in {asm.GetName().Name}: {ex.Message}"); + if (Activator.CreateInstance(hostType) is not IGregLanguageHost instance) + continue; + + GregLanguageRegistry.RegisterHost(instance.HostId, instance); + MelonLogger.Msg( + $"[gregCore] gregExt host registered: {instance.HostId} ({instance.HostName})"); } } + catch (ReflectionTypeLoadException ex) + { + MelonLogger.Warning( + $"[gregCore] Could not load types from {assembly.GetName().Name}: {ex.Message}"); + } + catch (Exception ex) + { + MelonLogger.Error( + $"[gregCore] gregExt discovery error in {assembly.GetName().Name}: {ex.Message}"); + } } } - /// - /// Assembly resolution shim to redirect legacy mod loader references to gregCore. - /// - public sealed class DataCenterModLoaderMod : MelonMod + private sealed class CurrentCompatibilityPointer { - static DataCenterModLoaderMod() + [JsonProperty("profile")] + public string Profile { get; set; } = string.Empty; + } +} + +/// +/// Narrow legacy assembly redirect. It never aliases arbitrary gregCore assembly +/// versions and therefore cannot hide binary compatibility errors. +/// +public sealed class DataCenterModLoaderMod : MelonMod +{ + private static readonly HashSet LegacyAssemblyNames = new(StringComparer.OrdinalIgnoreCase) + { + "DataCenterModLoader", + "DataCenterModLoader.Core" + }; + + static DataCenterModLoaderMod() + { + AppDomain.CurrentDomain.AssemblyResolve += (_, args) => { - AppDomain.CurrentDomain.AssemblyResolve += (sender, args) => - { - if (args.Name.StartsWith("DataCenterModLoader") || args.Name.StartsWith("gregCore")) - { - return typeof(DataCenterModLoaderMod).Assembly; - } - return null; - }; - } + string? simpleName = new AssemblyName(args.Name).Name; + return simpleName != null && LegacyAssemblyNames.Contains(simpleName) + ? typeof(DataCenterModLoaderMod).Assembly + : null; + }; } } diff --git a/src/GameLayer/Hooks/GregCompatBridge.cs b/src/GameLayer/Hooks/GregCompatBridge.cs index 75de9e4b..32141e38 100644 --- a/src/GameLayer/Hooks/GregCompatBridge.cs +++ b/src/GameLayer/Hooks/GregCompatBridge.cs @@ -1,58 +1,241 @@ -using System; -using System.Reflection; -using gregCore.Core.Abstractions; - -namespace gregCore.GameLayer.Hooks; - -/// -/// Prüft die Existenz von Methoden vor dem Harmony-Patching (Harmony Layer). -/// Schützt vor Game-Version-Drift und IL2CPP-Inkompatibilitäten. -/// -public sealed class GregCompatBridge -{ - private readonly IGregLogger _logger; - - public GregCompatBridge(IGregLogger logger) - { - _logger = logger.ForContext("CompatBridge"); - } - - /// - /// Prüft, ob eine Methode in der Assembly existiert. - /// - public bool VerifyMethod(string ns, string className, string methodName) - { - try - { - var fullName = $"{ns}.{className}"; - var type = Type.GetType(fullName); - if (type == null) - { - // Versuche über Assembly-CSharp zu finden - var assembly = Assembly.Load("Assembly-CSharp"); - type = assembly?.GetType(fullName); - } - - if (type == null) - { - _logger.Warning($"Klasse nicht gefunden: {fullName}"); - return false; - } - - var method = type.GetMethod(methodName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static); - if (method == null) - { - _logger.Warning($"Methode nicht gefunden: {fullName}.{methodName}"); - return false; - } - - _logger.Success($"Methode verifiziert: {fullName}.{methodName}"); - return true; - } - catch (Exception ex) - { - _logger.Error($"Fehler bei der Verifizierung von {ns}.{className}.{methodName}: {ex.Message}"); - return false; - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using gregCore.Core.Abstractions; + +namespace gregCore.GameLayer.Hooks; + +public enum MethodCompatibilityStatus +{ + Compatible, + AssemblyMissing, + TypeMissing, + ParameterTypeMissing, + MethodMissing, + Ambiguous, + ReturnTypeMismatch, + Error +} + +public sealed record MethodCompatibilityRequest( + string Assembly, + string Type, + string Method, + IReadOnlyList ParameterTypes, + string? ReturnType = null, + bool? Static = null, + int GenericArity = 0); + +public sealed record MethodCompatibilityResult( + MethodCompatibilityStatus Status, + MethodCompatibilityRequest Request, + Type? ResolvedType = null, + MethodBase? ResolvedMethod = null, + string? ActualSignature = null, + string? Reason = null) +{ + public bool IsCompatible => Status == MethodCompatibilityStatus.Compatible; +} + +/// +/// Performs non-patching compatibility checks against complete signatures. +/// This is used by diagnostics and CI smoke tests before Harmony is invoked. +/// +public sealed class GregCompatBridge +{ + private readonly IGregLogger _logger; + + public GregCompatBridge(IGregLogger logger) + { + _logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForContext("CompatBridge"); + } + + public MethodCompatibilityResult VerifyMethod(MethodCompatibilityRequest request) + { + try + { + Assembly? assembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(candidate => candidate.GetName().Name?.Equals( + request.Assembly, StringComparison.OrdinalIgnoreCase) == true); + if (assembly == null) + { + return Report(new MethodCompatibilityResult( + MethodCompatibilityStatus.AssemblyMissing, + request, + Reason: $"Assembly not loaded: {request.Assembly}")); + } + + Type? type = assembly.GetType(request.Type, throwOnError: false, ignoreCase: false); + if (type == null) + { + return Report(new MethodCompatibilityResult( + MethodCompatibilityStatus.TypeMissing, + request, + Reason: $"Type not found: {request.Type}")); + } + + var parameterTypes = new Type[request.ParameterTypes.Count]; + for (int index = 0; index < request.ParameterTypes.Count; index++) + { + Type? resolved = ResolveType(request.ParameterTypes[index]); + if (resolved == null) + { + return Report(new MethodCompatibilityResult( + MethodCompatibilityStatus.ParameterTypeMissing, + request, + type, + Reason: $"Parameter {index} type not found: {request.ParameterTypes[index]}")); + } + parameterTypes[index] = resolved; + } + + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static | + BindingFlags.FlattenHierarchy; + MethodInfo[] matches = type.GetMethods(flags) + .Where(method => method.Name.Equals(request.Method, StringComparison.Ordinal)) + .Where(method => !request.Static.HasValue || method.IsStatic == request.Static.Value) + .Where(method => GenericArity(method) == request.GenericArity) + .Where(method => ParametersEqual(method.GetParameters(), parameterTypes)) + .ToArray(); + + if (matches.Length == 0) + { + return Report(new MethodCompatibilityResult( + MethodCompatibilityStatus.MethodMissing, + request, + type, + Reason: "No method matched the complete signature.")); + } + + if (matches.Length > 1) + { + return Report(new MethodCompatibilityResult( + MethodCompatibilityStatus.Ambiguous, + request, + type, + Reason: $"{matches.Length} methods matched the requested signature.")); + } + + MethodInfo method = matches[0]; + if (!string.IsNullOrWhiteSpace(request.ReturnType)) + { + Type? expectedReturnType = ResolveType(request.ReturnType); + if (expectedReturnType == null || expectedReturnType != method.ReturnType) + { + return Report(new MethodCompatibilityResult( + MethodCompatibilityStatus.ReturnTypeMismatch, + request, + type, + method, + Format(method), + $"Expected return type {request.ReturnType}, found {method.ReturnType.FullName}.")); + } + } + + return Report(new MethodCompatibilityResult( + MethodCompatibilityStatus.Compatible, + request, + type, + method, + Format(method))); + } + catch (Exception ex) + { + return Report(new MethodCompatibilityResult( + MethodCompatibilityStatus.Error, + request, + Reason: ex.Message)); + } + } + + /// + /// Backward-compatible name-only probe. New code should use the structured overload. + /// + [Obsolete("Use VerifyMethod(MethodCompatibilityRequest) with a complete signature.")] + public bool VerifyMethod(string ns, string className, string methodName) + { + string typeName = string.IsNullOrWhiteSpace(ns) ? className : $"{ns}.{className}"; + try + { + Assembly? assembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(candidate => candidate.GetName().Name == "Assembly-CSharp"); + Type? type = assembly?.GetType(typeName, throwOnError: false, ignoreCase: false); + if (type == null) return false; + + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + return type.GetMethods(flags).Any(method => method.Name == methodName); + } + catch + { + return false; + } + } + + private MethodCompatibilityResult Report(MethodCompatibilityResult result) + { + if (result.IsCompatible) + _logger.Success($"Compatible method: {result.ActualSignature}"); + else + _logger.Warning($"Compatibility check failed ({result.Status}): {result.Reason}"); + return result; + } + + private static int GenericArity(MethodInfo method) => + method.IsGenericMethod ? method.GetGenericArguments().Length : 0; + + private static bool ParametersEqual(ParameterInfo[] actual, Type[] expected) + { + if (actual.Length != expected.Length) return false; + for (int index = 0; index < actual.Length; index++) + { + if (actual[index].ParameterType != expected[index]) return false; + } + return true; + } + + private static Type? ResolveType(string name) + { + if (string.IsNullOrWhiteSpace(name)) return null; + string text = name.Trim(); + bool byRef = text.EndsWith("&", StringComparison.Ordinal); + if (byRef) text = text[..^1]; + bool array = text.EndsWith("[]", StringComparison.Ordinal); + if (array) text = text[..^2]; + + Type? type = text switch + { + "void" or "Void" or "System.Void" => typeof(void), + "bool" or "Boolean" or "System.Boolean" => typeof(bool), + "int" or "Int32" or "System.Int32" => typeof(int), + "long" or "Int64" or "System.Int64" => typeof(long), + "float" or "Single" or "System.Single" => typeof(float), + "double" or "Double" or "System.Double" => typeof(double), + "string" or "String" or "System.String" => typeof(string), + "object" or "Object" or "System.Object" => typeof(object), + _ => Type.GetType(text, throwOnError: false, ignoreCase: false) + }; + + if (type == null) + { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + type = assembly.GetType(text, throwOnError: false, ignoreCase: false) + ?? assembly.GetType($"Il2Cpp.{text}", throwOnError: false, ignoreCase: false) + ?? assembly.GetType($"UnityEngine.{text}", throwOnError: false, ignoreCase: false); + if (type != null) break; + } + } + + if (type == null) return null; + if (array) type = type.MakeArrayType(); + if (byRef) type = type.MakeByRefType(); + return type; + } + + private static string Format(MethodBase method) => + $"{method.DeclaringType?.FullName}.{method.Name}(" + + string.Join(", ", method.GetParameters().Select(parameter => parameter.ParameterType.FullName)) + ")"; +} diff --git a/src/GameLayer/Hooks/GregDynamicHookPatcher.cs b/src/GameLayer/Hooks/GregDynamicHookPatcher.cs index 20a9e634..aad83091 100644 --- a/src/GameLayer/Hooks/GregDynamicHookPatcher.cs +++ b/src/GameLayer/Hooks/GregDynamicHookPatcher.cs @@ -4,322 +4,726 @@ using System.Linq; using System.Reflection; using HarmonyLib; -using MelonLoader; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using gregCore.Core.Abstractions; using gregCore.Core.Events; using gregCore.Core.Models; -namespace gregCore.GameLayer.Hooks +namespace gregCore.GameLayer.Hooks; + +/// +/// Applies profile-aware Harmony patches from the v2 hook manifest. +/// Legacy array manifests remain supported as a migration path, but all +/// parameters must resolve and all overloads must be unambiguous. +/// +public sealed class GregDynamicHookPatcher { - /// - /// Dynamically applies Harmony patches for all hooks defined in game_hooks.json. - /// Uses a generic postfix to dispatch events to GregEventBus. - /// - public sealed class GregDynamicHookPatcher + private readonly HarmonyLib.Harmony _harmony; + private readonly GregEventBus _eventBus; + private readonly IGregLogger _logger; + private readonly Dictionary _definitions = new(StringComparer.Ordinal); + private readonly HashSet _installedHookIds = new(StringComparer.Ordinal); + private int _installedCount; + private int _failedCount; + + public int InstalledCount => _installedCount; + public int FailedCount => _failedCount; + public int TotalHooks { get; private set; } + public string? ManifestProfileId { get; private set; } + + public GregDynamicHookPatcher(HarmonyLib.Harmony harmony, GregEventBus eventBus, IGregLogger logger) { - private readonly HarmonyLib.Harmony _harmony; - private readonly GregEventBus _eventBus; - private readonly IGregLogger _logger; - private int _installedCount; - private int _failedCount; - - public int InstalledCount => _installedCount; - public int FailedCount => _failedCount; - public int TotalHooks { get; private set; } + _harmony = harmony ?? throw new ArgumentNullException(nameof(harmony)); + _eventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus)); + _logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForContext("DynamicHookPatcher"); + } - public GregDynamicHookPatcher(HarmonyLib.Harmony harmony, GregEventBus eventBus, IGregLogger logger) + public void InstallFromFile( + string hooksFilePath, + string? activeProfileId = null, + IReadOnlyCollection? enabledGroups = null) + { + if (!File.Exists(hooksFilePath)) { - _harmony = harmony; - _eventBus = eventBus; - _logger = logger.ForContext("DynamicHookPatcher"); + _logger.Warning($"Hooks file not found: {hooksFilePath}"); + return; } - /// - /// Loads game_hooks.json and applies patches for all resolvable methods. - /// - public void InstallFromFile(string hooksFilePath) + try { - if (!File.Exists(hooksFilePath)) + HookManifestV2 manifest = LoadManifest(hooksFilePath); + if (!string.IsNullOrWhiteSpace(manifest.ProfileId)) + ManifestProfileId ??= manifest.ProfileId; + TotalHooks += manifest.Hooks.Count; + + bool profileMismatch = + !string.IsNullOrWhiteSpace(activeProfileId) && + !string.IsNullOrWhiteSpace(manifest.ProfileId) && + !manifest.ProfileId.Equals(activeProfileId, StringComparison.OrdinalIgnoreCase); + + if (profileMismatch) { - _logger.Warning($"Hooks file not found: {hooksFilePath}"); - return; + _logger.Warning( + $"Hook manifest profile '{manifest.ProfileId}' does not match active profile '{activeProfileId}'. Required hooks from this manifest are disabled."); } - try + foreach (HookDefinitionV2 incomingDefinition in manifest.Hooks) { - var json = File.ReadAllText(hooksFilePath); - var hooks = JsonConvert.DeserializeObject>(json); - - if (hooks == null || hooks.Count == 0) + if (string.IsNullOrWhiteSpace(incomingDefinition.Id)) { - _logger.Warning("No hooks found in hooks file."); - return; + _failedCount++; + continue; } - TotalHooks = hooks.Count; - _logger.Info($"[DynamicPatcher] Loaded {hooks.Count} hook definitions from manifest."); - - // Group by unique method to avoid duplicate patches - var methodGroups = new Dictionary>(); - - foreach (var hook in hooks) + // The first definition wins. This preserves v2 definitions when + // the legacy fallback manifest is loaded afterwards. + if (!_definitions.TryGetValue(incomingDefinition.Id, out HookDefinitionV2? definition)) { - try - { - var method = ResolveMethod(hook); - if (method == null) - { - _failedCount++; - continue; - } - - if (!methodGroups.TryGetValue(method, out var list)) - { - list = new List(); - methodGroups[method] = list; - } - list.Add(hook); - } - catch (Exception ex) - { - _logger.Debug($"Failed to resolve hook {hook.ClassName}.{hook.MethodName}: {ex.Message}"); - _failedCount++; - } + definition = incomingDefinition; + _definitions.Add(definition.Id, definition); } - // Apply patches - foreach (var kvp in methodGroups) - { - try - { - var method = kvp.Key; - var hookNames = kvp.Value.Select(h => GetHookName(h)).ToList(); + if (profileMismatch && definition.Required) + continue; - lock (_globalMethodToHookNames) - { - _globalMethodToHookNames[method] = hookNames; - } + if (enabledGroups != null && enabledGroups.Count > 0 && + !enabledGroups.Contains(definition.Group, StringComparer.OrdinalIgnoreCase)) + continue; - _harmony.Patch(method, postfix: new HarmonyMethod(typeof(GregDynamicHookPatcher), nameof(GenericPostfix))); - _installedCount++; - } - catch (Exception ex) - { - _logger.Warning($"Failed to patch {kvp.Key.DeclaringType?.Name}.{kvp.Key.Name}: {ex.Message}"); - _failedCount++; - } - } + // High-frequency hooks are activated only when a consumer exists. + if (definition.HighFrequency && !_eventBus.HasSubscribers(definition.Id)) + continue; - _logger.Info($"[DynamicPatcher] Installed {_installedCount} patches, failed {_failedCount} hooks."); - } - catch (Exception ex) - { - _logger.Error("Failed to install dynamic hooks", ex); + InstallDefinition(definition, activeProfileId); } - } - private static string GetHookName(GameHookJsonDef hook) + _logger.Info( + $"[DynamicPatcher] Manifest v{manifest.SchemaVersion}, profile={manifest.ProfileId ?? "legacy"}, " + + $"installed={_installedCount}, failed={_failedCount}, declared={TotalHooks}."); + } + catch (Exception ex) { - return $"greg.{hook.Group}.{hook.MethodName}"; + _logger.Error("Failed to install dynamic hooks", ex); } + } - private MethodBase? ResolveMethod(GameHookJsonDef hook) - { - var fullTypeName = $"{hook.Namespace}.{hook.ClassName}"; - var type = SafeTypeByName(fullTypeName); + /// + /// Activates a deferred/high-frequency hook after a consumer subscribes. + /// + public bool InstallById(string hookId, string? activeProfileId = null) + { + if (_installedHookIds.Contains(hookId)) return true; + if (!_definitions.TryGetValue(hookId, out HookDefinitionV2? definition)) return false; + return InstallDefinition(definition, activeProfileId); + } - if (type == null) - { - // Try without namespace prefix for Il2Cpp types - type = SafeTypeByName(hook.ClassName); - } + private bool InstallDefinition(HookDefinitionV2 definition, string? activeProfileId) + { + if (_installedHookIds.Contains(definition.Id)) return true; + + MethodBase? resolved = null; + string? failure = null; - if (type == null) return null; + foreach (HookCandidateV2 candidate in definition.Candidates) + { + if (!CandidateSupportsProfile(candidate, activeProfileId)) + continue; - Type[]? paramTypes = null; - if (hook.Parameters != null && hook.Parameters.Count > 0) + MethodResolution resolution = ResolveCandidate(candidate); + if (resolution.Method != null) { - paramTypes = hook.Parameters - .Select(p => ParseParameterType(p.Type)) - .Where(t => t != null) - .ToArray()!; + resolved = resolution.Method; + break; } - return SafeGetMethod(type, hook.MethodName, paramTypes); + failure = resolution.Reason; } - private static MethodBase? SafeGetMethod(Type type, string methodName, Type[]? paramTypes) + if (resolved == null) { - try + _failedCount++; + string severity = definition.Required ? "required" : "optional"; + _logger.Warning($"Unable to resolve {severity} hook {definition.Id}: {failure ?? "no matching candidate"}"); + return false; + } + + string patchKind = NormalizePatchKind(definition.PatchKind); + var binding = new HookRuntimeBinding( + definition.Id, + definition.CaptureArguments, + definition.HighFrequency); + bool bindingAdded = false; + bool patchReserved = false; + + try + { + lock (RuntimeSync) { - const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.FlattenHierarchy; + if (!RuntimeBindings.TryGetValue(resolved, out List? bindings)) + { + bindings = new List(); + RuntimeBindings[resolved] = bindings; + ParameterCache[resolved] = resolved.GetParameters(); + } - if (paramTypes == null || paramTypes.Length == 0) + if (!bindings.Any(existing => existing.HookId.Equals(definition.Id, StringComparison.Ordinal))) { - // GetMethod(name, flags) is safe and doesn't log warnings - return type.GetMethod(methodName, flags); + bindings.Add(binding); + bindingAdded = true; } - // GetMethod(name, flags, binder, types, modifiers) is also safe - return type.GetMethod(methodName, flags, null, paramTypes, null); + if (!RuntimePatchKinds.TryGetValue(resolved, out HashSet? patchKinds)) + { + patchKinds = new HashSet(StringComparer.Ordinal); + RuntimePatchKinds[resolved] = patchKinds; + } + + // Several stable greg hook IDs may map to one game method. Harmony + // is installed only once per method and patch kind; dispatch fans + // out to all runtime bindings. + patchReserved = patchKinds.Add(patchKind); } - catch + + if (patchReserved) { - return null; + var patchMethod = new HarmonyMethod( + typeof(GregDynamicHookPatcher), + patchKind == "prefix" ? nameof(GenericPrefix) : nameof(GenericPostfix)); + + if (patchKind == "prefix") + _harmony.Patch(resolved, prefix: patchMethod); + else + _harmony.Patch(resolved, postfix: patchMethod); } + + _installedHookIds.Add(definition.Id); + _installedCount++; + return true; + } + catch (Exception ex) + { + lock (RuntimeSync) + { + if (bindingAdded && RuntimeBindings.TryGetValue(resolved, out List? bindings)) + { + bindings.RemoveAll(existing => existing.HookId.Equals(definition.Id, StringComparison.Ordinal)); + if (bindings.Count == 0) + { + RuntimeBindings.Remove(resolved); + ParameterCache.Remove(resolved); + } + } + + if (patchReserved && RuntimePatchKinds.TryGetValue(resolved, out HashSet? patchKinds)) + { + patchKinds.Remove(patchKind); + if (patchKinds.Count == 0) + RuntimePatchKinds.Remove(resolved); + } + } + + _failedCount++; + _logger.Warning($"Failed to patch {FormatMethod(resolved)} for {definition.Id}: {ex.Message}"); + return false; } + } - private static readonly Dictionary _typeCache = new(); + private HookManifestV2 LoadManifest(string hooksFilePath) + { + string json = File.ReadAllText(hooksFilePath); + JToken root = JToken.Parse(json); - private static Type? SafeTypeByName(string typeName) + if (root.Type == JTokenType.Array) { - if (string.IsNullOrWhiteSpace(typeName)) return null; + List legacy = root.ToObject>() ?? new(); + return ConvertLegacy(legacy); + } - if (_typeCache.TryGetValue(typeName, out var cached)) return cached; + HookManifestV2 manifest = root.ToObject() + ?? throw new InvalidDataException("Hook manifest is empty."); - // Fast path: fully qualified or simple type in current/mscorlib - var t = Type.GetType(typeName); - if (t != null) - { - _typeCache[typeName] = t; - return t; - } + if (manifest.SchemaVersion != 2) + throw new InvalidDataException($"Unsupported hook manifest schema {manifest.SchemaVersion}."); - // Manual search to avoid Assembly.GetTypes() which throws TypeLoadException on dummy DLLs - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + foreach (HookDefinitionV2 hook in manifest.Hooks) + { + if (hook.Candidates.Count == 0) + throw new InvalidDataException($"Hook {hook.Id} has no candidates."); + } + + return manifest; + } + + private static HookManifestV2 ConvertLegacy(List hooks) + { + return new HookManifestV2 + { + SchemaVersion = 1, + ProfileId = null, + Hooks = hooks.Select(hook => new HookDefinitionV2 { - t = asm.GetType(typeName, throwOnError: false, ignoreCase: false); - if (t != null) + Id = $"greg.{hook.Group}.{hook.MethodName}", + Group = hook.Group, + Required = false, + HighFrequency = IsHighFrequencyMethod(hook.MethodName), + CaptureArguments = true, + PatchKind = "postfix", + Candidates = new List { - _typeCache[typeName] = t; - return t; + new() + { + Assembly = "Assembly-CSharp", + Type = string.IsNullOrWhiteSpace(hook.Namespace) + ? hook.ClassName + : $"{hook.Namespace}.{hook.ClassName}", + Method = hook.MethodName, + GenericArity = 0, + Static = null, + ReturnType = NormalizeLegacyTypeName(hook.ReturnType), + ParameterTypes = hook.Parameters.Select(parameter => + NormalizeLegacyTypeName(parameter.Type)).ToList() + } } + }).ToList() + }; + } + + private static bool CandidateSupportsProfile(HookCandidateV2 candidate, string? activeProfileId) + { + if (candidate.Profiles.Count == 0 || string.IsNullOrWhiteSpace(activeProfileId)) + return true; + + return candidate.Profiles.Contains(activeProfileId, StringComparer.OrdinalIgnoreCase); + } + + private static MethodResolution ResolveCandidate(HookCandidateV2 candidate) + { + Type? type = ResolveType(candidate.Type, candidate.Assembly); + if (type == null) + return MethodResolution.Fail($"type not found: {candidate.Type} in {candidate.Assembly}"); + + var parameterTypes = new Type[candidate.ParameterTypes.Count]; + for (int index = 0; index < candidate.ParameterTypes.Count; index++) + { + Type? parameterType = ResolveSignatureType(candidate.ParameterTypes[index]); + if (parameterType == null) + { + return MethodResolution.Fail( + $"parameter {index} type not found: {candidate.ParameterTypes[index]}"); } + parameterTypes[index] = parameterType; + } + + const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static | + BindingFlags.FlattenHierarchy; - _typeCache[typeName] = null; - return null; + MethodInfo[] namedMethods; + try + { + namedMethods = type.GetMethods(flags) + .Where(method => method.Name.Equals(candidate.Method, StringComparison.Ordinal)) + .ToArray(); + } + catch (Exception ex) + { + return MethodResolution.Fail($"unable to enumerate methods: {ex.Message}"); } - private static Type? ParseParameterType(string typeName) + MethodBase[] matching = namedMethods + .Where(method => !candidate.Static.HasValue || method.IsStatic == candidate.Static.Value) + .Where(method => GetGenericArity(method) == candidate.GenericArity) + .Where(method => ParametersMatch(method.GetParameters(), parameterTypes)) + .Where(method => ReturnTypeMatches(method.ReturnType, candidate.ReturnType)) + .Cast() + .ToArray(); + + return matching.Length switch { - if (string.IsNullOrWhiteSpace(typeName)) return null; + 1 => MethodResolution.Success(matching[0]), + 0 => MethodResolution.Fail($"signature not found: {FormatCandidate(candidate)}"), + _ => MethodResolution.Fail($"ambiguous signature ({matching.Length} matches): {FormatCandidate(candidate)}") + }; + } - // Primitive type mapping - var result = typeName switch - { - "Void" => typeof(void), - "Boolean" or "Bool" => typeof(bool), - "Int32" or "Int" => typeof(int), - "Int64" or "Long" => typeof(long), - "UInt32" or "UInt" => typeof(uint), - "UInt64" or "ULong" => typeof(ulong), - "Single" or "Float" => typeof(float), - "Double" => typeof(double), - "String" => typeof(string), - "Object" => typeof(object), - _ => null - }; - - if (result != null) return result; - - // Try direct type resolution safely - result = SafeTypeByName(typeName); - if (result != null) return result; - - // Try Il2Cpp prefix for game types - result = SafeTypeByName($"Il2Cpp.{typeName}"); - if (result != null) return result; - - // Try UnityEngine prefix for Unity types - result = SafeTypeByName($"UnityEngine.{typeName}"); - if (result != null) return result; - - return null; + private static int GetGenericArity(MethodInfo method) => + method.IsGenericMethodDefinition || method.IsGenericMethod + ? method.GetGenericArguments().Length + : 0; + + private static bool ParametersMatch(ParameterInfo[] actual, Type[] expected) + { + if (actual.Length != expected.Length) return false; + for (int index = 0; index < actual.Length; index++) + { + if (actual[index].ParameterType != expected[index]) + return false; + } + return true; + } + + private static bool ReturnTypeMatches(Type actual, string expectedName) + { + if (string.IsNullOrWhiteSpace(expectedName)) return true; + Type? expected = ResolveSignatureType(expectedName); + return expected != null && actual == expected; + } + + private static Type? ResolveType(string typeName, string? assemblyName) + { + if (string.IsNullOrWhiteSpace(typeName)) return null; + string cacheKey = $"{assemblyName}|{typeName}"; + + lock (TypeCache) + { + if (TypeCache.TryGetValue(cacheKey, out Type? cached)) + return cached; } - // ─── Static state for Harmony Postfix ──────────────────────────── + Type? resolved = null; + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (!string.IsNullOrWhiteSpace(assemblyName) && + !assembly.GetName().Name!.Equals(assemblyName, StringComparison.OrdinalIgnoreCase)) + continue; - private static readonly Dictionary> _globalMethodToHookNames = new(); - private static GregEventBus? _globalEventBus; - private static IGregLogger? _globalLogger; + resolved = assembly.GetType(typeName, throwOnError: false, ignoreCase: false); + if (resolved != null) break; + } + + resolved ??= Type.GetType(typeName, throwOnError: false, ignoreCase: false); - public static void SetGlobalBus(GregEventBus bus) => _globalEventBus = bus; - public static void SetGlobalLogger(IGregLogger logger) => _globalLogger = logger; + // Legacy convenience fallbacks. V2 manifests should use fully qualified names. + resolved ??= FindTypeInLoadedAssemblies($"Il2Cpp.{typeName}"); + resolved ??= FindTypeInLoadedAssemblies($"UnityEngine.{typeName}"); + resolved ??= FindTypeInLoadedAssemblies(typeName); - // ─── Harmony Postfix ───────────────────────────────────────────── + lock (TypeCache) + TypeCache[cacheKey] = resolved; + return resolved; + } - public static void GenericPostfix(MethodBase __originalMethod, object[] __args) + private static Type? FindTypeInLoadedAssemblies(string typeName) + { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { - if (_globalEventBus == null) return; + Type? type = assembly.GetType(typeName, throwOnError: false, ignoreCase: false); + if (type != null) return type; + } + return null; + } - List? hookNames; - lock (_globalMethodToHookNames) + private static Type? ResolveSignatureType(string typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) return null; + string text = typeName.Trim(); + + bool byRef = text.StartsWith("ref ", StringComparison.Ordinal) || + text.StartsWith("out ", StringComparison.Ordinal) || + text.StartsWith("in ", StringComparison.Ordinal) || + text.EndsWith("&", StringComparison.Ordinal); + if (byRef) + { + text = text.TrimEnd('&').Trim(); + foreach (string prefix in new[] { "ref ", "out ", "in " }) { - if (!_globalMethodToHookNames.TryGetValue(__originalMethod, out hookNames)) return; + if (text.StartsWith(prefix, StringComparison.Ordinal)) + text = text[prefix.Length..].Trim(); } + } + + bool pointer = text.EndsWith("*", StringComparison.Ordinal); + if (pointer) text = text[..^1].Trim(); + + int arrayDepth = 0; + while (text.EndsWith("[]", StringComparison.Ordinal)) + { + arrayDepth++; + text = text[..^2].Trim(); + } - var payloadData = new Dictionary + Type? resolved = ResolveSimpleOrGenericType(text); + if (resolved == null) return null; + + for (int index = 0; index < arrayDepth; index++) + resolved = resolved.MakeArrayType(); + if (pointer) resolved = resolved.MakePointerType(); + if (byRef) resolved = resolved.MakeByRefType(); + return resolved; + } + + private static Type? ResolveSimpleOrGenericType(string typeName) + { + if (PrimitiveTypes.TryGetValue(typeName, out Type? primitive)) + return primitive; + + int open = typeName.IndexOf('<'); + if (open > 0 && typeName.EndsWith(">", StringComparison.Ordinal)) + { + string genericName = typeName[..open].Trim(); + string argumentText = typeName[(open + 1)..^1]; + List argumentNames = SplitGenericArguments(argumentText); + Type[] arguments = new Type[argumentNames.Count]; + for (int index = 0; index < argumentNames.Count; index++) { - { "method", __originalMethod.Name }, - { "type", __originalMethod.DeclaringType?.Name ?? "Unknown" } - }; + Type? argument = ResolveSignatureType(argumentNames[index]); + if (argument == null) return null; + arguments[index] = argument; + } - if (__args != null && __args.Length > 0) + Type? genericDefinition = ResolveType($"{genericName}`{arguments.Length}", null); + if (genericDefinition == null && !genericName.Contains('.')) { - var parameters = __originalMethod.GetParameters(); - for (int i = 0; i < Math.Min(__args.Length, parameters.Length); i++) - { - try - { - payloadData[$"arg_{parameters[i].Name}"] = __args[i] ?? "null"; - } - catch - { - payloadData[$"arg_{i}"] = ""; - } - } + genericDefinition = ResolveType( + $"Il2CppSystem.Collections.Generic.{genericName}`{arguments.Length}", null) + ?? ResolveType($"System.Collections.Generic.{genericName}`{arguments.Length}", null); } - var payload = new EventPayload + try { - HookName = "", - OccurredAtUtc = DateTime.UtcNow, - Data = payloadData, - IsCancelable = false, - IsCancelled = false - }; - - foreach (var hookName in hookNames) + return genericDefinition?.MakeGenericType(arguments); + } + catch { - try - { - _globalEventBus.Publish(hookName, payload with { HookName = hookName }); - } - catch (Exception ex) - { - _globalLogger?.Error($"Hook dispatch failed for {hookName}", ex); - } + return null; } } + + return ResolveType(typeName, null); } - // ─── JSON Models ─────────────────────────────────────────────────── + private static List SplitGenericArguments(string text) + { + var result = new List(); + int depth = 0; + int start = 0; + for (int index = 0; index < text.Length; index++) + { + char character = text[index]; + if (character == '<') depth++; + else if (character == '>') depth--; + else if (character == ',' && depth == 0) + { + result.Add(text[start..index].Trim()); + start = index + 1; + } + } + result.Add(text[start..].Trim()); + return result; + } - public class GameHookJsonDef + private static string NormalizeLegacyTypeName(string typeName) => typeName switch + { + "Void" => "System.Void", + "Boolean" or "Bool" => "System.Boolean", + "Int32" or "Int" => "System.Int32", + "Int64" or "Long" => "System.Int64", + "UInt32" or "UInt" => "System.UInt32", + "UInt64" or "ULong" => "System.UInt64", + "Single" or "Float" => "System.Single", + "Double" => "System.Double", + "String" => "System.String", + "Object" => "System.Object", + _ => typeName + }; + + private static bool IsHighFrequencyMethod(string methodName) => + methodName is "Update" or "FixedUpdate" or "LateUpdate" or "OnUpdate"; + + private static string NormalizePatchKind(string patchKind) => + patchKind.Equals("prefix", StringComparison.OrdinalIgnoreCase) ? "prefix" : "postfix"; + + private static string FormatCandidate(HookCandidateV2 candidate) => + $"{candidate.Type}.{candidate.Method}({string.Join(", ", candidate.ParameterTypes)})"; + + private static string FormatMethod(MethodBase method) => + $"{method.DeclaringType?.FullName}.{method.Name}({string.Join(", ", method.GetParameters().Select(parameter => parameter.ParameterType.FullName))})"; + + private static readonly object RuntimeSync = new(); + private static readonly Dictionary> RuntimeBindings = new(); + private static readonly Dictionary ParameterCache = new(); + private static readonly Dictionary> RuntimePatchKinds = new(); + private static readonly Dictionary TypeCache = new(StringComparer.Ordinal); + private static GregEventBus? GlobalEventBus; + private static IGregLogger? GlobalLogger; + + private static readonly Dictionary PrimitiveTypes = new(StringComparer.OrdinalIgnoreCase) { - public string Group { get; set; } = ""; - public string Namespace { get; set; } = ""; - public string ClassName { get; set; } = ""; - public string MethodName { get; set; } = ""; - public string ReturnType { get; set; } = ""; - public bool IsVoid { get; set; } - public List Parameters { get; set; } = new(); + ["void"] = typeof(void), ["System.Void"] = typeof(void), + ["bool"] = typeof(bool), ["Boolean"] = typeof(bool), ["System.Boolean"] = typeof(bool), + ["byte"] = typeof(byte), ["System.Byte"] = typeof(byte), + ["sbyte"] = typeof(sbyte), ["System.SByte"] = typeof(sbyte), + ["short"] = typeof(short), ["Int16"] = typeof(short), ["System.Int16"] = typeof(short), + ["ushort"] = typeof(ushort), ["UInt16"] = typeof(ushort), ["System.UInt16"] = typeof(ushort), + ["int"] = typeof(int), ["Int32"] = typeof(int), ["System.Int32"] = typeof(int), + ["uint"] = typeof(uint), ["UInt32"] = typeof(uint), ["System.UInt32"] = typeof(uint), + ["long"] = typeof(long), ["Int64"] = typeof(long), ["System.Int64"] = typeof(long), + ["ulong"] = typeof(ulong), ["UInt64"] = typeof(ulong), ["System.UInt64"] = typeof(ulong), + ["float"] = typeof(float), ["Single"] = typeof(float), ["System.Single"] = typeof(float), + ["double"] = typeof(double), ["System.Double"] = typeof(double), + ["char"] = typeof(char), ["System.Char"] = typeof(char), + ["string"] = typeof(string), ["String"] = typeof(string), ["System.String"] = typeof(string), + ["object"] = typeof(object), ["Object"] = typeof(object), ["System.Object"] = typeof(object), + ["IntPtr"] = typeof(IntPtr), ["System.IntPtr"] = typeof(IntPtr), + ["UIntPtr"] = typeof(UIntPtr), ["System.UIntPtr"] = typeof(UIntPtr) + }; + + public static void SetGlobalBus(GregEventBus bus) => GlobalEventBus = bus; + public static void SetGlobalLogger(IGregLogger logger) => GlobalLogger = logger; + + public static void GenericPrefix(MethodBase __originalMethod, object[] __args) => + Dispatch(__originalMethod, __args); + + public static void GenericPostfix(MethodBase __originalMethod, object[] __args) => + Dispatch(__originalMethod, __args); + + private static void Dispatch(MethodBase originalMethod, object[]? arguments) + { + GregEventBus? eventBus = GlobalEventBus; + if (eventBus == null) return; + + HookRuntimeBinding[] bindings; + ParameterInfo[] parameters; + lock (RuntimeSync) + { + if (!RuntimeBindings.TryGetValue(originalMethod, out List? registered)) + return; + bindings = registered.ToArray(); + parameters = ParameterCache.TryGetValue(originalMethod, out ParameterInfo[]? cached) + ? cached + : Array.Empty(); + } + + HookRuntimeBinding[] subscribed = bindings + .Where(binding => eventBus.HasSubscribers(binding.HookId)) + .ToArray(); + if (subscribed.Length == 0) return; + + bool captureArguments = subscribed.Any(binding => binding.CaptureArguments); + var payloadData = new Dictionary(captureArguments ? parameters.Length + 2 : 2) + { + ["method"] = originalMethod.Name, + ["type"] = originalMethod.DeclaringType?.FullName ?? "Unknown" + }; + + if (captureArguments && arguments != null) + { + int count = Math.Min(arguments.Length, parameters.Length); + for (int index = 0; index < count; index++) + { + string name = parameters[index].Name ?? index.ToString(); + payloadData[$"arg_{name}"] = arguments[index] ?? "null"; + } + } + + var payload = new EventPayload + { + HookName = string.Empty, + OccurredAtUtc = DateTime.UtcNow, + Data = payloadData, + IsCancelable = false, + IsCancelled = false + }; + + foreach (HookRuntimeBinding binding in subscribed) + { + try + { + eventBus.Publish(binding.HookId, payload with { HookName = binding.HookId }); + } + catch (Exception ex) + { + GlobalLogger?.Error($"Hook dispatch failed for {binding.HookId}", ex); + } + } } - public class GameHookParameterDef + private sealed record HookRuntimeBinding(string HookId, bool CaptureArguments, bool HighFrequency); + + private sealed record MethodResolution(MethodBase? Method, string? Reason) { - public string Name { get; set; } = ""; - public string Type { get; set; } = ""; + public static MethodResolution Success(MethodBase method) => new(method, null); + public static MethodResolution Fail(string reason) => new(null, reason); } } + +public sealed class HookManifestV2 +{ + [JsonProperty("schemaVersion")] + public int SchemaVersion { get; set; } + + [JsonProperty("profileId")] + public string? ProfileId { get; set; } + + [JsonProperty("hooks")] + public List Hooks { get; set; } = new(); +} + +public sealed class HookDefinitionV2 +{ + [JsonProperty("id")] + public string Id { get; set; } = string.Empty; + + [JsonProperty("group")] + public string Group { get; set; } = string.Empty; + + [JsonProperty("required")] + public bool Required { get; set; } + + [JsonProperty("highFrequency")] + public bool HighFrequency { get; set; } + + [JsonProperty("captureArguments")] + public bool CaptureArguments { get; set; } = true; + + [JsonProperty("patchKind")] + public string PatchKind { get; set; } = "postfix"; + + [JsonProperty("candidates")] + public List Candidates { get; set; } = new(); +} + +public sealed class HookCandidateV2 +{ + [JsonProperty("profiles")] + public List Profiles { get; set; } = new(); + + [JsonProperty("assembly")] + public string Assembly { get; set; } = "Assembly-CSharp"; + + [JsonProperty("type")] + public string Type { get; set; } = string.Empty; + + [JsonProperty("method")] + public string Method { get; set; } = string.Empty; + + [JsonProperty("genericArity")] + public int GenericArity { get; set; } + + [JsonProperty("static")] + public bool? Static { get; set; } + + [JsonProperty("returnType")] + public string ReturnType { get; set; } = "System.Void"; + + [JsonProperty("parameterTypes")] + public List ParameterTypes { get; set; } = new(); +} + +public sealed class GameHookJsonDef +{ + public string Group { get; set; } = string.Empty; + public string Namespace { get; set; } = string.Empty; + public string ClassName { get; set; } = string.Empty; + public string MethodName { get; set; } = string.Empty; + public string ReturnType { get; set; } = string.Empty; + public bool IsVoid { get; set; } + public List Parameters { get; set; } = new(); +} + +public sealed class GameHookParameterDef +{ + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; +} diff --git a/src/GameLayer/Hooks/GregNativeEventHooks.cs b/src/GameLayer/Hooks/GregNativeEventHooks.cs index 74d3b5f4..34495371 100644 --- a/src/GameLayer/Hooks/GregNativeEventHooks.cs +++ b/src/GameLayer/Hooks/GregNativeEventHooks.cs @@ -1,4 +1,8 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; using HarmonyLib; using gregCore.Core.Abstractions; using gregCore.Core.Events; @@ -6,53 +10,53 @@ namespace gregCore.GameLayer.Hooks; /// -/// Die Harmony-Brücke zwischen dem Spiel und gregCore. -/// Delegiert an GregDynamicHookPatcher für alle 1771+ Hooks. +/// Loader-to-Harmony bridge. Game methods are resolved exclusively from +/// compatibility manifests so this assembly no longer needs compile-time +/// Harmony attributes for individual Assembly-CSharp types. /// [HarmonyPatch] public sealed class GregNativeEventHooks : SafePatch { - private static bool _isInstalled = false; + private static bool _isInstalled; private static GregDynamicHookPatcher? _dynamicPatcher; - public static void Install(IGregLogger logger, GregHookBus hookBus, GregEventBus eventBus, HarmonyLib.Harmony harmony) + public static GregDynamicHookPatcher? DynamicPatcher => _dynamicPatcher; + + public static void Install( + IGregLogger logger, + GregHookBus hookBus, + GregEventBus eventBus, + HarmonyLib.Harmony harmony, + string? activeProfileId = null, + bool safeMode = false) { if (_isInstalled) return; Setup(logger, hookBus); + if (safeMode) + { + _logger?.Warning("Compatibility safe mode is active. Game Harmony hooks were not installed."); + _isInstalled = true; + return; + } + try { - // Initialize dynamic patcher for all 1771+ hooks from game_hooks.json _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"); - - if (!System.IO.File.Exists(hooksFile)) - { - // Fallback: look in assembly directory - var asmDir = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); - if (!string.IsNullOrEmpty(asmDir)) - { - hooksFile = System.IO.Path.Combine(asmDir, "game_hooks.json"); - } - } - - if (!System.IO.File.Exists(hooksFile)) - { - // Final fallback: project root - hooksFile = System.IO.Path.Combine( - global::MelonLoader.Utils.MelonEnvironment.GameRootDirectory, - "game_hooks.json"); - } - - _dynamicPatcher.InstallFromFile(hooksFile); - - _logger?.Success($"GregNativeEventHooks Harmony Bridge installiert. Patched {_dynamicPatcher.InstalledCount} methods."); + IReadOnlyList manifests = FindHookManifests(); + if (manifests.Count == 0) + _logger?.Warning("No hook manifest found. The managed framework remains available without game hooks."); + + foreach (string manifest in manifests) + _dynamicPatcher.InstallFromFile(manifest, activeProfileId); + + _logger?.Success( + $"GregNativeEventHooks installed {_dynamicPatcher.InstalledCount} hooks; " + + $"{_dynamicPatcher.FailedCount} definitions could not be resolved."); } catch (Exception ex) { @@ -62,60 +66,46 @@ public static void Install(IGregLogger logger, GregHookBus hookBus, GregEventBus _isInstalled = true; } - // --- Domäne: Economy --- - [HarmonyPatch(typeof(global::Il2Cpp.Player), nameof(global::Il2Cpp.Player.UpdateCoin))] - [HarmonyPostfix] - public static void Postfix_PlayerCoinChanged(global::Il2Cpp.Player __instance, float _coinChhangeAmount) + private static IReadOnlyList FindHookManifests() { - try - { - if (__instance == null || __instance.Pointer == IntPtr.Zero) return; - TriggerHook("greg.PLAYER.CoinChanged", "Amount", _coinChhangeAmount, "Total", _coinChhangeAmount); - } - catch (Exception ex) - { - _logger?.Error("Hook PlayerCoinChanged failed", ex); - } - } + var result = new List(); + string modsDirectory = global::MelonLoader.Utils.MelonEnvironment.ModsDirectory; + string gameRoot = global::MelonLoader.Utils.MelonEnvironment.GameRootDirectory; + string? assemblyDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - // --- Domäne: Persistence --- - [HarmonyPatch(typeof(global::Il2Cpp.SaveSystem), nameof(global::Il2Cpp.SaveSystem.SaveGame))] - [HarmonyPostfix] - public static void Postfix_GameSaved() - { - try - { - TriggerHook("greg.SYSTEM.GameSaved", "Timestamp", DateTime.Now.ToString()); - } - catch (Exception ex) - { - _logger?.Error("Hook GameSaved failed", ex); - } + AddFirstExisting(result, + Path.Combine(modsDirectory, "game_hooks.v2.json"), + assemblyDirectory == null ? null : Path.Combine(assemblyDirectory, "game_hooks.v2.json"), + Path.Combine(gameRoot, "framework", "game_hooks.v2.json"), + Path.Combine(gameRoot, "game_hooks.v2.json")); + + AddFirstExisting(result, + Path.Combine(modsDirectory, "game_hooks.json"), + assemblyDirectory == null ? null : Path.Combine(assemblyDirectory, "game_hooks.json"), + Path.Combine(gameRoot, "game_hooks.json")); + + return result; } - // --- Domäne: UI --- - [HarmonyPatch(typeof(global::Il2Cpp.PauseMenu), nameof(global::Il2Cpp.PauseMenu.OnEnable))] - [HarmonyPostfix] - public static void Postfix_PauseMenuOpened() + private static void AddFirstExisting(List result, params string?[] candidates) { - try - { - greg.Logging.GregLogger.Msg("Pause Menu Opened", "NativeHooks"); - TriggerHook("greg.UI.PauseMenu.Opened", "InstanceId", 1); - } - catch (Exception ex) + foreach (string? candidate in candidates) { - _logger?.Error("Hook PauseMenuOpened failed", ex); + if (string.IsNullOrWhiteSpace(candidate) || !File.Exists(candidate)) + continue; + + if (!result.Contains(candidate, StringComparer.OrdinalIgnoreCase)) + result.Add(candidate); + return; } } - // --- WallRack Hook Constants --- - public const string WorldWallRegistered = "greg.WORLD.WallRegistered"; - public const string WorldWallRemoved = "greg.WORLD.WallRemoved"; - public const string WorldWallPlaced = "greg.WORLD.WallPlaced"; - public const string WorldWallDeviceMounted = "greg.WORLD.WallDeviceMounted"; + public const string WorldWallRegistered = "greg.WORLD.WallRegistered"; + public const string WorldWallRemoved = "greg.WORLD.WallRemoved"; + public const string WorldWallPlaced = "greg.WORLD.WallPlaced"; + public const string WorldWallDeviceMounted = "greg.WORLD.WallDeviceMounted"; public const string WorldWallDeviceUnmounted = "greg.WORLD.WallDeviceUnmounted"; - public const string WorldWallDeviceSwapped = "greg.WORLD.WallDeviceSwapped"; - public const string WorldWallDeviceLabelSet = "greg.WORLD.WallDeviceLabelSet"; - public const string SystemButtonBuyWall = "greg.SYSTEM.ButtonBuyWall"; + public const string WorldWallDeviceSwapped = "greg.WORLD.WallDeviceSwapped"; + public const string WorldWallDeviceLabelSet = "greg.WORLD.WallDeviceLabelSet"; + public const string SystemButtonBuyWall = "greg.SYSTEM.ButtonBuyWall"; } diff --git a/src/GameLayer/Interop/Il2CppTypeRegistry.cs b/src/GameLayer/Interop/Il2CppTypeRegistry.cs new file mode 100644 index 00000000..397e5d04 --- /dev/null +++ b/src/GameLayer/Interop/Il2CppTypeRegistry.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Il2CppInterop.Runtime.Injection; +using gregCore.Core.Abstractions; + +namespace gregCore.GameLayer.Interop; + +public enum Il2CppRegistrationStatus +{ + NotAttempted, + Registered, + AlreadyRegistered, + UnsupportedType, + Failed, + SkippedByCompatibilityProfile +} + +public sealed record Il2CppRegistrationResult( + Type ManagedType, + Il2CppRegistrationStatus Status, + bool Required, + string? Message = null, + Exception? Exception = null) +{ + public bool Succeeded => Status is Il2CppRegistrationStatus.Registered or Il2CppRegistrationStatus.AlreadyRegistered; +} + +/// +/// Single registration boundary for managed components injected into IL2CPP. +/// It validates constructor shape, is idempotent and produces per-type results +/// instead of allowing one failure to abort every registration. +/// +public sealed class Il2CppTypeRegistry +{ + private readonly IGregLogger _logger; + private readonly ConcurrentDictionary _results = new(); + + public Il2CppTypeRegistry(IGregLogger logger) + { + _logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForContext("Il2CppTypeRegistry"); + } + + public IReadOnlyCollection Results => _results.Values.ToArray(); + + public Il2CppRegistrationResult Register(bool required = false, bool profileAllowsInjection = true) + where T : class + { + Type managedType = typeof(T); + if (_results.TryGetValue(managedType, out Il2CppRegistrationResult? cached)) + { + return cached with + { + Status = cached.Succeeded + ? Il2CppRegistrationStatus.AlreadyRegistered + : cached.Status + }; + } + + if (!profileAllowsInjection) + { + return Store(new Il2CppRegistrationResult( + managedType, + Il2CppRegistrationStatus.SkippedByCompatibilityProfile, + required, + "The active compatibility profile disabled class injection.")); + } + + if (!HasSupportedConstructionPath(managedType, out string? validationMessage)) + { + return Store(new Il2CppRegistrationResult( + managedType, + Il2CppRegistrationStatus.UnsupportedType, + required, + validationMessage)); + } + + try + { + // The non-generic overload is present across the supported + // Il2CppInterop 1.x line and avoids coupling this wrapper to + // changing generic constraints. + ClassInjector.RegisterTypeInIl2Cpp(managedType); + return Store(new Il2CppRegistrationResult( + managedType, + Il2CppRegistrationStatus.Registered, + required)); + } + catch (Exception ex) + { + return Store(new Il2CppRegistrationResult( + managedType, + Il2CppRegistrationStatus.Failed, + required, + ex.Message, + ex)); + } + } + + public bool RequiredRegistrationsSucceeded() => + _results.Values.Where(result => result.Required).All(result => result.Succeeded); + + private Il2CppRegistrationResult Store(Il2CppRegistrationResult result) + { + _results[result.ManagedType] = result; + + if (result.Succeeded) + { + _logger.Info($"IL2CPP type registered: {result.ManagedType.FullName}"); + } + else if (result.Required) + { + _logger.Error( + $"Required IL2CPP type registration failed: {result.ManagedType.FullName}: {result.Message}", + result.Exception); + } + else + { + _logger.Warning( + $"Optional IL2CPP type registration skipped/failed: {result.ManagedType.FullName}: {result.Message}"); + } + + return result; + } + + private static bool HasSupportedConstructionPath(Type type, out string? message) + { + if (type.IsAbstract || type.IsInterface || type.ContainsGenericParameters) + { + message = "Injected types must be closed, non-abstract classes."; + return false; + } + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + bool hasPointerConstructor = type.GetConstructor( + flags, + binder: null, + types: new[] { typeof(IntPtr) }, + modifiers: null) != null; + + if (!hasPointerConstructor) + { + message = "Missing required constructor with a single System.IntPtr parameter."; + return false; + } + + message = null; + return true; + } +} diff --git a/src/gregCore.Abstractions/LoaderContracts.cs b/src/gregCore.Abstractions/LoaderContracts.cs new file mode 100644 index 00000000..a90489ca --- /dev/null +++ b/src/gregCore.Abstractions/LoaderContracts.cs @@ -0,0 +1,50 @@ +using System; + +namespace gregCore.Abstractions; + +public enum GregLogLevel +{ + Trace, + Debug, + Information, + Warning, + Error, + Critical +} + +public interface IGregLogSink +{ + void Write(GregLogLevel level, string message, Exception? exception = null); +} + +public sealed class LoaderRuntimeInfo +{ + public string LoaderId { get; set; } = string.Empty; + public string LoaderVersion { get; set; } = string.Empty; + public string RuntimeId { get; set; } = string.Empty; + public string Platform { get; set; } = string.Empty; + public string Architecture { get; set; } = string.Empty; + public string GameRootDirectory { get; set; } = string.Empty; + public string ModsDirectory { get; set; } = string.Empty; +} + +/// +/// Lifecycle surface implemented by loader-specific hosts. Managed framework +/// services depend on this contract rather than MelonLoader or BepInEx types. +/// +public interface ILoaderHost +{ + LoaderRuntimeInfo Runtime { get; } + IGregLogSink Log { get; } + + void RegisterUpdate(Action callback); + void RegisterSceneLoaded(Action callback); + void RegisterShutdown(Action callback); +} + +public interface ICompatibilityContext +{ + string ProfileId { get; } + bool SafeMode { get; } + bool Supports(string capability); +} diff --git a/src/gregCore.Abstractions/gregCore.Abstractions.csproj b/src/gregCore.Abstractions/gregCore.Abstractions.csproj new file mode 100644 index 00000000..27b25894 --- /dev/null +++ b/src/gregCore.Abstractions/gregCore.Abstractions.csproj @@ -0,0 +1,12 @@ + + + netstandard2.0 + gregCore.Abstractions + gregCore.Abstractions + false + true + + + + + diff --git a/src/gregCore.Bridge/gregCore.Bridge.csproj b/src/gregCore.Bridge/gregCore.Bridge.csproj new file mode 100644 index 00000000..6954bdd4 --- /dev/null +++ b/src/gregCore.Bridge/gregCore.Bridge.csproj @@ -0,0 +1,13 @@ + + + net6.0 + gregCore.Bridge + gregCore.Bridge + false + + + + + + + diff --git a/src/gregCore.Compatibility/gregCore.Compatibility.csproj b/src/gregCore.Compatibility/gregCore.Compatibility.csproj new file mode 100644 index 00000000..8bb38a83 --- /dev/null +++ b/src/gregCore.Compatibility/gregCore.Compatibility.csproj @@ -0,0 +1,12 @@ + + + net6.0 + gregCore.Compatibility + gregCore.Compatibility + false + + + + + + diff --git a/src/gregCore.Core/gregCore.Core.csproj b/src/gregCore.Core/gregCore.Core.csproj new file mode 100644 index 00000000..2329c265 --- /dev/null +++ b/src/gregCore.Core/gregCore.Core.csproj @@ -0,0 +1,12 @@ + + + netstandard2.0 + gregCore.Core + gregCore.Core + false + + + + + + diff --git a/src/gregCore.Hooks/gregCore.Hooks.csproj b/src/gregCore.Hooks/gregCore.Hooks.csproj new file mode 100644 index 00000000..1436ccb3 --- /dev/null +++ b/src/gregCore.Hooks/gregCore.Hooks.csproj @@ -0,0 +1,12 @@ + + + net6.0 + gregCore.Hooks + gregCore.Hooks + false + + + + + + diff --git a/src/gregCore.Mod/gregCore.Mod.csproj b/src/gregCore.Mod/gregCore.Mod.csproj new file mode 100644 index 00000000..8116bfce --- /dev/null +++ b/src/gregCore.Mod/gregCore.Mod.csproj @@ -0,0 +1,12 @@ + + + net6.0 + gregCore.Mod + gregCore.Mod + false + + + + + + diff --git a/src/gregCore.Patches/gregCore.Patches.csproj b/src/gregCore.Patches/gregCore.Patches.csproj new file mode 100644 index 00000000..3e2b3bcc --- /dev/null +++ b/src/gregCore.Patches/gregCore.Patches.csproj @@ -0,0 +1,12 @@ + + + net6.0 + gregCore.Patches + gregCore.Patches + false + + + + + + diff --git a/src/gregCore.SDK/gregCore.SDK.csproj b/src/gregCore.SDK/gregCore.SDK.csproj new file mode 100644 index 00000000..a093cbbb --- /dev/null +++ b/src/gregCore.SDK/gregCore.SDK.csproj @@ -0,0 +1,12 @@ + + + netstandard2.0 + gregCore.SDK + gregCore.SDK + false + + + + + + diff --git a/src/gregCore.Shared/gregCore.Shared.csproj b/src/gregCore.Shared/gregCore.Shared.csproj new file mode 100644 index 00000000..afe7dfbd --- /dev/null +++ b/src/gregCore.Shared/gregCore.Shared.csproj @@ -0,0 +1,12 @@ + + + netstandard2.0 + gregCore.Shared + gregCore.Shared + false + + + + + + diff --git a/src/gregCore.UI/gregCore.UI.csproj b/src/gregCore.UI/gregCore.UI.csproj new file mode 100644 index 00000000..1b5e960e --- /dev/null +++ b/src/gregCore.UI/gregCore.UI.csproj @@ -0,0 +1,12 @@ + + + net6.0 + gregCore.UI + gregCore.UI + false + + + + + + diff --git a/tests/CompatibilityProfilesTests.cs b/tests/CompatibilityProfilesTests.cs new file mode 100644 index 00000000..c7d4d3e7 --- /dev/null +++ b/tests/CompatibilityProfilesTests.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using FluentAssertions; +using gregCore.Core.Compatibility; +using Xunit; + +namespace gregCore.Tests; + +public sealed class CompatibilityProfilesTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + "gregcore-compat-tests-" + Guid.NewGuid().ToString("N")); + + public CompatibilityProfilesTests() + { + Directory.CreateDirectory(_directory); + } + + [Fact] + public void Verify_returns_size_verified_for_matching_required_reference() + { + string referencePath = WriteReference("Assembly-CSharp.dll", new byte[] { 1, 2, 3, 4 }); + CompatibilityProfile profile = CreateProfile( + new ReferenceCompatibility + { + Path = "Assembly-CSharp.dll", + Required = true, + Size = 4, + Sha256 = null + }); + + CompatibilityReport report = CompatibilityVerifier.Verify( + profile, + reference => reference.Path == "Assembly-CSharp.dll" ? referencePath : null, + detectedUnityVersion: "6000.5.3f1", + detectedArchitecture: "x64", + detectedPlatform: "windows"); + + report.Level.Should().Be(CompatibilityLevel.SizeVerified); + report.SafeMode.Should().BeFalse(); + report.CanLoadGameAdapters.Should().BeTrue(); + report.Issues.Should().BeEmpty(); + } + + [Fact] + public void Verify_enables_safe_mode_for_required_hash_mismatch() + { + string referencePath = WriteReference("Assembly-CSharp.dll", new byte[] { 1, 2, 3, 4 }); + CompatibilityProfile profile = CreateProfile( + new ReferenceCompatibility + { + Path = "Assembly-CSharp.dll", + Required = true, + Size = 4, + Sha256 = new string('0', 64) + }); + + CompatibilityReport report = CompatibilityVerifier.Verify( + profile, + _ => referencePath, + detectedUnityVersion: "6000.5.3f1", + detectedArchitecture: "x64", + detectedPlatform: "windows"); + + report.Level.Should().Be(CompatibilityLevel.Incompatible); + report.SafeMode.Should().BeTrue(); + report.CanLoadGameAdapters.Should().BeFalse(); + report.Issues.Should().ContainSingle(issue => + issue.Code == "REFERENCE_HASH_MISMATCH" && issue.IsFatal); + } + + [Fact] + public void Verify_rejects_unexpected_exact_unity_version() + { + string referencePath = WriteReference("Assembly-CSharp.dll", new byte[] { 7 }); + CompatibilityProfile profile = CreateProfile( + new ReferenceCompatibility + { + Path = "Assembly-CSharp.dll", + Required = true, + Size = 1 + }); + profile.Unity.Version = "6000.5.3f1"; + profile.Unity.ExactVersionKnown = true; + + CompatibilityReport report = CompatibilityVerifier.Verify( + profile, + _ => referencePath, + detectedUnityVersion: "6000.5.4f1", + detectedArchitecture: "x64", + detectedPlatform: "windows"); + + report.SafeMode.Should().BeTrue(); + report.Issues.Should().ContainSingle(issue => + issue.Code == "UNITY_VERSION_MISMATCH" && issue.IsFatal); + } + + [Fact] + public void Verify_returns_hash_verified_when_all_declared_hashes_match() + { + byte[] bytes = { 9, 8, 7, 6 }; + string referencePath = WriteReference("Assembly-CSharp.dll", bytes); + CompatibilityProfile profile = CreateProfile( + new ReferenceCompatibility + { + Path = "Assembly-CSharp.dll", + Required = true, + Size = bytes.Length, + Sha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant() + }); + + CompatibilityReport report = CompatibilityVerifier.Verify( + profile, + _ => referencePath, + detectedUnityVersion: "6000.5.3f1", + detectedArchitecture: "x64", + detectedPlatform: "linux"); + + report.Level.Should().Be(CompatibilityLevel.HashVerified); + report.SafeMode.Should().BeFalse(); + } + + private string WriteReference(string name, byte[] content) + { + string path = Path.Combine(_directory, name); + File.WriteAllBytes(path, content); + return path; + } + + private static CompatibilityProfile CreateProfile(ReferenceCompatibility reference) + { + return new CompatibilityProfile + { + SchemaVersion = 2, + ProfileId = "test-profile", + Status = "supported", + Framework = new FrameworkCompatibility + { + VersionLine = "1.2.x", + MinimumVersion = "1.2.1", + MaximumVersionExclusive = "2.0.0" + }, + Game = new GameCompatibility + { + Id = "data-center", + Version = "test" + }, + Unity = new UnityCompatibility + { + Version = "6000.5", + ExactVersionKnown = false, + Backend = "IL2CPP" + }, + Runtime = new RuntimeCompatibility + { + Loader = "MelonLoader", + LoaderVersion = "0.7.x", + Interop = "Il2CppInterop", + Architectures = new List { "x64" }, + Platforms = new List { "windows", "linux" } + }, + ReferenceFiles = new List { reference }, + Features = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["classInjection"] = true + } + }; + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } +}