diff --git a/.github/workflows/project-refrain-trainer-ci.yml b/.github/workflows/project-refrain-trainer-ci.yml new file mode 100644 index 0000000..b671827 --- /dev/null +++ b/.github/workflows/project-refrain-trainer-ci.yml @@ -0,0 +1,45 @@ +name: "[Project] Refrain Trainer CI" + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: windows-latest + timeout-minutes: 10 + defaults: + run: + shell: powershell + working-directory: others/game-tools/refrain-trainer + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build and check owned native fixture + run: | + .\check.cmd + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Package and verify archive + run: .\package.ps1 + + - name: Upload preview and validation output + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: refrain-trainer-windows-preview + if-no-files-found: error + path: | + others/game-tools/refrain-trainer/dist/*.zip + others/game-tools/refrain-trainer/dist/*.sha256 + others/game-tools/refrain-trainer/test-output/native-test-report.txt + others/game-tools/refrain-trainer/test-output/trainer-preview.png diff --git a/README.md b/README.md index c4565b1..fd97301 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ Personal monorepo for apps, packages, local Python projects, and assorted experi | `apps/youtube-auto-resume-extension` | Chromium Manifest V3 YouTube playback assistant. | — | | `packages/*` | Reusable frontend packages. | — | | `workbench/*` | Local Python utilities and small projects. | — | -| `others/*` | Others. | — | +| `others/*` | Standalone game tools, userscripts, interface studies, and archived experiments. | — | + +Archived tools include [Refrain Trainer](./others/game-tools/refrain-trainer/README.md), a one-off Chinese Windows trainer for Labyrinth of Refrain. See the [Others index](./others/README.md) for the full inventory. ## Related repositories diff --git a/apps/landing/src/config/projects/others.ts b/apps/landing/src/config/projects/others.ts index 77d5235..65b6122 100644 --- a/apps/landing/src/config/projects/others.ts +++ b/apps/landing/src/config/projects/others.ts @@ -1,6 +1,18 @@ import type { CatalogProject } from "../../types/project" export const otherProjectEntries = [ + { + title: "Refrain Trainer", + path: "others/game-tools/refrain-trainer", + updatedAt: "2026-10-18T17:32:14+08:00", + summary: + "An archived, one-off Chinese Windows trainer for Labyrinth of Refrain, with one-hit kills, guaranteed Witch's Crest drops, and optional Legendary equipment.", + label: "Game trainer · Preview", + lifecycle: "archived", + kind: "other", + presentation: "catalog", + section: "others", + }, { title: "Google AI Mode Aurora Study", path: "others/interface-studies/google-ai-mode-aurora", diff --git a/docs/repository-rules.md b/docs/repository-rules.md index b90b654..6de1166 100644 --- a/docs/repository-rules.md +++ b/docs/repository-rules.md @@ -320,6 +320,7 @@ Current workflow ownership: | `group-apps-packages-ci.yml` | Baseline dependency audit, check, and build for apps and packages | | `group-workbench-python-ci.yml` | Workbench Ruff, registered tests, and dependency audits | | `project-liminal-drift-ci.yml` | Liminal Drift project and browser validation | +| `project-refrain-trainer-ci.yml` | Manual-only reproduction of the archived Windows trainer build, native checks, and ZIP | | `project-shika-ci.yml` | Shika static, command, migration, schema, and production validation | | `project-youtube-auto-resume-ci.yml` | YouTube userscript unit, build-drift, and browser validation | | `project-youtube-auto-resume-extension-ci.yml` | YouTube extension build and browser validation | diff --git a/others/README.md b/others/README.md index 371fed8..1c31ef7 100644 --- a/others/README.md +++ b/others/README.md @@ -4,9 +4,12 @@ Projects that do not belong to deployable apps, reusable packages, or the local | Path | Purpose | | ----------------------------------------- | --------------------------------------------------------------------------------------- | +| `game-tools/refrain-trainer` | Archived one-off Chinese Windows trainer for Labyrinth of Refrain. | | `userscripts/bilibili-follow-cycle` | Archived Bilibili browser userscript. | | `userscripts/youtube-auto-resume` | Archived YouTube userscript retained as the migration baseline for the extension app. | | `github-actions/star-cycle` | Retired GitHub Star/Unstar workflow experiment. | | `interface-studies/google-ai-mode-aurora` | Forensic analysis and dependency-free reproduction of Google Search's Aurora edge glow. | GitHub only executes workflow files from the repository-level `.github/workflows/` directory. The Star Cycle YAML stored here is intentionally inactive. + +Refrain Trainer is an archived, standalone C#/.NET Framework tool, outside the pnpm workspace. Its [README](./game-tools/refrain-trainer/README.md) records Windows build, validation, packaging, and the explicit unlicensed status. The Windows workflow is manual-only for reproduction; ongoing maintenance is not planned. Generated binaries, archives, game data, and save files are not committed. diff --git a/others/game-tools/refrain-trainer/.editorconfig b/others/game-tools/refrain-trainer/.editorconfig new file mode 100644 index 0000000..fed2924 --- /dev/null +++ b/others/game-tools/refrain-trainer/.editorconfig @@ -0,0 +1,2 @@ +[*.{cs,py}] +indent_size = 4 diff --git a/others/game-tools/refrain-trainer/.gitignore b/others/game-tools/refrain-trainer/.gitignore new file mode 100644 index 0000000..f071066 --- /dev/null +++ b/others/game-tools/refrain-trainer/.gitignore @@ -0,0 +1,4 @@ +/dist/ +/test-output/ +*.exe +*.zip diff --git a/others/game-tools/refrain-trainer/README.md b/others/game-tools/refrain-trainer/README.md new file mode 100644 index 0000000..6a4d276 --- /dev/null +++ b/others/game-tools/refrain-trainer/README.md @@ -0,0 +1,86 @@ +# 鲁弗兰迷宫修改器 + +适用于 Steam 版 **Labyrinth of Refrain: Coven of Dusk** 的中文 Windows 修改器,提供一击必杀、魔女大纹章固定掉落和传说装备选项。 + +当前状态:**已归档,中文 v2 测试版**。这是按一次具体需求制作的工具,本仓库保留源码、使用说明和构建方式,不计划持续维护或适配后续游戏版本。已完成一次真实贝可罗尼亚大王战斗和掉落验证;保存后重新读档、装备详情逐项核对及结算耗时对比未完成。详见 [验证说明](./docs/validation.md)。 + +这是独立的 C#/.NET Framework 工具,不属于 pnpm workspace。仓库归档保存源码和构建脚本;生成的 EXE、ZIP、测试报告、游戏数据和玩家存档不纳入 Git。 + +## 使用条件 + +- Windows 10/11,已启用 .NET Framework 4.x。 +- Steam App `566540`,Build `3256141`,32 位原始 `refrain.exe`。 +- 游戏主程序 SHA-256:`FC0F248BDC42BCFAF9C8D679F99A707FA8D20DC570F2B16203F1403EC9B58087`。 +- 游戏与修改器使用相同的运行权限。 + +运行修改器无需安装 Cheat Engine、Python 或 Node.js。游戏可以安装在任意盘符和目录;连接时从运行中的游戏进程取得路径,读取该目录的装备表。主程序、原始指令或装备表不匹配时会拒绝启用。 + +## 快速使用 + +1. 从 Steam 启动游戏并读入存档。 +2. 运行 `RefrainTrainer.exe`,点击“连接游戏”。四个开关默认关闭。 +3. 刷魔女大纹章时开启“一击必杀”和“魔女大纹章必掉”,其余两个开关保持关闭。 +4. 击败贝可罗尼亚大王(King Pecoronia),在战斗结算中领取饰品。 +5. 在菜单或战斗指令界面切换功能;不需要修改或准备读档时点击“全部停用”。 + +| 功能 | 效果 | +| ------------------ | ----------------------------------------------------------------------------------------- | +| 一击必杀 | 敌人受到实际伤害、生命值减少时归零;出生、治疗、零伤害及未命中保留原规则。 | +| 魔女大纹章必掉 | 每名被击败的贝可罗尼亚大王固定生成 1 件已鉴定的传说魔女大纹章,复用游戏原有固定掉落流程。 | +| 额外随机传说装备 | 每名被击败的敌人额外生成 1 件当前区域的随机传说装备,同时保留原掉落;会增加结算装备数量。 | +| 其他新装备也为传说 | 经过装备生成函数的新装备使用传说稀有度;不会主动修改背包里已有装备。 | + +传说(Legendary)是最高稀有度,前缀和具体属性仍由游戏随机生成。两个掉落功能自行生成的装备已使用传说稀有度,不依赖“其他新装备也为传说”。额外随机装备覆盖 56 个区域,无法识别的特殊区域使用传说破旧剑(Worn Sword)作为备用底材。 + +程序只修改运行中的游戏内存,不替换游戏 EXE,也不直接编辑存档。关闭窗口会尝试恢复原指令;已经获得的装备由游戏负责保存。强制结束修改器后,修改可能仍留在游戏进程中;重启游戏可清除。如果提示“未能完整恢复”,先退出游戏,再关闭修改器。 + +## 构建和打包 + +构建使用 Windows 自带的 `%WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe`,无需 Visual Studio 或 NuGet 依赖。打包另需 Windows PowerShell 5.1 或 PowerShell 7,以及 .NET 的 ZIP 支持。 + +在本项目目录执行: + +```powershell +.\source\build.cmd +.\check.cmd +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\package.ps1 +``` + +`build.cmd` 生成 `dist/RefrainTrainer.exe`,编译警告按错误处理。`check.cmd` 重新编译并运行独立进程检查;测试前退出游戏,检查程序发现游戏运行时会拒绝继续。 + +`package.ps1` 重新构建,通过明确的文件清单打包源码、说明和 EXE,并回读 ZIP,核对全部文件的 SHA-256。产物为: + +- `dist/RefrainTrainer-Steam-3256141-zh-CN-v2.zip` +- `dist/RefrainTrainer-Steam-3256141-zh-CN-v2.zip.sha256` + +压缩包可直接转发,解压后运行根目录的 `RefrainTrainer.exe`。包内源码仍可按上述步骤重新构建,构建结果写入 `dist/`。 + +尚未建立公开 Release 或安装渠道。归档项目的 Windows CI 仅支持手动触发,用于按需重现构建、检查和打包,并上传该次运行的 artifact;不在提交或拉取请求时自动运行,不创建版本标签或 GitHub Release。 + +## 验证命令 + +无需游戏文件的检查: + +```powershell +.\check.cmd +``` + +结果写入 `test-output/native-test-report.txt` 和 `test-output/trainer-preview.png`。测试只向自己创建的 x86 子进程写入测试指令,并检查开关、恢复和未连接窗口状态。 + +可选的完整指令模拟需要本地合法安装的匹配版本游戏,以及 [uv](https://docs.astral.sh/uv/) 管理的 Python 3.12、`pefile==2024.8.26`、`unicorn==2.1.4`。它只读安装文件,不连接游戏: + +```powershell +$gameDirectory = 'D:\SteamLibrary\steamapps\common\Labyrinth of Refrain Coven of Dusk' +$trainerExe = Join-Path $PWD 'dist\RefrainTrainer.exe' +$fixturesDirectory = Join-Path $PWD 'test-output\fixtures' +$exportArguments = '--export-fixtures "{0}" "{1}"' -f $gameDirectory, $fixturesDirectory +$exportProcess = Start-Process -FilePath $trainerExe -ArgumentList $exportArguments -WindowStyle Hidden -Wait -PassThru +if ($exportProcess.ExitCode -ne 0) { throw 'Fixture export failed.' } +uv run --no-project --python 3.12 --with pefile==2024.8.26 --with unicorn==2.1.4 python .\source\tests\test_patches.py --game-exe "$gameDirectory\refrain.exe" --fixtures $fixturesDirectory --report .\test-output\patch-test-report.json +``` + +首次运行 uv 可能需要网络下载 Python 和依赖。完整模拟不在公共 CI 中执行,因为它需要未随仓库分发的游戏程序和数据。 + +## 许可证与归属 + +本项目目前 **未授予复用许可证(UNLICENSED)**,不继承仓库其他目录的许可证。游戏名称及游戏内容归各自权利人所有,本项目是非官方工具;仓库和分发包均不包含游戏程序、装备数据表或玩家存档。 diff --git a/others/game-tools/refrain-trainer/check.cmd b/others/game-tools/refrain-trainer/check.cmd new file mode 100644 index 0000000..9a03ca8 --- /dev/null +++ b/others/game-tools/refrain-trainer/check.cmd @@ -0,0 +1,6 @@ +@echo off +setlocal +call "%~dp0source\build.cmd" +if errorlevel 1 exit /b 1 +call "%~dp0source\tests\run-native.cmd" +exit /b %errorlevel% diff --git a/others/game-tools/refrain-trainer/docs/validation.md b/others/game-tools/refrain-trainer/docs/validation.md new file mode 100644 index 0000000..a56577d --- /dev/null +++ b/others/game-tools/refrain-trainer/docs/validation.md @@ -0,0 +1,49 @@ +# 中文 v2 验证记录 + +日期:2026-10-18。对象:Steam App 566540 / Build 3256141 / x86 `refrain.exe`。 + +## 真实战斗 + +在 Unclean Ornit / Azu-Cardia 开启“一击必杀”和“魔女大纹章必掉”,其余两个功能关闭。 + +- King Pecoronia 初始生命值为 101390/101390。 +- 一次攻击显示 4359 伤害后出现击败提示,正常结算经验 12200。 +- 掉落画面显示 `Obtained Stalwart Witch’s Crest from battle!`。 +- 修改器显示一击必杀 1 次、指定掉落 1 次、额外装备 0 件,结算后正常返回探索。 + +本次没有保存新获得的装备,未执行保存后重新读档,也未逐页核对背包中该件装备的稀有度详情和全部属性。 + +## 工程检查 + +- C# x86 编译成功,警告按错误处理。 +- 原始游戏 EXE 哈希和四处原始指令匹配。 +- 587 个 Unicorn 指令模拟用例全部通过,覆盖生命值边界、56 个区域的装备表、调用约定、栈和寄存器恢复、稀有度及掉落分支。 +- 指定掉落在 0 至 99 的 100 个随机输入下均生成一次,物品编号为 212035、稀有度参数为 3(Legendary)、已鉴定标记为 1。 +- 非目标、存活敌人、浮点无序值保留原分支,指定掉落和额外随机装备独立工作。 +- 独立 x86 测试进程的 490 项断言通过,包含 40 次启用和 40 次停用,检查写入回读、原指令和页权限恢复、线程上下文、不匹配拒绝写入和未连接窗口状态。 +- 中文界面已渲染检查,并在实际桌面缩放下检查四个选项、按钮、说明和状态区域。 + +指令模拟执行真实补丁机器码、原始 HP 更新函数及固定掉落后续指令;分配、随机数和战利品追加等调用使用符合调用约定的测试替身。独立进程测试使用自行创建的程序。两种工程检查与真实战斗分别记录。 + +## 尚未验证 + +- 保存后重新读档及装备详细属性。 +- 所有特殊战斗和普通敌人掉落的完整实战对照。 +- 同场景的战斗结算耗时对比。 + +本版将指定饰品与额外随机装备分开;关闭额外随机装备可以避免该功能追加物品,但尚无耗时数据证明所有结算卡顿已消除。 + +## 实现位置 + +以下位置相对游戏模块基址: + +| 功能 | 偏移 | +| ------------------ | ----------- | +| 敌人 HP 更新 | `+0x51ED0` | +| 魔女大纹章固定掉落 | `+0x21FC15` | +| 额外随机装备 | `+0x21FE8F` | +| 新装备稀有度 | `+0x8EC12` | + +固定掉落识别原物品编号 212035,仅在目标死亡后覆盖判定,复用原生生成路径。切换功能时暂停游戏线程并检查指令位置,写入后回读并恢复页权限;已经暴露给游戏的补丁内存保留至游戏退出。 + +复现命令、环境要求和 CI 范围见 [项目 README](../README.md)。测试输出只在本地 `test-output/` 或 CI artifact 中保留,不作为源码提交。 diff --git a/others/game-tools/refrain-trainer/package.ps1 b/others/game-tools/refrain-trainer/package.ps1 new file mode 100644 index 0000000..2bbd5f0 --- /dev/null +++ b/others/game-tools/refrain-trainer/package.ps1 @@ -0,0 +1,91 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot 'source\build.cmd') +if ($LASTEXITCODE -ne 0) { throw 'Trainer build failed.' } + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +$dist = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'dist')) +$packageName = 'RefrainTrainer-zh-CN-v2' +$archiveName = 'RefrainTrainer-Steam-3256141-zh-CN-v2.zip' +$stage = [IO.Path]::GetFullPath((Join-Path $dist ('package-' + [Guid]::NewGuid().ToString('N')))) +if (-not $stage.StartsWith($dist + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Package staging directory must remain inside dist.' +} +$packageRoot = Join-Path $stage $packageName +$temporaryArchive = Join-Path $stage $archiveName +$archivePath = Join-Path $dist $archiveName +$utf8 = New-Object Text.UTF8Encoding($false) +$sources = @( + 'README.md', + 'docs/validation.md', + 'check.cmd', + 'package.ps1', + 'source/build.cmd', + 'source/Program.cs', + 'source/Trainer.cs', + 'source/Patches.cs', + 'source/tests/NativeChecks.cs', + 'source/tests/run-native.cmd', + 'source/tests/test_patches.py' +) + +try { + New-Item -ItemType Directory -Path $packageRoot -Force | Out-Null + foreach ($relativePath in $sources) { + $target = Join-Path $packageRoot $relativePath + New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force | Out-Null + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $relativePath) -Destination $target + } + Copy-Item -LiteralPath (Join-Path $dist 'RefrainTrainer.exe') -Destination $packageRoot + + $entries = @( + foreach ($relativePath in (($sources + 'RefrainTrainer.exe') | Sort-Object)) { + $file = Get-Item -LiteralPath (Join-Path $packageRoot $relativePath) + [ordered]@{ + path = $relativePath + bytes = $file.Length + sha256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash + } + } + ) + $manifest = [ordered]@{ release = $packageName; steamBuild = 3256141; files = $entries } + $manifestJson = ($manifest | ConvertTo-Json -Depth 5) + "`n" + [IO.File]::WriteAllText((Join-Path $packageRoot 'SHA256.json'), $manifestJson, $utf8) + [IO.Compression.ZipFile]::CreateFromDirectory($packageRoot, $temporaryArchive, [IO.Compression.CompressionLevel]::Optimal, $true) + + $zip = [IO.Compression.ZipFile]::OpenRead($temporaryArchive) + try { + $files = @($zip.Entries | Where-Object { $_.Name.Length -gt 0 }) + $expectedNames = @($entries | ForEach-Object { $packageName + '/' + $_.path }) + ($packageName + '/SHA256.json') + $actualNames = @($files | ForEach-Object { $_.FullName.Replace('\', '/') }) + if ($files.Count -ne $expectedNames.Count -or (Compare-Object $actualNames $expectedNames)) { + throw 'ZIP file list does not match the package allowlist.' + } + foreach ($file in $files) { + $relativePath = $file.FullName.Replace('\', '/').Substring($packageName.Length + 1) + $expectedHash = (Get-FileHash -LiteralPath (Join-Path $packageRoot $relativePath) -Algorithm SHA256).Hash + $stream = $file.Open() + $hasher = [Security.Cryptography.SHA256]::Create() + try { + $actualHash = [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-', '') + if ($actualHash -ne $expectedHash) { throw "ZIP content mismatch: $relativePath" } + } + finally { + $hasher.Dispose() + $stream.Dispose() + } + } + } + finally { $zip.Dispose() } + + Move-Item -LiteralPath $temporaryArchive -Destination $archivePath -Force + $archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash + [IO.File]::WriteAllText(($archivePath + '.sha256'), "$archiveHash $archiveName`n", $utf8) + Write-Output "Packaged and verified $($entries.Count + 1) files: $archivePath" + Write-Output "SHA-256: $archiveHash" +} +finally { + if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Recurse -Force } +} diff --git a/others/game-tools/refrain-trainer/source/Patches.cs b/others/game-tools/refrain-trainer/source/Patches.cs new file mode 100644 index 0000000..788701e --- /dev/null +++ b/others/game-tools/refrain-trainer/source/Patches.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace RefrainTrainer +{ + public sealed class X86Code + { + readonly uint origin; + readonly List bytes = new List(); + readonly Dictionary labels = new Dictionary(); + readonly List> fixups = new List>(); + + public X86Code(uint address) { origin = address; } + public void Emit(params byte[] values) { bytes.AddRange(values); } + public void Dword(uint value) { Emit(BitConverter.GetBytes(value)); } + public void Label(string name) { labels.Add(name, bytes.Count); } + public void Branch(byte condition, string label) + { + Emit(0x0F, condition); + fixups.Add(Tuple.Create(bytes.Count, label)); + Dword(0); + } + public void Jump(string label) + { + Emit(0xE9); + fixups.Add(Tuple.Create(bytes.Count, label)); + Dword(0); + } + public void Relative(byte opcode, uint target) + { + Emit(opcode); + Dword(unchecked(target - (origin + (uint)bytes.Count + 4))); + } + public byte[] Finish() + { + byte[] result = bytes.ToArray(); + foreach (var fix in fixups) + Array.Copy(BitConverter.GetBytes(labels[fix.Item2] - fix.Item1 - 4), 0, result, fix.Item1, 4); + return result; + } + } + + public sealed class EquipmentCatalog + { + public readonly SortedDictionary>> Areas = new SortedDictionary>>(); + + public static EquipmentCatalog Read(string gameDirectory) + { + string folder = Path.Combine(gameDirectory, @"Media\D3D11\Majo"); + string file = new[] { "csv_en", "csv", "csv_fr" } + .Select(x => Path.Combine(folder, x, @"item\drop_table_high.lse")) + .FirstOrDefault(File.Exists); + if (file == null) throw new InvalidDataException("找不到游戏的装备掉落表。请在 Steam 验证游戏文件后重试。"); + string equipmentFile = Path.Combine(Path.GetDirectoryName(file), "equip_table.lse"); + var validItems = new HashSet(DecodeTable(equipmentFile).Split('\n') + .Select(line => Regex.Match(line, @"^(\d+),")) + .Where(match => match.Success) + .Select(match => Int32.Parse(match.Groups[1].Value))); + if (!validItems.Contains(200001) || !validItems.Contains((int)PatchCode.CrestItem)) throw new InvalidDataException("无法识别游戏装备表,未启用修改。"); + var catalog = new EquipmentCatalog(); + foreach (string line in DecodeTable(file).Split('\n')) + { + if (!Regex.IsMatch(line.TrimEnd('\r'), @"^\d+,\d+,\d+,\d+,\d+$")) continue; + int[] columns = line.TrimEnd('\r').Split(',').Select(Int32.Parse).ToArray(); + int area = columns[0], rank = columns[1], item = columns[2]; + if (area < 1000 || area > 9999 || item / 100000 != 2) continue; + if (!validItems.Contains(item)) throw new InvalidDataException("掉落表包含无法识别的装备,未启用修改。"); + if (rank < 0 || rank > 10000) throw new InvalidDataException("装备等级超出支持范围。"); + List> entries; + if (!catalog.Areas.TryGetValue(area, out entries)) + catalog.Areas.Add(area, entries = new List>()); + entries.Add(Tuple.Create(rank, item)); + } + if (catalog.Areas.Count < 20 || catalog.Areas.Count > 200) + throw new InvalidDataException("无法识别当前掉落表,未启用修改。"); + foreach (int area in catalog.Areas.Keys.ToArray()) + catalog.Areas[area] = catalog.Areas[area].OrderBy(x => x.Item1).ThenBy(x => x.Item2).ToList(); + return catalog; + } + + static string DecodeTable(string file) + { + byte[] data = File.ReadAllBytes(file); + if (data.Length > 4 * 1024 * 1024) throw new InvalidDataException("游戏数据表大小异常,未启用修改。"); + if (data.Length < 2) throw new InvalidDataException("游戏数据表为空,未启用修改。"); + byte key = data[data.Length - 1]; + for (int i = 0; i < data.Length - 1; i++) data[i] ^= (byte)((i + key) & 255); + return Encoding.ASCII.GetString(data, 0, data.Length - 1); + } + + public byte[] Build(uint address) + { + using (var stream = new MemoryStream()) + using (var writer = new BinaryWriter(stream)) + { + writer.Write(Areas.Count); + uint cursor = address + 4 + (uint)Areas.Count * 16; + foreach (var pair in Areas) + { + writer.Write(pair.Key); + writer.Write(pair.Value.Count); + writer.Write(pair.Value[0].Item1); + writer.Write(cursor); + cursor += (uint)pair.Value.Count * 8; + } + foreach (var pair in Areas) + foreach (var entry in pair.Value) + { + writer.Write(entry.Item1); + writer.Write(entry.Item2); + } + return stream.ToArray(); + } + } + } + + public static class PatchCode + { + public const string GameSha256 = "FC0F248BDC42BCFAF9C8D679F99A707FA8D20DC570F2B16203F1403EC9B58087"; + public const uint HitOffset = 0x51ED0; + public const uint LootOffset = 0x21FE8F; + public const uint RarityOffset = 0x8EC12; + public const uint CrestOffset = 0x21FC15; + public const uint CrestItem = 212035; + public static readonly byte[] CrestOriginal = { 0x0F, 0x2F, 0x45, 0xC4, 0x0F, 0x83, 0x70, 0x02, 0x00, 0x00 }; + public static readonly byte[] HitOriginal = { 0x55, 0x8B, 0xEC, 0x56, 0x6A, 0x00 }; + public static readonly byte[] LootOriginal = { 0x83, 0xC7, 0x04, 0xFF, 0x4D, 0xE0 }; + public static readonly byte[] RarityOriginal = { 0x8B, 0x4D, 0x0C }; + public static readonly byte[] RarityEnabled = { 0x6A, 0x03, 0x59 }; + + public static byte[] Redirect(uint source, uint destination, int length = 6) + { + var code = new X86Code(source); + code.Relative(0xE9, destination); + if (length < 5) throw new ArgumentOutOfRangeException("length"); + for (int i = 5; i < length; i++) code.Emit(0x90); + return code.Finish(); + } + + public static byte[] Hit(uint gameBase, uint address) + { + var c = new X86Code(address); + c.Emit(0x9C, 0x50); // Preserve flags and the scratch register. + c.Emit(0x8B, 0x81, 0xF8, 0x00, 0x00, 0x00); + c.Emit(0x85, 0xC0); c.Branch(0x8E, "original"); + c.Emit(0x39, 0x44, 0x24, 0x0C); // Proposed HP versus current enemy HP. + c.Branch(0x8D, "original"); + c.Emit(0xC7, 0x44, 0x24, 0x0C, 0, 0, 0, 0); + c.Emit(0xFF, 0x05); c.Dword(address + 0xF004); + c.Label("original"); + c.Emit(0x58, 0x9D); + c.Emit(HitOriginal); + c.Relative(0xE9, gameBase + HitOffset + 6); + return c.Finish(); + } + + public static byte[] Crest(uint gameBase, uint address, uint counter) + { + var c = new X86Code(address); + c.Emit(0x9C, 0x50, 0x8B, 0x07); + c.Emit(0x83, 0xB8, 0xF8, 0, 0, 0, 0); + c.Branch(0x8F, "original"); + c.Emit(0x81, 0x78, 0x5C); c.Dword(CrestItem); + c.Branch(0x85, "original"); + c.Emit(0x58, 0x9D); + c.Emit(0xC7, 0x45, 0xB0); c.Dword(CrestItem); + c.Emit(0xC7, 0x45, 0xB4, 3, 0, 0, 0); + c.Emit(0xC7, 0x45, 0xB8, 1, 0, 0, 0); + c.Emit(0xFF, 0x05); c.Dword(counter); + // Use the native fixed-drop path; no additional item generation is needed. + c.Relative(0xE9, gameBase + 0x21FE59); + c.Label("original"); + c.Emit(0x58, 0x9D, 0x0F, 0x2F, 0x45, 0xC4); + c.Emit(0x0F); c.Relative(0x83, gameBase + LootOffset); + c.Relative(0xE9, gameBase + CrestOffset + (uint)CrestOriginal.Length); + return c.Finish(); + } + + public static byte[] Loot(uint gameBase, uint address, uint catalog) + { + var c = new X86Code(address); + c.Emit(0x9C, 0x60, 0x8B, 0xDC); + c.Emit(0x81, 0xEC, 0x20, 0x02, 0, 0, 0x83, 0xE4, 0xF0); + c.Emit(0x0F, 0xAE, 0x04, 0x24); // Preserve x87/SSE across game function calls. + c.Emit(0x89, 0x9C, 0x24, 0x00, 0x02, 0, 0, 0x8B, 0xDC); + c.Emit(0x83, 0xEC, 0x10); + c.Emit(0x8B, 0x07, 0x83, 0xB8, 0xF8, 0, 0, 0, 0); + c.Branch(0x8F, "done"); // Surviving enemies receive no bonus loot. + c.Emit(0x8B, 0x50, 0x6C); + c.Emit(0x8B, 0x35); c.Dword(gameBase + 0x6BB3BC); + c.Emit(0x85, 0xF6); c.Branch(0x84, "fallback"); + c.Emit(0x8B, 0xB6, 0x84, 0xDC, 0x01, 0x00); + c.Emit(0x8B, 0x0D); c.Dword(catalog); + c.Emit(0xBF); c.Dword(catalog + 4); + c.Label("findArea"); + c.Emit(0x39, 0x37); c.Branch(0x84, "found"); + c.Emit(0x83, 0xC7, 0x10, 0x49); c.Branch(0x85, "findArea"); + c.Jump("fallback"); + c.Label("found"); + c.Emit(0x3B, 0x57, 0x08, 0x0F, 0x4C, 0x57, 0x08); + c.Emit(0x8B, 0x4F, 0x04, 0x8B, 0x7F, 0x0C, 0x33, 0xF6); + c.Label("count"); + c.Emit(0x3B, 0x14, 0xF7); c.Branch(0x8C, "choose"); + c.Emit(0x46, 0x3B, 0xF1); c.Branch(0x8C, "count"); + c.Label("choose"); + c.Emit(0x85, 0xF6); c.Branch(0x84, "fallback"); + c.Emit(0x4E, 0x56, 0x6A, 0x00, 0x8B, 0x0D); c.Dword(gameBase + 0x6BB348); + c.Relative(0xE8, gameBase + 0x211180); + c.Emit(0x8B, 0x74, 0xC7, 0x04); + c.Jump("record"); + c.Label("fallback"); + c.Emit(0xBE); c.Dword(200001); // Worn Sword is valid even outside normal dungeon tables. + c.Label("record"); + c.Emit(0x89, 0x34, 0x24, 0xC7, 0x44, 0x24, 0x04, 3, 0, 0, 0); + c.Emit(0xC7, 0x44, 0x24, 0x08, 1, 0, 0, 0); + c.Emit(0x8B, 0x4D, 0xBC, 0x54); + c.Relative(0xE8, gameBase + 0x22E610); + c.Emit(0x6A, 1, 0x6A, 0xFF, 0x6A, 3, 0x56, 0x8B, 0x4D, 0xE4); + c.Relative(0xE8, gameBase + 0x1045E0); + c.Emit(0xFF, 0x05); c.Dword(catalog + 0xE000); + c.Label("done"); + c.Emit(0x8B, 0xE3, 0x0F, 0xAE, 0x0C, 0x24); + c.Emit(0x8B, 0xA4, 0x24, 0x00, 0x02, 0, 0, 0x61, 0x9D); + c.Emit(LootOriginal); + c.Relative(0xE9, gameBase + LootOffset + 6); + return c.Finish(); + } + } +} diff --git a/others/game-tools/refrain-trainer/source/Program.cs b/others/game-tools/refrain-trainer/source/Program.cs new file mode 100644 index 0000000..f98aadb --- /dev/null +++ b/others/game-tools/refrain-trainer/source/Program.cs @@ -0,0 +1,170 @@ +using System; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Threading; +using System.Windows.Forms; + +namespace RefrainTrainer +{ + static class Program + { + [STAThread] + static int Main(string[] args) + { + if (args.Length == 3 && args[0] == "--export-fixtures") + { + Directory.CreateDirectory(args[2]); + var catalog = EquipmentCatalog.Read(args[1]); + File.WriteAllBytes(Path.Combine(args[2], "hit.bin"), PatchCode.Hit(0x400000, 0x2000000)); + File.WriteAllBytes(Path.Combine(args[2], "loot.bin"), PatchCode.Loot(0x400000, 0x2000400, 0x2001000)); + File.WriteAllBytes(Path.Combine(args[2], "catalog.bin"), catalog.Build(0x2001000)); + File.WriteAllBytes(Path.Combine(args[2], "rarity.bin"), PatchCode.RarityEnabled); + File.WriteAllBytes(Path.Combine(args[2], "crest.bin"), PatchCode.Crest(0x400000, 0x2000800, 0x200F008)); + File.WriteAllText(Path.Combine(args[2], "catalog-count.txt"), catalog.Areas.Count.ToString()); + return 0; + } + bool isNew; + using (var mutex = new Mutex(true, "Local\\RefrainTrainer_3256141", out isNew)) + { + if (!isNew) + { + MessageBox.Show("修改器已经打开,请使用现有窗口。", "鲁弗兰迷宫修改器", MessageBoxButtons.OK, MessageBoxIcon.Information); + return 1; + } + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new TrainerWindow()); + } + return 0; + } + } + + public sealed class TrainerWindow : Form + { + readonly Trainer trainer = new Trainer(); + readonly CheckBox[] options = new CheckBox[4]; + readonly TextBox status = new TextBox(); + readonly Label counters = new Label(); + readonly Button connect = new Button(); + readonly Button disable = new Button(); + readonly System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer(); + bool refreshing; + + public TrainerWindow() + { + Text = "鲁弗兰迷宫修改器 · Steam 版本 3256141 · 指定饰品测试版"; + Font = new Font("Microsoft YaHei UI", 10F); + AutoScaleMode = AutoScaleMode.Dpi; + StartPosition = FormStartPosition.CenterScreen; + ClientSize = new Size(740, 710); + MinimumSize = new Size(756, 750); + BackColor = SystemColors.Window; + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 10, Padding = new Padding(24), AutoScroll = true }; + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + Controls.Add(layout); + AddLabel(layout, "鲁弗兰迷宫修改器", 16, FontStyle.Bold); + AddLabel(layout, "Steam 版本 3256141 · 指定掉落与随机装备独立开关", 10, FontStyle.Regular); + + var actions = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, WrapContents = true, Margin = new Padding(0, 12, 0, 14) }; + connect.Text = "连接游戏"; connect.AutoSize = true; connect.Padding = new Padding(10, 5, 10, 5); + connect.Click += delegate { Perform(delegate { trainer.Connect(); SetStatus("已连接游戏(进程编号 " + trainer.ProcessId + ")。所有开关默认关闭,请在菜单或战斗指令界面切换。"); }); }; + disable.Text = "全部停用"; disable.AutoSize = true; disable.Padding = new Padding(10, 5, 10, 5); + disable.Click += delegate { Perform(delegate { trainer.DisableAll(); SetStatus("全部修改已停用。已经获得的装备会保留;后续操作按原规则进行。"); }); }; + actions.Controls.Add(connect); actions.Controls.Add(disable); layout.Controls.Add(actions); + + AddOption(layout, 0, "一击必杀", "敌人受到实际伤害时,生命值降为 0。未命中、零伤害和特殊无敌机制仍按游戏规则处理。"); + AddOption(layout, 3, "魔女大纹章必掉", "击败贝可罗尼亚大王,固定掉落 1 件已鉴定的传说魔女大纹章。刷这件饰品时开启本项即可。"); + AddOption(layout, 1, "额外随机传说装备", "每名被击败的敌人额外生成 1 件区域随机装备,会增加战斗结算的装备数量。只刷大纹章时可关闭。"); + AddOption(layout, 2, "其他新装备也为传说", "原有掉落、宝箱等途径新生成的装备也使用传说品质;已有装备保持原样。"); + + counters.AutoSize = true; counters.Margin = new Padding(0, 12, 0, 10); + counters.Text = "本次连接:一击必杀 0 次 · 指定掉落 0 次 · 额外装备 0 件"; + layout.Controls.Add(counters); + status.ReadOnly = true; status.Multiline = true; status.ScrollBars = ScrollBars.Vertical; + status.Dock = DockStyle.Fill; status.MinimumSize = new Size(0, 95); + status.BackColor = SystemColors.Control; status.BorderStyle = BorderStyle.FixedSingle; + status.Text = "请从 Steam 启动游戏,再连接修改器。\r\n刷魔女大纹章时,建议只开启“一击必杀”和“魔女大纹章必掉”。\r\n保存游戏后,已获得的装备会留在存档中,建议先使用单独的存档槽。"; + layout.Controls.Add(status); + AddLabel(layout, "关闭窗口会停用修改。强制结束修改器时,请重启游戏以清除修改。", 9, FontStyle.Regular); + timer.Interval = 1000; + timer.Tick += delegate + { + try + { + if (trainer.Patches != null && !trainer.Connected) + { + trainer.Dispose(); + SetStatus("游戏已退出,修改已随进程清除。重新启动游戏后可以再次连接。"); + } + RefreshState(); + } + catch (Exception error) { SetStatus(error.Message); } + }; + timer.Start(); + FormClosing += OnClosing; + RefreshState(); + } + + void AddLabel(TableLayoutPanel layout, string text, float size, FontStyle style) + { + layout.Controls.Add(new Label { Text = text, AutoSize = true, MaximumSize = new Size(690, 0), Font = new Font(Font.FontFamily, size, style), Margin = new Padding(0, 0, 0, 6) }); + } + + void AddOption(TableLayoutPanel layout, int index, string title, string description) + { + var panel = new TableLayoutPanel { AutoSize = true, ColumnCount = 1, Dock = DockStyle.Fill, Margin = new Padding(0, 6, 0, 12) }; + var check = new CheckBox { Text = title, AutoSize = true, Font = new Font(Font, FontStyle.Bold), Margin = new Padding(0, 0, 0, 5) }; + options[index] = check; + check.CheckedChanged += delegate + { + if (refreshing) return; + bool selected = check.Checked; + Perform(delegate { trainer.Set(index, selected); SetStatus(title + (selected ? "已启用。" : "已停用。")); }); + }; + panel.Controls.Add(check); + panel.Controls.Add(new Label { Text = description, AutoSize = true, MaximumSize = new Size(650, 0), Margin = new Padding(24, 0, 0, 0) }); + layout.Controls.Add(panel); + } + + void SetStatus(string message) { status.Text = message; } + void Perform(Action action) + { + try { action(); } + catch (Exception error) { SetStatus(error.Message); } + finally { RefreshState(); } + } + void RefreshState() + { + refreshing = true; + try + { + bool connected = trainer.Connected; + connect.Enabled = !connected; disable.Enabled = connected; + for (int i = 0; i < options.Length; i++) + { + bool uncertain = connected && trainer.Patches[i].RequiresRestart; + options[i].Enabled = connected && !uncertain; + options[i].CheckState = uncertain ? CheckState.Indeterminate + : connected && trainer.Patches[i].Enabled ? CheckState.Checked : CheckState.Unchecked; + } + if (connected) counters.Text = String.Format("本次连接:一击必杀 {0} 次 · 指定掉落 {1} 次 · 额外装备 {2} 件", trainer.HitCount, trainer.CrestCount, trainer.BonusCount); + else counters.Text = "本次连接:一击必杀 0 次 · 指定掉落 0 次 · 额外装备 0 件"; + } + catch (Exception error) + { + SetStatus("暂时无法更新连接状态,请重试。\r\n" + error.Message); + } + finally { refreshing = false; } + } + void OnClosing(object sender, FormClosingEventArgs e) + { + try { trainer.DisableAll(); trainer.Dispose(); timer.Stop(); timer.Dispose(); } + catch (Exception error) + { + e.Cancel = true; + SetStatus("还有修改未能停用,窗口暂时保留。请返回游戏菜单后点“全部停用”,或退出游戏。\r\n" + error.Message); + } + } + } +} diff --git a/others/game-tools/refrain-trainer/source/Trainer.cs b/others/game-tools/refrain-trainer/source/Trainer.cs new file mode 100644 index 0000000..f30f2e6 --- /dev/null +++ b/others/game-tools/refrain-trainer/source/Trainer.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; + +namespace RefrainTrainer +{ + internal static class Native + { + [DllImport("kernel32.dll", SetLastError = true)] internal static extern IntPtr OpenProcess(uint access, bool inherit, int pid); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] data, UIntPtr size, out UIntPtr read); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool WriteProcessMemory(IntPtr process, IntPtr address, byte[] data, UIntPtr size, out UIntPtr written); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool VirtualProtectEx(IntPtr process, IntPtr address, UIntPtr size, uint protection, out uint previous); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern IntPtr VirtualAllocEx(IntPtr process, IntPtr address, UIntPtr size, uint allocation, uint protection); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool VirtualFreeEx(IntPtr process, IntPtr address, UIntPtr size, uint freeType); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool FlushInstructionCache(IntPtr process, IntPtr address, UIntPtr size); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern IntPtr OpenThread(uint access, bool inherit, uint threadId); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool GetThreadContext(IntPtr thread, byte[] context); + [DllImport("ntdll.dll")] internal static extern int NtSuspendProcess(IntPtr process); + [DllImport("ntdll.dll")] internal static extern int NtResumeProcess(IntPtr process); + + internal static IntPtr Pointer(uint address) { return new IntPtr(unchecked((int)address)); } + internal static Exception Error(string action) { return new Win32Exception(Marshal.GetLastWin32Error(), action); } + } + + public sealed class GamePatch + { + public readonly string Name; + public readonly uint Address; + public readonly byte[] Original; + public readonly byte[] EnabledBytes; + public bool Enabled { get; internal set; } + public bool RequiresRestart { get; internal set; } + public GamePatch(string name, uint address, byte[] original, byte[] enabled) + { + Name = name; Address = address; Original = original; EnabledBytes = enabled; + } + } + + public sealed class Trainer : IDisposable + { + Process process; + IntPtr handle; + IntPtr allocation; + bool payloadExposed; + public string GameDirectory { get; private set; } + public int ProcessId { get { return process == null ? 0 : process.Id; } } + public GamePatch[] Patches { get; private set; } + public bool Connected { get { return handle != IntPtr.Zero && process != null && !process.HasExited; } } + public uint BonusCount { get { return BitConverter.ToUInt32(Read(unchecked((uint)allocation.ToInt32()) + 0xF000, 4), 0); } } + public uint HitCount { get { return BitConverter.ToUInt32(Read(unchecked((uint)allocation.ToInt32()) + 0xF004, 4), 0); } } + public uint CrestCount { get { return BitConverter.ToUInt32(Read(unchecked((uint)allocation.ToInt32()) + 0xF008, 4), 0); } } + + public static string Hash(string path) + { + using (var stream = File.OpenRead(path)) + using (var hash = SHA256.Create()) + return BitConverter.ToString(hash.ComputeHash(stream)).Replace("-", ""); + } + + public void Connect() + { + if (Connected) return; + Dispose(); + Process[] found = Process.GetProcessesByName("refrain"); + if (found.Length != 1) + { + foreach (var item in found) item.Dispose(); + throw new InvalidOperationException(found.Length == 0 + ? "未找到游戏。请从 Steam 启动游戏,再点击“连接游戏”。" + : "检测到多个 refrain.exe。请只保留一个游戏窗口后重试。"); + } + process = found[0]; + try + { + string exe = process.MainModule.FileName; + if (Hash(exe) != PatchCode.GameSha256) + throw new InvalidOperationException("游戏主程序与已适配版本不同,未写入任何修改。此版本支持 Steam 版本号 3256141 的原始主程序。"); + GameDirectory = Path.GetDirectoryName(exe); + uint gameBase = unchecked((uint)process.MainModule.BaseAddress.ToInt32()); + handle = Native.OpenProcess(0x438 | 0x800, false, process.Id); + if (handle == IntPtr.Zero) throw Native.Error("无法连接游戏。请让游戏与修改器使用相同的运行权限。"); + Expect(gameBase + PatchCode.HitOffset, PatchCode.HitOriginal); + Expect(gameBase + PatchCode.LootOffset, PatchCode.LootOriginal); + Expect(gameBase + PatchCode.RarityOffset, PatchCode.RarityOriginal); + Expect(gameBase + PatchCode.CrestOffset, PatchCode.CrestOriginal); + EquipmentCatalog catalog = EquipmentCatalog.Read(GameDirectory); + allocation = Native.VirtualAllocEx(handle, IntPtr.Zero, (UIntPtr)0x10000, 0x3000, 0x04); + if (allocation == IntPtr.Zero) throw Native.Error("无法分配修改器所需内存。"); + uint cave = unchecked((uint)allocation.ToInt32()); + byte[] data = catalog.Build(cave + 0x1000); + if (data.Length > 0xE000) throw new InvalidDataException("装备表超出支持大小。"); + Write(cave, PatchCode.Hit(gameBase, cave)); + Write(cave + 0x400, PatchCode.Loot(gameBase, cave + 0x400, cave + 0x1000)); + Write(cave + 0x800, PatchCode.Crest(gameBase, cave + 0x800, cave + 0xF008)); + Write(cave + 0x1000, data); + uint previous; + if (!Native.VirtualProtectEx(handle, allocation, (UIntPtr)0x1000, 0x20, out previous)) + throw Native.Error("无法准备修改器指令。"); + Patches = new[] { + new GamePatch("一击必杀", gameBase + PatchCode.HitOffset, PatchCode.HitOriginal, PatchCode.Redirect(gameBase + PatchCode.HitOffset, cave)), + new GamePatch("额外随机传说装备", gameBase + PatchCode.LootOffset, PatchCode.LootOriginal, PatchCode.Redirect(gameBase + PatchCode.LootOffset, cave + 0x400)), + new GamePatch("其他新装备也为传说", gameBase + PatchCode.RarityOffset, PatchCode.RarityOriginal, PatchCode.RarityEnabled), + new GamePatch("魔女大纹章必掉", gameBase + PatchCode.CrestOffset, PatchCode.CrestOriginal, PatchCode.Redirect(gameBase + PatchCode.CrestOffset, cave + 0x800, PatchCode.CrestOriginal.Length)) + }; + } + catch { Dispose(); throw; } + } + + byte[] Read(uint address, int length) + { + byte[] result = new byte[length]; + UIntPtr count; + if (!Native.ReadProcessMemory(handle, Native.Pointer(address), result, (UIntPtr)length, out count) || count.ToUInt64() != (ulong)length) + throw Native.Error("无法读取游戏内存。"); + return result; + } + + void Expect(uint address, byte[] expected) + { + if (!Read(address, expected.Length).SequenceEqual(expected)) + throw new InvalidOperationException("目标位置已被其他修改器更改,或游戏版本不匹配。请关闭其他修改器,重启游戏后再连接。"); + } + + void Write(uint address, byte[] bytes) + { + UIntPtr count; + uint previous; + IntPtr pointer = Native.Pointer(address); + if (!Native.VirtualProtectEx(handle, pointer, (UIntPtr)bytes.Length, 0x40, out previous)) + throw Native.Error("无法临时更改目标内存权限。"); + try + { + if (!Native.WriteProcessMemory(handle, pointer, bytes, (UIntPtr)bytes.Length, out count) || count.ToUInt64() != (ulong)bytes.Length) + throw Native.Error("写入未完成。"); + if (!Native.FlushInstructionCache(handle, pointer, (UIntPtr)bytes.Length)) + throw Native.Error("无法刷新游戏指令缓存。"); + if (!Read(address, bytes.Length).SequenceEqual(bytes)) + throw new InvalidOperationException("写入后的校验未通过。"); + } + finally + { + uint ignored; + if (!Native.VirtualProtectEx(handle, pointer, (UIntPtr)bytes.Length, previous, out ignored)) + throw Native.Error("无法恢复目标内存权限。请退出游戏后重新启动。"); + } + } + + void CheckInstructionPointers(GamePatch patch) + { + process.Refresh(); + foreach (ProcessThread thread in process.Threads) + { + IntPtr threadHandle = Native.OpenThread(0x48, false, (uint)thread.Id); + if (threadHandle == IntPtr.Zero) throw Native.Error("无法检查游戏线程,未切换开关。"); + try + { + byte[] context = new byte[716]; + Array.Copy(BitConverter.GetBytes(0x10001), context, 4); + if (!Native.GetThreadContext(threadHandle, context)) throw Native.Error("无法检查游戏指令位置,未切换开关。"); + uint ip = BitConverter.ToUInt32(context, 184); + if (ip > patch.Address && ip < patch.Address + patch.Original.Length) + throw new InvalidOperationException("游戏正执行相关操作。请在菜单或战斗指令界面重试。"); + } + finally { Native.CloseHandle(threadHandle); } + } + } + + public void Set(int index, bool enabled) + { + if (!Connected) throw new InvalidOperationException("游戏已退出,请重新启动并连接。"); + GamePatch patch = Patches[index]; + if (patch.RequiresRestart) + throw new InvalidOperationException("上次写入未能完整恢复,请退出游戏后重新启动。"); + if (patch.Enabled == enabled) return; + byte[] oldBytes = patch.Enabled ? patch.EnabledBytes : patch.Original; + byte[] newBytes = enabled ? patch.EnabledBytes : patch.Original; + if (Native.NtSuspendProcess(handle) < 0) + throw new InvalidOperationException("无法暂停游戏线程以切换修改,未写入修改。"); + try + { + Expect(patch.Address, oldBytes); + CheckInstructionPointers(patch); + if (enabled) payloadExposed = true; + try { Write(patch.Address, newBytes); } + catch (Exception writeError) + { + try { Write(patch.Address, oldBytes); } + catch (Exception restoreError) + { + patch.RequiresRestart = true; + throw new InvalidOperationException("修改未能完整恢复,请退出游戏后重新启动。\r\n" + writeError.Message + "\r\n" + restoreError.Message); + } + throw; + } + patch.Enabled = enabled; + } + finally + { + if (Native.NtResumeProcess(handle) < 0 && Native.NtResumeProcess(handle) < 0) + throw new InvalidOperationException("修改后无法恢复游戏线程运行,请退出游戏后重新启动。"); + } + } + + public void DisableAll() + { + if (!Connected) return; + var failures = new List(); + for (int i = 0; i < Patches.Length; i++) + { + try { Set(i, false); } + catch (Exception error) { failures.Add(Patches[i].Name + ":" + error.Message); } + } + if (failures.Count > 0) throw new InvalidOperationException(String.Join(Environment.NewLine, failures)); + } + + public void Dispose() + { + if (handle != IntPtr.Zero) + { + if (Patches != null && Patches.Any(x => x.Enabled || x.RequiresRestart) && Connected) + throw new InvalidOperationException("仍有修改处于启用状态,请先全部停用。"); + // Retain exposed code until process exit: a suspended call stack may still return into it. + if (allocation != IntPtr.Zero && !payloadExposed) + Native.VirtualFreeEx(handle, allocation, UIntPtr.Zero, 0x8000); + Native.CloseHandle(handle); + } + if (process != null) process.Dispose(); + process = null; handle = IntPtr.Zero; allocation = IntPtr.Zero; Patches = null; + payloadExposed = false; + } + } +} diff --git a/others/game-tools/refrain-trainer/source/build.cmd b/others/game-tools/refrain-trainer/source/build.cmd new file mode 100644 index 0000000..43054ac --- /dev/null +++ b/others/game-tools/refrain-trainer/source/build.cmd @@ -0,0 +1,11 @@ +@echo off +setlocal +set "COMPILER=%WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe" +if not exist "%COMPILER%" ( + echo .NET Framework C# compiler was not found. + exit /b 1 +) +if not exist "%~dp0..\dist" mkdir "%~dp0..\dist" +"%COMPILER%" /nologo /target:winexe /platform:x86 /optimize+ /warnaserror+ /r:System.dll /r:System.Core.dll /r:System.Drawing.dll /r:System.Windows.Forms.dll /out:"%~dp0..\dist\RefrainTrainer.exe" "%~dp0Program.cs" "%~dp0Trainer.cs" "%~dp0Patches.cs" +if errorlevel 1 exit /b 1 +echo Built dist\RefrainTrainer.exe diff --git a/others/game-tools/refrain-trainer/source/tests/NativeChecks.cs b/others/game-tools/refrain-trainer/source/tests/NativeChecks.cs new file mode 100644 index 0000000..c9a6baa --- /dev/null +++ b/others/game-tools/refrain-trainer/source/tests/NativeChecks.cs @@ -0,0 +1,166 @@ +using System; +using System.Diagnostics; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; +using System.Windows.Forms; +using RefrainTrainer; + +static class NativeChecks +{ + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr VirtualAlloc(IntPtr address, UIntPtr size, uint allocation, uint protection); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint protection, out uint previous); + [DllImport("kernel32.dll", SetLastError = true)] + static extern UIntPtr VirtualQueryEx(IntPtr process, IntPtr address, byte[] info, UIntPtr length); + static readonly byte[] Original = { 0x55, 0x8B, 0xEC, 0x56, 0x6A, 0 }; + static readonly byte[][] Originals = { PatchCode.HitOriginal, PatchCode.LootOriginal, PatchCode.RarityOriginal, PatchCode.CrestOriginal }; + static int checks; + + static void Check(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + checks++; + } + + static void SetField(object target, string name, object value) + { + target.GetType().GetField(name, BindingFlags.NonPublic | BindingFlags.Instance).SetValue(target, value); + } + + static byte[] Read(IntPtr handle, uint address, int length) + { + byte[] data = new byte[length]; + UIntPtr count; + Check(Native.ReadProcessMemory(handle, Native.Pointer(address), data, (UIntPtr)length, out count), "Read fixture memory"); + Check(count.ToUInt64() == (uint)length, "Complete fixture read"); + return data; + } + + static void Protection(IntPtr handle, uint address) + { + byte[] info = new byte[28]; + Check(VirtualQueryEx(handle, Native.Pointer(address), info, (UIntPtr)info.Length) != UIntPtr.Zero, "Query protection"); + Check(BitConverter.ToUInt32(info, 20) == 0x20, "Original RX protection restored"); + } + + [STAThread] + static int Main(string[] args) + { + if (args.Length == 2 && args[0] == "--fixture") + { + IntPtr memory = VirtualAlloc(IntPtr.Zero, (UIntPtr)4096, 0x3000, 0x04); + for (int i = 0; i < Originals.Length; i++) Marshal.Copy(Originals[i], 0, IntPtr.Add(memory, i * 16), Originals[i].Length); + uint previous; + if (!VirtualProtect(memory, (UIntPtr)4096, 0x20, out previous)) return 2; + File.WriteAllText(args[1], memory.ToInt32().ToString("X8")); + Thread.Sleep(Timeout.Infinite); + return 0; + } + Process[] games = Process.GetProcessesByName("refrain"); + foreach (Process game in games) game.Dispose(); + if (games.Length != 0) + { + Console.Error.WriteLine("Close the game before running native checks; no game process was opened."); + return 2; + } + string work = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string marker = Path.Combine(work, "native-fixture-" + Guid.NewGuid().ToString("N") + ".txt"); + Process child = null; + Trainer trainer = new Trainer(); + IntPtr processHandle = IntPtr.Zero; + try + { + child = Process.Start(new ProcessStartInfo(Assembly.GetExecutingAssembly().Location, "--fixture \"" + marker + "\"") + { + UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden + }); + for (int i = 0; i < 100 && !File.Exists(marker); i++) Thread.Sleep(50); + Check(File.Exists(marker), "Fixture startup"); + uint address = UInt32.Parse(File.ReadAllText(marker), System.Globalization.NumberStyles.HexNumber); + processHandle = Native.OpenProcess(0x438 | 0x800, false, child.Id); + Check(processHandle != IntPtr.Zero, "Open owned fixture process"); + SetField(trainer, "process", Process.GetProcessById(child.Id)); + SetField(trainer, "handle", processHandle); + var patches = Enumerable.Range(0, Originals.Length).Select(i => new GamePatch("fixture", address + (uint)i * 16, (byte[])Originals[i].Clone(), + Enumerable.Repeat((byte)0x90, Originals[i].Length).ToArray())).ToArray(); + typeof(Trainer).GetProperty("Patches").SetValue(trainer, patches, null); + for (int round = 0; round < 10; round++) + { + for (int i = 0; i < patches.Length; i++) + { + trainer.Set(i, true); + Check(Read(processHandle, patches[i].Address, patches[i].Original.Length).SequenceEqual(patches[i].EnabledBytes), "Enable bytes"); + Check(patches[i].Enabled, "Enable state"); + Protection(processHandle, patches[i].Address); + trainer.Set(i, true); + } + trainer.DisableAll(); + foreach (GamePatch patch in patches) + { + Check(Read(processHandle, patch.Address, patch.Original.Length).SequenceEqual(patch.Original), "Restore bytes"); + Check(!patch.Enabled, "Restore state"); + Protection(processHandle, patch.Address); + } + } + patches[0].Original[0] = 0xCC; + bool refused = false; + try { trainer.Set(0, true); } + catch (InvalidOperationException) { refused = true; } + patches[0].Original[0] = 0x55; + Check(refused && !patches[0].Enabled, "Reject unexpected bytes"); + Check(Read(processHandle, address, 6).SequenceEqual(Original), "Rejection preserves bytes"); + trainer.Dispose(); + processHandle = IntPtr.Zero; + Check(!trainer.Connected, "Dispose closes connection"); + child.Kill(); child.WaitForExit(5000); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + using (var form = new TrainerWindow()) + { + form.StartPosition = FormStartPosition.Manual; + form.Location = new Point(-30000, -30000); + form.ShowInTaskbar = false; + form.Opacity = 0; + form.Show(); + Application.DoEvents(); + form.PerformLayout(); + using (var bitmap = new Bitmap(form.Width, form.Height)) + { + form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, form.Size)); + bitmap.Save(Path.Combine(work, "trainer-preview.png")); + } + var options = (CheckBox[])typeof(TrainerWindow).GetField("options", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(form); + Check(options.All(x => !x.Checked && !x.Enabled), "Options disabled before connection"); + var connect = (Button)typeof(TrainerWindow).GetField("connect", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(form); + connect.PerformClick(); + var status = (TextBox)typeof(TrainerWindow).GetField("status", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(form); + Check(status.Text.Contains("未找到游戏"), "Missing game has actionable message"); + Check(options.All(x => !x.Checked && !x.Enabled), "Failed connection keeps options off"); + form.Close(); + } + File.WriteAllText(Path.Combine(work, "native-test-report.txt"), "PASS: " + checks + " assertions; 40 enables and 40 disables on an owned x86 fixture; memory readback, protection restoration, context reads, mismatch rejection, disconnected UI. No game process was modified."); + Console.WriteLine(File.ReadAllText(Path.Combine(work, "native-test-report.txt"))); + return 0; + } + catch (Exception error) + { + Console.Error.WriteLine(error); + return 1; + } + finally + { + if (child != null) + { + if (!child.HasExited) { child.Kill(); child.WaitForExit(5000); } + child.Dispose(); + } + try { trainer.Dispose(); } catch { if (processHandle != IntPtr.Zero) Native.CloseHandle(processHandle); } + if (File.Exists(marker)) File.Delete(marker); + } + } +} diff --git a/others/game-tools/refrain-trainer/source/tests/run-native.cmd b/others/game-tools/refrain-trainer/source/tests/run-native.cmd new file mode 100644 index 0000000..33e7aa6 --- /dev/null +++ b/others/game-tools/refrain-trainer/source/tests/run-native.cmd @@ -0,0 +1,13 @@ +@echo off +setlocal +set "COMPILER=%WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe" +if not exist "%COMPILER%" ( + echo .NET Framework C# compiler was not found. + exit /b 1 +) +set "TESTOUT=%~dp0..\..\test-output" +if not exist "%TESTOUT%" mkdir "%TESTOUT%" +"%COMPILER%" /nologo /target:exe /platform:x86 /optimize+ /warnaserror+ /main:NativeChecks /r:System.dll /r:System.Core.dll /r:System.Drawing.dll /r:System.Windows.Forms.dll /out:"%TESTOUT%\NativeChecks.exe" "%~dp0NativeChecks.cs" "%~dp0..\Program.cs" "%~dp0..\Trainer.cs" "%~dp0..\Patches.cs" +if errorlevel 1 exit /b 1 +"%TESTOUT%\NativeChecks.exe" +exit /b %errorlevel% diff --git a/others/game-tools/refrain-trainer/source/tests/test_patches.py b/others/game-tools/refrain-trainer/source/tests/test_patches.py new file mode 100644 index 0000000..6e915c2 --- /dev/null +++ b/others/game-tools/refrain-trainer/source/tests/test_patches.py @@ -0,0 +1,340 @@ +from pathlib import Path +import argparse +import hashlib +import json +import re +import struct + +import pefile +from unicorn import Uc, UC_ARCH_X86, UC_MODE_32, UC_HOOK_CODE +from unicorn.x86_const import * + +parser = argparse.ArgumentParser() +parser.add_argument("--game-exe", type=Path, required=True) +parser.add_argument("--fixtures", type=Path, required=True) +parser.add_argument("--report", type=Path, default=Path("patch-test-report.json")) +args = parser.parse_args() +FIX = args.fixtures +GAME = args.game_exe +BASE, CAVE, STACK, DATA = 0x400000, 0x2000000, 0x3000000, 0x4000000 +SENTINEL = 0x2000C00 +raw = GAME.read_bytes() +pe = pefile.PE(data=raw) +mapped = pe.get_memory_mapped_image() +catalog = (FIX / "catalog.bin").read_bytes() +hit = (FIX / "hit.bin").read_bytes() +loot = (FIX / "loot.bin").read_bytes() +rarity = (FIX / "rarity.bin").read_bytes() +crest = (FIX / "crest.bin").read_bytes() +results = [] + + +def check(condition, message): + if not condition: + raise AssertionError(message) + + +def dword(value): + return struct.pack("{proposed}: expected {expected}") + check(read32(uc, CAVE + 0xF004) == triggers, "Hit counter") + check(uc.reg_read(UC_X86_REG_ESP) == sp + 8, "HP stack balance") + for register, value in preserved.items(): + check(uc.reg_read(register) == value, "HP callee-saved register") + results.append({"test": "enemy_hp", "current": current, "proposed": proposed, "result": expected, "passed": True}) + + +def parse_catalog(): + areas = {} + count = struct.unpack_from(" 0 else (["rng"] if eligible and dungeon else []) + ["result", "equipment"]) + check(calls == expected_calls, "Exactly one result and equipment for defeated enemies") + check(read32(uc, CAVE + 0xF000) == (0 if hp > 0 else 1), "Loot counter") + for register, value in saved.items(): + check(uc.reg_read(register) == (value + 4 if register == UC_X86_REG_EDI else value), f"Loot register {register} restored") + check(read32(uc, frame - 0x20) == remaining - 1, "Original enemy loop decrement") + check(bool(uc.reg_read(UC_X86_REG_EFLAGS) & 0x40) == (remaining == 1), "Original loop ZF") + for register, value in float_expected.items(): + check(uc.reg_read(register) == value, f"SSE/x87 register {register} restored") + results.append({"test": "bonus_loot", "area": area, "rank": rank, "pick_last": pick_last, "hp": hp, "item": None if hp > 0 else expected_item, "passed": True}) + + +def test_crest(roll, hp=0, bonus=False): + uc = machine() + uc.mem_write(0x61FC15, redirect(0x61FC15, CAVE + 0x800, 10)) + if bonus: + uc.mem_write(0x61FE8F, redirect(0x61FE8F, CAVE + 0x400)) + frame, sp = STACK + 0x18000, STACK + 0x17004 + enemy, iterator, vector, manager = DATA, DATA + 0x400, DATA + 0x30000, DATA + 0x31000 + write32(uc, iterator, enemy) + write32(uc, enemy + 0xF8, hp) + write32(uc, enemy + 0x5C, 212035) + write32(uc, enemy + 0x60, 2) + write32(uc, frame - 0x44, vector) + write32(uc, frame - 0x1C, manager) + write32(uc, frame - 0x20, 2) + uc.mem_write(frame - 0x3C, struct.pack('