diff --git a/.github/scripts/steam-workshop.ps1 b/.github/scripts/steam-workshop.ps1 new file mode 100644 index 0000000..a241d29 --- /dev/null +++ b/.github/scripts/steam-workshop.ps1 @@ -0,0 +1,72 @@ +# Logs in to Steam with steamcmd using a cached login and, in Upload mode, updates the Workshop item. +# +# Environment (repository secrets): +# STEAM_USERNAME Steam account that owns the Workshop item. +# STEAM_CONFIG_VDF base64 of config/config.vdf from a steamcmd that has logged in to that account, +# so no password or Steam Guard confirmation is needed. Refresh it when it expires. +param( + [Parameter(Mandatory)] [ValidateSet('LoginCheck', 'Upload')] [string] $Mode, + [string] $ContentFolder, + [string] $ChangeNote = '' +) + +$ErrorActionPreference = 'Stop' + +if (-not $env:STEAM_USERNAME -or -not $env:STEAM_CONFIG_VDF) { + throw 'The STEAM_USERNAME and STEAM_CONFIG_VDF secrets are required.' +} + +$steamcmdDir = Join-Path $env:RUNNER_TEMP 'steamcmd' +New-Item -ItemType Directory -Force $steamcmdDir, "$steamcmdDir\config" | Out-Null +Invoke-WebRequest https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip -OutFile "$steamcmdDir\steamcmd.zip" +Expand-Archive "$steamcmdDir\steamcmd.zip" $steamcmdDir -Force +# The first run only updates steamcmd itself. +& "$steamcmdDir\steamcmd.exe" +quit | Out-Null + +$steamArgs = @('+login', $env:STEAM_USERNAME) + +if ($Mode -eq 'Upload') { + # Forward slashes avoid any question of backslash escaping in the VDF. + $content = (Resolve-Path $ContentFolder).Path -replace '\\', '/' + $publishedId = (Get-Content (Join-Path $ContentFolder 'About/PublishedFileId.txt') -Raw).Trim() + $note = $ChangeNote -replace '"', "'" + + # Only the content and change note are set; title, description, preview and visibility stay as they are. + $itemVdf = Join-Path $env:RUNNER_TEMP 'workshop_item.vdf' + @" +"workshopitem" +{ + "appid" "294100" + "publishedfileid" "$publishedId" + "contentfolder" "$content" + "changenote" "$note" +} +"@ | Set-Content -Path $itemVdf -Encoding utf8 + + Write-Host "Updating Workshop item $publishedId from $content" + $steamArgs += @('+workshop_build_item', $itemVdf) +} + +$steamArgs += '+quit' + +[IO.File]::WriteAllBytes("$steamcmdDir\config\config.vdf", [Convert]::FromBase64String($env:STEAM_CONFIG_VDF)) +try { + # Print each line as it arrives and keep it for the checks below. + $output = & "$steamcmdDir\steamcmd.exe" @steamArgs | ForEach-Object { Write-Host $_; $_ } + $exitCode = $LASTEXITCODE +} +finally { + Remove-Item -Force "$steamcmdDir\config\config.vdf" -ErrorAction SilentlyContinue +} + +$text = $output -join "`n" +if ($text -notmatch 'Waiting for user info\.\.\.OK') { + throw 'Steam login failed. The cached login was probably rejected; log in with steamcmd locally and update STEAM_CONFIG_VDF.' +} +if ($Mode -eq 'Upload' -and ($exitCode -ne 0 -or $text -match 'ERROR|FAILED')) { + throw "Workshop upload failed (steamcmd exit code $exitCode)." +} + +Write-Host "Steam $Mode succeeded." +# steamcmd's exit code was checked above; do not let the runner fail the step on it. +$global:LASTEXITCODE = 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..af2183c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,134 @@ +name: release + +# Assemblies are built locally (the 1.7 target needs game files) and committed. +# Pushing a tag like v1.2.0 checks, tests and packages the mod, creates a GitHub release and +# updates the Steam Workshop item. Tags with a suffix (v1.2.0-beta.1) become GitHub pre-releases +# and are not uploaded to the Workshop. +# Running the workflow manually only checks that the Steam login in the secrets still works. + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the assembly freshness check reads history + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + + - name: Check version + run: | + version="${GITHUB_REF_NAME#v}" + version="${version%%-*}" + manifest=$(python3 -c "import xml.etree.ElementTree as ET; print(ET.parse('About/Manifest.xml').getroot().findtext('version'))") + project=$(python3 -c "import xml.etree.ElementTree as ET; print(ET.parse('Source/ModMismatchFormatter/ModMismatchFormatter.csproj').getroot().findtext('PropertyGroup/Version'))") + echo "tag $version, About/Manifest.xml $manifest, csproj $project" + if [ "$version" != "$manifest" ] || [ "$version" != "$project" ]; then + echo "::error::Tag $GITHUB_REF_NAME must match About/Manifest.xml ($manifest) and the csproj ($project)." + exit 1 + fi + + - name: Check assemblies + run: | + source_time=$(git log -1 --format=%ct -- Source/ModMismatchFormatter) + for v in 1.6 1.7; do + dll="$v/Assemblies/ModMisMatchWindowPatch.dll" + if [ ! -f "$dll" ]; then + echo "::error::$dll is missing. Build locally and commit it." + exit 1 + fi + # A warning, not an error: a source change that does not change the IL leaves the DLL untouched. + if [ "$(git log -1 --format=%ct -- "$dll")" -lt "$source_time" ]; then + echo "::warning::$dll was committed before the last change in Source/ModMismatchFormatter. Make sure it was rebuilt." + fi + done + + - name: Validate mod XML + run: | + python3 - <<'PY' + import pathlib, sys, xml.etree.ElementTree as ET + files = [p for p in pathlib.Path('.').rglob('*.xml') if not any(part in ('bin', 'obj', '.git') for part in p.parts)] + failed = False + for path in files: + try: + ET.parse(path) + except ET.ParseError as e: + print(f'::error file={path}::{e}') + failed = True + print(f'checked {len(files)} XML files') + sys.exit(1 if failed else 0) + PY + + - name: Test + run: dotnet test --project Source/ModMismatchFormatter.Tests/ModMismatchFormatter.Tests.csproj -c Release + + - name: Package + run: | + shopt -s nullglob + stage=dist/ModMismatchFormatter + mkdir -p "$stage" + # Mod content only: metadata, translations and the per-version assembly folders. + cp -r About Languages LoadFolders.xml README.md [0-9]* v[0-9]* "$stage/" + asset="dist/ModMismatchFormatter-$GITHUB_REF_NAME.zip" + (cd dist && zip -qr "../$asset" ModMismatchFormatter) + unzip -l "$asset" + echo "ASSET=$asset" >> "$GITHUB_ENV" + + - uses: actions/upload-artifact@v7 + with: + name: mod + path: dist/ModMismatchFormatter + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + run: | + flags=() + if [[ "$GITHUB_REF_NAME" == *-* ]]; then flags+=(--prerelease); fi + gh release create "$GITHUB_REF_NAME" "$ASSET" --title "$GITHUB_REF_NAME" --generate-notes --verify-tag "${flags[@]}" + + workshop: + needs: release + if: ${{ !contains(github.ref_name, '-') }} + runs-on: windows-latest + # A rejected cached login makes steamcmd wait for a password or a phone confirmation. + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + name: mod + path: mod + + - name: Upload to Steam Workshop + shell: pwsh + env: + STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }} + STEAM_CONFIG_VDF: ${{ secrets.STEAM_CONFIG_VDF }} + run: ./.github/scripts/steam-workshop.ps1 -Mode Upload -ContentFolder mod -ChangeNote "${{ github.ref_name }}: https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}" + + steam-login-check: + if: github.event_name == 'workflow_dispatch' + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - name: Check Steam login + shell: pwsh + env: + STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }} + STEAM_CONFIG_VDF: ${{ secrets.STEAM_CONFIG_VDF }} + run: ./.github/scripts/steam-workshop.ps1 -Mode LoginCheck diff --git a/.gitignore b/.gitignore index 880b93b..f446019 100644 --- a/.gitignore +++ b/.gitignore @@ -355,4 +355,6 @@ MigrationBackup/ .ionide/ #custom folders -obj \ No newline at end of file +obj +# Machine-specific MSBuild overrides +Source/Local.props diff --git a/1.6/Assemblies/ModMisMatchWindowPatch.dll b/1.6/Assemblies/ModMisMatchWindowPatch.dll index 4ad4ca6..9ba062a 100644 Binary files a/1.6/Assemblies/ModMisMatchWindowPatch.dll and b/1.6/Assemblies/ModMisMatchWindowPatch.dll differ diff --git a/1.7/Assemblies/ModMisMatchWindowPatch.dll b/1.7/Assemblies/ModMisMatchWindowPatch.dll new file mode 100644 index 0000000..1cb42fc Binary files /dev/null and b/1.7/Assemblies/ModMisMatchWindowPatch.dll differ diff --git a/About/About.xml b/About/About.xml index 07731f9..461b1a1 100644 --- a/About/About.xml +++ b/About/About.xml @@ -11,6 +11,7 @@
  • 1.4
  • 1.5
  • 1.6
  • +
  • 1.7
  • Madeline.ModMismatchFormatter ==english description== diff --git a/About/Manifest.xml b/About/Manifest.xml index 7f1c08d..9b49268 100644 --- a/About/Manifest.xml +++ b/About/Manifest.xml @@ -1,5 +1,5 @@  Better ModMismatch Window - 1.1.3 + 1.2.0 \ No newline at end of file diff --git a/Languages/ChineseSimplified/Keyed/Translation.xml b/Languages/ChineseSimplified/Keyed/Translation.xml index 61abd33..2a9e1b2 100644 --- a/Languages/ChineseSimplified/Keyed/Translation.xml +++ b/Languages/ChineseSimplified/Keyed/Translation.xml @@ -9,4 +9,15 @@ 将Mod元数据写入存档(元数据包含Mod版本等信息)。推荐启用。 + 新增:{0} + 移除:{0} + 加载顺序变更:{0} + 版本变更:{0} + 显示未变更的Mod + 显示加载顺序变更 + 存档中没有此Mod,是之后新增的。 + 存档中有此Mod,但当前未载入。 + 存档时的位置。此Mod仍在载入,但加载顺序已改变。 + 当前位置。存档时此Mod位于其他位置。 + 与存档中记录的版本不同。 \ No newline at end of file diff --git a/Languages/English/Keyed/Translation.xml b/Languages/English/Keyed/Translation.xml index eb88014..2c0e56a 100644 --- a/Languages/English/Keyed/Translation.xml +++ b/Languages/English/Keyed/Translation.xml @@ -7,4 +7,15 @@ Enable Version checking between saved file and current version. check steam workshop for the detailed description. Enable writing mod metadata to the save Enable writing mod metadata to the save (mod versions, etc). enable recommended + Added: {0} + Removed: {0} + Load order changed: {0} + Version changed: {0} + Show unchanged mods + Show load order changes + Not in the save; added since. + In the save, but not running now. + Position in the save. This mod still runs, but at a different place in the load order now. + Current position. This mod was at a different place in the save's load order. + The version differs from the one recorded in the save. \ No newline at end of file diff --git a/Languages/Japanese/Keyed/Translation.xml b/Languages/Japanese/Keyed/Translation.xml index b10c49d..081e715 100644 --- a/Languages/Japanese/Keyed/Translation.xml +++ b/Languages/Japanese/Keyed/Translation.xml @@ -8,4 +8,15 @@ セーブデータにModのメタデータを書き込むことを有効化する: セーブデータへのModのメタデータ(各Modのバージョン情報など)の書き込みを有効化します。比較時にエラーを起こさないために有効化することを推奨 + 追加: {0} + 削除: {0} + ロード順の変更: {0} + バージョン変更: {0} + 変更のないModを表示 + ロード順の変更を表示 + セーブにはなく、その後追加されたModです。 + セーブにはありますが、現在は読み込まれていません。 + セーブ時の位置です。このModは現在も読み込まれていますが、ロード順が変わりました。 + 現在の位置です。セーブ時は別の位置にありました。 + セーブに記録されたバージョンと異なります。 \ No newline at end of file diff --git a/Languages/Korean/Keyed/Translation.xml b/Languages/Korean/Keyed/Translation.xml index 04d5db6..b749e6b 100644 --- a/Languages/Korean/Keyed/Translation.xml +++ b/Languages/Korean/Keyed/Translation.xml @@ -4,7 +4,18 @@ 현재 실행중인 모드배열 모드 불일치 창 설정 버전 비교 활성화 - 버전 비교 기능을 활성화 합니다. Manifest.xml에서 데이터를 읽어옵니다. + 버전 비교 기능을 활성화합니다. 버전은 About/Manifest.xml 또는 About.xml의 modVersion에서 읽어옵니다. 세이브 파일에 모드 메타데이터 기록하기 세이브 파일에 모드에 대한 메타데이터를 기록합니다. 사용하는 것을 권장합니다. + 추가됨: {0} + 제거됨: {0} + 로드 순서 변경: {0} + 버전 변경: {0} + 변경 없는 모드 표시 + 로드 순서 변경 표시 + 세이브에는 없고, 이후에 추가된 모드입니다. + 세이브에는 있지만, 지금은 실행 중이 아닌 모드입니다. + 세이브에 기록된 위치입니다. 이 모드는 지금도 실행 중이지만 로드 순서가 바뀌었습니다. + 현재 위치입니다. 세이브에서는 로드 순서상 다른 위치에 있었습니다. + 세이브에 기록된 버전과 다릅니다. \ No newline at end of file diff --git a/LoadFolders.xml b/LoadFolders.xml index dfed61c..e700168 100644 --- a/LoadFolders.xml +++ b/LoadFolders.xml @@ -20,4 +20,12 @@
  • /
  • 1.5
  • + +
  • /
  • +
  • 1.6
  • +
    + +
  • /
  • +
  • 1.7
  • +
    diff --git a/README.md b/README.md index 4482fb3..0b260b5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,61 @@ -![RW_Ver](https://img.shields.io/badge/Rimworld-1.0%201.1%201.2-brightgreen) +![RW_Ver](https://img.shields.io/badge/Rimworld-1.0%20~%201.7-brightgreen) ![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/zzzz465/ModMisMatchFormatter?label=mod%20version) ![downloadCounter](https://raster.shields.io/steam/downloads/1872244972.png?color=blue&label=Workshop&logo=steam) ![SteamSub](https://img.shields.io/steam/subscriptions/1872244972) +[![build](https://github.com/zzzz465/ModMisMatchFormatter/actions/workflows/build.yml/badge.svg)](https://github.com/zzzz465/ModMisMatchFormatter/actions/workflows/build.yml) simple mod for comparing two mismatching mod list -https://steamcommunity.com/sharedfiles/filedetails/?id=1872244972 \ No newline at end of file +https://steamcommunity.com/sharedfiles/filedetails/?id=1872244972 + +## Building + +Requires the .NET 10 SDK. + +``` +dotnet build Source/ModMismatchFormatter.slnx -c Release +dotnet test --project Source/ModMismatchFormatter.Tests/ModMismatchFormatter.Tests.csproj +``` + +| Target | RimWorld | Output | References | +| --- | --- | --- | --- | +| `net472` | 1.6 (Unity) | `1.6/Assemblies` | `Krafs.Rimworld.Ref`, `Lib.Harmony.Ref` 2.4 | +| `net10.0` | 1.7 (Waylith) | `1.7/Assemblies` | local game files, `Lib.Harmony.Ref` 3.0 | + +There is no reference package for RimWorld 1.7 yet, so the 1.7 target is only built when +`RimWorld17Dir` points at a folder containing the 1.7 `Assembly-CSharp.dll` and `UnityStubs.dll`. +It is picked up automatically from the default Steam path when that install is on the unstable +branch; otherwise set it in `Source/Local.props` (git-ignored): + +```xml + + + D:\Games\RimWorld-1.7 + + +``` + +## Releasing + +The assemblies are built locally and committed; CI never downloads the game. + +1. Bump the version in `About/Manifest.xml` and `` in `Source/ModMismatchFormatter/ModMismatchFormatter.csproj`. +2. Build both targets (`dotnet build Source/ModMismatchFormatter.slnx -c Release`, with RimWorld 1.7 + available) and commit `1.6/Assemblies` and `1.7/Assemblies`. +3. Push a tag: `git tag v1.2.0 && git push origin v1.2.0`. Tags with a suffix (`v1.2.0-beta.1`) become pre-releases. + +`.github/workflows/release.yml` then checks that the tag matches both versions, that the committed +assemblies exist (and warns if they predate the last source change), validates the XML, runs the +tests, creates a GitHub release with `ModMismatchFormatter-.zip` (mod files only), and updates +the Steam Workshop item from `About/PublishedFileId.txt` with the same files. Pre-release tags skip +the Workshop. + +The Workshop upload uses steamcmd with a cached login, from two repository secrets: + +- `STEAM_USERNAME`: the account that owns the Workshop item. +- `STEAM_CONFIG_VDF`: base64 of `config/config.vdf` from a steamcmd that has logged in to that account. + When it expires, log in with steamcmd again and update the secret. + +Running the workflow manually (Actions → release → Run workflow) only checks that this login still works. + +The assemblies for 1.0–1.5 are prebuilt and no longer rebuilt from source. diff --git a/Source/Directory.Build.props b/Source/Directory.Build.props new file mode 100644 index 0000000..cd23bcd --- /dev/null +++ b/Source/Directory.Build.props @@ -0,0 +1,26 @@ + + + + + + latest + enable + disable + true + + false + true + + + + + C:\Program Files (x86)\Steam\steamapps\common\RimWorld + $(RimWorldSteamDir) + + diff --git a/Source/HarmonyPatches.cs b/Source/HarmonyPatches.cs deleted file mode 100644 index 8b337bb..0000000 --- a/Source/HarmonyPatches.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using HarmonyLib; -using Verse; -using RimWorld; -using System.Reflection; -using Madeline.ModMismatchFormatter; -using System.IO; -using System.Xml.Linq; -using System.Reflection.Emit; - -namespace ModMisMatchWindowPatch -{ - [StaticConstructorOnStartup] - internal static class HarmonyPatches - { - static HarmonyPatches() - { - Log.Message("Patching Madeline.ModMismatchWindow"); - Harmony HMInstance = new Harmony("Madeline.ModMismatchWindowPatch"); - MethodInfo original = AccessTools.Method(typeof(ScribeMetaHeaderUtility), "TryCreateDialogsForVersionMismatchWarnings"); - MethodInfo prefix = AccessTools.Method(typeof(HarmonyPatches), "Prefix_TryCreateDialogForVersionMismatchWarnings"); - HMInstance.Patch(original, new HarmonyMethod(prefix)); - - var SaveGameOriginal = AccessTools.Method(typeof(GameDataSaveLoader), "SaveGame"); - var SaveGamePrefix = AccessTools.Method(typeof(HarmonyPatches), "Prefix_SaveGame"); - var SaveGamePostfix = AccessTools.Method(typeof(HarmonyPatches), nameof(HarmonyPatches.Postfix_SaveGame)); - HMInstance.Patch(SaveGameOriginal, new HarmonyMethod(SaveGamePrefix), new HarmonyMethod(SaveGamePostfix)); - - var CheckVersionAndLoadOriginal = AccessTools.Method(typeof(PreLoadUtility), "CheckVersionAndLoad"); - var CheckVersionAndLoadPrefix = AccessTools.Method(typeof(MetaHeaderUtility), "UpdateLastAccessedSaveFileInLoadSelection"); - HMInstance.Patch(CheckVersionAndLoadOriginal, new HarmonyMethod(CheckVersionAndLoadPrefix)); - } - - [HarmonyPriority(9999)] - static bool Prefix_TryCreateDialogForVersionMismatchWarnings(ref bool __result, Action confirmedAction) - { - SimpleLog.Log("Checking mod mismatch"); - bool isModListSame = ScribeMetaHeaderUtility.LoadedModsMatchesActiveMods(out _, out _); - bool useVersionCompare = ModMismatchFormatter.useVersionCompare; - - if(isModListSame) - { - SimpleLog.Log("ModList are same"); - if(useVersionCompare) - { - SimpleLog.Log("use version checking"); - bool isVersionSame = MetaHeaderUtility.isVersionSame(ModContentPackExtension.GetModsFromSave(useVersionCompare), ModContentPackExtension.GetModsFromActive(useVersionCompare)); - if(!isVersionSame) - { - SimpleLog.Log("Version is different..."); - string warningMessage = "Checking mismatch for mod and mod versions... please wait"; - Messages.Message(warningMessage, MessageTypeDefOf.SilentInput, false); - CreateModMismatchWindow(confirmedAction, useVersionCompare); - __result = true; - return false; - } - } - - __result = false; - return true; - } - else - { - SimpleLog.Log("Mod mismatch!"); - CreateModMismatchWindow(confirmedAction, useVersionCompare); - __result = true; - return false; - } - } - - [HarmonyPriority(9999)] - static void Prefix_SaveGame(string fileName) - { - SimpleLog.Log("Caching last saved file path"); - bool WriteMeta = ModMismatchFormatter.writeMetaToSave; - if(WriteMeta) - { - MetaHeaderUtility.StoreLastSavedFilePath(fileName); - } - } - - static void Postfix_SaveGame() - { - SimpleLog.Log("Trying to write additional meta headers to save file..."); - bool WriteMeta = ModMismatchFormatter.writeMetaToSave; - if(WriteMeta) - { - try - { - MetaHeaderUtility.UpdateModVersionMetaHeader(); - } - catch(Exception ex) - { - string report = $"exception was raised and cannot write mod meta headers to the save. mod versions are not saved. your save is fine though.\n==original exception==\n{ex.ToString()}"; - Log.Error(report); - } - } - } - - static void CreateModMismatchWindow(Action confirmedAction, bool useVersionCompare) - { - var renderer = new ModListerElementRenderer(); - renderer.useVersionChecking = useVersionCompare; - var formatter = new OrderFormatterImpl(); - Find.WindowStack.Add(new Madeline.ModMismatchFormatter.ModMismatchWindow(renderer, - formatter, - confirmedAction, useVersionCompare)); - } - } -} diff --git a/Source/ModMisMatchWindowPatch.csproj b/Source/ModMisMatchWindowPatch.csproj deleted file mode 100644 index 8ec6329..0000000 --- a/Source/ModMisMatchWindowPatch.csproj +++ /dev/null @@ -1,106 +0,0 @@ - - - - - Debug - AnyCPU - {B7F7D989-B085-4B3F-9423-462A50D77C02} - Library - Properties - ModMisMatchWindowPatch - ModMisMatchWindowPatch - v4.7.2 - 512 - true - - - - true - full - true - ..\1.6\Assemblies\ - DEBUG;TRACE - prompt - 4 - false - - - pdbonly - true - ..\v1.1\Assemblies\ - TRACE - prompt - 4 - false - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - ..\..\..\..\..\..\Program Files (x86)\Steam\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\Assembly-CSharp.dll - False - - - - - - ..\..\..\..\..\..\..\..\Program Files (x86)\Steam\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.dll - False - - - ..\..\..\..\..\..\..\..\Program Files (x86)\Steam\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.CoreModule.dll - False - - - ..\..\..\..\..\..\..\..\Program Files (x86)\Steam\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.IMGUIModule.dll - False - - - ..\..\..\..\..\..\..\..\Program Files (x86)\Steam\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.TextRenderingModule.dll - False - - - ..\..\..\..\..\..\..\..\Program Files (x86)\Steam\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.UI.dll - False - - - ..\..\..\..\..\..\..\..\Program Files (x86)\Steam\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.UIModule.dll - False - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Source/ModMisMatchWindowPatch.sln b/Source/ModMisMatchWindowPatch.sln deleted file mode 100644 index 97df075..0000000 --- a/Source/ModMisMatchWindowPatch.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29230.47 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModMisMatchWindowPatch", "ModMisMatchWindowPatch.csproj", "{B7F7D989-B085-4B3F-9423-462A50D77C02}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {B7F7D989-B085-4B3F-9423-462A50D77C02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B7F7D989-B085-4B3F-9423-462A50D77C02}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B7F7D989-B085-4B3F-9423-462A50D77C02}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B7F7D989-B085-4B3F-9423-462A50D77C02}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {C8116065-DF87-45E0-A3D0-8E2270ABB376} - EndGlobalSection -EndGlobal diff --git a/Source/ModMismatchFormatter.Tests/CallInserterTests.cs b/Source/ModMismatchFormatter.Tests/CallInserterTests.cs new file mode 100644 index 0000000..84d17f4 --- /dev/null +++ b/Source/ModMismatchFormatter.Tests/CallInserterTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; +using HarmonyLib; +using Madeline.ModMismatchFormatter.Patches; +using Xunit; + +namespace ModMismatchFormatter.Tests; + +/// +/// Exercises with real Harmony patching on a method shaped like +/// ScribeMetaHeaderUtility.WriteMetaHeader: if (EnterNode()) try { ... } finally { ExitNode(); } +/// +[Collection(nameof(CallInserterTests))] // Patches static state; keep these tests serial. +public class CallInserterTests +{ + private static readonly List Calls = []; + + private static bool EnterNode() + { + Calls.Add("enter"); + return true; + } + + private static void ExitNode() => Calls.Add("exit"); + + private static void Inserted() => Calls.Add("inserted"); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void WriteHeader() + { + if (!EnterNode()) + return; + + try + { + Calls.Add("body"); + } + finally + { + ExitNode(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void WriteHeaderThatThrows() + { + if (!EnterNode()) + return; + + try + { + throw new InvalidOperationException("boom"); + } + finally + { + ExitNode(); + } + } + + private static IEnumerable InsertBeforeExitNode(IEnumerable instructions) => + CallInserter.InsertBefore(instructions, Method(nameof(ExitNode)), Method(nameof(Inserted)), out _); + + private static MethodInfo Method(string name) => AccessTools.Method(typeof(CallInserterTests), name); + + private static void Patch(string original, string harmonyId) + { + new Harmony(harmonyId).Patch(Method(original), transpiler: new HarmonyMethod(Method(nameof(InsertBeforeExitNode)))); + } + + [Fact] + public void Inserted_call_runs_inside_the_node_before_it_closes() + { + Patch(nameof(WriteHeader), "tests.write-header"); + Calls.Clear(); + + WriteHeader(); + + Assert.Equal(["enter", "body", "inserted", "exit"], Calls); + } + + [Fact] + public void Finally_block_still_runs_when_the_body_throws() + { + Patch(nameof(WriteHeaderThatThrows), "tests.write-header-throws"); + Calls.Clear(); + + Assert.Throws(WriteHeaderThatThrows); + Assert.Equal(["enter", "inserted", "exit"], Calls); + } + + [Fact] + public void Reports_when_the_target_call_is_missing() + { + var instructions = new List { new(OpCodes.Nop), new(OpCodes.Ret) }; + + var result = CallInserter.InsertBefore(instructions, Method(nameof(ExitNode)), Method(nameof(Inserted)), out bool inserted); + + Assert.False(inserted); + Assert.Equal(2, result.Count); + } +} diff --git a/Source/Refactored/ListExtension.cs b/Source/ModMismatchFormatter.Tests/Legacy/ListExtension.cs similarity index 81% rename from Source/Refactored/ListExtension.cs rename to Source/ModMismatchFormatter.Tests/Legacy/ListExtension.cs index c7e307b..4cd3b30 100644 --- a/Source/Refactored/ListExtension.cs +++ b/Source/ModMismatchFormatter.Tests/Legacy/ListExtension.cs @@ -1,8 +1,11 @@ +// Verbatim copy of the pre-refactor alignment code (commit bbb3964), kept as a test oracle. +#nullable disable +#pragma warning disable using System; using System.Collections.Generic; using System.Linq; -namespace Madeline.ModMismatchFormatter +namespace Legacy { public static class ListExtension { diff --git a/Source/Refactored/Mod.cs b/Source/ModMismatchFormatter.Tests/Legacy/Mod.cs similarity index 87% rename from Source/Refactored/Mod.cs rename to Source/ModMismatchFormatter.Tests/Legacy/Mod.cs index b422942..bd8d355 100644 --- a/Source/Refactored/Mod.cs +++ b/Source/ModMismatchFormatter.Tests/Legacy/Mod.cs @@ -1,7 +1,10 @@ +// Verbatim copy of the pre-refactor alignment code (commit bbb3964), kept as a test oracle. +#nullable disable +#pragma warning disable using System; using System.Collections.Generic; -namespace Madeline.ModMismatchFormatter +namespace Legacy { public class Mod { diff --git a/Source/Refactored/ModPair.cs b/Source/ModMismatchFormatter.Tests/Legacy/ModPair.cs similarity index 67% rename from Source/Refactored/ModPair.cs rename to Source/ModMismatchFormatter.Tests/Legacy/ModPair.cs index 13a2b6d..8e25338 100644 --- a/Source/Refactored/ModPair.cs +++ b/Source/ModMismatchFormatter.Tests/Legacy/ModPair.cs @@ -1,7 +1,10 @@ +// Verbatim copy of the pre-refactor alignment code (commit bbb3964), kept as a test oracle. +#nullable disable +#pragma warning disable using System; using System.Collections.Generic; -namespace Madeline.ModMismatchFormatter +namespace Legacy { public class ModPair { diff --git a/Source/Refactored/ModPresenter.cs b/Source/ModMismatchFormatter.Tests/Legacy/ModPresenter.cs similarity index 97% rename from Source/Refactored/ModPresenter.cs rename to Source/ModMismatchFormatter.Tests/Legacy/ModPresenter.cs index 7d36ea3..40df034 100644 --- a/Source/Refactored/ModPresenter.cs +++ b/Source/ModMismatchFormatter.Tests/Legacy/ModPresenter.cs @@ -1,8 +1,11 @@ +// Verbatim copy of the pre-refactor alignment code (commit bbb3964), kept as a test oracle. +#nullable disable +#pragma warning disable using System; using System.Collections.Generic; using System.Linq; -namespace Madeline.ModMismatchFormatter +namespace Legacy { public class OrderFormatterImpl : iOrderFormatter { diff --git a/Source/Refactored/iOrderFormatter.cs b/Source/ModMismatchFormatter.Tests/Legacy/iOrderFormatter.cs similarity index 56% rename from Source/Refactored/iOrderFormatter.cs rename to Source/ModMismatchFormatter.Tests/Legacy/iOrderFormatter.cs index 70a49c9..16784e5 100644 --- a/Source/Refactored/iOrderFormatter.cs +++ b/Source/ModMismatchFormatter.Tests/Legacy/iOrderFormatter.cs @@ -1,7 +1,10 @@ +// Verbatim copy of the pre-refactor alignment code (commit bbb3964), kept as a test oracle. +#nullable disable +#pragma warning disable using System; using System.Collections.Generic; -namespace Madeline.ModMismatchFormatter +namespace Legacy { public interface iOrderFormatter { diff --git a/Source/ModMismatchFormatter.Tests/ModListAlignerTests.cs b/Source/ModMismatchFormatter.Tests/ModListAlignerTests.cs new file mode 100644 index 0000000..cf522d2 --- /dev/null +++ b/Source/ModMismatchFormatter.Tests/ModListAlignerTests.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Madeline.ModMismatchFormatter.Core; +using Xunit; + +namespace ModMismatchFormatter.Tests; + +public class ModListAlignerTests +{ + private static List Mods(IEnumerable ids) => + ids.Select(id => new ModEntry(id, id)).ToList(); + + private static List<(string?, string?)> Shape(IEnumerable rows) => + rows.Select(row => (row.Saved?.PackageId, row.Active?.PackageId)).ToList(); + + private static List<(string?, string?)> Shape(IEnumerable<(ModEntry? Saved, ModEntry? Active)> pairs) => + pairs.Select(pair => (pair.Saved?.PackageId, pair.Active?.PackageId)).ToList(); + + private static int Matched(IEnumerable rows) => rows.Count(row => row.Saved is not null && row.Active is not null); + + /// Independent textbook LCS length, used as the optimum to compare against. + private static int LcsLength(IReadOnlyList a, IReadOnlyList b) + { + var table = new int[a.Count + 1, b.Count + 1]; + for (int i = 1; i <= a.Count; i++) + for (int j = 1; j <= b.Count; j++) + table[i, j] = ModEntry.SameMod(a[i - 1], b[j - 1]) + ? table[i - 1, j - 1] + 1 + : Math.Max(table[i - 1, j], table[i, j - 1]); + return table[a.Count, b.Count]; + } + + private static List<(string?, string?)> LegacyShape(IReadOnlyList saved, IReadOnlyList active) + { + static List ToLegacy(IReadOnlyList mods) => + mods.Select((mod, i) => new Legacy.Mod(mod.PackageId, mod.PackageId, i)).ToList(); + + return new Legacy.OrderFormatterImpl() + .GetFormattedModPairs(ToLegacy(saved), ToLegacy(active)) + .Select(pair => (pair.Save.isPlaceHolder ? null : pair.Save.ModName, + pair.Loaded.isPlaceHolder ? null : pair.Loaded.ModName)) + .ToList(); + } + + [Fact] + public void Identical_lists_align_row_by_row() + { + var mods = Mods(["a", "b", "c"]); + + var rows = ModListAligner.Align(mods, mods); + + Assert.Equal([("a", "a"), ("b", "b"), ("c", "c")], Shape(rows)); + Assert.All(rows, row => Assert.Equal(RowState.Unchanged, row.State)); + } + + [Fact] + public void Added_and_removed_mods_get_a_gap_on_the_other_side() + { + var rows = ModListAligner.Align(Mods(["a", "x", "b"]), Mods(["a", "b", "y"])); + + Assert.Equal([("a", "a"), ("x", null), ("b", "b"), (null, "y")], Shape(rows)); + Assert.Equal( + [RowState.Unchanged, RowState.Removed, RowState.Unchanged, RowState.Added], + rows.Select(row => row.State)); + } + + [Fact] + public void Removed_is_shown_before_added_at_the_same_position() + { + var rows = ModListAligner.Align(Mods(["a", "x", "b"]), Mods(["a", "y", "b"])); + + Assert.Equal([("a", "a"), ("x", null), (null, "y"), ("b", "b")], Shape(rows)); + } + + [Fact] + public void Reordered_mod_is_reported_as_moved_not_added_and_removed() + { + var rows = ModListAligner.Align(Mods(["a", "b", "c", "d"]), Mods(["a", "c", "d", "b"])); + + Assert.Equal([("a", "a"), ("b", null), ("c", "c"), ("d", "d"), (null, "b")], Shape(rows)); + Assert.Equal(RowState.MovedFrom, rows[1].State); + Assert.Equal(RowState.MovedTo, rows[4].State); + } + + [Fact] + public void Matches_package_ids_case_insensitively_and_old_saves_by_folder_name() + { + var saved = new List { new("Ludeon.RimWorld", "Core"), new("1234567", "Workshop mod") }; + var active = new List { new("ludeon.rimworld", "Core"), new("author.mod", "Workshop mod", FolderName: "1234567") }; + + var rows = ModListAligner.Align(saved, active); + + Assert.Equal(2, rows.Count); + Assert.All(rows, row => Assert.Equal(RowState.Unchanged, row.State)); + } + + [Theory] + [InlineData("1.0", "1.1", RowState.VersionChanged)] + [InlineData("1.0", "1.0", RowState.Unchanged)] + [InlineData(null, "1.1", RowState.Unchanged)] // Save has no version record. + [InlineData("1.0", null, RowState.Unchanged)] + public void Version_change_needs_both_versions_known(string? savedVersion, string? activeVersion, RowState expected) + { + var rows = ModListAligner.Align([new ModEntry("a", "A", savedVersion)], [new ModEntry("a", "A", activeVersion)]); + + Assert.Equal(expected, Assert.Single(rows).State); + } + + [Fact] + public void Empty_lists_produce_no_rows() + { + Assert.Empty(ModListAligner.Align([], [])); + } + + [Fact] + public void Sample_mod_lists_align_optimally() + { + var saved = Mods(SampleModLists.Saved); + var active = Mods(SampleModLists.Active); + + var rows = ModListAligner.Align(saved, active); + + Assert.Equal(LcsLength(saved, active), Matched(rows)); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void Matches_as_many_mods_as_possible_and_never_fewer_than_the_old_algorithm(int seed) + { + var random = new Random(seed); + + for (int run = 0; run < 2000; run++) + { + var (saved, active) = RandomModLists(random); + + var rows = ModListAligner.Align(saved, active); + + Assert.Equal(LcsLength(saved, active), Matched(rows)); + if (saved.Count > 0 && active.Count > 0) // The old code throws on an empty list. + Assert.True(Matched(rows) >= LegacyShape(saved, active).Count(p => p.Item1 is not null && p.Item2 is not null)); + } + } + + [Theory] + [InlineData(ModListAligner.DefaultMaxTableCells)] + [InlineData(0)] // Forces the greedy fallback for the middle part. + public void Every_mod_appears_once_and_in_its_original_order(int maxTableCells) + { + var random = new Random(42); + + for (int run = 0; run < 500; run++) + { + var (saved, active) = RandomModLists(random); + + var rows = ModListAligner.Align(saved, active, maxTableCells); + + Assert.Equal(saved, rows.Where(row => row.Saved is not null).Select(row => row.Saved!)); + Assert.Equal(active, rows.Where(row => row.Active is not null).Select(row => row.Active!)); + Assert.All(rows.Where(row => row.Saved is not null && row.Active is not null), + row => Assert.True(ModEntry.SameMod(row.Saved!, row.Active!))); + } + } + + [Fact] + public void Greedy_fallback_is_the_original_algorithm() + { + var random = new Random(7); + + for (int run = 0; run < 2000; run++) + { + var (saved, active) = RandomModLists(random); + if (saved.Count == 0 || active.Count == 0) + continue; + + var pairs = new List<(ModEntry?, ModEntry?)>(); + ModListAligner.AlignGreedy(saved, active, ModEntry.SameMod, pairs); + + Assert.Equal(LegacyShape(saved, active), Shape(pairs)); + } + } + + /// Builds a mod list and an edited copy (removals, additions, moves). + private static (List Saved, List Active) RandomModLists(Random random) + { + int count = random.Next(0, 40); + var saved = Enumerable.Range(0, count).Select(i => $"m{i}").ToList(); + var active = new List(saved); + + int edits = random.Next(0, 10); + for (int i = 0; i < edits; i++) + { + switch (random.Next(3)) + { + case 0 when active.Count > 0: + active.RemoveAt(random.Next(active.Count)); + break; + case 1: + active.Insert(random.Next(active.Count + 1), $"new{i}"); + break; + case 2 when active.Count > 1: + int from = random.Next(active.Count); + string moved = active[from]; + active.RemoveAt(from); + active.Insert(random.Next(active.Count + 1), moved); + break; + } + } + + return (Mods(saved), Mods(active)); + } +} diff --git a/Source/ModMismatchFormatter.Tests/ModMismatchFormatter.Tests.csproj b/Source/ModMismatchFormatter.Tests/ModMismatchFormatter.Tests.csproj new file mode 100644 index 0000000..640cf3e --- /dev/null +++ b/Source/ModMismatchFormatter.Tests/ModMismatchFormatter.Tests.csproj @@ -0,0 +1,21 @@ + + + + + net10.0 + Exe + false + + + + + + + + + + + + + + diff --git a/Source/ModMismatchFormatter.Tests/SampleModLists.cs b/Source/ModMismatchFormatter.Tests/SampleModLists.cs new file mode 100644 index 0000000..011a534 --- /dev/null +++ b/Source/ModMismatchFormatter.Tests/SampleModLists.cs @@ -0,0 +1,184 @@ +namespace ModMismatchFormatter.Tests; + +/// A real save/running mod list pair (Steam workshop ids) from the original UITester. +internal static class SampleModLists +{ + public static readonly string[] Saved = + [ + "Core", + "1567113677", + "1848471817", + "1508850027", + "818773962", + "1507748539", + "839005762", + "932008009", + "753498552", + "1180826364", + "1837246563", + "725219116", + "712141500", + "715565817", + "848972794", + "946390822", + "935732834", + "962732083", + "836308268", + "1496570408", + "1188485723", + "1185269798", + "1185906628", + "1185274792", + "1185276569", + "1244299809", + "1185281838", + "1185265132", + "1192504741", + "1185307686", + "1718190143", + "1718191613", + "1880253632", + "1845154007", + "1836382920", + "1814383360", + "1113137502", + "1541721856", + "1055485938", + "1658356618", + "1590579367", + "1542364782", + "1836900626", + "1895364938", + "1823540489", + "1849183875", + "1849184079", + "1895364782", + "1700683323", + "1895364585", + "1865597143", + "1582570547", + "761421485", + "1833163132", + "1558161673", + "1907283935", + "1907283872", + "1906780517", + "1854607105", + "1854610483", + "1819425140", + "1888705256", + "1914064942", + "1814987817", + "1814988282", + "1917061124", + "826998327", + "1321849735", + "850429707", + "746425621", + "1501832876", + "1424338139", + "1843574431", + "1446523594", + "1544626521", + "1591142767", + "1346064449", + "1537869190", + "725130005", + "1909914131", + "1539312975", + "1713343013", + "1908437382", + ]; + + public static readonly string[] Active = + [ + "Core", + "1567113677", + "1848471817", + "1508850027", + "818773962", + "1507748539", + "839005762", + "932008009", + "753498552", + "1180826364", + "1837246563", + "725219116", + "712141500", + "715565817", + "848972794", + "946390822", + "935732834", + "836308268", + "1496570408", + "1188485723", + "1185269798", + "1185274792", + "1718190143", + "1185276569", + "1718191613", + "962732083", + "1185307686", + "1185265132", + "1185906628", + "1055485938", + "1185281838", + "1244299809", + "1508292711", + "1192504741", + "1211694919", + "1845154007", + "1814383360", + "1578693166", + "1854376306", + "1541721856", + "1632800575", + "1658356618", + "865497369", + "1590579367", + "1113137502", + "1542364782", + "1836900626", + "1895364938", + "1823540489", + "1849183875", + "1849184079", + "1700683323", + "1895364585", + "1865597143", + "1582570547", + "1558161673", + "1907283935", + "1817690190", + "1895364782", + "1907283872", + "832333531", + "1906780517", + "1854607105", + "1917061124", + "735106432", + "1819425140", + "1888705256", + "1854610483", + "1814987817", + "1814988282", + "850429707", + "1914064942", + "746425621", + "1843574431", + "1544626521", + "1591142767", + "1346064449", + "1537869190", + "1216999901", + "1697864590", + "1865654125", + "725130005", + "1909914131", + "818322128", + "1539312975", + "1713343013", + "1446523594", + "1908437382", + ]; +} diff --git a/Source/ModMismatchFormatter.slnx b/Source/ModMismatchFormatter.slnx new file mode 100644 index 0000000..ed9eacb --- /dev/null +++ b/Source/ModMismatchFormatter.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/Source/ModMismatchFormatter/Core/AlignedRow.cs b/Source/ModMismatchFormatter/Core/AlignedRow.cs new file mode 100644 index 0000000..58004a4 --- /dev/null +++ b/Source/ModMismatchFormatter/Core/AlignedRow.cs @@ -0,0 +1,28 @@ +namespace Madeline.ModMismatchFormatter.Core; + +public enum RowState +{ + /// The mod is in both lists at this position. + Unchanged, + + /// The mod is in both lists at this position but its version differs. + VersionChanged, + + /// The mod is only in the save (it was removed). + Removed, + + /// The mod is only in the running list (it was added). + Added, + + /// The mod's position in the save; it is still running but loads elsewhere now. + MovedFrom, + + /// The mod's new position in the running list; it was elsewhere in the save. + MovedTo, +} + +/// One row of the side-by-side comparison. At least one side is set. +public readonly record struct AlignedRow(ModEntry? Saved, ModEntry? Active, RowState State) +{ + public bool IsDifference => State != RowState.Unchanged; +} diff --git a/Source/ModMismatchFormatter/Core/IsExternalInit.cs b/Source/ModMismatchFormatter/Core/IsExternalInit.cs new file mode 100644 index 0000000..590bf1b --- /dev/null +++ b/Source/ModMismatchFormatter/Core/IsExternalInit.cs @@ -0,0 +1,6 @@ +#if NETFRAMEWORK +namespace System.Runtime.CompilerServices; + +// Lets records and init-only setters compile for net472 (RimWorld 1.6). +internal static class IsExternalInit; +#endif diff --git a/Source/ModMismatchFormatter/Core/ModEntry.cs b/Source/ModMismatchFormatter/Core/ModEntry.cs new file mode 100644 index 0000000..ca7e604 --- /dev/null +++ b/Source/ModMismatchFormatter/Core/ModEntry.cs @@ -0,0 +1,20 @@ +using System; + +namespace Madeline.ModMismatchFormatter.Core; + +/// A mod as recorded in a save file or as currently running. +/// The mod's package id. Saves made before RimWorld 1.1 store the folder name here. +/// Display name. +/// Mod version, or null when it is not known. +/// Folder name of a running mod, used to match pre-1.1 saves. +public sealed record ModEntry(string PackageId, string Name, string? Version = null, string? FolderName = null) +{ + /// Whether a mod from the save and a running mod are the same mod (ignoring version). + public static bool SameMod(ModEntry saved, ModEntry active) => + string.Equals(saved.PackageId, active.PackageId, StringComparison.OrdinalIgnoreCase) + || (active.FolderName is not null && string.Equals(saved.PackageId, active.FolderName, StringComparison.OrdinalIgnoreCase)); + + /// Versions differ only when both are known; a save without version data proves nothing. + public static bool VersionChanged(string? saved, string? active) => + saved is not null && active is not null && saved != active; +} diff --git a/Source/ModMismatchFormatter/Core/ModListAligner.cs b/Source/ModMismatchFormatter/Core/ModListAligner.cs new file mode 100644 index 0000000..ac0161c --- /dev/null +++ b/Source/ModMismatchFormatter/Core/ModListAligner.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Madeline.ModMismatchFormatter.Core; + +/// +/// Lines up the save's mod list against the running mod list so that the same mod +/// appears on the same row, with gaps where a mod was added or removed. +/// +public static class ModListAligner +{ + // An LCS table of this many cells is 16 MB. Bigger differences fall back to the greedy pass. + internal const int DefaultMaxTableCells = 4_000_000; + + /// + /// Aligns the lists so that as many mods as possible share a row (longest common subsequence), + /// matching mods with . Mods present in both lists but in a + /// different order are reported as /. + /// + public static IReadOnlyList Align(IReadOnlyList saved, IReadOnlyList active) => + Align(saved, active, DefaultMaxTableCells); + + internal static IReadOnlyList Align(IReadOnlyList saved, IReadOnlyList active, int maxTableCells) + { + var pairs = new List<(ModEntry? Saved, ModEntry? Active)>(Math.Max(saved.Count, active.Count)); + + // Mod lists usually share long runs at both ends; only the middle needs the table. + int prefix = 0; + while (prefix < saved.Count && prefix < active.Count && ModEntry.SameMod(saved[prefix], active[prefix])) + prefix++; + + int suffix = 0; + while (suffix < saved.Count - prefix && suffix < active.Count - prefix + && ModEntry.SameMod(saved[saved.Count - 1 - suffix], active[active.Count - 1 - suffix])) + suffix++; + + for (int i = 0; i < prefix; i++) + pairs.Add((saved[i], active[i])); + + var savedMiddle = Slice(saved, prefix, saved.Count - prefix - suffix); + var activeMiddle = Slice(active, prefix, active.Count - prefix - suffix); + + if ((long)(savedMiddle.Count + 1) * (activeMiddle.Count + 1) <= maxTableCells) + AlignLcs(savedMiddle, activeMiddle, pairs); + else + AlignGreedy(savedMiddle, activeMiddle, ModEntry.SameMod, pairs); + + for (int i = suffix; i > 0; i--) + pairs.Add((saved[saved.Count - i], active[active.Count - i])); + + return Classify(pairs); + } + + private static void AlignLcs(IReadOnlyList saved, IReadOnlyList active, List<(ModEntry?, ModEntry?)> pairs) + { + int n = saved.Count, m = active.Count, width = m + 1; + + // lcs[i * width + j] = length of the LCS of saved[i..] and active[j..]. + var lcs = new int[(n + 1) * width]; + for (int i = n - 1; i >= 0; i--) + { + for (int j = m - 1; j >= 0; j--) + { + lcs[i * width + j] = ModEntry.SameMod(saved[i], active[j]) + ? lcs[(i + 1) * width + j + 1] + 1 + : Math.Max(lcs[(i + 1) * width + j], lcs[i * width + j + 1]); + } + } + + int s = 0, a = 0; + while (s < n && a < m) + { + if (ModEntry.SameMod(saved[s], active[a])) + pairs.Add((saved[s++], active[a++])); + else if (lcs[(s + 1) * width + a] >= lcs[s * width + a + 1]) + pairs.Add((saved[s++], null)); // On ties, show what was removed before what was added. + else + pairs.Add((null, active[a++])); + } + + while (s < n) + pairs.Add((saved[s++], null)); + while (a < m) + pairs.Add((null, active[a++])); + } + + /// + /// The original (pre-1.2) greedy alignment: when the current mods differ and each reappears + /// later in the other list, the side whose match is closer to the end of the list is emitted first. + /// Linear in memory, so it is kept for lists too different for the LCS table. + /// + internal static void AlignGreedy( + IReadOnlyList saved, IReadOnlyList active, + Func same, List<(ModEntry?, ModEntry?)> pairs) + { + int s = 0, a = 0; + + while (s < saved.Count && a < active.Count) + { + var savedMod = saved[s]; + var activeMod = active[a]; + + if (same(savedMod, activeMod)) + { + pairs.Add((savedMod, activeMod)); + s++; + a++; + continue; + } + + int savedInActive = LastIndex(active, a + 1, mod => same(savedMod, mod)); + int activeInSaved = LastIndex(saved, s + 1, mod => same(mod, activeMod)); + + bool takeActive = (savedInActive, activeInSaved) switch + { + (< 0, _) => false, + (_, < 0) => true, + _ => active.Count - savedInActive > saved.Count - activeInSaved, + }; + + if (takeActive) + pairs.Add((null, active[a++])); + else + pairs.Add((saved[s++], null)); + } + + while (s < saved.Count) + pairs.Add((saved[s++], null)); + while (a < active.Count) + pairs.Add((null, active[a++])); + } + + private static IReadOnlyList Classify(List<(ModEntry? Saved, ModEntry? Active)> pairs) + { + // A mod left unmatched on both sides is still running; only its load order changed. + var unmatchedActive = pairs.Where(p => p.Saved is null).Select(p => p.Active!).ToList(); + var unmatchedSaved = pairs.Where(p => p.Active is null).Select(p => p.Saved!).ToList(); + + return pairs.Select(pair => pair switch + { + (null, { } active) => new AlignedRow(null, active, + unmatchedSaved.Any(saved => ModEntry.SameMod(saved, active)) ? RowState.MovedTo : RowState.Added), + ({ } saved, null) => new AlignedRow(saved, null, + unmatchedActive.Any(active => ModEntry.SameMod(saved, active)) ? RowState.MovedFrom : RowState.Removed), + ({ } saved, { } active) => new AlignedRow(saved, active, + ModEntry.VersionChanged(saved.Version, active.Version) ? RowState.VersionChanged : RowState.Unchanged), + _ => throw new InvalidOperationException("A row must have at least one mod."), + }).ToList(); + } + + private static IReadOnlyList Slice(IReadOnlyList mods, int start, int count) + { + var slice = new ModEntry[count]; + for (int i = 0; i < count; i++) + slice[i] = mods[start + i]; + return slice; + } + + private static int LastIndex(IReadOnlyList mods, int start, Func match) + { + for (int i = mods.Count - 1; i >= start; i--) + { + if (match(mods[i])) + return i; + } + + return -1; + } +} diff --git a/Source/ModMismatchFormatter/ModLog.cs b/Source/ModMismatchFormatter/ModLog.cs new file mode 100644 index 0000000..b1d44f3 --- /dev/null +++ b/Source/ModMismatchFormatter/ModLog.cs @@ -0,0 +1,14 @@ +using System.Diagnostics; + +namespace Madeline.ModMismatchFormatter; + +internal static class ModLog +{ + private const string Prefix = "[ModMismatchFormatter] "; + + /// Verbose trace; compiled out of release builds. + [Conditional("DEBUG")] + public static void Debug(string message) => Verse.Log.Message(Prefix + message); + + public static void Error(string message) => Verse.Log.Error(Prefix + message); +} diff --git a/Source/ModMismatchFormatter/ModMismatchFormatter.cs b/Source/ModMismatchFormatter/ModMismatchFormatter.cs new file mode 100644 index 0000000..2540cf2 --- /dev/null +++ b/Source/ModMismatchFormatter/ModMismatchFormatter.cs @@ -0,0 +1,34 @@ +using UnityEngine; +using Verse; + +namespace Madeline.ModMismatchFormatter; + +// The class name is part of the settings file name (Mod__ModMismatchFormatter.xml); do not rename. +public sealed class ModMismatchFormatter : Mod +{ + public ModMismatchFormatter(ModContentPack content) : base(content) + { + Settings = GetSettings(); + } + + internal static Settings Settings { get; private set; } = null!; + + public override string SettingsCategory() => "SettingsLabel".Translate(); + + public override void DoSettingsWindowContents(Rect inRect) + { + var listing = new Listing_Standard(); + listing.Begin(inRect); + listing.CheckboxLabeled( + "EnableVersionChecking".Translate(), + ref Settings.useVersionCompare, + "useVersionCompareDescription".Translate()); + listing.CheckboxLabeled( + "EnableWritingCustomVersionHeader".Translate(), + ref Settings.saveVersionToSaveFile, + "writeMetaDescription".Translate()); + listing.End(); + + base.DoSettingsWindowContents(inRect); + } +} diff --git a/Source/ModMismatchFormatter/ModMismatchFormatter.csproj b/Source/ModMismatchFormatter/ModMismatchFormatter.csproj new file mode 100644 index 0000000..9e7622f --- /dev/null +++ b/Source/ModMismatchFormatter/ModMismatchFormatter.csproj @@ -0,0 +1,43 @@ + + + + + net472 + net472;net10.0 + + ModMisMatchWindowPatch + Madeline.ModMismatchFormatter + 1.2.0 + none + false + false + + + + ..\..\1.6\Assemblies\ + $(DefineConstants);RW16 + + + + ..\..\1.7\Assemblies\ + $(DefineConstants);RW17 + + + + + + + + + + + + + + + + diff --git a/Source/ModMismatchFormatter/ModVersions.cs b/Source/ModMismatchFormatter/ModVersions.cs new file mode 100644 index 0000000..873701d --- /dev/null +++ b/Source/ModMismatchFormatter/ModVersions.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml; +using System.Xml.Linq; +using Verse; + +namespace Madeline.ModMismatchFormatter; + +/// +/// Mod versions of the running mods, and the versions recorded in the save's meta header: +/// +/// <meta> +/// <modMetaDatas> +/// <li><Identifier>packageId</Identifier><version>1.2.3</version></li> +/// </modMetaDatas> +/// </meta> +/// +/// +internal static class ModVersions +{ + private const string ListNode = "modMetaDatas"; + private const string IdNode = "Identifier"; + private const string VersionNode = "version"; + + // Older releases wrote this placeholder instead of omitting the version. + private const string LegacyUnknown = "Unknown"; + + private static readonly Dictionary RunningVersionCache = []; + private static Dictionary savedVersions = new(StringComparer.OrdinalIgnoreCase); + + /// The running version of , or null when it does not declare one. + public static string? Running(ModContentPack mod) + { + if (!RunningVersionCache.TryGetValue(mod.RootDir, out var version)) + { + version = ReadManifestVersion(mod) ?? NullIfEmpty(mod.ModMetaData?.ModVersion); + RunningVersionCache[mod.RootDir] = version; + } + + return version; + } + + /// The version recorded in the last loaded meta header, or null when it is not recorded. + public static string? Saved(string packageId) => + savedVersions.TryGetValue(packageId, out var version) ? version : null; + + /// Reads the recorded versions from the meta header the scribe loader currently holds. + public static void ReadFromLoadedMetaHeader() + { + var versions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var list = Scribe.loader.curXmlParent?["meta"]?[ListNode]; + + if (list is not null) + { + foreach (XmlNode item in list.ChildNodes) + { + var id = item[IdNode]?.InnerText; + var version = NullIfEmpty(item[VersionNode]?.InnerText); + if (id is not null && version is not null && version != LegacyUnknown) + versions[id] = version; + } + } + + savedVersions = versions; + ModLog.Debug($"Read {versions.Count} mod versions from the meta header"); + } + + /// Writes the running mod versions. Must be called while the scribe is inside the meta node. + public static void WriteToMetaHeader() + { + if (!Scribe.EnterNode(ListNode)) + return; + + try + { + foreach (var mod in LoadedModManager.RunningMods) + { + if (!Scribe.EnterNode("li")) + continue; + + try + { + var id = mod.PackageId; + var version = Running(mod); + Scribe_Values.Look(ref id, IdNode); + Scribe_Values.Look(ref version, VersionNode); + } + finally + { + Scribe.ExitNode(); + } + } + } + finally + { + Scribe.ExitNode(); + } + } + + private static string? ReadManifestVersion(ModContentPack mod) + { + // Manifest.xml is the convention introduced by Fluffy's Mod Manager. + var path = Path.Combine(mod.RootDir, "About", "Manifest.xml"); + if (!File.Exists(path)) + return null; + + try + { + return NullIfEmpty(XDocument.Load(path).Root?.Element("version")?.Value); + } + catch (Exception ex) + { + ModLog.Error($"Could not read {path}: {ex.Message}"); + return null; + } + } + + private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value!.Trim(); +} diff --git a/Source/ModMismatchFormatter/Patches/CallInserter.cs b/Source/ModMismatchFormatter/Patches/CallInserter.cs new file mode 100644 index 0000000..4de673a --- /dev/null +++ b/Source/ModMismatchFormatter/Patches/CallInserter.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; + +namespace Madeline.ModMismatchFormatter.Patches; + +internal static class CallInserter +{ + /// + /// Inserts a call to right before the first call to . + /// Labels and exception blocks move to the inserted call, so it also works when the target call + /// starts a finally block. + /// + public static List InsertBefore( + IEnumerable instructions, MethodInfo target, MethodInfo insert, out bool inserted) + { + var result = new List(); + inserted = false; + + foreach (var instruction in instructions) + { + if (!inserted && instruction.Calls(target)) + { + result.Add(new CodeInstruction(OpCodes.Call, insert).MoveLabelsFrom(instruction).MoveBlocksFrom(instruction)); + inserted = true; + } + + result.Add(instruction); + } + + return result; + } +} diff --git a/Source/ModMismatchFormatter/Patches/HarmonyBootstrap.cs b/Source/ModMismatchFormatter/Patches/HarmonyBootstrap.cs new file mode 100644 index 0000000..09c4738 --- /dev/null +++ b/Source/ModMismatchFormatter/Patches/HarmonyBootstrap.cs @@ -0,0 +1,14 @@ +using HarmonyLib; +using Verse; + +namespace Madeline.ModMismatchFormatter.Patches; + +[StaticConstructorOnStartup] +internal static class HarmonyBootstrap +{ + static HarmonyBootstrap() + { + new Harmony("Madeline.ModMismatchWindowPatch").PatchAll(typeof(HarmonyBootstrap).Assembly); + ModLog.Debug("Patched"); + } +} diff --git a/Source/ModMismatchFormatter/Patches/MetaHeaderPatches.cs b/Source/ModMismatchFormatter/Patches/MetaHeaderPatches.cs new file mode 100644 index 0000000..eae39fd --- /dev/null +++ b/Source/ModMismatchFormatter/Patches/MetaHeaderPatches.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using HarmonyLib; +using Verse; + +namespace Madeline.ModMismatchFormatter.Patches; + +/// Remembers whether the meta header being written belongs to a savegame. +[HarmonyPatch(typeof(GameDataSaveLoader), nameof(GameDataSaveLoader.SaveGame))] +internal static class SaveGamePatch +{ + public static bool IsSavingGame { get; private set; } + + private static void Prefix() => IsSavingGame = true; + + private static void Finalizer() => IsSavingGame = false; +} + +/// Adds the running mod versions to a savegame's meta header while it is written. +[HarmonyPatch(typeof(ScribeMetaHeaderUtility), nameof(ScribeMetaHeaderUtility.WriteMetaHeader))] +internal static class WriteMetaHeaderPatch +{ + // WriteMetaHeader is: if (Scribe.EnterNode("meta")) try { ... } finally { Scribe.ExitNode(); } + // Writing right before that ExitNode puts the versions inside . + private static IEnumerable Transpiler(IEnumerable instructions) + { + var result = CallInserter.InsertBefore( + instructions, + AccessTools.Method(typeof(Scribe), nameof(Scribe.ExitNode)), + AccessTools.Method(typeof(WriteMetaHeaderPatch), nameof(WriteModVersions)), + out bool inserted); + + if (!inserted) + ModLog.Error("Could not find Scribe.ExitNode in WriteMetaHeader; mod versions will not be saved."); + + return result; + } + + private static void WriteModVersions() + { + if (Scribe.mode != LoadSaveMode.Saving || !SaveGamePatch.IsSavingGame || !ModMismatchFormatter.Settings.saveVersionToSaveFile) + return; + + try + { + ModVersions.WriteToMetaHeader(); + } + catch (Exception ex) + { + // Never break saving: the save is fine, it just will not carry mod versions. + ModLog.Error($"Could not write mod versions to the save. The save itself is fine.\n{ex}"); + } + } +} + +/// Reads the recorded mod versions whenever a meta header is loaded. +[HarmonyPatch(typeof(ScribeMetaHeaderUtility), nameof(ScribeMetaHeaderUtility.LoadGameDataHeader))] +internal static class LoadGameDataHeaderPatch +{ + private static void Postfix() + { + if (Scribe.mode != LoadSaveMode.LoadingVars) + return; + + try + { + ModVersions.ReadFromLoadedMetaHeader(); + } + catch (Exception ex) + { + ModLog.Error($"Could not read mod versions from the save.\n{ex}"); + } + } +} diff --git a/Source/ModMismatchFormatter/Patches/MismatchDialogPatch.cs b/Source/ModMismatchFormatter/Patches/MismatchDialogPatch.cs new file mode 100644 index 0000000..cd1f7c3 --- /dev/null +++ b/Source/ModMismatchFormatter/Patches/MismatchDialogPatch.cs @@ -0,0 +1,30 @@ +using System; +using System.Linq; +using HarmonyLib; +using Madeline.ModMismatchFormatter.Core; +using Madeline.ModMismatchFormatter.UI; +using Verse; + +namespace Madeline.ModMismatchFormatter.Patches; + +/// Replaces the vanilla mod mismatch dialog with . +[HarmonyPatch(typeof(ScribeMetaHeaderUtility), nameof(ScribeMetaHeaderUtility.TryCreateDialogsForVersionMismatchWarnings))] +internal static class MismatchDialogPatch +{ + [HarmonyPriority(9999)] // Ahead of other mods that replace this dialog. + private static bool Prefix(Action confirmedAction, ref bool __result) + { + bool compareVersions = ModMismatchFormatter.Settings.useVersionCompare; + + // Same mods, same versions: let vanilla check the game version. + if (ScribeMetaHeaderUtility.LoadedModsMatchesActiveMods(out _, out _) && (!compareVersions || !AnyVersionChanged())) + return true; + + Find.WindowStack.Add(new ModMismatchWindow(confirmedAction, compareVersions)); + __result = true; + return false; + } + + private static bool AnyVersionChanged() => + LoadedModManager.RunningMods.Any(mod => ModEntry.VersionChanged(ModVersions.Saved(mod.PackageId), ModVersions.Running(mod))); +} diff --git a/Source/ModMismatchFormatter/Settings.cs b/Source/ModMismatchFormatter/Settings.cs new file mode 100644 index 0000000..7648b63 --- /dev/null +++ b/Source/ModMismatchFormatter/Settings.cs @@ -0,0 +1,28 @@ +using Verse; + +namespace Madeline.ModMismatchFormatter; + +// The type name is written into the settings file as Class="..."; do not rename. +public sealed class Settings : ModSettings +{ + /// Compare each mod's version with the one recorded in the save. Off on a fresh install. + public bool useVersionCompare; + + /// Record running mod versions in the save's meta header. Cheap since 1.2, so on by default. + public bool saveVersionToSaveFile = true; + + /// Mismatch window: list mods that did not change. + public bool showUnchanged = true; + + /// Mismatch window: list mods whose load order changed. + public bool showOrderChanges = true; + + public override void ExposeData() + { + base.ExposeData(); + Scribe_Values.Look(ref useVersionCompare, "useVersionCompare", true, true); + Scribe_Values.Look(ref saveVersionToSaveFile, "saveVersionToSaveFile", true, true); + Scribe_Values.Look(ref showUnchanged, "showUnchanged", true); + Scribe_Values.Look(ref showOrderChanges, "showOrderChanges", true); + } +} diff --git a/Source/ModMismatchFormatter/UI/ModMismatchWindow.cs b/Source/ModMismatchFormatter/UI/ModMismatchWindow.cs new file mode 100644 index 0000000..5ea44a8 --- /dev/null +++ b/Source/ModMismatchFormatter/UI/ModMismatchWindow.cs @@ -0,0 +1,339 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Madeline.ModMismatchFormatter.Core; +using RimWorld; +using UnityEngine; +using Verse; +using Verse.Sound; + +namespace Madeline.ModMismatchFormatter.UI; + +/// Shows the save's mod list and the running mod list side by side, aligned row by row. +public sealed class ModMismatchWindow : Window +{ + private const float Gap = 6f; + private const float ColumnGap = 16f; + private const float ToolbarHeight = 24f; + private const float HeaderHeight = 24f; + private const float ButtonHeight = 38f; + private const float MarkWidth = 14f; + + private readonly Action loadGame; + private readonly bool showVersions; + private readonly QuickSearchWidget search = new(); + + private IReadOnlyList rows = []; + private List visibleRows = []; + private Dictionary counts = []; + private int savedCount; + private int activeCount; + private Vector2 scrollPosition; + + public ModMismatchWindow(Action loadGame, bool showVersions) + { + this.loadGame = loadGame; + this.showVersions = showVersions; + doCloseX = true; + absorbInputAroundWindow = true; + } + + public override Vector2 InitialSize => new(860f, 760f); + + private static Settings Settings => ModMismatchFormatter.Settings; + + public override void PreOpen() + { + base.PreOpen(); + + var saved = SavedMods(); + var active = RunningMods(); + savedCount = saved.Count; + activeCount = active.Count; + rows = ModListAligner.Align(saved, active); + counts = rows.GroupBy(row => row.State).ToDictionary(group => group.Key, group => group.Count()); + RefreshVisibleRows(); + } + + public override void DoWindowContents(Rect inRect) + { + float y = inRect.y; + + // Title with the summary on the same line; the summary wraps below when it does not fit. + string title = "ModsMismatchWarningTitle".Translate(); + Text.Font = GameFont.Medium; + float titleHeight = Text.LineHeight; + float titleWidth = Text.CalcSize(title).x; + Widgets.Label(new Rect(inRect.x, y, titleWidth, titleHeight), title); + Text.Font = GameFont.Small; + + var summary = SummaryEntries(); + float summaryWidth = summary.Sum(entry => SwatchSize + 4f + Text.CalcSize(entry.Label).x + 18f); + if (titleWidth + 24f + summaryWidth <= inRect.width) + { + DrawSummary(new Rect(inRect.x + titleWidth + 24f, y + (titleHeight - Text.LineHeight) / 2f, summaryWidth, Text.LineHeight), summary); + y += titleHeight + Gap; + } + else + { + y += titleHeight; + DrawSummary(new Rect(inRect.x, y, inRect.width, Text.LineHeight), summary); + y += Text.LineHeight + Gap; + } + + DrawToolbar(new Rect(inRect.x, y, inRect.width, ToolbarHeight)); + y += ToolbarHeight + Gap; + + var buttons = new Rect(inRect.x, inRect.yMax - ButtonHeight, inRect.width, ButtonHeight); + var list = new Rect(inRect.x, y + HeaderHeight, inRect.width, buttons.y - Gap - (y + HeaderHeight)); + float columnWidth = (list.width - GenUI.ScrollBarWidth - ColumnGap) / 2f; + + Widgets.Label(new Rect(list.x, y, columnWidth, HeaderHeight), $"{"SaveModListTitle".Translate()} ({savedCount})"); + Widgets.Label(new Rect(list.x + columnWidth + ColumnGap, y, columnWidth, HeaderHeight), $"{"ActiveModListTitle".Translate()} ({activeCount})"); + + DrawRows(list, columnWidth); + DrawButtons(buttons); + } + + private const float SwatchSize = 12f; + + // Colored counts that double as the legend. + private List<(RowState State, string Label)> SummaryEntries() + { + // A moved mod has one MovedFrom and one MovedTo row; count it once. + (RowState State, string Key)[] entries = + [ + (RowState.Added, "ModMismatchFormatter_Added"), + (RowState.Removed, "ModMismatchFormatter_Removed"), + (RowState.MovedFrom, "ModMismatchFormatter_Moved"), + (RowState.VersionChanged, "ModMismatchFormatter_VersionChanged"), + ]; + + return entries + .Where(entry => showVersions || entry.State != RowState.VersionChanged) + .Select(entry => (entry.State, (string)entry.Key.Translate(counts.TryGetValue(entry.State, out int count) ? count : 0))) + .ToList(); + } + + private static void DrawSummary(Rect rect, List<(RowState State, string Label)> entries) + { + float x = rect.x; + foreach (var (state, label) in entries) + { + float width = Text.CalcSize(label).x; + var swatch = new Rect(x, rect.y + (rect.height - SwatchSize) / 2f, SwatchSize, SwatchSize); + Widgets.DrawBoxSolid(swatch, Palette.For(state)); + Widgets.Label(new Rect(swatch.xMax + 4f, rect.y, width, rect.height), label); + x = swatch.xMax + 4f + width + 18f; + } + } + + private void DrawToolbar(Rect rect) + { + search.OnGUI(new Rect(rect.x, rect.y, Mathf.Min(260f, rect.width / 3f), rect.height), RefreshVisibleRows); + + float x = rect.x + Mathf.Min(260f, rect.width / 3f) + 24f; + bool changed = Toggle(ref x, rect, "ModMismatchFormatter_ShowUnchanged", ref Settings.showUnchanged); + changed |= Toggle(ref x, rect, "ModMismatchFormatter_ShowOrderChanges", ref Settings.showOrderChanges); + + if (changed) + { + Settings.Write(); + RefreshVisibleRows(); + } + } + + private static bool Toggle(ref float x, Rect toolbar, string key, ref bool value) + { + string label = key.Translate(); + float width = Text.CalcSize(label).x + 24f + 6f; + bool before = value; + Widgets.CheckboxLabeled(new Rect(x, toolbar.y, width, toolbar.height), label, ref value, placeCheckboxNearText: true); + x += width + 24f; + return before != value; + } + + private void RefreshVisibleRows() + { + visibleRows = rows.Where(IsVisible).ToList(); + search.noResultsMatched = search.filter.Active && visibleRows.Count == 0; + } + + private bool IsVisible(AlignedRow row) + { + if (!Settings.showUnchanged && row.State == RowState.Unchanged) + return false; + if (!Settings.showOrderChanges && row.State is RowState.MovedFrom or RowState.MovedTo) + return false; + if (!search.filter.Active) + return true; + + return new[] { row.Saved, row.Active }.Any(mod => + mod is not null && (search.filter.Matches(mod.Name) || search.filter.Matches(mod.PackageId))); + } + + // One line per row; the version sits at the right end of the same line. + private static float RowHeight => Text.LineHeightOf(GameFont.Small) + 2f; + + private void DrawRows(Rect outRect, float columnWidth) + { + Widgets.DrawBoxSolid(outRect, Palette.Background); + + float rowHeight = RowHeight; + var viewRect = new Rect(0f, 0f, outRect.width - GenUI.ScrollBarWidth, visibleRows.Count * rowHeight); + + Widgets.BeginScrollView(outRect, ref scrollPosition, viewRect); + // Only rows inside the viewport are drawn; mod lists can have well over a thousand entries. + int first = Mathf.Max(0, Mathf.FloorToInt(scrollPosition.y / rowHeight)); + int last = Mathf.Min(visibleRows.Count - 1, Mathf.CeilToInt((scrollPosition.y + outRect.height) / rowHeight)); + for (int i = first; i <= last; i++) + { + var row = visibleRows[i]; + var rowRect = new Rect(0f, i * rowHeight, viewRect.width, rowHeight); + if (i % 2 == 0) + Widgets.DrawLightHighlight(rowRect); + + DrawCell(new Rect(0f, rowRect.y, columnWidth, rowHeight), row.Saved, row, isSavedSide: true); + DrawCell(new Rect(columnWidth + ColumnGap, rowRect.y, columnWidth, rowHeight), row.Active, row, isSavedSide: false); + } + Widgets.EndScrollView(); + } + + private void DrawCell(Rect rect, ModEntry? mod, AlignedRow row, bool isSavedSide) + { + if (mod is null) + { + Widgets.DrawBoxSolid(rect.ContractedBy(0f, 1f), Palette.EmptyCell); + return; + } + + // Each state belongs to one side: removals/old positions on the left, the rest on the right. + bool ownsState = row.State is RowState.Removed or RowState.MovedFrom ? isSavedSide : !isSavedSide; + var state = ownsState ? row.State : RowState.Unchanged; + if (state != RowState.Unchanged) + Widgets.DrawBoxSolid(rect.ContractedBy(0f, 1f), Palette.For(state)); + Widgets.DrawHighlightIfMouseover(rect); + + // [mark][name ............ version] + string? versionLabel = showVersions ? VersionLabel(mod, row, isSavedSide) : null; + float versionWidth = 0f; + if (versionLabel is not null) + { + Text.Font = GameFont.Tiny; + versionWidth = Text.CalcSize(versionLabel).x + 6f; + Text.Font = GameFont.Small; + } + + var nameRect = new Rect(rect.x + 4f + MarkWidth, rect.y, rect.width - MarkWidth - 8f - versionWidth, rect.height); + + Text.Anchor = TextAnchor.MiddleLeft; + Widgets.Label(new Rect(rect.x + 4f, rect.y, MarkWidth, rect.height), Palette.Mark(state)); + Widgets.Label(nameRect, mod.Name.Truncate(nameRect.width)); + + if (versionLabel is not null) + { + Text.Font = GameFont.Tiny; + Text.Anchor = TextAnchor.MiddleRight; + GUI.color = state == RowState.Unchanged ? Color.gray : Palette.TextOnState; + Widgets.Label(new Rect(nameRect.xMax, rect.y, versionWidth, rect.height), versionLabel); + GUI.color = Color.white; + Text.Font = GameFont.Small; + } + Text.Anchor = TextAnchor.UpperLeft; + + TooltipHandler.TipRegion(rect, () => Tooltip(mod, state), mod.PackageId.GetHashCode() ^ (isSavedSide ? 1 : 2)); + } + + // Nothing for unknown versions: saves made without version data would otherwise repeat "Unknown" on every row. + private static string? VersionLabel(ModEntry mod, AlignedRow row, bool isSavedSide) => + mod.Version is null ? null + : row.State == RowState.VersionChanged && !isSavedSide ? $"{row.Saved!.Version} -> {mod.Version}" + : mod.Version; + + private static string Tooltip(ModEntry mod, RowState state) + { + string text = $"{mod.Name}\n{mod.PackageId}"; + if (mod.Version is not null) + text += $"\nver : {mod.Version}"; + if (state != RowState.Unchanged) + text += $"\n\n{$"ModMismatchFormatter_State_{state}".Translate()}"; + return text; + } + + private void DrawButtons(Rect area) + { + const int count = 3; + float width = (area.width - Gap * (count - 1)) / count; + Rect Button(int i) => new(area.x + i * (width + Gap), area.y, width, area.height); + + if (Widgets.ButtonText(Button(0), "ChangeLoadedMods".Translate())) + { + Find.WindowStack.Add(Dialog_MessageBox.CreateConfirmation( + "ConfirmLoadSaveList".Translate(), ChangeLoadedModsToSave, destructive: true)); + } + + if (Widgets.ButtonText(Button(1), "SaveModList".Translate())) + SaveRunningModList(); + + if (Widgets.ButtonText(Button(2), "LoadAnyway".Translate())) + LoadAnyway(); + } + + public override void OnAcceptKeyPressed() => LoadAnyway(); + + private void LoadAnyway() + { + SoundDefOf.Click.PlayOneShotOnCamera(); + loadGame(); + Close(); + } + + // Mirrors vanilla Dialog_ModMismatch: activate the save's mod list and restart. + private static void ChangeLoadedModsToSave() + { + var ids = ScribeMetaHeaderUtility.loadedModIdsList.Select(id => id.ToLower()).ToList(); + var names = ScribeMetaHeaderUtility.loadedModNamesList; + + if (Current.ProgramState == ProgramState.Entry) + ModsConfig.SetActiveToList(ids); + ModsConfig.SaveFromList(ids); + + var missing = ids + .Select((id, i) => (id, name: i < names.Count ? names[i] : id)) + .Where(mod => ModLister.GetModWithIdentifier(mod.id) is null) + .Select(mod => mod.name) + .ToList(); + if (missing.Count > 0) + Messages.Message($"{"MissingMods".Translate()}: {missing.ToCommaList()}", MessageTypeDefOf.RejectInput, historical: false); + + ModsConfig.RestartFromChangedMods(); + } + + // Mirrors vanilla Dialog_ModMismatch: save the running mod list as a preset. + private static void SaveRunningModList() + { + SoundDefOf.Click.PlayOneShotOnCamera(); + var running = LoadedModManager.RunningMods.ToList(); + var modList = new ModList + { + ids = running.Select(mod => mod.PackageId.ToLower()).ToList(), + names = running.Select(mod => mod.Name).ToList(), + }; + Find.WindowStack.Add(new Dialog_ModList_Save(modList)); + } + + private List SavedMods() + { + var ids = ScribeMetaHeaderUtility.loadedModIdsList ?? []; + var names = ScribeMetaHeaderUtility.loadedModNamesList ?? []; + + return ids + .Select((id, i) => new ModEntry(id, i < names.Count ? names[i] : id, showVersions ? ModVersions.Saved(id) : null)) + .ToList(); + } + + private List RunningMods() => + LoadedModManager.RunningMods + .Select(mod => new ModEntry(mod.PackageId, mod.Name, showVersions ? ModVersions.Running(mod) : null, mod.FolderName)) + .ToList(); +} diff --git a/Source/ModMismatchFormatter/UI/Palette.cs b/Source/ModMismatchFormatter/UI/Palette.cs new file mode 100644 index 0000000..5bda492 --- /dev/null +++ b/Source/ModMismatchFormatter/UI/Palette.cs @@ -0,0 +1,36 @@ +using Madeline.ModMismatchFormatter.Core; +using UnityEngine; + +namespace Madeline.ModMismatchFormatter.UI; + +internal static class Palette +{ + public static readonly Color Background = new(0.11f, 0.11f, 0.11f, 1f); + public static readonly Color EmptyCell = new(0.07f, 0.07f, 0.07f, 1f); + public static readonly Color Removed = new(0.29f, 0.09f, 0.09f, 1f); + public static readonly Color Added = new(0.29f, 0.33f, 0.19f, 1f); + // As dark as the other state colors so white text stays readable on it. + public static readonly Color VersionChanged = new(0.40f, 0.31f, 0.12f, 1f); + public static readonly Color Moved = new(0.16f, 0.24f, 0.36f, 1f); + + /// Secondary text on a state-colored cell, where plain gray would blend in. + public static readonly Color TextOnState = new(1f, 1f, 1f, 0.8f); + + public static Color For(RowState state) => state switch + { + RowState.Added => Added, + RowState.Removed => Removed, + RowState.VersionChanged => VersionChanged, + RowState.MovedFrom or RowState.MovedTo => Moved, + _ => Background, + }; + + public static string Mark(RowState state) => state switch + { + RowState.Added => "+", + RowState.Removed => "-", + RowState.VersionChanged => "?", + RowState.MovedFrom or RowState.MovedTo => "~", + _ => "", + }; +} diff --git a/Source/Properties/AssemblyInfo.cs b/Source/Properties/AssemblyInfo.cs deleted file mode 100644 index b98c9da..0000000 --- a/Source/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// 어셈블리에 대한 일반 정보는 다음 특성 집합을 통해 -// 제어됩니다. 어셈블리와 관련된 정보를 수정하려면 -// 이러한 특성 값을 변경하세요. -[assembly: AssemblyTitle("ModMisMatchWindowPatch")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("ModMisMatchWindowPatch")] -[assembly: AssemblyCopyright("Copyright © 2019")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// ComVisible을 false로 설정하면 이 어셈블리의 형식이 COM 구성 요소에 -// 표시되지 않습니다. COM에서 이 어셈블리의 형식에 액세스하려면 -// 해당 형식에 대해 ComVisible 특성을 true로 설정하세요. -[assembly: ComVisible(false)] - -// 이 프로젝트가 COM에 노출되는 경우 다음 GUID는 typelib의 ID를 나타냅니다. -[assembly: Guid("b7f7d989-b085-4b3f-9423-462a50d77c02")] - -// 어셈블리의 버전 정보는 다음 네 가지 값으로 구성됩니다. -// -// 주 버전 -// 부 버전 -// 빌드 번호 -// 수정 버전 -// -// 모든 값을 지정하거나 아래와 같이 '*'를 사용하여 빌드 번호 및 수정 번호를 -// 기본값으로 할 수 있습니다. -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Source/Refactored/ColorPresets.cs b/Source/Refactored/ColorPresets.cs deleted file mode 100644 index 4cc654e..0000000 --- a/Source/Refactored/ColorPresets.cs +++ /dev/null @@ -1,12 +0,0 @@ -using UnityEngine; - -namespace Madeline.ModMismatchFormatter -{ - public static class ColorPresets - { - public static Color Red = new Color(0.29f, 0.09f, 0.09f, 1); - public static Color Green = new Color(0.29f, 0.33f, 0.19f, 1); - public static Color Background = new Color(0.11f, 0.11f, 0.11f, 1); - public static Color Yellow = new Color32(178, 144, 83, 255); - } -} \ No newline at end of file diff --git a/Source/Refactored/MetaHeaderUtility.cs b/Source/Refactored/MetaHeaderUtility.cs deleted file mode 100644 index d47a68d..0000000 --- a/Source/Refactored/MetaHeaderUtility.cs +++ /dev/null @@ -1,252 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Reflection.Emit; -using Verse; -using System.Text; -using System.Xml.Linq; -using System.IO; -using System.Linq; - -namespace Madeline.ModMismatchFormatter -{ - public static class MetaHeaderUtility - { - #region Harmony Patch - internal static bool UpdateLastAccessedSaveFileInLoadSelection(string path) - { - LastAccessedSaveFilePathInLoadSelection = path; - return true; - } - #endregion - public static string LastAccessedSaveFilePathInLoadSelection { get; private set; } // 게임 시작 전, 세이브 파일 선택할때 마지막으로 접근한 파일 경로 - private static string CurrnetUsingFilePath { get; set; } - static readonly string MOD_META_DATAS = "modMetaDatas"; - static readonly string MOD_IDENTIFIER_NODENAME = "Identifier"; - static readonly string VERSION_NODENAME = "version"; - static XDocument xdoc; - static string SaveFilePathCache; - static Dictionary versionDict = new Dictionary(); - static bool isVersionDictDirty = false; - public static void BeginReading(string filePath) - { - if(xdoc != null || string.IsNullOrEmpty(CurrnetUsingFilePath)) - { - Log.Warning("xdoc is still open. force-closing"); - xdoc = null; - CurrnetUsingFilePath = null; - } - SimpleLog.Log($"Trying to load {filePath}"); - xdoc = XDocument.Load(filePath); - CurrnetUsingFilePath = filePath; - } - - public static void EndReading() - { - xdoc.Save(CurrnetUsingFilePath); - SimpleLog.Log("Saving xdoc"); - xdoc = null; - CurrnetUsingFilePath = null; - isVersionDictDirty = true; - } - - public static string GetVersion(string Identifier) - { - string version = "Unknown"; - if(xdoc == null) - throw new Exception($"invoke BeginReading before calling {nameof(RebuildVersionDict)}"); - - if(isVersionDictDirty) - { - RebuildVersionDict(); - } - - if(versionDict.TryGetValue(Identifier, out var value)) - version = value; - - return version; - } - - static void RebuildVersionDict() - { - SimpleLog.Log("Rebuilding VersionDict"); - isVersionDictDirty = false; - versionDict.Clear(); - - if(xdoc == null) - { - Log.Error($"invoke BeginReading before calling {nameof(RebuildVersionDict)}"); - return; - } - - try - { - var ModMetaHeaders = xdoc.Root.Element("meta")?.Element(MOD_META_DATAS); - if(ModMetaHeaders == null) - return; - - var elements = ModMetaHeaders.Elements(); - foreach(var node in elements) - { - var Identifier = node.Element(MOD_IDENTIFIER_NODENAME)?.Value; - var Version = node.Element(VERSION_NODENAME)?.Value; - - if(Identifier == null || Version == null) // variable sanity check - continue; - - versionDict[Identifier] = Version; - - SimpleLog.Log($"Set {Identifier} {Version}"); - } - } - catch(Exception ex) - { - Log.Error($"Exception while rebuilding VersionDict, exception : {ex.Message}"); - return; - } - } - - public static void SetVersions(List metaHeaders) - { - if(xdoc == null) - throw new Exception("Invoke BeginReading before using this method"); - - var meta = xdoc.Root.Element("meta"); - var modVersionsNode = GetElementWithForceCreation(meta, MOD_META_DATAS); - modVersionsNode.RemoveAll(); - - if(meta.Element(MOD_META_DATAS) == null) - throw new Exception("F"); - - for(int i = 0; i < metaHeaders.Count; i++) - { - var data = metaHeaders[i]; - var liNode = new XElement("li"); - liNode.Add(new XElement(MOD_IDENTIFIER_NODENAME) { Value = data.Identifier }); - liNode.Add(new XElement(VERSION_NODENAME) { Value = data.Version }); - SimpleLog.Log($"Writing {data.Identifier} {data.Version} to versionNode"); - modVersionsNode.Add(liNode); - } - } - - static XElement GetElementWithForceCreation(XElement node, string name) - { - var result = node.Element(name); - if(result == null) - node.Add(new XElement(name)); - - result = node.Element(name); - return result; - } - - public static bool isVersionSame(List saveMods, List activeMods) - { // saveMods와 activeMods는 길이가 무조건 같음 - SimpleLog.Log("Checking version difference..."); - if(saveMods.Count != activeMods.Count) - { - string ListOfSaveMods = string.Join(", ", saveMods.Select(mod => mod.Identifier)); - string ListOfActiveMods = string.Join(", ", activeMods.Select(mod => mod.Identifier)); - - StringBuilder exceptionMessage = new StringBuilder(); - - exceptionMessage.AppendLine("saveMods length and activeMods length are different."); - exceptionMessage.AppendLine($"Save Mod Count : {saveMods.Count} Active Mods Count : {activeMods.Count}"); - exceptionMessage.AppendLine("Active Mods List"); - exceptionMessage.AppendLine(ListOfActiveMods); - exceptionMessage.AppendLine("Save Mods List"); - exceptionMessage.AppendLine(ListOfSaveMods); - - throw new Exception(exceptionMessage.ToString()); - } - - int length = saveMods.Count; - for(int i = 0; i < length; i++) - { - var save = saveMods[i]; - var active = activeMods[i]; - - SimpleLog.Log($"Comparing {save.Identifier} ({save.Version}) - {active.Identifier} ({active.Version})"); - - if(save.Identifier != active.Identifier) - { - string ListOfSaveMods = string.Join(", ", saveMods.Select(mod => mod.Identifier)); - string ListOfActiveMods = string.Join(", ", activeMods.Select(mod => mod.Identifier)); - - StringBuilder exceptionMessage = new StringBuilder(); - - exceptionMessage.AppendLine($"two mod's identifier is different while comparing version, save mod identifier : {save.Identifier}, active mod identifier : {active.Identifier}"); - exceptionMessage.AppendLine($"Save Mod Count : {saveMods.Count} Active Mods Count : {activeMods.Count}"); - exceptionMessage.AppendLine("Active Mods List"); - exceptionMessage.AppendLine(ListOfActiveMods); - exceptionMessage.AppendLine("Save Mods List"); - exceptionMessage.AppendLine(ListOfSaveMods); - - throw new Exception(exceptionMessage.ToString()); - } - - if(save.Version != active.Version) - return false; - } - - return true; - } - - public static void StoreLastSavedFilePath(string path) - { - SaveFilePathCache = path; - } - - public static void UpdateModVersionMetaHeader() - { - string rawFilePath = SaveFilePathCache; - string filePath = GenFilePaths.FilePathForSavedGame(rawFilePath); - MetaHeaderUtility.BeginReading(filePath); - List metaHeaders = new List(); - foreach(var modContentPack in LoadedModManager.RunningMods) - { - // var metadata = modContentPack.GetMetaData(); - var version = MetaHeaderUtility.GetVersionFromManifestFile(modContentPack); - metaHeaders.Add(new ModMetaHeader() { Identifier = modContentPack.PackageId, Version = version }); - SimpleLog.Log($"Add metadata to metaHeaders list : {modContentPack.PackageId}, {version}"); - } - MetaHeaderUtility.SetVersions(metaHeaders); - MetaHeaderUtility.EndReading(); - } - - public static string GetVersionFromManifestFile(ModContentPack pack) - { - string MenifestFilePath = Path.Combine(pack.RootDir, Path.Combine("About", "Manifest.xml")); - string version = "Unknown"; - if(File.Exists(MenifestFilePath)) - { - try - { - XDocument Xdoc = XDocument.Load(MenifestFilePath); - var versionNode = Xdoc.Root.Element("version"); - version = versionNode.Value ?? "Unknown"; - } - catch(Exception ex) - { - string error = $"exception in GetVersionFromManifestFile line\n{ex.ToString()}"; - Log.Error(error); - } - } - else - { - NoManifestFileInModWarning(pack.Name); - } - return version; - } - - static void NoManifestFileInModWarning(string modName) - { - - } - } - - public struct ModMetaHeader - { - public string Identifier; - public string Version; - } -} \ No newline at end of file diff --git a/Source/Refactored/ModContentPackExtension.cs b/Source/Refactored/ModContentPackExtension.cs deleted file mode 100644 index 259e5c8..0000000 --- a/Source/Refactored/ModContentPackExtension.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Verse; -using System.Collections.Generic; -using System.Linq; -using System; - -namespace Madeline.ModMismatchFormatter -{ - public static class ModContentPackExtension - { - static Dictionary pairs = new Dictionary(); - public static ModMetaData GetMetaData(this ModContentPack pack) - { - throw new NotImplementedException(); - if(!(pairs.ContainsKey(pack))) - { - SetModContentPackInDict(pack); - } - - return pairs[pack]; - } - - static void SetModContentPackInDict(ModContentPack pack) - { - throw new NotImplementedException(); - var result = from meta in ModsConfig.ActiveModsInLoadOrder - where meta.Name == pack.Name - select meta; - pairs[pack] = result.First(); - } - - public static List GetModsFromActive(bool useVersionChecking = false) - { - List activeMods = new List(); - foreach(var RimworldMod in LoadedModManager.RunningMods) - { - var mod = new Mod(RimworldMod.PackageId, RimworldMod.Name, RimworldMod.loadOrder); - if(useVersionChecking) - mod.Version = MetaHeaderUtility.GetVersionFromManifestFile(RimworldMod); - activeMods.Add(mod); - } - return activeMods; - } - public static List GetModsFromSave(bool CheckModVersion = false) - { - List saveMods = null; - if (ScribeMetaHeaderUtility.loadedModNamesList != null && ScribeMetaHeaderUtility.loadedModIdsList != null) - { - CheckSaveHeaderValid(); - saveMods = ReadModsFromSaveHeader(CheckModVersion); - } - return saveMods; - } - - static void CheckSaveHeaderValid() - { - var modNamesCount = ScribeMetaHeaderUtility.loadedModNamesList.Count; - var modIdsCount = ScribeMetaHeaderUtility.loadedModIdsList.Count; - if(modNamesCount != modIdsCount) - throw new Exception("Mod Name length and ModID length in savefile is not matched"); - } - - static List ReadModsFromSaveHeader(bool readModVersion) - { - List saveMods = new List(); - MetaHeaderUtility.BeginReading(MetaHeaderUtility.LastAccessedSaveFilePathInLoadSelection); - for(int i = 0; i < ScribeMetaHeaderUtility.loadedModNamesList.Count; i++) - { - var mod = new Mod(ScribeMetaHeaderUtility.loadedModIdsList[i], ScribeMetaHeaderUtility.loadedModNamesList[i], i); - string identifier = mod.Identifier; - if(readModVersion) - mod.Version = MetaHeaderUtility.GetVersion(identifier); - saveMods.Add(mod); - } - MetaHeaderUtility.EndReading(); - return saveMods; - } - } -} \ No newline at end of file diff --git a/Source/Refactored/ModListerElementRenderer.cs b/Source/Refactored/ModListerElementRenderer.cs deleted file mode 100644 index bec5a29..0000000 --- a/Source/Refactored/ModListerElementRenderer.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System; -using System.Collections.Generic; -using UnityEngine; -using Verse; -using System.Linq; - -namespace Madeline.ModMismatchFormatter -{ - public class ModListerElementRenderer : iModListerElementRenderer - { - public GUIStyle ModStateStyle { get; set; } - public GUIStyle ModDescriptionStyle { get; set; } - public GUIStyle ModVersionStyle { get; set; } - public float LeftListCenterPos { get; protected set; } - public float RightListCenterPos { get; protected set; } - public bool useVersionChecking { get; set; } - enum ModState - { - Add, - Remove, - VersionChange, - None - } - enum VersionState - { - Upversion, - DownVersion, - None - } - public ModListerElementRenderer() - { - InitializeDefaultFont(); - } - - void InitializeDefaultFont() - { - ModDescriptionStyle = new GUIStyle(); - ModDescriptionStyle.fontSize = 15; - ModDescriptionStyle.alignment = TextAnchor.MiddleLeft; - ModDescriptionStyle.normal.textColor = Color.white; - ModDescriptionStyle.onNormal.textColor = Color.white; - ModDescriptionStyle.hover.textColor = Color.white; - ModDescriptionStyle.onHover.textColor = Color.white; - - ModStateStyle = new GUIStyle(); - ModStateStyle.fontSize = ModDescriptionStyle.fontSize; - ModStateStyle.alignment = TextAnchor.MiddleLeft; - ModStateStyle.normal.textColor = Color.white; - ModStateStyle.onNormal.textColor = Color.white; - ModStateStyle.hover.textColor = Color.white; - ModStateStyle.onHover.textColor = Color.white; - - - ModVersionStyle = new GUIStyle(); - ModVersionStyle.fontSize = 11; - ModVersionStyle.alignment = TextAnchor.MiddleLeft; - ModVersionStyle.normal.textColor = Color.white; - ModVersionStyle.onNormal.textColor = Color.white; - ModVersionStyle.hover.textColor = Color.white; - ModVersionStyle.onHover.textColor = Color.white; - - } - - public void RenderSaveMod(Rect left, ModPair pair) - { - var saveMod = pair.Save; - if(saveMod.isPlaceHolder) - RenderPlaceHolderMod(left); - else if(pair.Loaded.isPlaceHolder) - RenderSingleMod(left, saveMod, ModState.Remove); - else - RenderSingleMod(left, saveMod, ModState.None); - } - - public void RenderActiveMod(Rect right, ModPair pair) - { - if(pair.Loaded.isPlaceHolder) - RenderPlaceHolderMod(right); - else if(pair.Save.isPlaceHolder) - RenderSingleMod(right, pair.Loaded, ModState.Add); - else if(useVersionChecking && pair.Loaded.isVersionDifferent(pair.Save)) - RenderSingleMod(right, pair.Loaded, ModState.VersionChange); - else - RenderSingleMod(right, pair.Loaded, ModState.None); - } - - void RenderPlaceHolderMod(Rect root) - { - Widgets.DrawBoxSolid(root, ColorPresets.Background); - } - - void RenderSingleMod(Rect root, Mod mod, ModState modState) - { - Color bgColor; - if(modState == ModState.Add) - bgColor = ColorPresets.Green; - else if(modState == ModState.Remove) - bgColor = ColorPresets.Red; - else if(useVersionChecking && modState == ModState.VersionChange) - bgColor = ColorPresets.Yellow; - else - bgColor = ColorPresets.Background; - - Widgets.DrawBoxSolid(root, bgColor); - - float leftBoxWidth = 16f; - Rect ModStateRect = new Rect(root.x, root.y, leftBoxWidth, root.height); - // padding - switch (modState) - { - case ModState.Add: - ModStateRect.x += 4; - break; - case ModState.Remove: - ModStateRect.x += 6; - ModStateRect.y -= 2; - break; - } - - RenderModState(ModStateRect, modState); - - Rect DescriptionRect = new Rect(ModStateRect.xMax, root.y, root.width - ModStateRect.width, root.height); - RenderModDescriptionAndVersion(DescriptionRect, mod); - } - void RenderModDescriptionAndVersion(Rect Root, Mod mod) - { - string version = mod.Version ?? "Unknown"; - version = $"ver : {version}"; - string description = $"{mod.ModName}"; - - var DescriptionTextHeight = ModDescriptionStyle.CalcSize(new GUIContent(description)).y; - var VersionTextHeight = ModVersionStyle.CalcSize(new GUIContent(version)).y; - - float minimumHeight; - if(useVersionChecking) - minimumHeight = DescriptionTextHeight + VersionTextHeight; - else - minimumHeight = DescriptionTextHeight; - - if(minimumHeight > Root.height) - throw new Exception($"Root rect's height is smaller than minimum size {minimumHeight}"); - - // HACK: somehow text alignment is not correct so move label 3 pixel down - Rect DescriptionRect = new Rect(Root.x, Root.y + 3, Root.width, DescriptionTextHeight); - GUI.Label(DescriptionRect, description, ModDescriptionStyle); - - if(useVersionChecking) - { //TODO - mod을 보고 versionStyle를 넣어주자 - Rect VersionRect = new Rect(Root.x, Root.yMax - VersionTextHeight, Root.width, VersionTextHeight); - RenderVersionText(VersionRect, version, ModVersionStyle, VersionState.None); - } - } - - void RenderVersionText(Rect versionRect, string content, GUIStyle style, VersionState state) - { - //TODO - VersionState에 맞는 색깔 넣어주기 - GUI.Label(versionRect, content, style); - } - - void SetColor(GUIStyle style, Color color) - { // FIXME - 색깔이 고정되어있음 - style.alignment = TextAnchor.MiddleLeft; - style.normal.textColor = Color.white; - style.onNormal.textColor = Color.white; - style.hover.textColor = Color.white; - style.onHover.textColor = Color.white; - } - - void RenderModState(Rect root, ModState state) - { - GUIStyle style = ModStateStyle; - - string content = string.Empty; - if(state == ModState.Add) - content = "+"; - else if(state == ModState.Remove) - content = "-"; - else if(state == ModState.VersionChange) - content = "?"; - else if(state == ModState.None) - content = " "; - else - throw new Exception($"ModState {state.ToString()} is not supported yet. please contact to modder."); - - GUI.Label(root, content, style); - } - } -} \ No newline at end of file diff --git a/Source/Refactored/ModMismatchFormatter.cs b/Source/Refactored/ModMismatchFormatter.cs deleted file mode 100644 index ae83359..0000000 --- a/Source/Refactored/ModMismatchFormatter.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Verse; - -namespace Madeline.ModMismatchFormatter -{ - public class ModMismatchFormatter : Verse.Mod - { - static ModMismatchFormatter instance; - Settings settings; - internal static bool useVersionCompare - { - get { return instance.settings.useVersionCompare; } - } - internal static bool writeMetaToSave - { - get { return instance.settings.saveVersionToSaveFile; } - } - public ModMismatchFormatter(ModContentPack pack) : base(pack) - { - this.settings = GetSettings(); - instance = this; - } - - public override void DoSettingsWindowContents(UnityEngine.Rect inRect) - { - Listing_Standard listingStandard = new Listing_Standard(); - listingStandard.Begin(inRect); - - bool useVersionCompare = this.settings.useVersionCompare; - string description = "useVersionCompareDescription".Translate(); //"Enable Version checking between saved file and current version. check steam workshop for the detailed description."; - listingStandard.CheckboxLabeled("EnableVersionChecking".Translate(), ref useVersionCompare, description); - - bool writeMetaToSave = this.settings.saveVersionToSaveFile; - string writeMetaDescription = "writeMetaDescription".Translate(); - listingStandard.CheckboxLabeled("EnableWritingCustomVersionHeader".Translate(), ref writeMetaToSave, writeMetaDescription); - - this.settings.useVersionCompare = useVersionCompare; - this.settings.saveVersionToSaveFile = writeMetaToSave; - - listingStandard.End(); - - base.DoSettingsWindowContents(inRect); - } - - public override string SettingsCategory() - { - return "SettingsLabel".Translate(); - } - } -} \ No newline at end of file diff --git a/Source/Refactored/ModMismatchWindow.cs b/Source/Refactored/ModMismatchWindow.cs deleted file mode 100644 index f71e13b..0000000 --- a/Source/Refactored/ModMismatchWindow.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Collections.Generic; -using Verse; -using System.Linq; -using System; -using UnityEngine; - -namespace Madeline.ModMismatchFormatter -{ - public class ModMismatchWindow : Window - { - iModListerElementRenderer renderer; - iOrderFormatter formatter; - List pairs; - Action confirmAction; - Vector2 scrollPosition = Vector2.zero; - bool useVersionChecking; - public override Vector2 InitialSize - { - get { return new Vector2(650, 700); } - } - - public ModMismatchWindow(iModListerElementRenderer renderer, iOrderFormatter formatter, Action confirmAction, bool useVersionChecking) - { // 3항 연산자는..?? - this.renderer = renderer; - this.formatter = formatter; - this.confirmAction = confirmAction; - this.doCloseX = true; - this.useVersionChecking = useVersionChecking; - } - - public override void PreOpen() - { - base.PreOpen(); - InitializePair(); - SetDrawerFonts(); - } - - void SetDrawerFonts() - { - //renderer.ModDescriptionStyle = Text.CurFontStyle; - //renderer.ModStateStyle = Text.CurFontStyle; - ////renderer.ModVersionStyle = new GUIStyle(Text.CurFontStyle) { fontSize = Text.CurFontStyle.fontSize - 3 }; - } - - void InitializePair() - { - List activeMods = ModContentPackExtension.GetModsFromActive(useVersionChecking); - List saveMods = ModContentPackExtension.GetModsFromSave(useVersionChecking); - var result = formatter.GetFormattedModPairs(saveMods, activeMods); - pairs = result.ToList(); - } - - public override void DoWindowContents(Rect canvas) - { - int ItemCount = pairs.Count + 1; - //윗쪽 타이틀 설정 - Rect TitleRect = new Rect(canvas.xMin, canvas.yMin, canvas.width, 60f); - Widgets.Label(TitleRect, "ModsMismatchWarningTitle".Translate()); - - //1개 모드 사이즈 - float RectWidth = canvas.width / 2 - 40; - Vector2 SingleItemSize = new Vector2(RectWidth, 34); - if(!useVersionChecking) - SingleItemSize.y = 24f; - Rect SingleModItemRect = new Rect(Vector2.zero, SingleItemSize); - - //막대기 사이즈 - Rect Bar = new Rect(0, 0, RectWidth, 2f); - - //하위 버튼의 크기 - Vector2 ButtonSize = new Vector2(RectWidth - 50f, 40f); - - //TODO - 클래스로 분류해내기 - //좌측(Save) - GUI.contentColor = Color.white; - Rect LeftSaveRect = new Rect((canvas.width / 2) - RectWidth, TitleRect.yMax, RectWidth, canvas.height - TitleRect.height - 60); // 이걸 가지고 다른 모든 상자들의 길이를 측정함 - Rect LeftinSaveRect = new Rect(LeftSaveRect.xMin, LeftSaveRect.yMin, LeftSaveRect.width - 18f, SingleModItemRect.height * ItemCount + Bar.height * (ItemCount - 1)); - Widgets.DrawBoxSolid(LeftSaveRect, ColorPresets.Background); - - Rect LeftTitle = new Rect(LeftSaveRect.xMin, LeftSaveRect.yMin - 26f, RectWidth - 18f, 26f); // 18f는 스크롤바 크기 - Widgets.Label(LeftTitle, "SaveModListTitle".Translate()); - - //우측(Active) - Rect RightActiveRect = new Rect(LeftSaveRect.xMax + 16f, LeftSaveRect.yMin, LeftSaveRect.width, LeftSaveRect.height); // LeftSaveRect에 종속 - Rect RightinActiveRect = new Rect(RightActiveRect.xMin, RightActiveRect.yMin, RightActiveRect.width, LeftinSaveRect.height); // RightActiveRect에 종속 - Widgets.DrawBoxSolid(RightActiveRect, ColorPresets.Background); - - Rect RightTitle = new Rect(RightActiveRect.xMin, RightActiveRect.yMin - 26f, RectWidth - 18f, 26f); - Widgets.Label(RightTitle, "ActiveModListTitle".Translate()); - //개수 안맞으면 PlaceHolder 넣어줘야함 - - - Widgets.BeginScrollView(LeftSaveRect, ref scrollPosition, LeftinSaveRect, true); - SingleModItemRect.position = new Vector2(LeftinSaveRect.x, LeftinSaveRect.y); // 위치 초기화 - Bar.position = new Vector2(SingleModItemRect.x, SingleModItemRect.yMax + 1f); - Bar.width = SingleModItemRect.xMax - SingleModItemRect.xMin; - foreach(var pair in pairs) - { - renderer.RenderSaveMod(SingleModItemRect, pair); - Bar.y = SingleModItemRect.yMax; - RenderSeperationLine(Bar); - SingleModItemRect.y = Bar.yMax; - - } - Widgets.EndScrollView(); - - Widgets.BeginScrollView(RightActiveRect, ref scrollPosition, RightinActiveRect, false); - SingleModItemRect.position = new Vector2(RightinActiveRect.xMin, RightinActiveRect.yMin); // 위치 초기화 - Bar.position = new Vector2(SingleModItemRect.x, SingleModItemRect.yMax + 1f); - Bar.width = SingleModItemRect.xMax - SingleModItemRect.xMin; - foreach(var pair in pairs) - { - renderer.RenderActiveMod(SingleModItemRect, pair); - Bar.y = SingleModItemRect.yMax; - RenderSeperationLine(Bar); - SingleModItemRect.y = Bar.yMax; - } - Widgets.EndScrollView(); - - - float ButtonXPos = LeftSaveRect.xMin + ( (LeftSaveRect.width - ButtonSize.x) / 2 ) - 7f; // micro control. I'm lack of math skill. - float ButtonYPos = canvas.yMax - 10f - ButtonSize.y; - Rect LoadFromSaveButton = new Rect(new Vector2(ButtonXPos, ButtonYPos), ButtonSize); - RenderLoadFromSaveButton(LoadFromSaveButton); - - ButtonXPos = RightActiveRect.xMin + ( (RightActiveRect.width - ButtonSize.x) / 2 ) - 7f; - Rect LoadAnywayButton = new Rect(new Vector2(ButtonXPos, ButtonYPos), ButtonSize); - RenderLoadAnywayButton(LoadAnywayButton); - } - - void RenderSeperationLine(Rect rect) - { - Widgets.DrawBoxSolid(rect, Color.grey); - } - - void RenderLoadAnywayButton(Rect LoadAnywayRect) - { - if (Widgets.ButtonText(LoadAnywayRect, "LoadAnyway".Translate())) - { - confirmAction(); - } - } - - void RenderLoadFromSaveButton(Rect LoadFromSaveRect) - { - if (Widgets.ButtonText(LoadFromSaveRect, "ChangeLoadedMods".Translate())) - { - if (Current.ProgramState == ProgramState.Entry) - { - ModsConfig.SetActiveToList(ScribeMetaHeaderUtility.loadedModIdsList); - } - ModsConfig.SaveFromList(ScribeMetaHeaderUtility.loadedModIdsList); - ModsConfig.RestartFromChangedMods(); - } - } - } -} \ No newline at end of file diff --git a/Source/Refactored/Settings.cs b/Source/Refactored/Settings.cs deleted file mode 100644 index 3d4218e..0000000 --- a/Source/Refactored/Settings.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Collections.Generic; -using Verse; - -namespace Madeline.ModMismatchFormatter -{ - public class Settings : ModSettings - { - public bool useVersionCompare; - public bool saveVersionToSaveFile; - public override void ExposeData() - { - base.ExposeData(); - Scribe_Values.Look(ref useVersionCompare, "useVersionCompare", true, true); - Scribe_Values.Look(ref saveVersionToSaveFile, "saveVersionToSaveFile", true, true); - } - } -} \ No newline at end of file diff --git a/Source/Refactored/SimpleLog.cs b/Source/Refactored/SimpleLog.cs deleted file mode 100644 index f14e75c..0000000 --- a/Source/Refactored/SimpleLog.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Linq; -using System.Collections.Generic; -namespace Madeline.ModMismatchFormatter -{ - - public static class SimpleLog - { - public static bool PrintLog = false; - public static void Log(string msg) - { - if(PrintLog) - Verse.Log.Message(msg); - } - } - -} \ No newline at end of file diff --git a/Source/Refactored/UITester.cs b/Source/Refactored/UITester.cs deleted file mode 100644 index e8fbc34..0000000 --- a/Source/Refactored/UITester.cs +++ /dev/null @@ -1,228 +0,0 @@ -using UnityEngine; -using System.Collections.Generic; -using System.Linq; - -namespace Madeline.ModMismatchFormatter -{ - public class UITester// : MonoBehaviour - { - iOrderFormatter formatter; - //iCompareRectRenderer renderer; - bool initialized = false; - List result; - private void Awake() - { - //renderer = new CompareRectRenderer(); - //formatter = new ModPresenter(); - //Test(); - } - - private void OnGUI() - { - if(initialized) - { - //renderer.Render(new Rect(0, 0, 1200, 800), result); - } - } - - void Test() - { - string save = @"Core -1567113677 -1848471817 -1508850027 -818773962 -1507748539 -839005762 -932008009 -753498552 -1180826364 -1837246563 -725219116 -712141500 -715565817 -848972794 -946390822 -935732834 -962732083 -836308268 -1496570408 -1188485723 -1185269798 -1185906628 -1185274792 -1185276569 -1244299809 -1185281838 -1185265132 -1192504741 -1185307686 -1718190143 -1718191613 -1880253632 -1845154007 -1836382920 -1814383360 -1113137502 -1541721856 -1055485938 -1658356618 -1590579367 -1542364782 -1836900626 -1895364938 -1823540489 -1849183875 -1849184079 -1895364782 -1700683323 -1895364585 -1865597143 -1582570547 -761421485 -1833163132 -1558161673 -1907283935 -1907283872 -1906780517 -1854607105 -1854610483 -1819425140 -1888705256 -1914064942 -1814987817 -1814988282 -1917061124 -826998327 -1321849735 -850429707 -746425621 -1501832876 -1424338139 -1843574431 -1446523594 -1544626521 -1591142767 -1346064449 -1537869190 -725130005 -1909914131 -1539312975 -1713343013 -1908437382"; - - string active = @"Core -1567113677 -1848471817 -1508850027 -818773962 -1507748539 -839005762 -932008009 -753498552 -1180826364 -1837246563 -725219116 -712141500 -715565817 -848972794 -946390822 -935732834 -836308268 -1496570408 -1188485723 -1185269798 -1185274792 -1718190143 -1185276569 -1718191613 -962732083 -1185307686 -1185265132 -1185906628 -1055485938 -1185281838 -1244299809 -1508292711 -1192504741 -1211694919 -1845154007 -1814383360 -1578693166 -1854376306 -1541721856 -1632800575 -1658356618 -865497369 -1590579367 -1113137502 -1542364782 -1836900626 -1895364938 -1823540489 -1849183875 -1849184079 -1700683323 -1895364585 -1865597143 -1582570547 -1558161673 -1907283935 -1817690190 -1895364782 -1907283872 -832333531 -1906780517 -1854607105 -1917061124 -735106432 -1819425140 -1888705256 -1854610483 -1814987817 -1814988282 -850429707 -1914064942 -746425621 -1843574431 -1544626521 -1591142767 -1346064449 -1537869190 -1216999901 -1697864590 -1865654125 -725130005 -1909914131 -818322128 -1539312975 -1713343013 -1446523594 -1908437382 -"; - List saveMods = new List(); - List activeMods = new List(); - using(System.IO.StringReader reader = new System.IO.StringReader(save)) - { - string line; - while((line = reader.ReadLine()) != null) - { - saveMods.Add(new Mod(line, line) { Identifier = line }); - } - } - - using (System.IO.StringReader reader = new System.IO.StringReader(active)) - { - string line; - while ((line = reader.ReadLine()) != null) - { - activeMods.Add(new Mod(line, line) { Identifier = line }); - } - } - result = formatter.GetFormattedModPairs(saveMods, activeMods).ToList(); - initialized = true; - } - - - } -} \ No newline at end of file diff --git a/Source/Refactored/iModListerElementRenderer.cs b/Source/Refactored/iModListerElementRenderer.cs deleted file mode 100644 index 51e6521..0000000 --- a/Source/Refactored/iModListerElementRenderer.cs +++ /dev/null @@ -1,16 +0,0 @@ -using UnityEngine; -using System.Collections.Generic; - -namespace Madeline.ModMismatchFormatter -{ - public interface iModListerElementRenderer - { - GUIStyle ModStateStyle { get; set; } - GUIStyle ModDescriptionStyle { get; set; } - GUIStyle ModVersionStyle { get; set; } - bool useVersionChecking { set; } - - void RenderActiveMod(Rect right, ModPair pair); - void RenderSaveMod(Rect left, ModPair pair); - } -} \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +}