diff --git a/.github/actions/package-mod/action.yml b/.github/actions/package-mod/action.yml new file mode 100644 index 000000000..8b3b4ea63 --- /dev/null +++ b/.github/actions/package-mod/action.yml @@ -0,0 +1,122 @@ +name: Package Multiplayer mod +description: Assemble any number of independently built game versions. +inputs: + destination: + description: Destination mod directory. + required: true + builds-root: + description: Directory containing one build per version, each with About and files. + required: true + versions: + description: JSON list of all required game versions; missing builds fail packaging. + required: true + current-version: + description: Version whose About metadata supplies the package name and description. + required: true + display-name: + description: Optional package display name. + default: '' + stamp-version: + description: Stamp the release version and current source commit. + default: 'false' +runs: + using: composite + steps: + - name: Assemble versioned mod + shell: bash + env: + DESTINATION: ${{ inputs.destination }} + BUILDS_ROOT: ${{ inputs.builds-root }} + VERSIONS: ${{ inputs.versions }} + CURRENT_VERSION: ${{ inputs.current-version }} + DISPLAY_NAME: ${{ inputs.display-name }} + STAMP_VERSION: ${{ inputs.stamp-version }} + run: | + python3 - <<'PYTHON' + from copy import deepcopy + from pathlib import Path + import os + import json + import re + import shutil + import subprocess + import xml.etree.ElementTree as ET + + destination = Path(os.environ['DESTINATION']) + builds_root = Path(os.environ['BUILDS_ROOT']) + sources = {p.name: p for p in builds_root.iterdir() if p.is_dir()} + current = os.environ['CURRENT_VERSION'] + required = json.loads(os.environ['VERSIONS']) + if len(set(required)) != len(required) or set(sources) != set(required) or current not in sources or any(not re.fullmatch(r'\d+\.\d+', v) for v in sources): + raise SystemExit('Expected version-named build directories including the current version') + versions = sorted(sources, key=lambda value: tuple(map(int, value.split('.')))) + # Validate every independent build before copying any files. + metadata = {} + for version, source in sources.items(): + metadata[version] = ET.parse(source / 'About/About.xml').getroot() + if version not in [item.text for item in metadata[version].findall('supportedVersions/li')]: + raise SystemExit(f'Source metadata does not support {version}') + for relative in ('Assemblies/MultiplayerLoader.dll', 'AssembliesCustom/Multiplayer.dll', 'AssembliesCustom/MultiplayerCommon.dll'): + path = source / 'files' / relative + if not path.is_file(): + raise SystemExit(f'Missing {version} build output: {path}') + for dll in (source / 'files').rglob('*.dll'): + name = dll.name.lower() + dependency_api = name == '0prepatcherapi.dll' + if name in ('0harmony.dll', 'assembly-csharp.dll', 'verse.dll', 'unitystubs.dll') or name.startswith(('unityengine', 'mono.cecil')) or (name.startswith(('0prepatcher', 'prepatcher')) and not dependency_api): + raise SystemExit(f'Unexpected bundled game or dependency DLL: {dll}') + destination.mkdir(parents=True, exist_ok=False) + shutil.copytree(sources[current] / 'About', destination / 'About') + root = deepcopy(metadata[current]) + for tag in ('supportedVersions', 'modDependencies', 'modDependenciesByVersion'): + for old in list(root.findall(tag)): + root.remove(old) + supported = ET.SubElement(root, 'supportedVersions') + dependencies = ET.SubElement(root, 'modDependenciesByVersion') + folders = ET.Element('loadFolders') + for version in versions: + shutil.copytree(sources[version] / 'files', destination / version, ignore=shutil.ignore_patterns('.git')) + ET.SubElement(supported, 'li').text = version + selected = metadata[version].find(f'modDependenciesByVersion/v{version}') + if selected is None: + selected = metadata[version].find('modDependencies') + entry = ET.SubElement(dependencies, f'v{version}') + if selected is not None: + entry.extend(deepcopy(list(selected))) + config_path = sources[version] / 'source.json' + config = json.loads(config_path.read_text()) if config_path.exists() else {} + identifiers = {item.findtext('packageId') for item in entry} + for dependency in config.get('extra-dependencies', []): + if dependency['packageId'] not in identifiers: + item = ET.SubElement(entry, 'li') + for key, value in dependency.items(): + ET.SubElement(item, key).text = value + identifiers.add(dependency['packageId']) + folder = ET.SubElement(folders, f'v{version}') + ET.SubElement(folder, 'li').text = '/' + ET.SubElement(folder, 'li').text = version + # Preserve all source ordering requirements and any release dependency additions. + for tag in ('loadAfter', 'loadBefore'): + ordering = root.find(tag) + if ordering is None: + ordering = ET.SubElement(root, tag) + existing = {item.text for item in ordering} + values = [item.text for version in versions for item in metadata[version].findall(f'{tag}/li')] + if tag == 'loadAfter': + values += [item.findtext('packageId') for entry in dependencies for item in entry] + for value in values: + if value and value not in existing: + ET.SubElement(ordering, 'li').text = value + existing.add(value) + if os.environ['DISPLAY_NAME']: + root.find('name').text = os.environ['DISPLAY_NAME'] + if os.environ['STAMP_VERSION'] == 'true': + source = Path('Source/Common/Version.cs').read_text(encoding='utf-8') + version = re.search(r'\bSimpleVersion\s*=\s*"([^"]+)"', source) + if version is None: + raise SystemExit('Cannot find SimpleVersion') + commit = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'], text=True).strip() + root.find('modVersion').text = f'{version[1]} ({commit})' + ET.ElementTree(root).write(destination / 'About/About.xml', encoding='utf-8', xml_declaration=True) + ET.ElementTree(folders).write(destination / 'LoadFolders.xml', encoding='utf-8', xml_declaration=True) + PYTHON diff --git a/.github/package-versions.json b/.github/package-versions.json new file mode 100644 index 000000000..3ae2c5c31 --- /dev/null +++ b/.github/package-versions.json @@ -0,0 +1,21 @@ +{ + "current": "1.7", + "releases": [ + { + "version": "1.5", + "branch": "rw-1.5", + "extra-dependencies": [ + { + "packageId": "zetrith.prepatcher", + "displayName": "Prepatcher", + "steamWorkshopUrl": "steam://url/CommunityFilePage/2934420800" + } + ] + }, + { + "version": "1.6", + "branch": "rw-1.6", + "fallback": "9df94bd04ea9a94c00bd39a276e58c4c2f965740" + } + ] +} diff --git a/.github/workflows/alpha-notes.md b/.github/workflows/alpha-notes.md index f8ec5c59f..6bdab1d4b 100644 --- a/.github/workflows/alpha-notes.md +++ b/.github/workflows/alpha-notes.md @@ -1,35 +1,5 @@ -### This 'alpha' release is an automatically generated snapshot of the current state of development. It is continuously updated with work-in-progress changes that may be broken, incomplete, or incompatible. +Development build for RimWorld 1.7, bundled with independently built 1.5 and 1.6 versions. -#### Supported RimWorld versions: latest 1.6 - -#### Installation -1. Download the `Multiplayer-beta.zip` file below. -2. Open your RimWorld installation directory - * **Steam**: Right-click RimWorld in your Steam library → `Manage` → `Browse local Files` - * Or navigate directly to: - - Windows: `C:\Program Files (x86)\Steam\steamapps\common\RimWorld\Mods` - - Mac: `~/Library/Application Support/Steam/steamapps/common/RimWorld/RimWorldMac.app/Mods` (Right-click on `RimWorldMac.app`, then click `Show Package Contents` to see the `Mods` folder) - - Linux: `~/.steam/steam/steamapps/common/RimWorld/Mods` -3. Extract the zip file into the `Mods` folder. - * You should have a `Multiplayer` folder in the `Mods` folder (`Mods/Multiplayer`) - * Make sure you do not have this directory structure: `Mods/Multiplayer-beta/Multiplayer`. If you do, move the `Multiplayer` folder to the parent directory. - -#### Standalone server - -Download `Server-beta.zip` if you want to host a dedicated standalone server for testing. - -**Setup** -1. Download and extract `Server-beta.zip`. -2. Open the folder for your platform: - - `Server/Windows` - - `Server/Linux` -3. Start the server using: - - Windows: `Server.exe` - - Linux: `./Server.sh` -4. The server will start and wait for the first connection. - -**First-time configuration (bootstrap)** -No manual configuration files are required. -The **first player to connect** via the Multiplayer mod client will be prompted to perform the initial setup: -they can configure the game world, scenario, and other options directly from within RimWorld. -Subsequent players connect to the already-running session. \ No newline at end of file +Install the dependencies matching your game version. The 1.5 and 1.6 packages +require Prepatcher; 1.7 requires Harmony 3. Extract the mod ZIP to Mods; +LoadFolders.xml selects matching assets and assemblies. See README.md for source selection. diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index d4e2db162..b5a88c526 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -1,79 +1,51 @@ name: Build beta - -env: - SLN_PATH: Source/ - on: workflow_dispatch: push: - branches: - - dev - paths-ignore: - - 'README.md' - + branches: [dev] + paths-ignore: ['README.md'] +permissions: + contents: read jobs: build: - name: Build beta + name: Build beta mod and server packages + uses: ./.github/workflows/build-packages.yml + with: + artifact-suffix: beta-${{ github.run_id }} + display-name: Multiplayer [Continuous] + release: + name: Publish beta release + needs: build runs-on: ubuntu-latest + permissions: + contents: write env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} steps: - - name: Checkout Repository - uses: actions/checkout@v4 + - name: Check out release metadata + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - submodules: recursive - - - name: Setup Dotnet - uses: actions/setup-dotnet@v4 + persist-credentials: false + - name: Download combined mod package + uses: actions/download-artifact@v4 with: - dotnet-version: 9.x - - - name: Install Mod Dependencies - run: dotnet restore ${{ env.SLN_PATH }} - - - name: Build Mod - run: dotnet build ${{ env.SLN_PATH }} --configuration Release --no-restore - - - name: Publish Server (Windows) - run: dotnet publish ${{ env.SLN_PATH }}Server/Server.csproj --configuration Release --runtime win-x64 --self-contained false -p:UseAppHost=true -o output/Server/Windows - - - name: Publish Server (Linux) - run: dotnet publish ${{ env.SLN_PATH }}Server/Server.csproj --configuration Release --runtime linux-x64 --self-contained false -p:UseAppHost=true -o output/Server/Linux - - - name: Add Server Launchers - run: | - cat > output/Server/Linux/Server.sh <<'EOF' - #!/usr/bin/env bash - set -euo pipefail - SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - exec dotnet "$SCRIPT_DIR/Server.dll" "$@" - EOF - - chmod +x output/Server/Linux/Server.sh - - - name: Package files + name: ${{ needs.build.outputs.mod-artifact }} + path: output + - name: Download combined server package + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.server-artifact }} + path: output + - name: Package release ZIPs + working-directory: output run: | - sed -i "s/\(.*\)<\/name>\$/\1 [Continuous]<\/name>/" About/About.xml - mkdir -p output/Multiplayer - mv About/ Assemblies/ AssembliesCustom/ Defs/ Languages/ Textures/ output/Multiplayer - cd output/ zip -r ../Multiplayer-beta.zip Multiplayer/ zip -r ../Server-beta.zip Server/ - cd ../ - - - name: Upload Mod Artifacts - uses: actions/upload-artifact@v4 - with: - name: Multiplayer-beta - path: output/ - - - name: Clean up last release + - name: Remove previous release continue-on-error: true + run: gh release delete --cleanup-tag --yes continuous + - name: Publish release run: | - gh release delete --cleanup-tag --yes "continuous" - - - name: Upload new release - run: | - gh release create --target "${{ github.sha }}" --title "Continuous" --notes-file ".github/workflows/alpha-notes.md" --draft "continuous" - gh release upload "continuous" Multiplayer-beta.zip Server-beta.zip - gh release edit "continuous" --draft=false + gh release create --target "${{ github.sha }}" --title Continuous --notes-file .github/workflows/alpha-notes.md --draft continuous + gh release upload continuous Multiplayer-beta.zip Server-beta.zip + gh release edit continuous --draft=false diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml new file mode 100644 index 000000000..eeb851159 --- /dev/null +++ b/.github/workflows/build-packages.yml @@ -0,0 +1,271 @@ +name: Build release packages +on: + workflow_call: + inputs: + artifact-suffix: + required: true + type: string + display-name: + default: '' + type: string + stamp-version: + default: false + type: boolean + include-servers: + default: true + type: boolean + outputs: + mod-artifact: + value: ${{ jobs.package.outputs.mod-artifact }} + server-artifact: + value: ${{ jobs.package.outputs.server-artifact }} +permissions: + contents: read +jobs: + configuration: + name: Select supported game versions + runs-on: ubuntu-latest + outputs: + current: ${{ steps.config.outputs.current }} + releases: ${{ steps.config.outputs.releases }} + versions: ${{ steps.config.outputs.versions }} + steps: + - name: Check out packaging configuration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - id: config + name: Read supported version builds + run: | + python3 - <<'PYTHON' + import json + import os + import re + from pathlib import Path + config = json.loads(Path('.github/package-versions.json').read_text()) + versions = [config['current']] + [entry['version'] for entry in config['releases']] + if len(set(versions)) != len(versions) or any(not re.fullmatch(r'\d+\.\d+', value) for value in versions): + raise SystemExit('Package versions must be unique major.minor values') + for entry in config['releases']: + if not entry.get('branch'): + raise SystemExit('Each release source must name an upstream branch') + if entry.get('fallback') and not re.fullmatch(r'[0-9a-f]{40}', entry['fallback']): + raise SystemExit('Fallbacks must be full commit SHAs') + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f"current={config['current']}\n") + output.write('versions=' + json.dumps(versions) + '\n') + output.write('releases=' + json.dumps(config['releases'], separators=(',', ':')) + '\n') + PYTHON + current: + name: Build RimWorld ${{ needs.configuration.outputs.current }} + needs: configuration + env: + GAME_VERSION: ${{ needs.configuration.outputs.current }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out current game source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: recursive + - name: Set up .NET SDK for RimWorld ${{ env.GAME_VERSION }} + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/*.csproj' + - name: Build RimWorld ${{ env.GAME_VERSION }} mod (Release) + run: dotnet build Source/Client/Multiplayer.csproj -c Release + - name: Stage RimWorld ${{ env.GAME_VERSION }} mod files + run: | + destination="artifacts/mod-build/$GAME_VERSION" + mkdir -p "$destination/files" + cp -R About "$destination/" + cp -R Defs Languages Textures "$GAME_VERSION/Assemblies" "$GAME_VERSION/AssembliesCustom" "$destination/files/" + rm -rf "$destination/files/Languages/.git" + git rev-parse HEAD > "$destination/files/source-commit.txt" + - name: Audit RimWorld ${{ env.GAME_VERSION }} mod references + run: dotnet run --project Source/RuntimeTests/MetadataAudit.csproj -c Release -- "artifacts/mod-build/$GAME_VERSION/files" + - name: Upload RimWorld ${{ env.GAME_VERSION }} mod build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mod-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} + path: artifacts/mod-build/ + if-no-files-found: error + retention-days: 7 + - name: Publish RimWorld ${{ env.GAME_VERSION }} Windows/Linux servers + if: inputs.include-servers + run: | + for os in win linux; do + dotnet publish Source/Server/Server.csproj -c Release -r "$os-x64" --self-contained false -p:UseAppHost=true -o "artifacts/server/Server/$GAME_VERSION/$(if [[ $os == win ]]; then echo Windows; else echo Linux; fi)" + done + - name: Upload RimWorld ${{ env.GAME_VERSION }} Windows/Linux server builds + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: inputs.include-servers + with: + name: server-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} + path: artifacts/server/ + retention-days: 7 + if-no-files-found: error + versions: + name: Build RimWorld ${{ matrix.source.version }} + needs: configuration + strategy: + fail-fast: false + matrix: + source: ${{ fromJSON(needs.configuration.outputs.releases) }} + env: + GAME_VERSION: ${{ matrix.source.version }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out build configuration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Resolve RimWorld ${{ env.GAME_VERSION }} source branch or fallback + id: source + env: + SOURCE_BRANCH: ${{ matrix.source.branch }} + SOURCE_FALLBACK: ${{ matrix.source.fallback || '' }} + run: | + if refs=$(git ls-remote --exit-code --heads https://github.com/rwmt/Multiplayer.git "refs/heads/$SOURCE_BRANCH"); then + sha=${refs%%$'\t'*} + echo "Using $SOURCE_BRANCH at $sha" + else + status=$? + if [[ "$status" != 2 ]]; then + echo "Could not check the source branch (git exit $status)" >&2 + exit "$status" + fi + if [[ -z "$SOURCE_FALLBACK" ]]; then + echo "Required branch $SOURCE_BRANCH does not exist and has no fallback" >&2 + exit 1 + fi + sha=$SOURCE_FALLBACK + echo "No $SOURCE_BRANCH branch; using pinned commit $sha" + fi + echo "sha=$sha" >> "$GITHUB_OUTPUT" + - name: Check out RimWorld ${{ env.GAME_VERSION }} source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: rwmt/Multiplayer + ref: ${{ steps.source.outputs.sha }} + path: version-src + persist-credentials: false + submodules: recursive + - name: Set up .NET SDK for RimWorld ${{ env.GAME_VERSION }} + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/*.csproj' + - name: Build RimWorld ${{ env.GAME_VERSION }} mod (Release) + working-directory: version-src + run: dotnet build Source/Client/Multiplayer.csproj -c Release + - name: Stage RimWorld ${{ env.GAME_VERSION }} mod files + env: + SOURCE_SHA: ${{ steps.source.outputs.sha }} + SOURCE_CONFIG: ${{ toJSON(matrix.source) }} + run: | + destination="artifacts/mod-build/$GAME_VERSION" + mkdir -p "$destination/files" + cp -R version-src/About "$destination/" + for directory in Assemblies AssembliesCustom Defs Languages Textures; do + cp -R "version-src/$directory" "$destination/files/" + done + rm -rf "$destination/files/Languages/.git" + echo "$SOURCE_SHA" > "$destination/files/source-commit.txt" + printf '%s\n' "$SOURCE_CONFIG" > "$destination/source.json" + - name: Upload RimWorld ${{ env.GAME_VERSION }} mod build + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mod-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} + path: artifacts/mod-build/ + retention-days: 7 + if-no-files-found: error + - name: Publish RimWorld ${{ env.GAME_VERSION }} Windows/Linux servers + if: inputs.include-servers + working-directory: version-src + run: | + for os in win linux; do + dotnet publish Source/Server/Server.csproj -c Release -r "$os-x64" --self-contained false -p:UseAppHost=true -o "../artifacts/server/Server/$GAME_VERSION/$(if [[ $os == win ]]; then echo Windows; else echo Linux; fi)" + done + - name: Upload RimWorld ${{ env.GAME_VERSION }} Windows/Linux server builds + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: inputs.include-servers + with: + name: server-build-${{ env.GAME_VERSION }}-${{ inputs.artifact-suffix }} + path: artifacts/server/ + retention-days: 7 + if-no-files-found: error + package: + name: Assemble release packages + needs: [configuration, current, versions] + runs-on: ubuntu-latest + outputs: + mod-artifact: ${{ steps.names.outputs.mod }} + server-artifact: ${{ steps.names.outputs.server }} + steps: + - name: Check out packaging action and release metadata + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download mod builds for all supported versions + uses: actions/download-artifact@v4 + with: + pattern: mod-build-*-${{ inputs.artifact-suffix }} + path: bundle + merge-multiple: true + - name: Assemble multi-version mod package + uses: ./.github/actions/package-mod + with: + destination: artifacts/mod/Multiplayer + builds-root: bundle + versions: ${{ needs.configuration.outputs.versions }} + current-version: ${{ needs.configuration.outputs.current }} + display-name: ${{ inputs.display-name }} + stamp-version: ${{ inputs.stamp-version }} + - name: Name mod and server artifacts + id: names + env: + SUFFIX: ${{ inputs.artifact-suffix }} + run: | + echo "mod=Multiplayer-mod-$SUFFIX" >> "$GITHUB_OUTPUT" + echo "server=Multiplayer-server-$SUFFIX" >> "$GITHUB_OUTPUT" + - name: Upload combined mod package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.names.outputs.mod }} + path: artifacts/mod/ + retention-days: 7 + if-no-files-found: error + - name: Download server builds for all supported versions + uses: actions/download-artifact@v4 + if: inputs.include-servers + with: + pattern: server-build-*-${{ inputs.artifact-suffix }} + path: artifacts/server + merge-multiple: true + - name: Add Linux server launchers for each version + if: inputs.include-servers + run: | + for directory in artifacts/server/Server/*/Linux; do + cat > "$directory/Server.sh" <<'EOF' + #!/usr/bin/env bash + # Run the server beside this script with the supplied arguments. + set -euo pipefail + SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + exec dotnet "$SCRIPT_DIR/Server.dll" "$@" + EOF + chmod +x "$directory/Server.sh" + done + - name: Upload combined server package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: inputs.include-servers + with: + name: ${{ steps.names.outputs.server }} + path: artifacts/server/ + retention-days: 7 + if-no-files-found: error diff --git a/.github/workflows/build-workshop.yml b/.github/workflows/build-workshop.yml index 441801a74..bd4c105e8 100644 --- a/.github/workflows/build-workshop.yml +++ b/.github/workflows/build-workshop.yml @@ -1,41 +1,15 @@ name: Build workshop - on: workflow_dispatch: push: - tags: - - 'v*' - + tags: ['v*'] +permissions: + contents: read jobs: build: - name: Build workshop - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: recursive # Updates the Languages submodule - - - name: Setup Dotnet - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.x - - - name: Run workshop bundler - run: ./workshop_bundler.sh - - - run: mkdir -p output - - - name: Move files - run: mv Multiplayer/ output/ - - - id: get_version - run: echo ::set-output name=VERSION::$(echo ${{ github.ref }} | cut -d / -f 3) # github.ref is refs/tags/ here - - - name: Upload mod artifacts - uses: actions/upload-artifact@v4 - with: - name: Multiplayer-${{ steps.get_version.outputs.VERSION }} - path: | - output/ + name: Build Workshop mod package + uses: ./.github/workflows/build-packages.yml + with: + artifact-suffix: workshop-${{ github.run_id }} + stamp-version: true + include-servers: false diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index b44397c6c..ef8e33f38 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -2,36 +2,66 @@ name: Validate Pull Request -env: - SLN_PATH: Source/ +on: pull_request + +permissions: + contents: read -on: - pull_request +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true jobs: builds: name: Builds runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 9.x - env: - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - + global-json-file: global.json + cache: true + cache-dependency-path: '**/*.csproj' + - name: Restore dependencies - run: dotnet restore ${{ env.SLN_PATH }} + run: dotnet restore Source/Multiplayer.sln + + - name: Build mod, server and tests (Debug) + run: dotnet build Source/Multiplayer.sln -c Debug --no-restore + + - name: Build mod, server and tests (Release) + run: dotnet build Source/Multiplayer.sln -c Release --no-restore - - name: Build debug - run: dotnet build ${{ env.SLN_PATH }} --no-restore - - - name: Build release - run: dotnet build ${{ env.SLN_PATH }} --configuration Release --no-restore + - name: Run unit tests + run: >- + dotnet test Source/Tests/Tests.csproj -c Release --no-build --no-restore + --logger "trx;LogFileName=TestResults.trx" + --results-directory artifacts/test-results + + - name: Upload unit test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-results-rimworld-1.7 + path: artifacts/test-results/ + retention-days: 7 + if-no-files-found: warn + overwrite: true - - name: Run tests - run: dotnet test ${{ env.SLN_PATH }} --no-restore + # Independent of tests so buildable PRs still supply reproduction packages. + package-artifacts: + name: Build PR mod and server packages + uses: ./.github/workflows/build-packages.yml + with: + artifact-suffix: pr${{ github.event.pull_request.number }}-${{ github.sha }} + display-name: "Multiplayer [PR #${{ github.event.pull_request.number }}]" diff --git a/.gitignore b/.gitignore index 066bc4f10..13a9db3fa 100644 --- a/.gitignore +++ b/.gitignore @@ -265,6 +265,11 @@ __pycache__/ /Assemblies /AssembliesCustom +/1.6/Assemblies/ +/1.6/AssembliesCustom/ +/1.7/Assemblies/ +/1.7/AssembliesCustom/ +/.versioned-build-backup-*/ /Source/Publicise-1.0.0.zip /Source/publicise_hash.txt /Source/Publicise.exe @@ -277,4 +282,4 @@ PersonalDefinitions.targets !Source/Client/Debug/ -/Source/mpdb \ No newline at end of file +/Source/mpdb diff --git a/About/About.xml b/About/About.xml index c69e97dd2..1014caa8e 100644 --- a/About/About.xml +++ b/About/About.xml @@ -3,24 +3,20 @@ rwmt.Multiplayer Multiplayer -
  • 1.6
  • +
  • 1.7
  • RimWorld Multiplayer Team https://github.com/rwmt/Multiplayer - + <b>Important: This mod should be placed right below Core and expansions in the mod list to work properly! -Requires RimWorld >= 1.6.4491</b>\n +RimWorld 1.7 requires Harmony 3</b>\n Multiplayer mod for RimWorld. FAQ - https://hackmd.io/@rimworldmultiplayer/docs/ Discord - https://discord.gg/S4bxXpv -
  • - zetrith.prepatcher - Prepatcher - steam://url/CommunityFilePage/2934420800 -
  • +
  • brrainz.harmonyHarmony 3steam://url/CommunityFilePage/2009463077
  • Ludeon.RimWorld
  • @@ -29,7 +25,6 @@ Discord - https://discord.gg/S4bxXpv
  • Ludeon.RimWorld.Biotech
  • Ludeon.RimWorld.Anomaly
  • Ludeon.RimWorld.Odyssey
  • -
  • zetrith.prepatcher
  • brrainz.harmony
  • diff --git a/LoadFolders.xml b/LoadFolders.xml new file mode 100644 index 000000000..4eef74656 --- /dev/null +++ b/LoadFolders.xml @@ -0,0 +1,4 @@ + + +
  • /
  • 1.7
  • +
    diff --git a/MajorReleaseProcess.txt b/MajorReleaseProcess.txt index 120a5726c..813d09dad 100644 --- a/MajorReleaseProcess.txt +++ b/MajorReleaseProcess.txt @@ -1,7 +1,7 @@ -1. Check mod version in Source/Common/Version.cs -2. Check RW version in About.xml (Multiplayer version is added automatically by workshop_bundler.sh) -3. Check Preview.png -4. Make a branch to freeze a release for old RW version, remember to compile and push assemblies, edit workshop_bundler.sh -5. Run workshop_bundler.sh -6. Github release zip generated by bundler -7. Steam workshop upload folder Multiplayer/ generated by bundler \ No newline at end of file +1. Check the mod version in Source/Common/Version.cs. +2. Check supported RimWorld versions in About/About.xml and the release sources in .github/package-versions.json. +3. Check About/Preview.png. +4. Preserve a release branch for older RimWorld versions when dropping support. +5. Push the release tag (v*) or manually run the Build workshop workflow. +6. Download the Multiplayer-mod-workshop- artifact from the workflow run. It contains Multiplayer/ with all configured version folders and a generated modVersion. +7. Use the downloaded ZIP for GitHub releases and the extracted Multiplayer/ folder for the Steam Workshop upload. diff --git a/README.md b/README.md index b0158fcad..c1c45312b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,68 @@ The RimWorld Multiplayer mod allows users to play full games of Rimworld coopera Please do all pull requests to the [dev](https://github.com/rwmt/Multiplayer/tree/dev) branch. +## Building for RimWorld 1.7 + +This source branch targets RimWorld 1.7, .NET 10, and Harmony 3. SDK selection +comes from `global.json`; later installed .NET 10 SDKs are allowed by `latestMinor`. +Prepatcher is not required. Normal builds have no game-version selector. + +```sh +dotnet build Source/Multiplayer.sln -c Release +dotnet test Source/Tests/Tests.csproj -c Release --no-build +./run-local-tests.sh +``` + +The client build stages resolved mod dependencies into `1.7/Assemblies` and +`1.7/AssembliesCustom`. Game and Harmony DLLs are supplied by the game/mod +installation and must not be packaged. See [the metadata audit](Source/RuntimeTests/README.md) +for local validation and its limitations. Full host/join, reload/rejoin, stockpile +filter/cache, and ritual scenarios still require live game testing. + +## Desync tracing modes + +RNG, world, map, and command synchronization checks remain enabled in every mode. +The normal **Fast** setting records lightweight event context (tick, RNG state and +thing identity), without walking managed call stacks or formatting stack strings. +**None** disables diagnostic event history. **Slow** additionally captures detailed +managed stacks for each eligible event and can significantly increase CPU and memory +usage. Use Slow only when collecting diagnostics for a reproducible issue. + +Diagnostic history also requires the session's desync trace logging to be enabled. +The selector is available in the normal mod settings in Release builds, and the +chosen mode is saved. Managed tracing works on ARM64 as well as x64. + +## Versioned CI packages + +The Workshop mod currently includes RimWorld 1.5 and 1.6. New packages retain +both and add the current 1.7 source build. `.github/package-versions.json` is the +single list of included versions: `current` identifies this source branch; +`releases` lists the upstream branches to build independently. Adding another +release entry creates another CI matrix job without changing the packaging code. + +PR, beta, and workshop workflows call `.github/workflows/build-packages.yml`. +The 1.5 job uses upstream `rw-1.5`. The 1.6 job uses `rw-1.6` when available, +otherwise commit `9df94bd04ea9a94c00bd39a276e58c4c2f965740`. Network failures fail +the job rather than silently selecting a fallback. Every version records the +resolved SHA in `/source-commit.txt` in the package. + +Each source is built unchanged with its own references. Backports are explicit; +changes to the 1.7 code do not automatically affect other game versions. Build +artifacts use a common `/About` and `/files` layout. The shared +packaging action consumes one `builds-root` and a list of required versions. It +fails on missing builds, preserves each version's Defs, Languages, Textures and +assemblies, and generates matching dependency metadata and `LoadFolders.xml`. +Only release metadata is shared at the package root. No bundler script is used. + +The 1.5 release entry explicitly retains the Workshop package's Prepatcher +requirement: the branch's older About metadata lists Harmony alone, but its ritual +implementation uses Prepatcher. Additional release dependencies are merged without +duplicating source declarations. + +PR runs upload mod, server, and test-result artifacts for seven days. Standalone +servers use `Server//Windows` and `Server//Linux` for every version. +The workshop workflow stamps the mod version and uploads the combined mod package. + ## Donations If you’re feeling generous these are people who have contributed greatly to the mod’s development and upkeep. diff --git a/Source/Client/AsyncTime/AsyncTimeComp.cs b/Source/Client/AsyncTime/AsyncTimeComp.cs index 32233940b..610daf613 100644 --- a/Source/Client/AsyncTime/AsyncTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncTimeComp.cs @@ -95,7 +95,8 @@ public int GameStartAbsTick public Queue cmds = new(); - public int CurrentPlayerCount { get; private set; } + private int currentPlayerCount; + public int CurrentPlayerCount => currentPlayerCount; public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public AsyncTimeComp(Map map, int gameStartAbsTick = 0) @@ -205,6 +206,7 @@ public void PostContext() public void ExposeData() { + Scribe_Values.Look(ref currentPlayerCount, "currentPlayerCount"); Scribe_Values.Look(ref mapTicks, "mapTicks"); Scribe_Values.Look(ref timeSpeedInt, "timeSpeed"); @@ -213,10 +215,12 @@ public void ExposeData() Scribe_Custom.LookULong(ref randState, "randState", 1); } - public int IncreasePlayerCount() => CurrentPlayerCount += 1; + public void ResetPlayerCount() => currentPlayerCount = 0; + + public int IncreasePlayerCount() => currentPlayerCount += 1; // This should never go below 0, this is just defensive programming. Hopefully not needed anymore, but // nevertheless still left. - public int DecreasePlayerCount() => CurrentPlayerCount = Math.Max(0, CurrentPlayerCount - 1); + public int DecreasePlayerCount() => currentPlayerCount = Math.Max(0, currentPlayerCount - 1); public void FinalizeInit() { diff --git a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs index 8184163ab..b3465419d 100644 --- a/Source/Client/AsyncTime/AsyncWorldTimeComp.cs +++ b/Source/Client/AsyncTime/AsyncWorldTimeComp.cs @@ -58,7 +58,8 @@ public TimeSpeed DesiredTimeSpeed public Queue Cmds => cmds; public Queue cmds = new(); - public int CurrentPlayerCount { get; private set; } + private int currentPlayerCount; + public int CurrentPlayerCount => currentPlayerCount; public int VTR => CurrentPlayerCount > 0 ? VTRSync.MinimumVtr : VTRSync.MaximumVtr; public int TickableId => -1; @@ -79,6 +80,7 @@ public AsyncWorldTimeComp(World world) public void ExposeData() { + Scribe_Values.Look(ref currentPlayerCount, "currentPlayerCount"); var timer = TickPatch.Timer; Scribe_Values.Look(ref timer, "timer"); TickPatch.SetTimer(timer); @@ -100,6 +102,8 @@ public void ExposeData() worldTicks = Find.TickManager.TicksGame; } + public void ResetPlayerCount() => currentPlayerCount = 0; + public void Tick() { tickingWorld = true; @@ -239,13 +243,13 @@ public void ExecuteCmd(ScheduledCommand cmd) if (previousMapId >= 0) prev = Find.Maps.FirstOrDefault(x => x.uniqueID == previousMapId)?.AsyncTime()?.DecreasePlayerCount() ?? -1; else if (previousMapId == VTRSync.WorldMapId) - prev = Multiplayer.AsyncWorldTime.CurrentPlayerCount -= 1; + prev = currentPlayerCount = Math.Max(0, currentPlayerCount - 1); var curr = -1; if (newMapId >= 0) curr = Find.Maps.FirstOrDefault(x => x.uniqueID == newMapId)?.AsyncTime()?.IncreasePlayerCount() ?? -1; else if (newMapId == VTRSync.WorldMapId) - curr = Multiplayer.AsyncWorldTime.CurrentPlayerCount += 1; + curr = currentPlayerCount += 1; MpLog.Debug($"[{worldTicks}|{Multiplayer.session.remoteTickUntil}] Player count change: previousMapId={previousMapId} ({prev}), newMapId={newMapId} ({curr}), mapCount={mapCount}"); } diff --git a/Source/Client/Debug/DebugActions.cs b/Source/Client/Debug/DebugActions.cs index 46d05147b..d1946e298 100644 --- a/Source/Client/Debug/DebugActions.cs +++ b/Source/Client/Debug/DebugActions.cs @@ -214,17 +214,10 @@ public static void SaveGame() File.WriteAllBytes($"game_{Multiplayer.username}.xml", data); } - [DebugAction(MultiplayerLocalCategory, name = "Trigger desync", allowedGameStates = AllowedGameStates.Playing)] - public static void TriggerDesync() - { - var logItem = StackTraceLogItemRaw.GetFromPool(); - var trace = logItem.raw; - int hash = 0; - int depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash); - Multiplayer.game.asyncWorldTimeComp.randState++; - Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); - } + + [DebugAction(MultiplayerLocalCategory, name = "Trigger desync", allowedGameStates = AllowedGameStates.Playing)] + public static void TriggerDesync() => Multiplayer.game.asyncWorldTimeComp.randState++; [DebugAction(MultiplayerLocalCategory, name = "Show desync window", allowedGameStates = AllowedGameStates.Playing)] public static void ShowDesync() @@ -359,19 +352,7 @@ static void MultiplayerMethodCallLogger(MethodBase __originalMethod) Debug.Log(__originalMethod.FullDescription()); } - [DebugAction(MultiplayerLocalCategory, "Dump addr info table", allowedGameStates = /* all */ AllowedGameStates.Invalid)] - public static void DumpAddrInfoTable() - { - Log.Message($"Dumping address info table {DeferredStackTracingImpl.hashTable.Entries}"); - foreach (var addrInfo in DeferredStackTracingImpl.hashTable) - { - if (addrInfo.nameHash == 0) continue; - var normalized = $"`{Native.MethodNameNormalizedFromAddr(addrInfo.addr, true)}`"; - var normal = Native.MethodNameFromAddr(addrInfo.addr, true); - var hash = addrInfo.nameHash; - Log.Message($"NORM: {normalized,-130} H:{hash,14} R:`{normal}`"); - } - } + [DebugAction(MultiplayerLocalCategory, "Show mod compat", allowedGameStates = AllowedGameStates.Playing)] public static void ShowModCompatDialog() @@ -382,17 +363,6 @@ public static void ShowModCompatDialog() #if DEBUG - [DebugOutput] - public static unsafe void PrintInlined() - { - foreach (var m in Harmony.GetAllPatchedMethods()) - { - // method->inline_info - if ((*(byte*)(m.MethodHandle.Value + 32) & 1) == 1) - Log.Warning($"Mono inlined {m.FullDescription()} {*(byte*)(m.MethodHandle.Value + 32) & 1} {*((ushort*)(m.MethodHandle.Value) + 1) & (ushort)MethodImplOptions.NoInlining}"); - } - } - [DebugOutput(true)] public static void PrintThingListers() { diff --git a/Source/Client/Desyncs/ClientSyncOpinion.cs b/Source/Client/Desyncs/ClientSyncOpinion.cs index 0ab3482f3..47b725edb 100644 --- a/Source/Client/Desyncs/ClientSyncOpinion.cs +++ b/Source/Client/Desyncs/ClientSyncOpinion.cs @@ -53,8 +53,6 @@ join map2 in other.mapStates on map1.mapId equals map2.mapId if (!commandRandomStates.SequenceEqual(other.commandRandomStates)) return "Random state from commands doesn't match"; - if (!simulating && !other.simulating && desyncStackTraceHashes.Any() && other.desyncStackTraceHashes.Any() && !desyncStackTraceHashes.SequenceEqual(other.desyncStackTraceHashes)) - return "Trace hashes don't match"; return null; } diff --git a/Source/Client/Desyncs/DeferredStackTracing.cs b/Source/Client/Desyncs/DeferredStackTracing.cs index db53da2df..c7cf79954 100644 --- a/Source/Client/Desyncs/DeferredStackTracing.cs +++ b/Source/Client/Desyncs/DeferredStackTracing.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Reflection; -using System.Runtime.InteropServices; using HarmonyLib; using Multiplayer.Client.Patches; using Multiplayer.Common; @@ -26,28 +25,20 @@ static IEnumerable TargetMethods() public static int acc; - private static bool SupportsDeferredStackTracing => - RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86; - public static void Postfix() { - if (Native.LmfPtr == 0) return; if (!ShouldAddStackTraceForDesyncLog()) return; - var logItem = StackTraceLogItemRaw.GetFromPool(); - var trace = logItem.raw; - int hash = 0; - // Skip this (DeferredStackTracing.Postfix) frame. Keeping it in the trace doesn't provide any value. - int depth = DeferredStackTracingImpl.TraceImpl(trace, ref hash, skipFrames: 1); - - Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, depth, hash); + if (!DesyncTraceCapture.TryCapture(Multiplayer.settings.desyncTracingMode, out var trace)) return; + var logItem = StackTraceLogItemRaw.GetFromPool(); + logItem.managedTrace = trace.Text; + Multiplayer.game.sync.TryAddStackTraceForDesyncLogRaw(logItem, trace.Depth, trace.Hash); acc++; } public static bool ShouldAddStackTraceForDesyncLog() { - if (!SupportsDeferredStackTracing) return false; if (Multiplayer.Client == null) return false; if (Multiplayer.settings.desyncTracingMode == DesyncTracingMode.None) return false; if (Multiplayer.game == null) return false; @@ -97,7 +88,8 @@ public static class EndCurrentJobPatch { static void Prefix(Pawn_JobTracker __instance) { - if (MpVersion.IsDebug && __instance.curJob != null && DeferredStackTracing.ShouldAddStackTraceForDesyncLog()) + if (MpVersion.IsDebug && __instance.curJob != null && DeferredStackTracing.ShouldAddStackTraceForDesyncLog() + && Multiplayer.settings.desyncTracingMode == DesyncTracingMode.Slow) Multiplayer.game.sync.TryAddInfoForDesyncLog($"EndCurrentJob for {__instance.pawn}: {__instance.curJob}", ""); } } diff --git a/Source/Client/Desyncs/DesyncTraceCapture.cs b/Source/Client/Desyncs/DesyncTraceCapture.cs new file mode 100644 index 000000000..3529beeeb --- /dev/null +++ b/Source/Client/Desyncs/DesyncTraceCapture.cs @@ -0,0 +1,49 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Multiplayer.Client.Desyncs; + +internal readonly record struct DesyncTrace(string Text, int Depth, int Hash); + +internal static class DesyncTraceCapture +{ + internal const int MaxDepth = 32; + internal const string FastDescription = "Detailed stack not captured (Fast tracing mode)."; + + // This decision must precede stack walking, formatting, and any pooled-item allocation. + internal static bool TryCapture(DesyncTracingMode mode, out DesyncTrace trace) + { + trace = default; + if (mode == DesyncTracingMode.Fast) + { + trace = new DesyncTrace(FastDescription, 0, 0); + return true; + } + if (mode != DesyncTracingMode.Slow) + return false; + + trace = CaptureSlow(); + return true; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static DesyncTrace CaptureSlow() + { + // Skip this helper. Retain caller frames even when the JIT inlines TryCapture. + var frames = new StackTrace(1, false).GetFrames(); + var text = new StringBuilder(); + int hash = 17; + int depth = Math.Min(frames?.Length ?? 0, MaxDepth); + for (int i = 0; i < depth; i++) + { + var method = frames[i].GetMethod(); + var name = $"{method?.DeclaringType?.FullName}.{method}"; + text.AppendLine(name); + foreach (char c in name) + hash = unchecked(hash * 31 + c); + } + return new DesyncTrace(text.ToString(), depth, hash); + } +} diff --git a/Source/Client/Desyncs/JittedMethods.cs b/Source/Client/Desyncs/JittedMethods.cs index 7928b04b4..0fb3dcf17 100644 --- a/Source/Client/Desyncs/JittedMethods.cs +++ b/Source/Client/Desyncs/JittedMethods.cs @@ -1,112 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Text; -using HarmonyLib; -using JetBrains.Annotations; -using Verse; - namespace Multiplayer.Client.Desyncs; -public class JittedMethod -{ - [CanBeNull] public MethodBase method; - [CanBeNull] public MethodBase from; - [CanBeNull] public int[] mapTicks; - public int worldTicks; - public int timer; - public bool inInterface; - - public string TimeString() - { - return $"m:{mapTicks?.Join(delimiter: ",") ?? "[]"} w:{worldTicks} t:{timer}"; - } - - public override string ToString() - { - return $"{method?.DeclaringType}.{method?.Name} from {from?.DeclaringType}.{from?.Name} {TimeString()} i:{inInterface}"; - } -} - public static class JittedMethods { - private static Queue methodQueue = new(); - private static IntPtr profiler; - private static bool adding; - - public static void Init() - { - profiler = Native.mono_profiler_create(IntPtr.Zero); - Native.mono_profiler_set_jit_done_callback(profiler, (_, method, _) => - { - if (adding) return; - - try - { - adding = true; - - if (UnityData.IsInMainThread && Multiplayer.settings != null && Native.mono_method_get_token(method) != 0) - { - var methodBase = Native.GetMethodBaseFromRuntimePointer(method); - methodQueue.Enqueue(new JittedMethod - { - method = methodBase, - from = new StackTrace().GetFrame(1)?.GetMethod(), - mapTicks = Multiplayer.game?.asyncTimeComps.Select(c => c.mapTicks).ToArray(), - worldTicks = Multiplayer.game?.asyncWorldTimeComp?.worldTicks ?? -1, - timer = TickPatch.Timer, - inInterface = Multiplayer.InInterface - }); - - if (methodQueue.Count > Multiplayer.settings.jittedMethodsInDesync) - methodQueue.Dequeue(); - } - } - finally - { - adding = false; - } - }); - } - - public static void OnApplicationQuit() - { - Native.mono_profiler_set_jit_done_callback(profiler, null); - } - - public static string GetJittedMethodsString() - { - // Two ToArrays to prevent a compilation in between from causing a "Collection was modified" exception - var jittedMethods = methodQueue.ToArray().Select((j, i) => (j, i)).ToArray(); - - var builder = new StringBuilder(); - builder.Append("In simulation:"); // This will get a newline from the != comparison below - var timeString = ""; - - foreach (var j in jittedMethods.Where(j => !j.j.inInterface)) - { - if (timeString != j.j.TimeString()) - builder.AppendLine(); - builder.AppendLine($"{j.i} {j.j}"); - timeString = j.j.TimeString(); - } - - builder.AppendLine(); - builder.AppendLine(); - - timeString = ""; - - builder.Append("In interface:"); - foreach (var j in jittedMethods.Where(j => j.j.inInterface)) - { - if (timeString != j.j.TimeString()) - builder.AppendLine(); - builder.AppendLine($"{j.i} {j.j}"); - timeString = j.j.TimeString(); - } - - return builder.ToString(); - } + public static string GetJittedMethodsString() => "JIT history is unavailable on CoreCLR."; } diff --git a/Source/Client/Desyncs/SaveableDesyncInfo.cs b/Source/Client/Desyncs/SaveableDesyncInfo.cs index 200c3dfee..42ff3547b 100644 --- a/Source/Client/Desyncs/SaveableDesyncInfo.cs +++ b/Source/Client/Desyncs/SaveableDesyncInfo.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.IO; using System.IO.Compression; @@ -128,7 +128,7 @@ private string GetDesyncDetails() .AppendLine($"Processor Speed (MHz)|||{SystemInfo.processorFrequency}") .AppendLine($"Thread Count|||{SystemInfo.processorCount}") .AppendLine("\n###GPU Info###") - .AppendLine($"GPU Family|||{SystemInfo.graphicsDeviceVendor}") + .AppendLine("GPU Family|||Unavailable") .AppendLine($"GPU Type|||{SystemInfo.graphicsDeviceType}") .AppendLine($"GPU Name|||{SystemInfo.graphicsDeviceName}") .AppendLine($"GPU VRAM|||{SystemInfo.graphicsMemorySize}") diff --git a/Source/Client/Desyncs/StackTraceLogItem.cs b/Source/Client/Desyncs/StackTraceLogItem.cs index b7da27da5..4c92a3f46 100644 --- a/Source/Client/Desyncs/StackTraceLogItem.cs +++ b/Source/Client/Desyncs/StackTraceLogItem.cs @@ -37,9 +37,9 @@ public class StackTraceLogItemObj : StackTraceLogItem public class StackTraceLogItemRaw : StackTraceLogItem { - public const int MaxDepth = 32; - public long[] raw = new long[MaxDepth]; + public const int MaxDepth = DesyncTraceCapture.MaxDepth; public int depth; + public string managedTrace; public int ticksGame; public ulong rngState; @@ -50,26 +50,7 @@ public class StackTraceLogItemRaw : StackTraceLogItem public override string AdditionalInfo => $"{ticksGame} {thingDef}{thingId} {factionName} {depth} {rngState} {moreInfo}"; - private static Dictionary methodNameCache = new(); - - public override string StackTraceString - { - get - { - var builder = new StringBuilder(); - for (int i = 0; i < depth; i++) - { - var addr = raw[i]; - - if (!methodNameCache.TryGetValue(addr, out string method)) - methodNameCache[addr] = method = Native.MethodNameFromAddr(raw[i], false); - - builder.AppendLine(method != null ? SyncCoordinator.MethodNameWithIL(method) : "Null"); - } - - return builder.ToString(); - } - } + public override string StackTraceString => managedTrace ?? string.Empty; public static StackTraceLogItemRaw GetFromPool() { @@ -78,6 +59,7 @@ public static StackTraceLogItemRaw GetFromPool() public override void Dispose() { + managedTrace = null; depth = 0; ticksGame = 0; rngState = 0; diff --git a/Source/Client/Factions/MultifactionPatches.cs b/Source/Client/Factions/MultifactionPatches.cs index 9eea55518..2d5ef474b 100644 --- a/Source/Client/Factions/MultifactionPatches.cs +++ b/Source/Client/Factions/MultifactionPatches.cs @@ -9,7 +9,7 @@ using System.Linq; using System.Reflection; using System.Reflection.Emit; -using System.Runtime.Remoting.Messaging; + using UnityEngine; using Verse; using Verse.AI; diff --git a/Source/Client/KeepDebugResolution.cs b/Source/Client/KeepDebugResolution.cs new file mode 100644 index 000000000..f05561617 --- /dev/null +++ b/Source/Client/KeepDebugResolution.cs @@ -0,0 +1,20 @@ +#if DEBUG +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using RimWorld; + +namespace Multiplayer.Client.Patches; + +[HarmonyPatch] +static class KeepDebugResolution +{ + // Include every overload: RimWorld 1.7 also accepts FullScreenMode. + static IEnumerable TargetMethods() => + AccessTools.GetDeclaredMethods(typeof(ResolutionUtility)) + .Where(method => method.Name == nameof(ResolutionUtility.SetResolutionRaw)); + + static bool Prefix() => false; +} +#endif diff --git a/Source/Client/Multiplayer.cs b/Source/Client/Multiplayer.cs index 5ea4b1760..337a57cc3 100644 --- a/Source/Client/Multiplayer.cs +++ b/Source/Client/Multiplayer.cs @@ -95,18 +95,6 @@ public static void InitMultiplayer(ModContentPack content) #endif modContentPack = content; - Native.EarlyInit( - Application.platform switch - { - RuntimePlatform.LinuxEditor => Native.NativeOS.Linux, - RuntimePlatform.LinuxPlayer => Native.NativeOS.Linux, - RuntimePlatform.OSXEditor => Native.NativeOS.OSX, - RuntimePlatform.OSXPlayer => Native.NativeOS.OSX, - _ => Native.NativeOS.Windows - }); - - DisableOmitFramePointer(); - JittedMethods.Init(); MultiplayerLoader.Multiplayer.settingsWindowDrawer = rect => MpSettingsUI.DoSettingsWindowContents(settings, rect); @@ -148,11 +136,7 @@ public static void InitMultiplayer(ModContentPack content) VersionChecker.Init(); } - [MethodImpl(MethodImplOptions.NoInlining)] - private static void DisableOmitFramePointer() - { - Native.mini_parse_debug_option("disable_omit_fp"); - } + private static void CheckInterfaceVersions() { diff --git a/Source/Client/Multiplayer.csproj b/Source/Client/Multiplayer.csproj index 26cdeb7b1..b00e072b1 100644 --- a/Source/Client/Multiplayer.csproj +++ b/Source/Client/Multiplayer.csproj @@ -1,11 +1,10 @@ - + - net48 + true true 12 false - bin false false 0.6.2 @@ -24,8 +23,8 @@ - - + + @@ -59,24 +58,27 @@ - ..\..\ + bin\$(Configuration)\ + ..\..\ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)'))=$(AssemblyName)/ - - - - - + + - + + + - - + + + diff --git a/Source/Client/MultiplayerData.cs b/Source/Client/MultiplayerData.cs index 0212296c9..cbafe06f5 100644 --- a/Source/Client/MultiplayerData.cs +++ b/Source/Client/MultiplayerData.cs @@ -135,6 +135,8 @@ internal static void CollectDefInfos() dict["ISyncSimple"] = GetDefInfo(ApiSerialization.syncSimples, TypeHash); dict["ISession"] = GetDefInfo(ApiSerialization.sessions, TypeHash); + // RimWorld 1.7 enables WaylithClr.MonoStringHashCode, keeping NameTriple's + // string-based hashes compatible with Mono and stable across processes. dict["PawnBio"] = GetDefInfo(SolidBioDatabase.allBios, b => b.name.GetHashCode()); foreach (var defType in GenTypes.AllLeafSubclasses(typeof(Def))) diff --git a/Source/Client/MultiplayerGame.cs b/Source/Client/MultiplayerGame.cs index ae9f3431d..9c52856f5 100644 --- a/Source/Client/MultiplayerGame.cs +++ b/Source/Client/MultiplayerGame.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -16,6 +17,9 @@ namespace Multiplayer.Client { public class MultiplayerGame { + private static readonly Action resetDebugSettings = MpReflection.CreateStaticConstructorInvoker(typeof(DebugSettings)); + private static readonly Action resetSingleThingResolver = MpReflection.CreateStaticConstructorInvoker(typeof(SymbolResolver_SingleThing)); + public SyncCoordinator sync = new(); public AsyncWorldTimeComp asyncWorldTimeComp; @@ -68,13 +72,13 @@ public MultiplayerGame() if (!field.IsLiteral && field.FieldType == typeof(bool)) field.SetValue(null, default(bool)); - typeof(DebugSettings).TypeInitializer.Invoke(null, null); + resetDebugSettings(); foreach (var resolver in DefDatabase.AllDefs.SelectMany(r => r.resolvers)) if (resolver is SymbolResolver_EdgeThing edgeThing) edgeThing.randomRotations = new List() { 0, 1, 2, 3 }; - typeof(SymbolResolver_SingleThing).TypeInitializer.Invoke(null, null); + resetSingleThingResolver(); FactionCreator.ClearData(); } diff --git a/Source/Client/MultiplayerStatic.cs b/Source/Client/MultiplayerStatic.cs index 49519f9b9..132074cdc 100644 --- a/Source/Client/MultiplayerStatic.cs +++ b/Source/Client/MultiplayerStatic.cs @@ -48,18 +48,6 @@ public static class MultiplayerStatic static MultiplayerStatic() { - Native.InitLmfPtr( - Application.platform switch - { - RuntimePlatform.LinuxEditor => Native.NativeOS.Linux, - RuntimePlatform.LinuxPlayer => Native.NativeOS.Linux, - RuntimePlatform.OSXEditor => Native.NativeOS.OSX, - RuntimePlatform.OSXPlayer => Native.NativeOS.OSX, - _ => Native.NativeOS.Windows - } - ); - - Native.HarmonyOriginalGetter = MpUtil.GetOriginalFromHarmonyReplacement; // UnityEngine.Debug.Log instead of Verse.Log.Message because the server runs on its own thread ServerLog.info = str => Debug.Log($"MpServerLog: {str}"); diff --git a/Source/Client/Networking/HostUtil.cs b/Source/Client/Networking/HostUtil.cs index 0b736217e..d63962f7a 100644 --- a/Source/Client/Networking/HostUtil.cs +++ b/Source/Client/Networking/HostUtil.cs @@ -7,6 +7,7 @@ using Multiplayer.Client.AsyncTime; using Multiplayer.Client.Comp; using Multiplayer.Client.Networking; +using Multiplayer.Client.Patches; using Multiplayer.Client.Util; using Multiplayer.Common; using RimWorld; @@ -26,6 +27,7 @@ public static async ClientTask HostServer(ServerSettings settings, bool fromRepl { Log.Message("Starting the server"); + VTRSync.Reset(); CreateSession(settings); if (!fromReplay) @@ -38,9 +40,15 @@ public static async ClientTask HostServer(ServerSettings settings, bool fromRepl PrepareGame(); SetGameState(settings); + // A new server has no viewers yet, including when hosting a replay. + Multiplayer.AsyncWorldTime.ResetPlayerCount(); + foreach (var asyncTime in Multiplayer.game.asyncTimeComps) + asyncTime.ResetPlayerCount(); + Multiplayer.session.dataSnapshot = await CreateGameData(); MakeHostOnServer(serverConn); + VTRSync.Start(); // todo handle sending cmds for hosting from loaded replay? SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, false); diff --git a/Source/Client/Networking/State/ClientLoadingState.cs b/Source/Client/Networking/State/ClientLoadingState.cs index 2622c9611..7bda54ead 100644 --- a/Source/Client/Networking/State/ClientLoadingState.cs +++ b/Source/Client/Networking/State/ClientLoadingState.cs @@ -3,6 +3,7 @@ using System.Linq; using Ionic.Zlib; using Multiplayer.Client.Saving; +using Multiplayer.Client.Patches; using Multiplayer.Common; using Verse; @@ -63,6 +64,9 @@ public void HandleWorldDataFragment(FragmentedPacket packet) [PacketHandler(Packets.Server_WorldData, allowFragmented: true)] public void HandleWorldData(ByteReader data) { + // Preserve the last reported map on rejoin, but don't report temporary + // loading views or views encountered while replaying catch-up commands. + VTRSync.Suspend(); var downloadMs = downloadTimeStopwatch.ElapsedMilliseconds; downloadTimeStopwatch.Reset(); Log.Message($"Game data size: {data.Length}. Took {downloadMs}ms to receive."); @@ -129,7 +133,11 @@ public void HandleWorldData(ByteReader data) TickPatch.SetSimulation( toTickUntil: true, - onFinish: () => Multiplayer.Client.Send(Packets.Client_WorldReady), + onFinish: () => + { + Multiplayer.Client.Send(Packets.Client_WorldReady); + VTRSync.Start(); + }, cancelButtonKey: "Quit", onCancel: GenScene.GoToMainMenu // Calls StopMultiplayer through a patch ); diff --git a/Source/Client/OnMainThread.cs b/Source/Client/OnMainThread.cs index 866c50248..ac082e114 100644 --- a/Source/Client/OnMainThread.cs +++ b/Source/Client/OnMainThread.cs @@ -62,7 +62,6 @@ public void Update() public void OnApplicationQuit() { - JittedMethods.OnApplicationQuit(); Multiplayer.StopMultiplayer(); } diff --git a/Source/Client/Patches/GravshipTravelSessionPatches.cs b/Source/Client/Patches/GravshipTravelSessionPatches.cs index 4939f821c..82cabdb10 100644 --- a/Source/Client/Patches/GravshipTravelSessionPatches.cs +++ b/Source/Client/Patches/GravshipTravelSessionPatches.cs @@ -33,7 +33,7 @@ static MethodBase TargetMethod() return MpMethodUtil.GetLambda(typeof(GravshipUtility), nameof(GravshipUtility.PreLaunchConfirmation), lambdaOrdinal: 4); } - static void Postfix(Dialog_MessageBox __instance) + static void Postfix() { if (Multiplayer.Client == null) return; if (!Multiplayer.ExecutingCmds) return; diff --git a/Source/Client/Patches/HashCodes.cs b/Source/Client/Patches/HashCodes.cs index 497fd75f8..638cd7cba 100644 --- a/Source/Client/Patches/HashCodes.cs +++ b/Source/Client/Patches/HashCodes.cs @@ -2,9 +2,7 @@ using Multiplayer.Client.Util; using RimWorld; using RimWorld.Planet; -using System; using System.Collections.Generic; -using System.Linq; using System.Reflection; using System.Reflection.Emit; using System.Runtime.CompilerServices; @@ -94,149 +92,22 @@ static void Postfix(Tradeable __instance, ref int __result) } } - // Have created a patch that will handle 2-8 System.HashCode.Combine functions. - // TODO: Check the following: - // TileQueryParams.GetHashCode - // UnmanagedGridTraverseParams.GetHashCode - // MapGridRequest.GetHashCode - // SimplifiedPastureNutritionSimulator.GetHashCode - // FieldAliasCache.GetHashCode - static class HashCodeDetours - { - private static readonly Type HashCodeType = typeof(string).Assembly.GetType("System.HashCode"); - - internal static MethodBase GetCombineIntMethod(int numOfInts) - { - Type[] intTypes = Enumerable.Repeat(typeof(int), numOfInts).ToArray(); - - // Look for a pre-compiled int,int,… overload (only exists on some runtimes) - MethodInfo method = HashCodeType.GetMethod("Combine", BindingFlags.Public | BindingFlags.Static, binder: null, types: intTypes, modifiers: null); - if (method != null) - return method; // good – no generics involved - - // Fall back to the generic definition and close it for - MethodInfo genericMethod = HashCodeType.GetMethods(BindingFlags.Public | BindingFlags.Static) - .First(x => - x.Name == "Combine" && - x.IsGenericMethodDefinition && - x.GetGenericArguments().Length == numOfInts); - - return genericMethod.MakeGenericMethod(intTypes); - } - } - - // ─── PATCH 2 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine2Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(2); - - static bool Prefix(int value1, int value2, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = Gen.HashCombineInt(value1, value2); - return false; - } - } - - // ─── PATCH 3 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine3Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(3); - - static bool Prefix(int value1, int value2, int value3, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = DeterministicHash.HashCombineInt(value1, value2, value3); - return false; - } - } - - // ─── PATCH 4 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine4Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(4); - - static bool Prefix(int value1, int value2, int value3, int value4, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = Gen.HashCombineInt(value1, value2, value3, value4); - return false; - } - } - - // ─── PATCH 5 ints ───────────────────────────────────────── + // Patch game callers rather than System.HashCode: ValueTuple and Dictionary + // also use Combine, and changing it when a session starts invalidates their keys. [HarmonyPatch] - static class Combine5Patch + static class DeterministicGameHashCalls { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(5); - - static bool Prefix(int value1, int value2, int value3, int value4, int value5, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, value5); - return false; - } - } - - // ─── PATCH 6 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine6Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(6); - - static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = DeterministicHash.HashCombineInt(value1, value2, value3, - value4, value5, value6); - return false; - } - } - - // ─── PATCH 7 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine7Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(7); - - static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, int value7, ref int __result) + static IEnumerable TargetMethods() { - if (Multiplayer.Client == null) - return true; - - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, - value5, value6, value7); - return false; + yield return AccessTools.PropertyGetter(typeof(Map), nameof(Map.NextGenSeed)); + yield return AccessTools.PropertyGetter(typeof(Gravship), nameof(Gravship.ConstantRandSeed)); + yield return AccessTools.Method(typeof(LayoutWorker), "FillAllRooms"); + yield return AccessTools.Method(typeof(Building_Trap), nameof(Building_Trap.KnowsOfTrap)); + yield return AccessTools.Method(typeof(PathFinderCostTuning), nameof(PathFinderCostTuning.GetHashCode)); + yield return AccessTools.Method(typeof(FastTileFinder.TileQueryParams), nameof(FastTileFinder.TileQueryParams.GetHashCode)); } - } - - // ─── PATCH 8 ints ───────────────────────────────────────── - [HarmonyPatch] - static class Combine8Patch - { - static MethodBase TargetMethod() => HashCodeDetours.GetCombineIntMethod(8); - static bool Prefix(int value1, int value2, int value3, int value4, int value5, int value6, int value7, int value8, ref int __result) - { - if (Multiplayer.Client == null) - return true; - - __result = DeterministicHash.HashCombineInt(value1, value2, value3, value4, - value5, value6, value7, value8); - return false; - } + static IEnumerable Transpiler(IEnumerable instructions) => + HashCodeTranspiler.ReplaceCalls(instructions); } } diff --git a/Source/Client/Patches/Letters.cs b/Source/Client/Patches/Letters.cs index 93ba7396b..d9f240851 100644 --- a/Source/Client/Patches/Letters.cs +++ b/Source/Client/Patches/Letters.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Reflection; using HarmonyLib; +using MethodInvoker = HarmonyLib.MethodInvoker; using RimWorld; using UnityEngine; using Verse; diff --git a/Source/Client/Patches/LongEvents.cs b/Source/Client/Patches/LongEvents.cs index 473faea57..8db92b002 100644 --- a/Source/Client/Patches/LongEvents.cs +++ b/Source/Client/Patches/LongEvents.cs @@ -37,32 +37,20 @@ public static bool IsTickMarked(Action action) [HarmonyPatch(typeof(LongEventHandler), nameof(LongEventHandler.LongEventsUpdate))] static class NewLongEvent { - public static bool currentEventWasMarked; - - static void Prefix(ref bool __state) + static void Prefix(out bool __state) { - __state = LongEventHandler.currentEvent == null; - currentEventWasMarked = MarkLongEvents.IsTickMarked(LongEventHandler.currentEvent?.eventAction); + __state = MarkLongEvents.IsTickMarked(LongEventHandler.currentEvent?.eventAction); } static void Postfix(bool __state) { - currentEventWasMarked = false; - if (Multiplayer.Client == null) return; - if (__state && MarkLongEvents.IsTickMarked(LongEventHandler.currentEvent?.eventAction)) - Multiplayer.Client.Send(ClientFreezePacket.Freeze()); - } - } - - [HarmonyPatch(typeof(LongEventHandler), nameof(LongEventHandler.ExecuteToExecuteWhenFinished))] - static class LongEventEnd - { - static void Postfix() - { - if (Multiplayer.Client != null && NewLongEvent.currentEventWasMarked) - Multiplayer.Client.Send(ClientFreezePacket.Unfreeze()); + // Observe the event's lifetime at the update boundary. Completion can + // also clear the event after an exception, without running finish callbacks. + var marked = MarkLongEvents.IsTickMarked(LongEventHandler.currentEvent?.eventAction); + if (__state != marked) + Multiplayer.Client.Send(new ClientFreezePacket(marked)); } } diff --git a/Source/Client/Patches/VTRSyncPatch.cs b/Source/Client/Patches/VTRSyncPatch.cs index eb5812a86..734a33be7 100644 --- a/Source/Client/Patches/VTRSyncPatch.cs +++ b/Source/Client/Patches/VTRSyncPatch.cs @@ -53,8 +53,9 @@ static class VTRSync // Special identifier for the world map (since it doesn't have a uniqueID like regular maps) public const int WorldMapId = -2; public const int InvalidMapId = -1; - public static int lastMovedToMapId = InvalidMapId; - public static int lastSentAtTick = -1; + private static readonly ViewedMapTracker viewedMap = new(); + public static int lastMovedToMapId => viewedMap.LastReportedMapId; + public static bool Enabled { get; private set; } // Vtr rates public const int MaximumVtr = 15; @@ -62,25 +63,32 @@ static class VTRSync public static int GetSynchronizedUpdateRate(Thing thing) => thing?.MapHeld?.AsyncTime()?.VTR ?? MaximumVtr; - public static void SendViewedMapUpdate(int previous, int current) + public static void Start() { - if (Multiplayer.reloading) - return; - - string warn = string.Empty; - if (previous != lastMovedToMapId) - warn = $" mismatch between expected previous map {previous} and last moved to map {lastMovedToMapId}"; - else if (previous == current) return; - int currentTick = Find.TickManager?.TicksGame ?? 0; - MpLog.Debug($"VTR MapSwitchPatch: {lastMovedToMapId}->{current} @ tick {currentTick}{warn}"); - Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(previous, current)); - lastMovedToMapId = current; + Enabled = true; + SendViewedMapUpdate(WorldRendererUtility.WorldSelected ? WorldMapId : Find.CurrentMap?.uniqueID ?? InvalidMapId); + } + + public static void Suspend() => Enabled = false; + + public static void SendViewedMapUpdate(int current) + { + if (!Enabled || Multiplayer.reloading || Multiplayer.Client is not { State: ConnectionStateEnum.ClientPlaying }) return; + + // Reloading can change the local view without reporting it. Use the + // last sent map as the previous map so the server sees a valid chain. + viewedMap.Report(current, (previous, next) => + { + MpLog.Debug($"VTR MapSwitchPatch: {previous}->{next} @ tick {Find.TickManager?.TicksGame ?? 0}"); + Multiplayer.Client.SendCommand(CommandType.PlayerCount, ScheduledCommand.Global, ByteWriter.GetBytes(previous, next)); + }); } public static void Reset() { - lastMovedToMapId = InvalidMapId; - lastSentAtTick = -1; + Enabled = false; + viewedMap.Reset(); + WorldRenderModePatch.Reset(); } } @@ -89,39 +97,20 @@ static class MapSwitchPatch { static void Prefix(Map value) { - if (Multiplayer.Client == null) return; + if (Multiplayer.Client == null || !VTRSync.Enabled) return; try { // WorldRenderModePatch will handle it if (VTRSync.lastMovedToMapId == VTRSync.WorldMapId) return; - int previousMap = GetPreviousMapId(); int newMap = value?.uniqueID ?? VTRSync.InvalidMapId; - int currentTick = Find.TickManager?.TicksGame ?? 0; - - if (previousMap == newMap) return; - if (VTRSync.lastMovedToMapId == newMap && currentTick == VTRSync.lastSentAtTick) return; - - VTRSync.SendViewedMapUpdate(previousMap, newMap); - VTRSync.lastSentAtTick = currentTick; + VTRSync.SendViewedMapUpdate(newMap); } catch (Exception ex) { MpLog.Error($"VTR MapSwitchPatch error: {ex}"); } } - - private static int GetPreviousMapId() - { - bool currentMapIsRemovedAndWasLatestMap = Current.Game.currentMapIndex >= Find.Maps.Count; - - if (currentMapIsRemovedAndWasLatestMap) - { - return VTRSync.InvalidMapId; - } - - return Find.CurrentMap?.uniqueID ?? VTRSync.InvalidMapId; - } } [HarmonyPatch(typeof(WorldRendererUtility), nameof(WorldRendererUtility.CurrentWorldRenderMode), MethodType.Getter)] @@ -129,23 +118,23 @@ static class WorldRenderModePatch { private static WorldRenderMode lastRenderMode = WorldRenderMode.None; + public static void Reset() => lastRenderMode = WorldRenderMode.None; + static void Postfix(WorldRenderMode __result) { - if (Multiplayer.Client == null) return; + if (Multiplayer.Client == null || !VTRSync.Enabled || Multiplayer.reloading) return; try { // Detect transition to world map (Planet mode) if (__result == WorldRenderMode.Planet && lastRenderMode != WorldRenderMode.Planet) { - if (VTRSync.lastMovedToMapId != VTRSync.InvalidMapId && VTRSync.lastMovedToMapId != VTRSync.WorldMapId) - { - VTRSync.SendViewedMapUpdate(VTRSync.lastMovedToMapId, VTRSync.WorldMapId); - } + var leavingMap = VTRSync.lastMovedToMapId >= 0; + VTRSync.SendViewedMapUpdate(VTRSync.WorldMapId); // On standalone with streaming, trigger a join point when leaving a map // so each player can save independently without disturbing others - if (Multiplayer.session?.ConnectedToStandaloneServer == true && Multiplayer.GameComp.multifaction && Multiplayer.GameComp.asyncTime) + if (leavingMap && Multiplayer.session?.ConnectedToStandaloneServer == true && Multiplayer.GameComp.multifaction && Multiplayer.GameComp.asyncTime) Autosaving.SendAutosavingRequest(JoinPointRequestReason.WorldTravel); } // Detect transition back to tile map @@ -154,7 +143,7 @@ static void Postfix(WorldRenderMode __result) var newMap = Find.CurrentMap?.uniqueID ?? VTRSync.InvalidMapId; if (newMap != VTRSync.InvalidMapId && VTRSync.lastMovedToMapId == VTRSync.WorldMapId) { - VTRSync.SendViewedMapUpdate(VTRSync.WorldMapId, newMap); + VTRSync.SendViewedMapUpdate(newMap); } } diff --git a/Source/Client/Patches/VanillaTweaks.cs b/Source/Client/Patches/VanillaTweaks.cs index a83a1a147..441b19f4a 100644 --- a/Source/Client/Patches/VanillaTweaks.cs +++ b/Source/Client/Patches/VanillaTweaks.cs @@ -19,9 +19,9 @@ static void Prefix() { if (done) return; GUI.skin.font = Text.fontStyles[1].font; - Text.fontStyles[1].font.fontNames = new[] { "arial", "arialbd", "ariali", "arialbi" }; done = true; } + } // Fix window focus handling diff --git a/Source/Client/Patches/ViewedMapTracker.cs b/Source/Client/Patches/ViewedMapTracker.cs new file mode 100644 index 000000000..092a5e63f --- /dev/null +++ b/Source/Client/Patches/ViewedMapTracker.cs @@ -0,0 +1,18 @@ +using System; + +namespace Multiplayer.Client.Patches; + +internal sealed class ViewedMapTracker +{ + public int LastReportedMapId { get; private set; } = -1; + + public void Report(int currentMapId, Action send) + { + if (currentMapId == LastReportedMapId) return; + + send(LastReportedMapId, currentMapId); + LastReportedMapId = currentMapId; + } + + public void Reset() => LastReportedMapId = -1; +} diff --git a/Source/Client/Saving/CrossRefs.cs b/Source/Client/Saving/CrossRefs.cs index f533a8736..9ea305160 100644 --- a/Source/Client/Saving/CrossRefs.cs +++ b/Source/Client/Saving/CrossRefs.cs @@ -216,16 +216,9 @@ public static class LoadedObjectsRegisterPatch { static bool Prefix(LoadedObjectDirectory __instance, ILoadReferenceable reffable) { - if (__instance is not SharedCrossRefs) return true; - if (reffable == null) return false; - - string key = reffable.GetUniqueLoadID(); - if (ScribeUtil.sharedCrossRefs.allObjectsByLoadID.ContainsKey(key)) return false; - - if (Scribe.mode == LoadSaveMode.ResolvingCrossRefs) - ScribeUtil.sharedCrossRefs.tempKeys.Add(key); + if (__instance is not SharedCrossRefs sharedCrossRefs) return true; - ScribeUtil.sharedCrossRefs.allObjectsByLoadID.Add(key, reffable); + sharedCrossRefs.RegisterLoaded(reffable); return false; } diff --git a/Source/Client/Saving/SharedCrossRefs.cs b/Source/Client/Saving/SharedCrossRefs.cs index 70fc93fcd..04af4689a 100644 --- a/Source/Client/Saving/SharedCrossRefs.cs +++ b/Source/Client/Saving/SharedCrossRefs.cs @@ -10,6 +10,21 @@ public class SharedCrossRefs : LoadedObjectDirectory // Used in CrossRefs patches public HashSet tempKeys = new(); + // Mod-owned calls must be idempotent even when the base method's Harmony + // prefix isn't reached. Loading and spawning can register the same object. + public new void RegisterLoaded(ILoadReferenceable reffable) + { + if (reffable == null) return; + + var key = reffable.GetUniqueLoadID(); + if (allObjectsByLoadID.ContainsKey(key)) return; + + if (Scribe.mode == LoadSaveMode.ResolvingCrossRefs) + tempKeys.Add(key); + + allObjectsByLoadID.Add(key, reffable); + } + public void Unregister(ILoadReferenceable reffable) { allObjectsByLoadID.Remove(reffable.GetUniqueLoadID()); diff --git a/Source/Client/Settings/DesyncTracingMode.cs b/Source/Client/Settings/DesyncTracingMode.cs new file mode 100644 index 000000000..4ff1d035c --- /dev/null +++ b/Source/Client/Settings/DesyncTracingMode.cs @@ -0,0 +1,8 @@ +namespace Multiplayer.Client; + +public enum DesyncTracingMode +{ + None, + Fast, + Slow +} diff --git a/Source/Client/Settings/MpSettings.cs b/Source/Client/Settings/MpSettings.cs index 03845c5dd..f45f30cf1 100644 --- a/Source/Client/Settings/MpSettings.cs +++ b/Source/Client/Settings/MpSettings.cs @@ -18,7 +18,6 @@ public class MpSettings : ModSettings public int autosaveSlots = 5; public bool showDevInfo; public bool includeReplayInDesync = VersionChecker.IsContinuousRelease; - public int jittedMethodsInDesync = 1500; public int desyncTracesRadius = 40; public string serverAddress = "127.0.0.1"; public bool appendNameToAutosave; @@ -65,8 +64,8 @@ public override void ExposeData() Scribe_Values.Look(ref autosaveSlots, "autosaveSlots", 5); Scribe_Values.Look(ref showDevInfo, "showDevInfo"); Scribe_Values.Look(ref includeReplayInDesync, "includeReplayInDesync", VersionChecker.IsContinuousRelease); - Scribe_Values.Look(ref jittedMethodsInDesync, "jittedMethodsInDesync", 1500); Scribe_Values.Look(ref desyncTracesRadius, "desyncTracesRadius", 40); + Scribe_Values.Look(ref desyncTracingMode, "desyncTracingMode", DesyncTracingMode.Fast); Scribe_Values.Look(ref serverAddress, "serverAddress", "127.0.0.1"); Scribe_Values.Look(ref showModCompatibility, "showModCompatibility", true); Scribe_Values.Look(ref hideTranslationMods, "hideTranslationMods", true); @@ -92,11 +91,6 @@ public override void ExposeData() } } - public enum DesyncTracingMode - { - None, Fast, Slow - } - public struct ColorRGBClient : IExposable { public byte r, g, b; diff --git a/Source/Client/Settings/MpSettingsUI.cs b/Source/Client/Settings/MpSettingsUI.cs index 4c857ca10..d05222019 100644 --- a/Source/Client/Settings/MpSettingsUI.cs +++ b/Source/Client/Settings/MpSettingsUI.cs @@ -13,7 +13,6 @@ public static class MpSettingsUI { private static string slotsBuffer; private static string desyncRadiusBuffer; - private static string jittedMethodsBuffer; private static Vector2 scrollPosition = Vector2.zero; private static SettingsPage currentPage = SettingsPage.General; @@ -108,12 +107,22 @@ public static void DoGeneralSettings(MpSettings settings, Rect inRect, Rect page if (VersionChecker.IsContinuousRelease || VersionChecker.IsLocalBuild) listing.CheckboxLabeled("MpIncludeReplayInDesync".Translate(), ref settings.includeReplayInDesync); + using (MpStyle.Set(TextAnchor.MiddleCenter)) + if (listing.ButtonTextLabeled("Desync tracing mode", settings.desyncTracingMode.ToString())) + settings.desyncTracingMode = settings.desyncTracingMode.Cycle(); + + listing.Label(settings.desyncTracingMode switch + { + DesyncTracingMode.None => "None: Diagnostic history disabled.", + DesyncTracingMode.Slow => "Slow: Detailed stacks (high CPU/memory cost).", + _ => "Fast: Context only (recommended)." + }, tooltip: "Desync detection stays enabled in every mode. Fast records event context without call stacks. Slow captures a managed stack for each eligible event and can significantly increase CPU and memory usage."); + if (Prefs.DevMode) { listing.CheckboxLabeled("Show debug info", ref settings.showDevInfo); listing.TextFieldNumericLabeled("Desync radius: ", ref settings.desyncTracesRadius, ref desyncRadiusBuffer, 1f, 200f); - listing.TextFieldNumericLabeled("Jitted methods: ", ref settings.jittedMethodsInDesync, ref jittedMethodsBuffer); if (MpVersion.IsDebug && FileAssoc.IsSupported()) { @@ -126,11 +135,6 @@ public static void DoGeneralSettings(MpSettings settings, Rect inRect, Rect page if (listing.ButtonText("Register file associations")) FileAssoc.Register(); } } -#if DEBUG - using (MpStyle.Set(TextAnchor.MiddleCenter)) - if (listing.ButtonTextLabeled("Desync tracing mode", settings.desyncTracingMode.ToString())) - settings.desyncTracingMode = settings.desyncTracingMode.Cycle(); -#endif } listing.End(); diff --git a/Source/Client/Syncing/Game/SyncDelegates.cs b/Source/Client/Syncing/Game/SyncDelegates.cs index fbacb638e..dc530fc61 100644 --- a/Source/Client/Syncing/Game/SyncDelegates.cs +++ b/Source/Client/Syncing/Game/SyncDelegates.cs @@ -333,7 +333,7 @@ The UI's main interaction area is split into three types of groups of pawns. SyncMethod.Register(typeof(PsychicRitualRoleAssignments), nameof(PsychicRitualRoleAssignments.TryAssignSpectate)); SyncMethod.Register(typeof(PsychicRitualRoleAssignments), nameof(PsychicRitualRoleAssignments.RemoveParticipant)); - SyncRituals.ApplyPrepatches(null); + SyncRituals.Init(); } private static void InitChoiceLetters() diff --git a/Source/Client/Syncing/Game/ThingFilterMarkers.cs b/Source/Client/Syncing/Game/ThingFilterMarkers.cs index 98651e066..8f9599a0c 100644 --- a/Source/Client/Syncing/Game/ThingFilterMarkers.cs +++ b/Source/Client/Syncing/Game/ThingFilterMarkers.cs @@ -37,56 +37,56 @@ static void TabStorageFillTab_Prefix(ITab_Storage __instance) DrawnThingFilter = new TabStorageWrapper(selParent); } - [MpPostfix(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] - static void TabStorageFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_Storage), nameof(ITab_Storage.FillTab))] + static void TabStorageFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] static void BillConfig_Prefix(Dialog_BillConfig __instance) => DrawnThingFilter = new BillConfigWrapper(__instance.bill); - [MpPostfix(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] - static void BillConfig_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_BillConfig), nameof(Dialog_BillConfig.DoWindowContents))] + static void BillConfig_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] static void ManageOutfit_Prefix(Dialog_ManageApparelPolicies __instance) => DrawnThingFilter = new OutfitWrapper(__instance.SelectedPolicy); - [MpPostfix(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] - static void ManageOutfit_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_ManageApparelPolicies), nameof(Dialog_ManageApparelPolicies.DoContentsRect))] + static void ManageOutfit_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] static void ManageFoodRestriction_Prefix(Dialog_ManageFoodPolicies __instance) => DrawnThingFilter = new FoodRestrictionWrapper(__instance.SelectedPolicy); - [MpPostfix(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] - static void ManageFoodRestriction_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(Dialog_ManageFoodPolicies), nameof(Dialog_ManageFoodPolicies.DoContentsRect))] + static void ManageFoodRestriction_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] static void TabPenAutocutFillTab_Prefix(ITab_PenAutoCut __instance) => DrawnThingFilter = new PenAutocutWrapper(__instance.SelectedCompAnimalPenMarker); - [MpPostfix(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] - static void TabPenAutocutFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_PenAutoCut), nameof(ITab_PenAutoCut.FillTab))] + static void TabPenAutocutFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] static void TabPenAnimalsFillTab_Prefix(ITab_PenAnimals __instance) => DrawnThingFilter = new PenAnimalsWrapper(__instance.SelectedCompAnimalPenMarker); - [MpPostfix(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] - static void TabPenAnimalsFillTab_Postfix() => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_PenAnimals), nameof(ITab_PenAnimals.FillTab))] + static void TabPenAnimalsFillTab_Finalizer() => DrawnThingFilter = null; [MpPrefix(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] static void TabWindTurbineAutocutFillTab_Prefix(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = new DefaultAutocutWrapper(__instance.AutoCut); - [MpPostfix(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] - static void TabWindTurbineAutocutFillTab_Postfix(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = null; + [MpFinalizer(typeof(ITab_WindTurbineAutoCut), nameof(ITab_WindTurbineAutoCut.FillTab))] + static void TabWindTurbineAutocutFillTab_Finalizer(ITab_WindTurbineAutoCut __instance) => DrawnThingFilter = null; [MpPrefix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] static void ThingFilterUI_Prefix() => drawingThingFilter = true; - [MpPostfix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] - static void ThingFilterUI_Postfix() => drawingThingFilter = false; + [MpFinalizer(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] + static void ThingFilterUI_Finalizer() => drawingThingFilter = false; // Reading policies need special handling as they draw two ThingFilters private static ReadingPolicy drawnReadingPolicy; @@ -94,8 +94,8 @@ static void TabWindTurbineAutocutFillTab_Prefix(ITab_WindTurbineAutoCut __instan [MpPrefix(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] static void Dialog_ManageReadingPolicies_Prefix(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = __instance.SelectedPolicy; - [MpPostfix(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] - static void Dialog_ManageReadingPolicies_Postfix(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = null; + [MpFinalizer(typeof(Dialog_ManageReadingPolicies), nameof(Dialog_ManageReadingPolicies.DoContentsRect))] + static void Dialog_ManageReadingPolicies_Finalizer(Dialog_ManageReadingPolicies __instance) => drawnReadingPolicy = null; [MpPrefix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] static void ThingFilterUI_ReadingPolicy_Prefix(ThingFilter filter) @@ -107,8 +107,8 @@ static void ThingFilterUI_ReadingPolicy_Prefix(ThingFilter filter) DrawnThingFilter = new ReadingPolicyEffectFilterWrapper(drawnReadingPolicy); } - [MpPostfix(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] - static void ThingFilterUI_ReadingPolicy_Postfix(ThingFilter filter) + [MpFinalizer(typeof(ThingFilterUI), nameof(ThingFilterUI.DoThingFilterConfigWindow))] + static void ThingFilterUI_ReadingPolicy_Finalizer(ThingFilter filter) { if (drawnReadingPolicy != null && drawnReadingPolicy.defFilter == filter) DrawnThingFilter = null; diff --git a/Source/Client/Syncing/Handler/SyncMethod.cs b/Source/Client/Syncing/Handler/SyncMethod.cs index 3e0ba4b19..0dd4b308a 100644 --- a/Source/Client/Syncing/Handler/SyncMethod.cs +++ b/Source/Client/Syncing/Handler/SyncMethod.cs @@ -2,10 +2,10 @@ using System.Linq; using System.Reflection; using HarmonyLib; +using MethodInvoker = HarmonyLib.MethodInvoker; using Multiplayer.API; using Multiplayer.Client.Util; using Multiplayer.Common; -using MultiplayerLoader; using Verse; namespace Multiplayer.Client @@ -20,7 +20,7 @@ public class SyncTransformer(Type networkType, Delegate writer, Delegate reader) public delegate void SyncMethodWriter(object obj, SyncType type, string debugInfo); - public class SyncMethod : SyncHandler, ISyncMethod, ISyncMethodForPrepatcher + public class SyncMethod : SyncHandler, ISyncMethod { public readonly Type targetType; public readonly MethodInfo method; diff --git a/Source/Client/Syncing/Sync.cs b/Source/Client/Syncing/Sync.cs index 50b7cc7be..5ef381126 100644 --- a/Source/Client/Syncing/Sync.cs +++ b/Source/Client/Syncing/Sync.cs @@ -263,7 +263,6 @@ public static SyncMethod RegisterSyncMethod(MethodInfo method, SyncType[] argTyp return (SyncMethod)internalIdToSyncMethod[id]; } - MpUtil.MarkNoInlining(method); var handler = new SyncMethod(method.IsStatic ? null : method.DeclaringType, null, method, argTypes); methodBaseToInternalId[method] = internalIdToSyncMethod.Count; diff --git a/Source/Client/Syncing/SyncTemplates.cs b/Source/Client/Syncing/SyncTemplates.cs index 88a379f93..079ae13fc 100644 --- a/Source/Client/Syncing/SyncTemplates.cs +++ b/Source/Client/Syncing/SyncTemplates.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -39,19 +39,7 @@ static IEnumerable Transpiler(MethodBase original, IEnumerable< if (pInfo.IsOut) { pType = pType.GetElementType(); yield return new CodeInstruction(OpCodes.Ldarg, argIndex); - yield return CreateDefaultCodes(gen, pType).Last(); - if (AccessTools.IsClass(pType)) - yield return new CodeInstruction(OpCodes.Stind_Ref); - if (AccessTools.IsValue(pType)) { - if (pType == typeof(float)) - yield return new CodeInstruction(OpCodes.Stind_R4, (float) 0); - else if (pType == typeof(double)) - yield return new CodeInstruction(OpCodes.Stind_R8, (double) 0); - else if (pType == typeof(long)) - yield return new CodeInstruction(OpCodes.Stind_I8, (long) 0); - else - yield return new CodeInstruction(OpCodes.Stind_I4, 0); - } + yield return new CodeInstruction(OpCodes.Initobj, pType); } } @@ -94,29 +82,21 @@ static IEnumerable Transpiler(MethodBase original, IEnumerable< static IEnumerable CreateDefaultCodes(ILGenerator generator, Type type) { + if (type == typeof(void)) yield break; if (type.IsByRef) type = type.GetElementType(); - if (AccessTools.IsClass(type)) { + if (!type.IsValueType) { yield return new CodeInstruction(OpCodes.Ldnull); yield break; } - if (AccessTools.IsStruct(type)) { + if (type.IsValueType) { var v = generator.DeclareLocal(type); yield return new CodeInstruction(OpCodes.Ldloca, v); yield return new CodeInstruction(OpCodes.Initobj, type); + yield return new CodeInstruction(OpCodes.Ldloc, v); yield break; } - if (AccessTools.IsValue(type)) { - if (type == typeof(float)) - yield return new CodeInstruction(OpCodes.Ldc_R4, (float) 0); - else if (type == typeof(double)) - yield return new CodeInstruction(OpCodes.Ldc_R8, (double) 0); - else if (type == typeof(long)) - yield return new CodeInstruction(OpCodes.Ldc_I8, (long) 0); - else - yield return new CodeInstruction(OpCodes.Ldc_I4, 0); - } } } diff --git a/Source/Client/UI/DebugPanel/SyncDebugPanel.cs b/Source/Client/UI/DebugPanel/SyncDebugPanel.cs index 8c5d644f1..c623f86d3 100644 --- a/Source/Client/UI/DebugPanel/SyncDebugPanel.cs +++ b/Source/Client/UI/DebugPanel/SyncDebugPanel.cs @@ -530,8 +530,6 @@ private static float DrawRngDebugSection(float x, float y, float width) DebugLine[] rngLines = [ new("Rand Calls:", $"{DeferredStackTracing.acc}", Color.white), new("Max Trace Depth:", $"{DeferredStackTracing.maxTraceDepth}", Color.white), - new("Hash Entries:", $"{DeferredStackTracingImpl.hashTable.Entries}/{DeferredStackTracingImpl.hashTable.Size}", Color.white), - new("Hash Collisions:", $"{DeferredStackTracingImpl.hashTable.Collisions}", Color.white) ]; return DrawSection(x, y, width, new("RNG & DEBUG", rngLines)); diff --git a/Source/Client/UI/IngameDebug.cs b/Source/Client/UI/IngameDebug.cs index 9f76da958..fa00065e0 100644 --- a/Source/Client/UI/IngameDebug.cs +++ b/Source/Client/UI/IngameDebug.cs @@ -60,7 +60,6 @@ internal static void DoDebugPrintout() text.AppendLine($"\nBuffered Changes: {SyncFieldUtil.bufferedChanges.Sum(kv => kv.Value.Count)}"); text.AppendLine($"Map Cmds: {async.cmds.Count}{separator} World Cmds: {Multiplayer.AsyncWorldTime.cmds.Count}"); text.AppendLine($"Stack Trace: {DeferredStackTracing.acc}{separator} Max Depth: {DeferredStackTracing.maxTraceDepth}"); - text.AppendLine($"Hash: {DeferredStackTracingImpl.hashTable.Entries}/{DeferredStackTracingImpl.hashTable.Size} ({DeferredStackTracingImpl.hashTable.Collisions} collisions)"); text.AppendLine($"\nIdeology: {Find.IdeoManager.classicMode}{separator} Ideo ID: {Find.IdeoManager.IdeosInViewOrder.FirstOrDefault()?.id ?? 0}"); text.AppendLine($"Haul Dest: {Find.CurrentMap.haulDestinationManager.AllHaulDestinationsListForReading.Count}{separator} Designations: {Find.CurrentMap.designationManager.designationsByDef.Count}"); diff --git a/Source/Client/UI/MainMenuAnimation.cs b/Source/Client/UI/MainMenuAnimation.cs index c1810c71f..c5c14873f 100644 --- a/Source/Client/UI/MainMenuAnimation.cs +++ b/Source/Client/UI/MainMenuAnimation.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using HarmonyLib; @@ -24,7 +24,8 @@ static class MainMenuAnimation // The background might be drawn at the same time as the loading of a map on a different thread calls Verse.Rand // Verse.Rand can't be used here because it isn't thread-safe - private static Random rand = new(SystemInfo.deviceUniqueIdentifier.GetHashCode()); + // Cosmetic only: no dependency on Unity's hardware identifier. + private static Random rand = new(); private static float newPulseTimer; diff --git a/Source/Client/Util/DeterministicHash.cs b/Source/Client/Util/DeterministicHash.cs index a8d9adf98..68330964d 100644 --- a/Source/Client/Util/DeterministicHash.cs +++ b/Source/Client/Util/DeterministicHash.cs @@ -5,6 +5,28 @@ public static class DeterministicHash public const int DefaultSeed = 352654597; public const int DefaultSeed2 = 1566083941; + public static int HashCombineInt(int v1, int v2) + { + unchecked + { + return (int)(v1 ^ (v2 + 2654435769u + (v1 << 6) + (v1 >> 2))); + } + } + + public static int HashCombineInt(int v1, int v2, int v3, int v4) + { + unchecked + { + int h1 = DefaultSeed; + int h2 = h1; + h1 = ((h1 << 5) + h1 + (h1 >> 27)) ^ v1; + h2 = ((h2 << 5) + h2 + (h2 >> 27)) ^ v2; + h1 = ((h1 << 5) + h1 + (h1 >> 27)) ^ v3; + h2 = ((h2 << 5) + h2 + (h2 >> 27)) ^ v4; + return h1 + h2 * DefaultSeed2; + } + } + // 3-value combiner ────────────────────────────────────── public static int HashCombineInt(int v1, int v2, int v3) { diff --git a/Source/Client/Util/HashCodeTranspiler.cs b/Source/Client/Util/HashCodeTranspiler.cs new file mode 100644 index 000000000..d0fcb160f --- /dev/null +++ b/Source/Client/Util/HashCodeTranspiler.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; + +namespace Multiplayer.Client.Util; + +internal static class HashCodeTranspiler +{ + public static IEnumerable ReplaceCalls(IEnumerable instructions) + { + foreach (var instruction in instructions) + { + if (instruction.opcode == OpCodes.Call && instruction.operand is MethodInfo method + && method.DeclaringType?.FullName == "System.HashCode" && method.Name == "Combine") + { + var args = method.GetParameters(); + if (args.Length is >= 2 and <= 8 && args.All(p => p.ParameterType == typeof(int))) + instruction.operand = AccessTools.Method(typeof(DeterministicHash), nameof(DeterministicHash.HashCombineInt), + Enumerable.Repeat(typeof(int), args.Length).ToArray()); + } + + // Retain labels and exception blocks on replaced calls. + yield return instruction; + } + } +} diff --git a/Source/Client/Util/MpPatch.cs b/Source/Client/Util/MpPatch.cs index cce8bdea6..f45e52e37 100644 --- a/Source/Client/Util/MpPatch.cs +++ b/Source/Client/Util/MpPatch.cs @@ -120,7 +120,8 @@ public static List DoMpPatches(this Harmony harmony, Type type) toPatch, (attr is MpPrefix) ? patch : null, (attr is MpPostfix) ? patch : null, - (attr is MpTranspiler) ? patch : null + (attr is MpTranspiler) ? patch : null, + finalizer: (attr is MpFinalizer) ? patch : null ); } @@ -189,6 +190,17 @@ public MpPostfix(Type parentType, string parentMethod, int lambdaOrdinal) : base } } + /// + /// Finalizer method attribute + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class MpFinalizer : MpPatch + { + public MpFinalizer(Type type, string method, Type[] argTypes = null) : base(type, method, argTypes) + { + } + } + /// /// Transpiler method attribute /// diff --git a/Source/Client/Util/MpUI.cs b/Source/Client/Util/MpUI.cs index b80ea128d..941fad4b6 100644 --- a/Source/Client/Util/MpUI.cs +++ b/Source/Client/Util/MpUI.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Reflection; using HarmonyLib; using RimWorld; using UnityEngine; @@ -263,10 +264,9 @@ public static void DoPasswordField(Rect rect, string controlName, ref string pas { te.scrollOffset = scrollOffset; te.text = new string(PassChar, password.Length); - AccessTools.Field(typeof(TextEditor), "m_RevealCursor").SetValue(te, true); - te.UpdateScrollOffsetIfNeeded(new Event()); } } + } public enum ElementOrder diff --git a/Source/Client/Util/MpUtil.cs b/Source/Client/Util/MpUtil.cs index 13264989b..95ed4c43f 100644 --- a/Source/Client/Util/MpUtil.cs +++ b/Source/Client/Util/MpUtil.cs @@ -16,11 +16,7 @@ namespace Multiplayer.Client { public static class MpUtil { - public static unsafe void MarkNoInlining(MethodBase method) - { - ushort* iflags = (ushort*)(method.MethodHandle.Value) + 1; - *iflags |= (ushort)MethodImplOptions.NoInlining; - } + public static object NewObjectNoCtor(Type type) { @@ -84,14 +80,6 @@ public static string DelegateMethodInfo(MethodBase m) .Replace("<", "[").Replace(">", "]"); } - public static MethodBase GetOriginalFromHarmonyReplacement(long replacementAddr) - { - // Todo: this is using a non-public API of Harmony, we should refactor to use https://harmony.pardeike.net/api/HarmonyLib.Harmony.html#HarmonyLib_Harmony_GetOriginalMethod_System_Reflection_MethodInfo_ - // as pardeike suggested in https://github.com/rwmt/Multiplayer/pull/270#issuecomment-1003298289 - return HarmonySharedState.originals - .FirstOrDefault(kv => kv.Key.MethodHandle.GetFunctionPointer().ToInt64() == replacementAddr).Value; - } - public static string JoinStringsAtMost(this IEnumerable strs, int atMost = 3) { return strs.Take(atMost).Join() + (strs.Count() > atMost ? "..." : ""); diff --git a/Source/Client/Windows/ServerBrowser.cs b/Source/Client/Windows/ServerBrowser.cs index 81267de58..c2b144838 100644 --- a/Source/Client/Windows/ServerBrowser.cs +++ b/Source/Client/Windows/ServerBrowser.cs @@ -135,11 +135,11 @@ bool Button(Texture2D icon, string labelKey, string tip, Color baseIconColor, fl Application.OpenURL(Multiplayer.DiscordLink); x += 10; - Widgets.Label(new Rect(x, 0, 400, 24), "Note: Multiplayer for 1.6 is a work in progress."); + Widgets.Label(new Rect(x, 0, 400, 24), "Note: Multiplayer for 1.7 is a work in progress."); const string v15Notice = """ - 1.6 and Odyssey compatibility is a work-in-progress. Compatibility with other mods is likely going to take the longest to flesh out. + 1.7 compatibility is a work-in-progress. Compatibility with other mods is likely going to take the longest to flesh out. Downgrade RimWorld to 1.5 if you want to continue playing a stable version. """; diff --git a/Source/Common/Common.csproj b/Source/Common/Common.csproj index 9e94cb170..51022113a 100644 --- a/Source/Common/Common.csproj +++ b/Source/Common/Common.csproj @@ -1,20 +1,19 @@ - + - net48 + true true enable 12 - false false Multiplayer.Common MultiplayerCommon - + - + diff --git a/Source/Common/DeferredStackTracingImpl.cs b/Source/Common/DeferredStackTracingImpl.cs deleted file mode 100644 index 92c3a71a0..000000000 --- a/Source/Common/DeferredStackTracingImpl.cs +++ /dev/null @@ -1,377 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Text; -using HarmonyLib; -using Multiplayer.Common; - -namespace Multiplayer.Client.Desyncs; - -public struct AddrTable() : IEnumerable -{ - private const int StartingN = 10; // 1024 - private const int StartingShift = 64 - StartingN; - private const int StartingSize = 1 << StartingN; - private const float LoadFactor = 0.5f; - - private AddrInfo[] hashtable = new AddrInfo[StartingSize]; - - public int Size => hashtable.Length; - public int Entries { get; private set; } = 0; - public int Collisions { get; private set; } = 0; - - private int shift = StartingShift; - - public ref AddrInfo GetOrCreateAddrInfo(long ret) - { - int indexmask = Size - 1; - int index = (int)(HashAddr((ulong)ret) >> shift); - ref var info = ref hashtable[index]; - int colls = 0; - - // Open addressing - while (info.addr != 0 && info.addr != ret) - { - index = (index + 1) & indexmask; - info = ref hashtable[index]; - colls++; - } - if (colls > Collisions) Collisions = colls; - - // When returning an unpopulated AddrInfo, assume it's going to get populated shortly and consider it used - // immediately. - if (info.addr == 0 && Entries++ > Size * LoadFactor) ResizeHashtable(); - return ref info; - } - - private static ulong HashAddr(ulong addr) => ((addr >> 4) | addr << 60) * 11400714819323198485; - - private void ResizeHashtable() - { - var oldTable = hashtable; - - hashtable = new AddrInfo[Size * 2]; - shift--; - Collisions = 0; - - int indexmask = Size - 1; - - for (int i = 0; i < oldTable.Length; i++) - { - ref var oldInfo = ref oldTable[i]; - if (oldInfo.addr != 0) - { - int index = (int)(HashAddr((ulong)oldInfo.addr) >> shift); - - while (hashtable[index].addr != 0) - index = (index + 1) & indexmask; - - ref var newInfo = ref hashtable[index]; - newInfo.addr = oldInfo.addr; - newInfo.stackUsage = oldInfo.stackUsage; - newInfo.nameHash = oldInfo.nameHash; - } - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - public IEnumerator GetEnumerator() - { - // AsEnumerable needed to get a generic IEnumerable - using var enumerator = hashtable.AsEnumerable().GetEnumerator(); - while (enumerator.MoveNext()) - { - var addr = enumerator.Current; - if (addr.addr == 0) continue; - yield return addr; - } - } -} - -public struct AddrInfo -{ - public long addr; - public long stackUsage; - public long nameHash; -} - -public static class DeferredStackTracingImpl -{ - const long NotJit = long.MaxValue; - const long RbpBased = long.MaxValue - 1; - - const long UsesRbpAsGpr = 1L << 50; - const long UsesRbx = 1L << 51; - const long RbpInfoClearMask = ~(UsesRbpAsGpr | UsesRbx); - - public const int HashInfluence = 6; - - public static AddrTable hashTable = new(); - - public static unsafe int TraceImpl(long[] traceIn, ref int hash, int skipFrames = 0) - { - if (Native.LmfPtr == 0) - return 0; - - long rbp = GetRbp(); - - long stck = rbp; - rbp = *(long*)rbp; - - long lmfPtr = *(long*)Native.LmfPtr; - - int depth = 0; // frames seen - int index = 0; // frames returned through long[] traceIn - while (true) - { - var ret = *(long*)(stck + 8); - ref var info = ref hashTable.GetOrCreateAddrInfo(ret); - if (info.addr == 0) UpdateNewElement(ref info, ret); - - long stackUsage = info.stackUsage; - if (stackUsage == NotJit) - { - // LMF (Last Managed Frame) layout on x64: - // previous - // rbp - // rsp - - lmfPtr = *(long*)lmfPtr; - var lmfRbp = *(long*)(lmfPtr + 8); - - if (lmfPtr == 0 || lmfRbp == 0) - break; - - rbp = lmfRbp; - stck = *(long*)(lmfPtr + 16) - 16; - - continue; - } - - if (depth >= skipFrames) - { - traceIn[index] = ret; - - // info.nameHash == 0 marks methods to skip - if (index < HashInfluence && info.nameHash != 0) hash = HashCombineInt(hash, (int)info.nameHash); - - index++; - } - - // traceIn length limits above all how many frames are visited, not how many are populated. - if (info.nameHash != 0 && ++depth == traceIn.Length) - break; - - if (stackUsage == RbpBased) - { - stck = rbp; - rbp = *(long*)rbp; - continue; - } - - stck += 8; - - if ((stackUsage & UsesRbpAsGpr) != 0) - { - if ((stackUsage & UsesRbx) != 0) - rbp = *(long*)(stck + 16); - else - rbp = *(long*)(stck + 8); - - stackUsage &= RbpInfoClearMask; - } - - stck += stackUsage; - } - - return index; - } - - - private static void UpdateNewElement(ref AddrInfo info, long ret) - { - info.addr = ret; - info.stackUsage = GetStackUsage(ret); - - var normalizedMethodNameBetweenOS = Native.MethodNameNormalizedFromAddr(ret, true); - - info.nameHash = - normalizedMethodNameBetweenOS == null ? 1 : - Native.GetMethodAggressiveInlining(ret) ? 0 : - StableStringHash(normalizedMethodNameBetweenOS); - } - - private static unsafe long GetStackUsage(long addr) - { - var ji = Native.mono_jit_info_table_find(Native.DomainPtr, (IntPtr)addr); - - if (ji == IntPtr.Zero) - return NotJit; - - var start = (uint*)Native.mono_jit_info_get_code_start(ji); - long usage = 0; - - // Emitted at: https://github.com/Unity-Technologies/mono/blob/2022.3.35f1/mono/mini/mini-amd64.c#L7652 - // - diverges into: https://github.com/Unity-Technologies/mono/blob/2022.3.35f1/mono/arch/amd64/amd64-codegen.h#L190-L193 - if ((*start & 0xFFFFFF) == 0xEC8348) // sub rsp,XX (4883EC XX) - { - usage = *start >> 24; - start += 1; - } - // - diverges into: https://github.com/Unity-Technologies/mono/blob/2022.3.35f1/mono/arch/amd64/amd64-codegen.h#L199-L202 - // basically just a long form of the above branch. - else if ((*start & 0xFFFFFF) == 0xEC8148) // sub rsp,XXXXXXXX (4881EC XXXXXXXX) - { - usage = *(uint*)((long)start + 3); - start = (uint*)((long)start + 7); - } - - if (usage != 0) - { - CheckRbpUsage(start, ref usage); - return usage; - } - - // https://github.com/Unity-Technologies/mono/blob/2022.3.35f1/mono/mini/mini-amd64.c#L7559 - // push rbp (55) - if (*(byte*)start == 0x55) - return RbpBased; - - throw new Exception($"Deferred stack tracing: Unknown function header {*start} {Native.MethodNameFromAddr(addr, false)}"); - } - - private static unsafe void CheckRbpUsage(uint* at, ref long stackUsage) - { - // If rbp is used as a gp reg then the prologue looks like (after frame alloc): - // mov [rsp],rbp (48892C24) - // or: - // mov [rsp],rbx (48891C24) - // mov [rsp+8],rbp (48896C2408) - // (The callee saved registers are always in the same order - // and are saved at the bottom of the frame) - - if (*at == 0x242C8948) - { - stackUsage |= UsesRbpAsGpr; - } - else if (*at == 0x241C8948 && *(at + 1) == 0x246C8948) - { - stackUsage |= UsesRbpAsGpr; - stackUsage |= UsesRbx; - } - } - - static DeferredStackTracingImpl() - { - // All of this code assumes that the rbp pointer offset stays the same throughout the method invocations. - // Mono is generally allowed to recompile code, which could cause issues for us, but GetRpb is annotated as - // NoInline and NoOptimization to heavily discourage any changes and avoid breaking. - - var method = AccessTools.DeclaredMethod(typeof(DeferredStackTracingImpl), nameof(GetRbp))!; - var rbpCodeStart = Native.mono_compile_method(method.MethodHandle.Value); - // Add 1 byte because Mono recognizes the jit_info to be just after the code start address returned by - // the compile method. - var jitInfo = Native.mono_jit_info_table_find(Native.DomainPtr, rbpCodeStart + 1); - var instStart = Native.mono_jit_info_get_code_start(jitInfo); - var instLen = Native.mono_jit_info_get_code_size(jitInfo); - // Search for the following instruction: - // mov rax, imm (48b8 ) - // It should directly precede: - // mov [rbp-XX], rax - // From which we can extract the offset from rbp. - byte[] magicBytes = [0x48, 0xb8, ..BitConverter.GetBytes(MagicNumber)]; - unsafe - { - // Make sure we don't access out-of-bounds memory. - // magicBytes.Length -- mov rax, - // sizeof(uint) -- mov [rbp-XX], rax - var maxLen = instLen - magicBytes.Length - sizeof(uint); - for (int i = 0; i < maxLen; i++) - { - byte* at = (byte*)instStart + i; - var matches = ByteSpanEqual(at, magicBytes.Length, magicBytes); - if (!matches) continue; - - uint* match = (uint*)(at + magicBytes.Length); - // mov [rbp-XX], rax (488945XX) - if ((*match & 0xFFFFFF) == 0x458948) - { - offsetFromRbp = (sbyte)(*match >> 24); - return; - } - } - - // To analyze the assembly dump, remove the offset prefixes at the start of each line and paste the hex to - // a site like https://defuse.ca/online-x86-assembler.htm#disassembly2. Choose x64. Search for the - // magic number and compare the code with the loop above. - var asm = HexDump((byte*)instStart, instLen); - ServerLog.Error( - $"Unexpected GetRpb asm structure. Couldn't find a magic bytes match. " + - $"Using fallback offset ({offsetFromRbp}). " + - $"Asm dump for the method: \n{asm}"); - } - } - - private static unsafe bool ByteSpanEqual(byte* start, int len, byte[] arr) - { - for (var i = 0; i < len; i++) - if (*(start + i) != arr[i]) - return false; - return true; - } - - private static unsafe string HexDump(byte* start, int len, int bytesPerLine = 16) - { - var sb = new StringBuilder(); - - for (int i = 0; i < len; i += bytesPerLine) - { - sb.Append($"{i:X4}: "); - - for (int j = 0; j < bytesPerLine && i + j < len; j++) sb.Append($"{start[i + j]:X2} "); - - sb.AppendLine(); - } - - return sb.ToString(); - } - - // Magic number is used to locate the relevant method code. - private const long MagicNumber = 0x0123456789ABCDEF; - private static sbyte offsetFromRbp = -8; - - [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] - private static unsafe long GetRbp() - { - // This variable declaration compiles down to the following IL: - // ldc.i8 - // stloc.0 - // In turn, the second IL instruction compiles to amd64 as: - // mov qword ptr [rbp-XX], rax - // From which we can extract the offset (XX) to reliably calculate the rbp address - long register = MagicNumber; - - return *(long*)((byte*)®ister - offsetFromRbp); - } - - private static int HashCombineInt(int seed, int value) => - (int)(seed ^ (value + 2654435769u + (seed << 6) + (seed >> 2))); - - private static int StableStringHash(string? str) - { - if (str == null) - { - return 0; - } - int num = 23; - int length = str.Length; - for (int i = 0; i < length; i++) - { - num = num * 31 + str[i]; - } - return num; - } -} diff --git a/Source/Common/Native.cs b/Source/Common/Native.cs deleted file mode 100644 index 12abbd423..000000000 --- a/Source/Common/Native.cs +++ /dev/null @@ -1,269 +0,0 @@ -using System; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Threading; -using HarmonyLib; - -namespace Multiplayer.Client -{ - public static class Native - { - public enum NativeOS - { - Windows, OSX, Linux, Dummy - } - - public static IntPtr DomainPtr { get; private set; } - - // LMF is Last Managed Frame - // current_thread->internal_thread->thread_info->tls[4] - public static long LmfPtr { get; private set; } - - public static Func? HarmonyOriginalGetter { get; set; } - - public static void EarlyInit(NativeOS os) - { - if (os == NativeOS.Linux) - TheLinuxWay(); - if (os == NativeOS.OSX) - TheOSXWay(); - - EarlyInitInternal(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - static void TheLinuxWay() => mono_dllmap_insert_linux(IntPtr.Zero, MonoWindows, null, MonoLinux, null); - - [MethodImpl(MethodImplOptions.NoInlining)] - static void TheOSXWay() => mono_dllmap_insert_osx(IntPtr.Zero, MonoWindows, null, MonoOSX, null); - - [MethodImpl(MethodImplOptions.NoInlining)] - private static void EarlyInitInternal() - { - DomainPtr = mono_domain_get(); - } - - public static unsafe void InitLmfPtr(NativeOS os) - { - // Don't bother on 32 bit runtimes - if (IntPtr.Size == 4) - return; - - const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | - BindingFlags.NonPublic; - - var internalThreadField = typeof(Thread).GetField("internal_thread", all); - var threadInfoField = internalThreadField.FieldType.GetField("runtime_thread_info", all); - var threadInfoPtr = (long)(IntPtr)threadInfoField.GetValue(internalThreadField.GetValue(Thread.CurrentThread)); - - // Struct offset found manually - // Navigate by string: "Handle Stack" - // Updated for RimWorld 1.6 (Unity 2022.3.35f1, Mono 6.13.0) - if (os == NativeOS.Linux) - LmfPtr = threadInfoPtr + 0x450; // Updated: 1.5 was 0x460, -16 bytes = 0x450 (following Windows pattern) - else if (os == NativeOS.Windows) - LmfPtr = threadInfoPtr + 0x418; // Updated for 1.6. Seems to work so far. - else if (os == NativeOS.OSX) - LmfPtr = threadInfoPtr + 0x3E8; // Updated: 1.5 was 0x3F8, -16 bytes = 0x3E8 (following Windows pattern) - else if (os == NativeOS.Dummy) - { - LmfPtr = (long)Marshal.AllocHGlobal(3 * 8); - *(long*)LmfPtr = LmfPtr; - *(long*)(LmfPtr + 8) = 0; - } - } - - private static IntPtr MaybeFindHarmonyOriginalMethod(long addr, bool harmonyOriginals) - { - var ji = mono_jit_info_table_find(DomainPtr, (IntPtr)addr); - if (ji == IntPtr.Zero) return IntPtr.Zero; - - var ptr = mono_jit_info_get_method(ji); - var codeStart = (long)mono_jit_info_get_code_start(ji); - - if (harmonyOriginals && HarmonyOriginalGetter != null) - { - var original = HarmonyOriginalGetter(codeStart); - if (original != null) - ptr = original.MethodHandle.Value; - } - - return ptr; - } - - public static string? MethodNameFromAddr(long addr, bool harmonyOriginals) - { - var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); - if (ptr == IntPtr.Zero) return null; - var name = mono_debug_print_stack_frame(ptr, -1, DomainPtr); - return string.IsNullOrEmpty(name) ? null : name; - } - - public static string? MethodNameNormalizedFromAddr(long addr, bool harmonyOriginals) - { - var ptr = MaybeFindHarmonyOriginalMethod(addr, harmonyOriginals); - return ptr == IntPtr.Zero ? null : mono_method_get_reflection_name(ptr); - } - - private static ConstructorInfo runtimeMethodHandleCtor = AccessTools.Constructor(typeof(RuntimeMethodHandle), [typeof(IntPtr)]); - - public static bool GetMethodAggressiveInlining(long addr) - { - var domain = DomainPtr; - var ji = mono_jit_info_table_find(domain, (IntPtr)addr); - - if (ji == IntPtr.Zero) return false; - - var methodHandle = mono_jit_info_get_method(ji); - var methodBase = GetMethodBaseFromRuntimePointer(methodHandle); - - if (methodBase == null) return false; - - return (methodBase.MethodImplementationFlags & MethodImplAttributes.AggressiveInlining) != 0; - } - - public static MethodBase? GetMethodBaseFromRuntimePointer(IntPtr methodHandle) - { - if (methodHandle == IntPtr.Zero) - return null; - var rmh = (RuntimeMethodHandle)runtimeMethodHandleCtor.Invoke([methodHandle]); - var rth = new RuntimeTypeHandle(); - var methodBase = MethodBase.GetMethodFromHandle(rmh, rth); - return methodBase; - } - - // const string MonoWindows = "mono-2.0-sgen.dll"; - const string MonoWindows = "mono-2.0-bdwgc"; - const string MonoLinux = "libmonobdwgc-2.0.so"; - const string MonoOSX = "libmonobdwgc-2.0.dylib"; - - [DllImport(MonoLinux, EntryPoint = "mono_dllmap_insert")] - private static extern void mono_dllmap_insert_linux(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); - - [DllImport(MonoOSX, EntryPoint = "mono_dllmap_insert")] - private static extern void mono_dllmap_insert_osx(IntPtr assembly, string? dll, string? func, string? tdll, string? tfunc); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_table_find(IntPtr domain, IntPtr addr); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_get_method(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_jit_info_get_code_start(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern int mono_jit_info_get_code_size(IntPtr ji); - - [DllImport(MonoWindows)] - public static extern string mono_debug_print_stack_frame(IntPtr method, int nativeOffset, IntPtr domain); - - [DllImport(MonoWindows)] - public static extern int mini_parse_debug_option(string option); - - [DllImport(MonoWindows)] - public static extern void mono_set_defaults(int verboseLevel, int opts); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_domain_get(); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_compile_method(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_from_mono_type(IntPtr type); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_vtable(IntPtr domain, IntPtr klass); - - [DllImport(MonoWindows)] - public static extern int mono_method_get_token(IntPtr method); - - [DllImport(MonoWindows)] - public static extern string mono_method_get_reflection_name(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_method_get_class(IntPtr method); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_class_get_image(IntPtr klass); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_profiler_create(IntPtr profiler); - - public delegate void JitDoneCallback(IntPtr profiler, IntPtr method, IntPtr jinfo); - - [DllImport(MonoWindows)] - public static extern IntPtr mono_profiler_set_jit_done_callback(IntPtr profiler, JitDoneCallback? cb); - - public static unsafe bool CctorRan(Type t) - { - return *((byte*)mono_class_vtable(mono_domain_get(), mono_class_from_mono_type(t.TypeHandle.Value)) + 45) != 0; - } - } - - /*public static class IcedDisasm - { - public unsafe static void DisasmMethod(long codeStart) - { - DisasmMethod(codeStart, Native.mono_jit_info_get_code_size(Native.mono_jit_info_table_find(Native.DomainPtr, (IntPtr)codeStart))); - } - - public unsafe static void DisasmMethod(long codeStart, long codeSize) - { - //Log.Message($"code {Convert.ToString(*(uint*)codeStart, 16)}"); - //return; - - var codeRip = (ulong)codeStart; - var codeReader = new UnsafeCodeReader() { start = (byte*)codeStart }; - var decoder = Decoder.Create(64, codeReader); - decoder.IP = codeRip; - ulong endRip = decoder.IP + (uint)codeSize + 8; - - var instructions = new InstructionList(); - while (decoder.IP < endRip) - decoder.Decode(out instructions.AllocUninitializedElement()); - - var formatter = new NasmFormatter(); - formatter.Options.DigitSeparator = "`"; - formatter.Options.FirstOperandCharIndex = 10; - var output = new StringOutput(); - - bool cond = (*(uint*)codeStart & 0xFFFFFF) != 0xEC8348 && *(byte*)codeStart != 0x55; - - Log.Message("Code size: " + codeSize, true); - - foreach (ref var instr in instructions) - { - var stringb = new StringBuilder(); - formatter.Format(instr, output); - stringb.Append(instr.IP.ToString("X16")); - stringb.Append(" "); - int instrLen = instr.Length; - int byteBaseIndex = (int)(instr.IP - codeRip); - for (int i = 0; i < instrLen; i++) - stringb.Append(((byte*)codeStart)[byteBaseIndex + i].ToString("X2")); - int missingBytes = 10 - instrLen; - for (int i = 0; i < missingBytes; i++) - stringb.Append(" "); - stringb.Append(" "); - stringb.Append(output.ToStringAndReset()); - //if (cond) - Log.Message(stringb.ToString(), ignoreStopLoggingLimit: true); - } - } - - public unsafe class UnsafeCodeReader : CodeReader - { - public byte* start; - - public unsafe override int ReadByte() - { - var val = *start; - start++; - return val; - } - } - }*/ -} diff --git a/Source/Common/Networking/MpConnectionState.cs b/Source/Common/Networking/MpConnectionState.cs index 8551f48eb..18351c88d 100644 --- a/Source/Common/Networking/MpConnectionState.cs +++ b/Source/Common/Networking/MpConnectionState.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Reflection.Emit; using HarmonyLib; +using MethodInvoker = HarmonyLib.MethodInvoker; using Multiplayer.Common.Networking.Packet; namespace Multiplayer.Common diff --git a/Source/Common/Util/MpReflection.cs b/Source/Common/Util/MpReflection.cs index d44e25906..59c5f9158 100644 --- a/Source/Common/Util/MpReflection.cs +++ b/Source/Common/Util/MpReflection.cs @@ -22,6 +22,18 @@ public static class MpReflection private static Dictionary getters = new(); private static Dictionary setters = new(); + public static Action CreateStaticConstructorInvoker(Type type) + { + var constructor = type.TypeInitializer ?? throw new ArgumentException("Type has no static constructor", nameof(type)); + // ConstructorInfo.Invoke only ensures initialization on CoreCLR. An + // explicit call is needed to execute the initializer again on reload. + var method = new DynamicMethod("MP_Reinitialize_" + type.Name, typeof(void), Type.EmptyTypes, type, true); + var il = method.GetILGenerator(); + il.Emit(OpCodes.Call, constructor); + il.Emit(OpCodes.Ret); + return (Action)method.CreateDelegate(typeof(Action)); + } + /// /// Get the value of a static property/field in type specified by memberPath /// diff --git a/Source/Directory.Build.props b/Source/Directory.Build.props new file mode 100644 index 000000000..86b55129a --- /dev/null +++ b/Source/Directory.Build.props @@ -0,0 +1,8 @@ + + + net10.0 + 1.7.4985-unstable + 3.0.0-rc.3 + $(DefaultItemExcludesInProjectFolder);**/bin/**;**/obj/** + + diff --git a/Source/Multiplayer.sln b/Source/Multiplayer.sln index 8c8f90207..5d94f4a03 100644 --- a/Source/Multiplayer.sln +++ b/Source/Multiplayer.sln @@ -13,8 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiplayerLoader", "MultiplayerLoader\MultiplayerLoader.csproj", "{D9F53E9B-A72C-4183-B549-63B649320B61}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestsOnMono", "TestsOnMono\TestsOnMono.csproj", "{B9258593-604D-4862-96EF-192B2BC6250E}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGen", "SourceGen\SourceGen.csproj", "{61D773A9-7B5D-443D-893E-F5C7A56272CA}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatCommandContracts", "ChatCommandContracts\ChatCommandContracts.csproj", "{64FFAC22-1A74-492B-965E-7AA953F263C3}" @@ -49,10 +47,6 @@ Global {D9F53E9B-A72C-4183-B549-63B649320B61}.Debug|Any CPU.Build.0 = Debug|Any CPU {D9F53E9B-A72C-4183-B549-63B649320B61}.Release|Any CPU.ActiveCfg = Release|Any CPU {D9F53E9B-A72C-4183-B549-63B649320B61}.Release|Any CPU.Build.0 = Release|Any CPU - {B9258593-604D-4862-96EF-192B2BC6250E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B9258593-604D-4862-96EF-192B2BC6250E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B9258593-604D-4862-96EF-192B2BC6250E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B9258593-604D-4862-96EF-192B2BC6250E}.Release|Any CPU.Build.0 = Release|Any CPU {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Debug|Any CPU.Build.0 = Debug|Any CPU {61D773A9-7B5D-443D-893E-F5C7A56272CA}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/Source/MultiplayerLoader/ISyncMethodForPrepatcher.cs b/Source/MultiplayerLoader/ISyncMethodForPrepatcher.cs deleted file mode 100644 index 7146a3f57..000000000 --- a/Source/MultiplayerLoader/ISyncMethodForPrepatcher.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; -using Multiplayer.API; - -namespace MultiplayerLoader; - -public interface ISyncMethodForPrepatcher : ISyncMethod -{ - Type TargetType { get; } -} diff --git a/Source/MultiplayerLoader/Multiplayer.Harmony.cs b/Source/MultiplayerLoader/Multiplayer.Harmony.cs new file mode 100644 index 000000000..196b8886c --- /dev/null +++ b/Source/MultiplayerLoader/Multiplayer.Harmony.cs @@ -0,0 +1,16 @@ +using System; +using HarmonyLib; + +namespace MultiplayerLoader; + +public partial class Multiplayer +{ + private void CheckHarmonyVersion() + { + var harmonyVersion = typeof(Harmony).Assembly.GetName().Version; + if (harmonyVersion.Major < 3) + throw new InvalidOperationException( + $"Multiplayer requires Harmony 3, but Harmony {harmonyVersion} was loaded. " + + "Update the Harmony mod and any mods that supply their own 0Harmony.dll."); + } +} diff --git a/Source/MultiplayerLoader/MultiplayerLoader.cs b/Source/MultiplayerLoader/MultiplayerLoader.cs index a3bb738a9..57a36ed04 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.cs +++ b/Source/MultiplayerLoader/MultiplayerLoader.cs @@ -9,19 +9,22 @@ namespace MultiplayerLoader { // This class has to be named Multiplayer for backwards compatibility of the settings file location - public class Multiplayer : Mod + public partial class Multiplayer : Mod { public static Multiplayer instance; public static Action settingsWindowDrawer; public Multiplayer(ModContentPack content) : base(content) { + CheckHarmonyVersion(); + instance = this; LoadAssembliesCustom(); FindTypeInAppDomain("Multiplayer.Client.Multiplayer")!.GetMethod("InitMultiplayer")!.Invoke(null, [content]); } + public static Type? FindTypeInAppDomain(string typeFullName) => AppDomain.CurrentDomain.GetAssemblies() .Select(assembly => assembly.GetType(typeFullName, throwOnError: false, ignoreCase: false)) diff --git a/Source/MultiplayerLoader/MultiplayerLoader.csproj b/Source/MultiplayerLoader/MultiplayerLoader.csproj index 018083c71..cbe5c2f76 100644 --- a/Source/MultiplayerLoader/MultiplayerLoader.csproj +++ b/Source/MultiplayerLoader/MultiplayerLoader.csproj @@ -1,7 +1,6 @@ - + - net48 true 12 false @@ -10,18 +9,18 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Source/MultiplayerLoader/Prepatches.cs b/Source/MultiplayerLoader/Prepatches.cs deleted file mode 100644 index 3261b5497..000000000 --- a/Source/MultiplayerLoader/Prepatches.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Mono.Cecil; -using Mono.Cecil.Cil; -using MonoMod.Utils; -using Multiplayer.Common; -using Prepatcher; -using RimWorld; - -namespace MultiplayerLoader; - -public static class Prepatches -{ - [FreePatch] - static void DontSetResolution(ModuleDefinition module) - { - if (MpVersion.IsDebug) - { - var resolutionUtilityType = module.ImportReference(typeof(ResolutionUtility)).Resolve(); - resolutionUtilityType.FindMethod(nameof(ResolutionUtility.SetResolutionRaw))!.Body.Instructions.Insert( - 0, - Instruction.Create(OpCodes.Ret) - ); - } - } - - [FreePatch] - static void ApplySyncPrepatches(ModuleDefinition module) - { - SyncRituals.ApplyPrepatches(module); - } - - internal static void AddPrepatcherPrefix(ModuleDefinition module, int id, MethodInfo target, MethodInfo prefix) - { - var targetMethod = module.ImportReference(target).Resolve(); - var firstInst = targetMethod.Body.Instructions.First(); - - IEnumerable PrefixInstructions() - { - yield return Instruction.Create(OpCodes.Ldc_I4, id); // Load index - yield return Instruction.Create(OpCodes.Ldarg_0); // Load __instance - - // Create __args array - yield return Instruction.Create(OpCodes.Ldc_I4, target.GetParameters().Length); - yield return Instruction.Create(OpCodes.Newarr, module.TypeSystem.Object); - - int idx = 0; - foreach (var pInfo in target.GetParameters()) - { - var pType = pInfo.ParameterType; - - yield return Instruction.Create(OpCodes.Dup); - yield return Instruction.Create(OpCodes.Ldc_I4, idx); - yield return Instruction.Create(OpCodes.Ldarg, targetMethod.Parameters.ElementAt(idx)); - - if (pType.IsByRef) - throw new Exception("Byrefs aren't handled"); - if (pType.IsValueType) - yield return Instruction.Create(OpCodes.Box, module.ImportReference(pType)); - - yield return Instruction.Create(OpCodes.Stelem_Ref); - - idx++; - } - - yield return Instruction.Create(OpCodes.Call, module.ImportReference(prefix)); - yield return Instruction.Create(OpCodes.Brtrue, firstInst); - - if (target.ReturnType == typeof(bool)) - yield return Instruction.Create(OpCodes.Ldc_I4_0); - else if (target.ReturnType == typeof(void)) - yield return Instruction.Create(OpCodes.Nop); - else - throw new Exception($"Can't handle return type {target.ReturnType}"); - - yield return Instruction.Create(OpCodes.Ret); - } - - targetMethod.Body.Instructions.InsertRange(0, PrefixInstructions()); - } -} diff --git a/Source/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs b/Source/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs new file mode 100644 index 000000000..ad429deae --- /dev/null +++ b/Source/MultiplayerLoader/SyncRituals.RuntimeHarmony.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Multiplayer.API; + +namespace MultiplayerLoader; + +public static partial class SyncRituals +{ + private static readonly Dictionary<(Type, string), ISyncMethod> runtimeSyncMethods = new(); + private static readonly HashSet runtimePatchedMethods = new(); + private static readonly Harmony runtimeHarmony = new("rwmt.Multiplayer.Rituals"); + + public static bool RuntimePrefix(object __instance, object[] __args, MethodBase __originalMethod) + { + return !runtimeSyncMethods.TryGetValue((__instance.GetType(), __originalMethod.Name), out var sync) + || !sync.DoSync(__instance, __args); + } + + public static void Init() + { + void Register(string method, Type widgetType, Action postProcess) + { + // Keep the concrete widget type for serializers and dispatch on shared generic bodies. + var sync = (ISyncMethod)syncMethod.Invoke(null, [widgetType, method, null]); + postProcess?.Invoke(sync); + runtimeSyncMethods.Add((widgetType, method), sync); + + // The closed generic method is the runtime target for each concrete widget. + var target = AccessTools.Method(widgetType, method); + if (runtimePatchedMethods.Add(target)) + runtimeHarmony.Patch(target, prefix: new HarmonyMethod(typeof(SyncRituals), nameof(RuntimePrefix))); + } + + RegisterRitualSyncMethods(Register); + } +} diff --git a/Source/MultiplayerLoader/SyncRituals.cs b/Source/MultiplayerLoader/SyncRituals.cs index 24a1c4af6..1e7a4407f 100644 --- a/Source/MultiplayerLoader/SyncRituals.cs +++ b/Source/MultiplayerLoader/SyncRituals.cs @@ -1,60 +1,20 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using HarmonyLib; -using Mono.Cecil; using Multiplayer.API; using Multiplayer.Client.Util; using RimWorld; namespace MultiplayerLoader; -public static class SyncRituals +public static partial class SyncRituals { - private static List syncMethodsWithPrepatches = new(); + private static readonly MethodInfo syncMethod = AccessTools.Method("Multiplayer.Client.Sync:Method"); - public static bool PrepatcherPrefix(int index, object __instance, object[] __args) + private static void RegisterRitualSyncMethods(Action> register) { - // This prefix is applied to a generic method and called by all possible instantiations - // This check makes sure the type is correct - if (syncMethodsWithPrepatches[index].TargetType == __instance.GetType()) - return !syncMethodsWithPrepatches[index].DoSync(__instance, __args); - return true; - } - - private static MethodInfo syncMethod = AccessTools.Method("Multiplayer.Client.Sync:Method"); - - // This method is called twice (two passes) - // The first pass with module != null is called from Prepatcher and applies prepatches - // The second pass with module == null is called from Sync initialization and registers SyncMethods - public static void ApplyPrepatches(ModuleDefinition module) - { - void Register(Type baseType, string method, Type derivedType, Action postProcess = null) - { - if (module != null) - { - Prepatches.AddPrepatcherPrefix( - module, - syncMethodsWithPrepatches.Count, - AccessTools.Method(baseType, method), - AccessTools.Method(typeof(SyncRituals), nameof(PrepatcherPrefix)) - ); - - // The ids need to be in sync between passes (note the array is recreated after Prepatcher restarts the game) - syncMethodsWithPrepatches.Add(null); - } - else - { - // Sync.Method doesn't apply a Harmony patch and doesn't require the DeclaringType to be the target type - var newSyncMethod = (ISyncMethodForPrepatcher)syncMethod.Invoke(null, [derivedType, method, null]); - postProcess?.Invoke(newSyncMethod); - syncMethodsWithPrepatches.Add(newSyncMethod); - } - } - - // ======= PawnRitualRoleSelectionWidget ======= - var ritualRolesSerializer = Serializer.New, (PawnRitualRoleSelectionWidget roleSelectionWidget, string Key)>( RitualRoleWriter, RitualRoleReader @@ -64,58 +24,63 @@ void Register(Type baseType, string method, Type derivedType, Action ); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), + // Keep one ordered registration list for both version-specific patch paths. + register( + nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), typeof(PawnRitualRoleSelectionWidget), - m => m.TransformArgument(1, ritualRolesSerializer)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), + method => method.TransformArgument(1, ritualRolesSerializer)); + register( + nameof(PawnRoleSelectionWidgetBase.TryAssignReplace), typeof(PawnPsychicRitualRoleSelectionWidget), - m => m.TransformArgument(1, psychicRitualRolesSerializer)); + method => method.TransformArgument(1, psychicRitualRolesSerializer)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnRitualRoleSelectionWidget)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), - typeof(PawnPsychicRitualRoleSelectionWidget)); + register( + nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), + typeof(PawnRitualRoleSelectionWidget), + null); + register( + nameof(PawnRoleSelectionWidgetBase.TryAssignAnyRole), + typeof(PawnPsychicRitualRoleSelectionWidget), + null); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), + register( + nameof(PawnRoleSelectionWidgetBase.TryAssign), typeof(PawnRitualRoleSelectionWidget), - m => m.TransformArgument(1, ritualRolesSerializer)); - Register(typeof(PawnRoleSelectionWidgetBase<>), nameof(PawnRoleSelectionWidgetBase.TryAssign), + method => method.TransformArgument(1, ritualRolesSerializer)); + register( + nameof(PawnRoleSelectionWidgetBase.TryAssign), typeof(PawnPsychicRitualRoleSelectionWidget), - m => m.TransformArgument(1, psychicRitualRolesSerializer)); + method => method.TransformArgument(1, psychicRitualRolesSerializer)); - Register( - typeof(PawnRoleSelectionWidgetBase<>), + register( MpMethodUtil.GetLambda( typeof(PawnRoleSelectionWidgetBase), nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), lambdaOrdinal: 7).Name, - typeof(PawnRitualRoleSelectionWidget) - ); // Roles right click delegate (try assign spectate) - Register( - typeof(PawnRoleSelectionWidgetBase<>), + typeof(PawnRitualRoleSelectionWidget), + null); // Roles right click delegate (try assign spectate) + register( MpMethodUtil.GetLambda( typeof(PawnRoleSelectionWidgetBase), nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), lambdaOrdinal: 7).Name, - typeof(PawnPsychicRitualRoleSelectionWidget) - ); // Roles right click delegate (try assign spectate) + typeof(PawnPsychicRitualRoleSelectionWidget), + null); // Roles right click delegate (try assign spectate) - Register( - typeof(PawnRoleSelectionWidgetBase<>), + register( MpMethodUtil.GetLambda( typeof(PawnRoleSelectionWidgetBase), nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), lambdaOrdinal: 2).Name, - typeof(PawnRitualRoleSelectionWidget) - ); // Not participating left click delegate (try assign any role or spectate) - Register( - typeof(PawnRoleSelectionWidgetBase<>), + typeof(PawnRitualRoleSelectionWidget), + null); // Not participating left click delegate (try assign any role or spectate) + register( MpMethodUtil.GetLambda( typeof(PawnRoleSelectionWidgetBase), nameof(PawnRoleSelectionWidgetBase.DrawPawnListInternal), lambdaOrdinal: 2).Name, - typeof(PawnPsychicRitualRoleSelectionWidget) - ); // Not participating left click delegate (try assign any role or spectate) + typeof(PawnPsychicRitualRoleSelectionWidget), + null); // Not participating left click delegate (try assign any role or spectate) } private static (WidgetType roleSelectionWidget, string Key) RitualRoleWriter(IEnumerable roles, object target, object[] _) @@ -123,13 +88,13 @@ private static (WidgetType roleSelectionWidget, string Key) RitualRoleWriter g.SequenceEqual(roles))?.Key); + return (roleSelectionWidget, roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(group => group.SequenceEqual(roles))?.Key); } private static IEnumerable RitualRoleReader((WidgetType roleSelectionWidget, string Key) data) where WidgetType : PawnRoleSelectionWidgetBase where RoleType : class, ILordJobRole { - return data.roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(g => g.Key == data.Key); + return data.roleSelectionWidget.assignments.RoleGroups().FirstOrDefault(group => group.Key == data.Key); } } diff --git a/Source/RuntimeTests/MetadataAudit.cs b/Source/RuntimeTests/MetadataAudit.cs new file mode 100644 index 000000000..b6028b0dd --- /dev/null +++ b/Source/RuntimeTests/MetadataAudit.cs @@ -0,0 +1,132 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +if (args.Length != 1) + throw new ArgumentException("Usage: MetadataAudit "); + +var root = Path.GetFullPath(args[0]); +var binaries = new Dictionary +{ + ["client"] = Path.Combine(root, "AssembliesCustom", "Multiplayer.dll"), + ["common"] = Path.Combine(root, "AssembliesCustom", "MultiplayerCommon.dll"), + ["loader"] = Path.Combine(root, "Assemblies", "MultiplayerLoader.dll"), +}; + +var assemblies = new Dictionary(); +foreach (var (role, path) in binaries) +{ + if (!File.Exists(path)) + throw new FileNotFoundException($"Missing 1.7 {role} assembly", path); + + using var stream = File.OpenRead(path); + using var pe = new PEReader(stream); + if (!pe.HasMetadata) + throw new BadImageFormatException($"No CLR metadata in {path}"); + var reader = pe.GetMetadataReader(); + var definition = reader.GetAssemblyDefinition(); + var name = reader.GetString(definition.Name); + var framework = ReadTargetFramework(reader); + if (framework != ".NETCoreApp,Version=v10.0") + throw new InvalidDataException($"{role} {name} targets {framework ?? "unknown"}, expected .NETCoreApp,Version=v10.0"); + + var references = reader.AssemblyReferences + .Select(handle => reader.GetAssemblyReference(handle)) + .Select(reference => new AssemblyReferenceInfo(reader.GetString(reference.Name), reference.Version)) + .ToArray(); + var harmonyReference = references.FirstOrDefault(reference => reference.Name == "0Harmony"); + if (harmonyReference?.Version.Major != 3) + throw new InvalidDataException($"{role} must reference Harmony major 3; got {harmonyReference?.Version.ToString() ?? "no 0Harmony AssemblyRef"}."); + var forbidden = references.FirstOrDefault(reference => + reference.Name.Contains("Prepatcher", StringComparison.OrdinalIgnoreCase) + || reference.Name.Contains("Mono.Cecil", StringComparison.OrdinalIgnoreCase)); + if (forbidden is not null) + throw new InvalidDataException($"{role} has forbidden AssemblyRef {forbidden.Name}, Version={forbidden.Version}"); + + foreach (var handle in reader.TypeReferences) + { + var type = reader.GetTypeReference(handle); + var ns = reader.GetString(type.Namespace); + var typeName = reader.GetString(type.Name); + if (ns.StartsWith("Prepatcher", StringComparison.Ordinal) + || ns.StartsWith("Mono.Cecil", StringComparison.Ordinal) + || typeName.Contains("ISyncMethodForPrepatcher", StringComparison.Ordinal)) + throw new InvalidDataException($"{role} contains forbidden TypeRef {ns}.{typeName}"); + } + + assemblies.Add(role, new AssemblyMetadata(name, framework!, references)); + Console.WriteLine($"PASS {role}: {name}; target framework {framework}"); + foreach (var reference in references.Where(reference => + reference.Name is "System.Runtime" or "Assembly-CSharp" or "0Harmony")) + Console.WriteLine($" AssemblyRef {reference.Name}, Version={reference.Version}"); +} + +var clientReferences = assemblies["client"].References; +var runtimeReference = clientReferences.FirstOrDefault(reference => reference.Name == "System.Runtime"); +if (runtimeReference?.Version.Major != 10) + throw new InvalidDataException("Client does not reference System.Runtime 10."); +var gameReference = clientReferences.FirstOrDefault(reference => reference.Name == "Assembly-CSharp"); +if (gameReference is null || gameReference.Version.Major != 1 || gameReference.Version.Minor != 7) + throw new InvalidDataException("Client does not reference the RimWorld 1.7 Assembly-CSharp API."); +foreach (var directory in new[] { Path.Combine(root, "Assemblies"), Path.Combine(root, "AssembliesCustom") }) +foreach (var path in Directory.EnumerateFiles(directory, "*.dll")) +{ + var name = Path.GetFileName(path); + var lowered = name.ToLowerInvariant(); + if (name == "0Harmony.dll" || lowered.Contains("prepatcher") || lowered.Contains("mono.cecil")) + throw new InvalidDataException($"Unexpected bundled Harmony/Prepatcher assembly: {path}"); + if (name == "UnityStubs.dll" || name == "Assembly-CSharp.dll" || name == "Verse.dll" + || name.StartsWith("UnityEngine", StringComparison.Ordinal)) + throw new InvalidDataException($"Game reference DLL must not be packaged: {path}"); +} + +Console.WriteLine("PASS 1.7 AssemblyRef and TypeRef audit: .NET 10, RimWorld 1.7 API, external Harmony, no Prepatcher/Cecil/game DLLs."); +Console.WriteLine("Metadata-only audit; no game assembly was loaded or executed."); + +static string? ReadTargetFramework(MetadataReader reader) +{ + foreach (var handle in reader.GetAssemblyDefinition().GetCustomAttributes()) + { + var attribute = reader.GetCustomAttribute(handle); + var constructorType = GetAttributeType(reader, attribute.Constructor); + if (constructorType != ("System.Runtime.Versioning", "TargetFrameworkAttribute")) + continue; + + var value = reader.GetBlobReader(attribute.Value); + if (value.ReadUInt16() != 1) + throw new BadImageFormatException("Malformed TargetFrameworkAttribute custom-attribute blob."); + return value.ReadSerializedString(); + } + return null; +} + +static (string Namespace, string Name) GetAttributeType(MetadataReader reader, EntityHandle constructor) +{ + EntityHandle parent = constructor.Kind switch + { + HandleKind.MemberReference => reader.GetMemberReference((MemberReferenceHandle)constructor).Parent, + HandleKind.MethodDefinition => reader.GetMethodDefinition((MethodDefinitionHandle)constructor).GetDeclaringType(), + _ => default, + }; + + return parent.Kind switch + { + HandleKind.TypeReference => ReadTypeReference(reader, (TypeReferenceHandle)parent), + HandleKind.TypeDefinition => ReadTypeDefinition(reader, (TypeDefinitionHandle)parent), + _ => (string.Empty, string.Empty), + }; +} + +static (string Namespace, string Name) ReadTypeReference(MetadataReader reader, TypeReferenceHandle handle) +{ + var type = reader.GetTypeReference(handle); + return (reader.GetString(type.Namespace), reader.GetString(type.Name)); +} + +static (string Namespace, string Name) ReadTypeDefinition(MetadataReader reader, TypeDefinitionHandle handle) +{ + var type = reader.GetTypeDefinition(handle); + return (reader.GetString(type.Namespace), reader.GetString(type.Name)); +} + +sealed record AssemblyMetadata(string Name, string TargetFramework, AssemblyReferenceInfo[] References); +sealed record AssemblyReferenceInfo(string Name, Version Version); diff --git a/Source/RuntimeTests/MetadataAudit.csproj b/Source/RuntimeTests/MetadataAudit.csproj new file mode 100644 index 000000000..238f157e2 --- /dev/null +++ b/Source/RuntimeTests/MetadataAudit.csproj @@ -0,0 +1,8 @@ + + + Exe + net10.0 + enable + enable + + diff --git a/Source/RuntimeTests/README.md b/Source/RuntimeTests/README.md new file mode 100644 index 000000000..72e5b529c --- /dev/null +++ b/Source/RuntimeTests/README.md @@ -0,0 +1,11 @@ +# RimWorld 1.7 metadata audit + +Build the current mod, then run `./run-local-tests.sh [built-mod-root]`. +The root defaults to this checkout and must contain `1.7/Assemblies` and +`1.7/AssembliesCustom`. Docker and Python 3 are required; no game installation is +needed. The Docker SDK is selected from the repository's `global.json`. + +The audit reads assembly metadata without loading game code. It checks .NET 10, +the RimWorld 1.7 reference, external Harmony 3, and absence of Prepatcher/Cecil or +bundled game DLLs. It does not verify live game loading or multiplayer gameplay. +Mono/Prepatcher probes belong with the frozen legacy source, not this branch. diff --git a/Source/RuntimeTests/audit-rimworld-1.7-reference.sh b/Source/RuntimeTests/audit-rimworld-1.7-reference.sh new file mode 100755 index 000000000..f74ca680e --- /dev/null +++ b/Source/RuntimeTests/audit-rimworld-1.7-reference.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Audit built RimWorld 1.7 assemblies for target framework and references. +set -euo pipefail +PROBE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$PROBE_DIR/../.." && pwd)" +MOD_ROOT="${1:-$REPO_ROOT}" +GLOBAL_JSON="$REPO_ROOT/global.json" + +if ! MOD_ROOT="$(cd -- "$MOD_ROOT" 2>/dev/null && pwd)"; then + echo "Mod root directory does not exist: ${1:-$REPO_ROOT}" >&2 + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required to read the SDK version from $GLOBAL_JSON, but was not found in PATH." >&2 + exit 1 +fi + +SDK_VERSION="$(python3 - "$GLOBAL_JSON" <<'PY' +import json +import re +import sys + +path = sys.argv[1] +try: + with open(path, encoding="utf-8") as config_file: + config = json.load(config_file) +except OSError as exc: + raise SystemExit(f"Cannot read required SDK config {path}: {exc}") +except json.JSONDecodeError as exc: + raise SystemExit(f"Malformed SDK config {path}: {exc}") + +sdk = config.get("sdk") if isinstance(config, dict) else None +version = sdk.get("version") if isinstance(sdk, dict) else None +if not isinstance(version, str) or not re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", version): + raise SystemExit(f"SDK config {path} must contain a valid sdk.version string") +print(version) +PY +)" + +for file in \ + "$MOD_ROOT/1.7/AssembliesCustom/Multiplayer.dll" \ + "$MOD_ROOT/1.7/AssembliesCustom/MultiplayerCommon.dll" \ + "$MOD_ROOT/1.7/Assemblies/MultiplayerLoader.dll"; do + if [[ ! -f "$file" ]]; then + echo "Required 1.7 audit input is missing: $file" >&2 + exit 1 + fi +done + +docker run --rm \ + -e DOTNET_CLI_TELEMETRY_OPTOUT=1 \ + -e DOTNET_NOLOGO=1 \ + -v "$MOD_ROOT":/mod:ro \ + -v "$PROBE_DIR":/probe:ro \ + -v "$GLOBAL_JSON":/tmp/global.json:ro \ + -w /tmp "mcr.microsoft.com/dotnet/sdk:$SDK_VERSION" bash -lc ' + set -euo pipefail + mkdir -p /tmp/mp-reference-audit + cp /probe/MetadataAudit.csproj /probe/MetadataAudit.cs /tmp/mp-reference-audit/ + dotnet --version + dotnet run --project /tmp/mp-reference-audit/MetadataAudit.csproj --configuration Release -- /mod/1.7 + ' diff --git a/Source/Server/Server.csproj b/Source/Server/Server.csproj index 322a8f5fe..afaf0c7f9 100644 --- a/Source/Server/Server.csproj +++ b/Source/Server/Server.csproj @@ -2,7 +2,6 @@ Exe - net8.0 enable enable 12 @@ -17,7 +16,7 @@ - + diff --git a/Source/Tests/CoreClrRuntimeTest.cs b/Source/Tests/CoreClrRuntimeTest.cs new file mode 100644 index 000000000..c4d5e09a8 --- /dev/null +++ b/Source/Tests/CoreClrRuntimeTest.cs @@ -0,0 +1,34 @@ +using Multiplayer.Client; + +namespace Tests; + +public class CoreClrRuntimeTest +{ + [Test] + public void StaticConstructorInvokerRestoresDefaultsOnEveryReload() + { + var reset = MpReflection.CreateStaticConstructorInvoker(typeof(StaticDefaults)); + var originalValues = StaticDefaults.Values; + + for (var reload = 0; reload < 2; reload++) + { + StaticDefaults.EffectsEnabled = false; + StaticDefaults.Values.Clear(); + + reset(); + + Assert.That(StaticDefaults.EffectsEnabled, Is.True); + Assert.That(StaticDefaults.Values, Is.EqualTo(new[] { 1, 2, 3 })); + Assert.That(StaticDefaults.Values, Is.Not.SameAs(originalValues)); + originalValues = StaticDefaults.Values; + } + } + + private static class StaticDefaults + { + public static bool EffectsEnabled = true; + public static readonly List Values = new() { 1, 2, 3 }; + } + + +} diff --git a/Source/Tests/DesyncTraceCaptureTest.cs b/Source/Tests/DesyncTraceCaptureTest.cs new file mode 100644 index 000000000..cb8a53ca5 --- /dev/null +++ b/Source/Tests/DesyncTraceCaptureTest.cs @@ -0,0 +1,60 @@ +using System.Runtime.CompilerServices; +using Multiplayer.Client; +using Multiplayer.Client.Desyncs; + +namespace Tests; + +public class DesyncTraceCaptureTest +{ + [TestCase(DesyncTracingMode.None)] + [TestCase((DesyncTracingMode)99)] + public void DisabledOrUnknownModeDoesNotCollect(DesyncTracingMode mode) + { + Assert.That(DesyncTraceCapture.TryCapture(mode, out var trace), Is.False); + Assert.That(trace, Is.EqualTo(default(DesyncTrace))); + } + + [Test] + public void FastReturnsContextMarkerWithoutAllocatingManagedStacks() + { + for (int i = 0; i < 100; i++) + DesyncTraceCapture.TryCapture(DesyncTracingMode.Fast, out _); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 10000; i++) + DesyncTraceCapture.TryCapture(DesyncTracingMode.Fast, out _); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.That(allocated, Is.Zero, "Fast must not allocate stack traces or format event strings"); + Assert.That(DesyncTraceCapture.TryCapture(DesyncTracingMode.Fast, out var trace), Is.True); + Assert.That(trace.Text, Is.SameAs(DesyncTraceCapture.FastDescription)); + Assert.That(trace.Depth, Is.Zero); + Assert.That(trace.Hash, Is.Zero); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static DesyncTrace CaptureFromDiagnosticCaller() + { + DesyncTraceCapture.TryCapture(DesyncTracingMode.Slow, out var trace); + return trace; + } + + [Test] + public void SlowCapturesDetailedManagedCallerFrames() + { + var trace = CaptureFromDiagnosticCaller(); + Assert.That(trace.Text, Does.Contain(nameof(CaptureFromDiagnosticCaller))); + Assert.That(trace.Depth, Is.InRange(1, DesyncTraceCapture.MaxDepth)); + Assert.That(trace.Text, Is.Not.EqualTo(DesyncTraceCapture.FastDescription)); + } + + [Test] + public void SwitchingModesDoesNotReuseSlowStackData() + { + DesyncTraceCapture.TryCapture(DesyncTracingMode.Slow, out var trace); + Assert.That(trace.Depth, Is.Positive); + DesyncTraceCapture.TryCapture(DesyncTracingMode.Fast, out trace); + Assert.That(trace.Text, Is.EqualTo(DesyncTraceCapture.FastDescription)); + DesyncTraceCapture.TryCapture(DesyncTracingMode.None, out trace); + Assert.That(trace, Is.EqualTo(default(DesyncTrace))); + } +} diff --git a/Source/Tests/HashCodeTranspilerTest.cs b/Source/Tests/HashCodeTranspilerTest.cs new file mode 100644 index 000000000..d14b08012 --- /dev/null +++ b/Source/Tests/HashCodeTranspilerTest.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; +using HarmonyLib; +using Multiplayer.Client.Util; + +namespace Tests; + +[NonParallelizable] +public class HashCodeTranspilerTest +{ + [Test] + public void PatchingGameCallerPreservesExistingDictionaryKeys() + { + var key = (new object(), "ingredients"); + var dictionary = new Dictionary<(object, string), string> { [key] = "cached" }; + var tupleHash = key.GetHashCode(); + var systemHash = HashCode.Combine(17, 42); + var target = AccessTools.Method(typeof(HashCodeTranspilerTest), nameof(GameSeed)); + var harmony = new Harmony("Tests.GameHashCalls"); + + try + { + harmony.Patch(target, transpiler: new HarmonyMethod(typeof(HashCodeTranspiler), nameof(HashCodeTranspiler.ReplaceCalls))); + + Assert.That(target.Invoke(null, new object[] { 17, 42 }), Is.EqualTo(DeterministicHash.HashCombineInt(17, 42))); + Assert.That(HashCode.Combine(17, 42), Is.EqualTo(systemHash)); + Assert.That(key.GetHashCode(), Is.EqualTo(tupleHash)); + Assert.That(dictionary[key], Is.EqualTo("cached")); + Assert.That(dictionary.Remove(key), Is.True); + } + finally + { + harmony.Unpatch(target, AccessTools.Method(typeof(HashCodeTranspiler), nameof(HashCodeTranspiler.ReplaceCalls))); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int GameSeed(int a, int b) => HashCode.Combine(a, b); + + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + public void ReplacesIntegerCombinesWithoutDroppingLabels(int arity) + { + var combine = typeof(HashCode).GetMethods().Single(m => m.Name == "Combine" && m.GetGenericArguments().Length == arity) + .MakeGenericMethod(Enumerable.Repeat(typeof(int), arity).ToArray()); + var generator = new DynamicMethod("labels", typeof(void), Type.EmptyTypes).GetILGenerator(); + var label = generator.DefineLabel(); + var call = new CodeInstruction(OpCodes.Call, combine); + call.labels.Add(label); + + var replaced = HashCodeTranspiler.ReplaceCalls(new[] { call }).Single(); + + Assert.That(replaced.labels, Does.Contain(label)); + var replacement = (MethodInfo)replaced.operand; + Assert.That(replacement.DeclaringType, Is.EqualTo(typeof(DeterministicHash))); + Assert.That(replacement.GetParameters().Select(p => p.ParameterType), Is.EqualTo(Enumerable.Repeat(typeof(int), arity))); + } + + [Test] + public void LeavesMixedTypeCombinesAlone() + { + var combine = typeof(HashCode).GetMethods().Single(m => m.Name == "Combine" && m.GetGenericArguments().Length == 2) + .MakeGenericMethod(typeof(int), typeof(string)); + var call = new CodeInstruction(OpCodes.Call, combine); + + Assert.That(HashCodeTranspiler.ReplaceCalls(new[] { call }).Single().operand, Is.EqualTo(combine)); + } +} diff --git a/Source/Tests/Tests.csproj b/Source/Tests/Tests.csproj index 17b0b2fce..97f7ab6c1 100644 --- a/Source/Tests/Tests.csproj +++ b/Source/Tests/Tests.csproj @@ -1,7 +1,6 @@ - net8.0 enable enable 12 @@ -18,12 +17,17 @@ - + + + + + + diff --git a/Source/Tests/ViewedMapTrackerTest.cs b/Source/Tests/ViewedMapTrackerTest.cs new file mode 100644 index 000000000..207403385 --- /dev/null +++ b/Source/Tests/ViewedMapTrackerTest.cs @@ -0,0 +1,58 @@ +using Multiplayer.Client.Patches; + +namespace Tests; + +public class ViewedMapTrackerTest +{ + [Test] + public void ReportsInitialWorldViewWithoutVisitingATileMap() + { + var tracker = new ViewedMapTracker(); + var sent = new List<(int, int)>(); + + tracker.Report(-2, (previous, current) => sent.Add((previous, current))); + + Assert.That(sent, Is.EqualTo(new[] { (-1, -2) })); + } + + [Test] + public void ReportsInitialMapAndPreservesTransitionChainAfterReload() + { + var tracker = new ViewedMapTracker(); + var sent = new List<(int, int)>(); + void Send(int previous, int current) => sent.Add((previous, current)); + + tracker.Report(0, Send); + tracker.Report(0, Send); + // A reload changes the local map without sending a transition. + tracker.Report(-2, Send); + tracker.Report(5, Send); + + Assert.That(sent, Is.EqualTo(new[] { (-1, 0), (0, -2), (-2, 5) })); + } + + [Test] + public void FailedSendDoesNotAdvanceReportedMap() + { + var tracker = new ViewedMapTracker(); + Assert.Throws(() => tracker.Report(0, (_, _) => throw new InvalidOperationException())); + var sent = new List<(int, int)>(); + + tracker.Report(1, (previous, current) => sent.Add((previous, current))); + + Assert.That(sent, Is.EqualTo(new[] { (-1, 1) })); + } + + [Test] + public void NewSessionStartsWithUnknownMapAgain() + { + var tracker = new ViewedMapTracker(); + var sent = new List<(int, int)>(); + void Send(int previous, int current) => sent.Add((previous, current)); + tracker.Report(0, Send); + tracker.Reset(); + tracker.Report(0, Send); + + Assert.That(sent, Is.EqualTo(new[] { (-1, 0), (-1, 0) })); + } +} diff --git a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt index b87199a17..c5901528d 100644 --- a/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ClientInitDataPacket.verified.txt @@ -1,3 +1,3 @@ -This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object +This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object -05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-00-24-00-00-00-63-64-60-60-10-00-E2-A2-F2-DC-12-BD-DC-D2-9C-92-CC-82-9C-C4-CA-D4-22-6E-A0-98-2F-82-0B-E4-81-01-33-98-04-00 (117 bytes) +05-00-00-00-31-2E-30-2E-30-04-00-00-00-01-00-00-00-02-00-00-00-03-00-00-00-04-00-00-00-01-00-00-00-01-00-00-00-00-00-00-03-02-00-00-00-03-00-00-00-6B-65-79-01-00-00-00-7B-00-00-00-04-00-00-00-6B-65-79-32-00-00-00-00-00-00-00-00-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (119 bytes) diff --git a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt index 98e950920..17e380770 100644 --- a/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt +++ b/Source/Tests/packet-serializations/ServerJoinDataPacket.verified.txt @@ -1,3 +1,3 @@ -This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object +This packet is not byte-stable while serialized, meaning it can be serialized differently due to various factors, but it does deserialize into the same object -08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-00-24-00-00-00-63-64-60-60-10-00-E2-A2-F2-DC-12-BD-DC-D2-9C-92-CC-82-9C-C4-CA-D4-22-6E-A0-98-2F-82-0B-E4-81-01-33-98-04-00 (95 bytes) +08-00-00-00-47-61-6D-65-4E-61-6D-65-01-00-00-00-08-00-00-00-31-2E-36-2E-34-35-36-36-0D-00-00-00-30-2E-31-31-2E-30-2B-31-32-33-34-35-36-05-00-00-00-00-00-02-03-01-00-26-00-00-00-63-64-60-60-10-60-60-60-28-2A-CF-2D-D1-CB-2D-CD-29-C9-2C-C8-49-AC-4C-2D-E2-66-60-60-F0-45-70-19-A0-80-19-4C-01-00 (97 bytes) diff --git a/Source/TestsOnMono/ExternMethods.cs b/Source/TestsOnMono/ExternMethods.cs deleted file mode 100644 index b7133d5c0..000000000 --- a/Source/TestsOnMono/ExternMethods.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Runtime.InteropServices; -using Multiplayer.Common; - -namespace TestsOnMono; - -public static class ExternMethods -{ - [DllImport("ucrtbase.dll", EntryPoint = "fegetround", CallingConvention = CallingConvention.Cdecl)] - public static extern RoundModeEnum GetRound(); - - [DllImport("ucrtbase.dll", EntryPoint = "fesetround", CallingConvention = CallingConvention.Cdecl)] - public static extern int SetRound(RoundModeEnum roundingMode); -} diff --git a/Source/TestsOnMono/Program.cs b/Source/TestsOnMono/Program.cs deleted file mode 100644 index c4ec90ee1..000000000 --- a/Source/TestsOnMono/Program.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Reflection; -using System.Runtime.CompilerServices; -using HarmonyLib; -using Multiplayer.Client; -using Multiplayer.Client.Desyncs; -using Multiplayer.Common; - -// These only work on Windows Mono -namespace TestsOnMono; - -static class Program -{ - // Test DeferredStackTracing - public static void Main1(string[] args) - { - Native.mini_parse_debug_option("disable_omit_fp"); - Native.InitLmfPtr(Native.NativeOS.Dummy); - Native.EarlyInit(Native.NativeOS.Dummy); - - TestClass.Test1(); - TestClass.Test(); - } - - // Test rounding modes - public static void Main2(string[] args) - { - void Print() - { - Console.WriteLine(ExternMethods.GetRound()); - Console.WriteLine(RoundMode.GetCurrentRoundMode()); - } - - Print(); - ExternMethods.SetRound(RoundModeEnum.Upward); - Print(); - ExternMethods.SetRound(RoundModeEnum.Downward); - Print(); - ExternMethods.SetRound(RoundModeEnum.TowardZero); - Print(); - } - - // Test patching generic methods with Harmony - public static void Main3(string[] args) - { - TestClassForPatches test = new() - { - field = new SomeClassDerived { a = 2, b = 1 } - }; - - Console.WriteLine(test.GetField().b); - - new Harmony("test").Patch( - typeof(TestClassForPatches).GetMethod("GetField"), - postfix: new HarmonyMethod(typeof(Program).GetMethod("GenericPostfix")) - ); - - Console.WriteLine(test.GetField().b); - } - - public static void Main(string[] args) - { - var profiler = Native.mono_profiler_create(IntPtr.Zero); - Native.mono_profiler_set_jit_done_callback(profiler, (_, method, _) => - { - var methodBase = Native.GetMethodBaseFromRuntimePointer(method); - Console.WriteLine($"{methodBase.DeclaringType}.{methodBase.Name}"); - Console.WriteLine(MethodBase.GetCurrentMethod()); - }); - Test(); - } - - private static void Test() - { - Test2(); - } - - private static void Test2() - { - - } - - public static void GenericPostfix(ref SomeClass __result) - { - __result = __result; - } - - class TestClass - { - public static void Test() - { - int hash = 0; - long[] trace = new long[8]; - DeferredStackTracingImpl.TraceImpl(trace, ref hash); - Console.WriteLine(hash); - - foreach (long l in trace) - Console.WriteLine(Native.MethodNameFromAddr(l, false)); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.NoInlining)] - public static void Test1() - { - Test(); - } - } - - public class SomeClass - { - public int a; - - [MethodImpl(MethodImplOptions.NoInlining)] - public int GetA() => a; - } - - public class SomeClassDerived : SomeClass - { - public int b; - } - - public class SomeClass2 - { - public int a; - - [MethodImpl(MethodImplOptions.NoInlining)] - public int GetA() => a; - } - - class TestClassForPatches - { - public T field; - - [MethodImpl(MethodImplOptions.NoInlining)] - public T GetField() => field; - } -} diff --git a/Source/TestsOnMono/TestsOnMono.csproj b/Source/TestsOnMono/TestsOnMono.csproj deleted file mode 100644 index 187ca8c74..000000000 --- a/Source/TestsOnMono/TestsOnMono.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - Exe - net48 - enable - 11 - true - - - - - - - - diff --git a/global.json b/global.json new file mode 100644 index 000000000..0b82a988b --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.302", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} diff --git a/run-local-tests.sh b/run-local-tests.sh new file mode 100755 index 000000000..4dfeea25f --- /dev/null +++ b/run-local-tests.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Run the local assembly metadata audit for the current RimWorld 1.7 build. +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +if [[ "${1:-}" == --help || "${1:-}" == -h ]]; then + echo "Usage: $0 [built-mod-root]" + echo "Requires Docker and Python 3. No game installation is needed for the metadata audit." + exit 0 +fi +if [[ $# -gt 1 ]]; then + echo "Usage: $0 [built-mod-root]" >&2 + exit 2 +fi +bash "$ROOT/Source/RuntimeTests/audit-rimworld-1.7-reference.sh" "${1:-$ROOT}" diff --git a/workshop_bundler.sh b/workshop_bundler.sh deleted file mode 100755 index bc0b3a188..000000000 --- a/workshop_bundler.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash - -VERSION=$(grep -Po '(?<=SimpleVersion = ")[0-9\.]+' Source/Common/Version.cs) - -git submodule update --init --recursive || { echo 'git submodule update FAILED' ; exit 1; } - -cd Source || exit -dotnet build --configuration Release || { echo 'dotnet build FAILED' ; exit 1; } -cd .. - -rm -rf Multiplayer/ -mkdir -p Multiplayer -cd Multiplayer || exit - -# About/ and Textures/ are shared between all versions -cp -r ../About ../Textures . - -cat < LoadFolders.xml - - -
  • /
  • -
  • 1.6
  • -
    - -
  • /
  • -
  • 1.5
  • -
    -
    -EOF - -GIT_COMMIT=$(git rev-parse --short HEAD 2>&1) -GIT_COMMIT_STATUS=$? -if [ $GIT_COMMIT_STATUS -eq 0 ]; then - FULL_VERSION="${VERSION} (${GIT_COMMIT})" -else - FULL_VERSION="${VERSION}" - echo "WARN: Failed to check git commit: ${GIT_COMMIT}" -fi -sed -i "// a \ \ \ \
  • 1.5
  • " About/About.xml -sed -i "s/.*<\/modVersion>.*\$/${FULL_VERSION}<\/modVersion>/" About/About.xml - -# The current version -mkdir -p 1.6 -cp -r ../Assemblies ../AssembliesCustom ../Defs ../Languages 1.6/ -rm -f 1.6/Languages/.git 1.6/Languages/LICENSE 1.6/Languages/README.md - -# Past versions -git clone -b rw-1.5 --depth=1 --single-branch --recurse-submodules https://github.com/rwmt/Multiplayer.git 1.5 || { echo 'Git cloning 1.5 FAILED' ; exit 1; } -shopt -s extglob -shopt -s dotglob -rm -rf -- 1.5/!(Languages|Assemblies|AssembliesCustom|Defs) -rm -f 1.5/Languages/.git 1.5/Languages/LICENSE 1.5/Languages/README.md - -cd .. - -# Zip for Github releases -rm -f Multiplayer-v$VERSION.zip -zip -r -q Multiplayer-v$VERSION.zip Multiplayer - -echo "Ok, $PWD/Multiplayer-v$VERSION.zip ready for uploading to Workshop"