feat(all): add game SDK and harden client login and game rules

- Add costume burst upgrades with durable replay and fix permanent SR equipment gacha.
- Add server game.json rules for optional collaboration UR weapons, with packaging and documentation.
- Add GameNames/GameSdk NuGet tooling, readable types, reobfuscation, complete source/PDB navigation and shared-runtime installation.
- Split plugin responsibilities, prefer typeof/nameof, centralize MSBuild settings and ignore local paths; remove the unused LoginUI test project.
- Fix login recovery and TLS through native platform HTTP and a private game relay; read device region directly from the OS time zone.

Validation: go test ./... -count=1, go vet ./..., plugin Release builds, SDK/runtime self-tests, Roslyn embedded-source navigation, gofmt check and git diff --check.
This commit is contained in:
2026-10-03 23:04:32 +08:00
parent 5600f10b31
commit 992fa47fd5
83 changed files with 8970 additions and 2828 deletions
+3
View File
@@ -8,6 +8,9 @@
.env.*
!.env.example
go/config.json
plugins/**/Directory.Build.props
!plugins/Directory.Build.props
plugins/Directory.Build.local.props
# Project notes and local/runtime data are intentionally outside source control
docs/
+1 -1
View File
@@ -17,7 +17,7 @@ RUN apt-get update \
WORKDIR /app
COPY --from=build /out/bd2server /app/bd2server
COPY versions.json authentication.json resources.json /app/
COPY versions.json authentication.json resources.json game.json /app/
COPY go/seed /app/go/seed
RUN mkdir -p /app/data && chown 10001:10001 /app/data
+33
View File
@@ -0,0 +1,33 @@
# 服务端游戏规则配置
`game.json` 是服务端统一的游戏规则配置入口。后续可配置的游戏规则在此按功能分组扩展;认证、资源源、版本清单仍分别使用现有配置文件。
发布部署时,将文件放在 `bd2server.exe`(Linux/macOS 为 `bd2server`)旁。开发入口读取仓库根目录的同名文件。修改后重启服务端生效,客户端无需配置此文件。发布包只在服务端包中提供 `game.json` 和本指南。
默认内容:
```json
{
"schema_version": 1,
"gacha": {
"include_collaboration_ur_weapons": false
}
}
```
## 联动 UR 专武
`gacha.include_collaboration_ur_weapons` 必须是 JSON 布尔值:
| 值 | UR 必得装备券的结果 |
|---|---|
| `false`(默认) | 使用当前 GameData 的原始 UR 必得装备券池。 |
| `true` | 在原池中加入 14 种联动角色 UR 专武(装备 ID `943200` 至 `943213`)。 |
开关仅作用于 UR 必得装备券。新增装备沿用同星级原有装备的单件权重,原有星级权重规则保持不变;加入候选后,同星级内的概率按扩展后的权重总和计算。该设置只改变券池候选,不会直接发放装备。专武绑定角色,同一角色的多个服装共用专武。
## 校验与升级
`schema_version` 当前必须为 `1`。字段拼写错误、未知字段、错误类型(例如字符串 `"true"`)、`null`、格式错误或文件末尾多出的 JSON 都会使服务端拒绝启动,并在错误中给出文件路径。省略 `gacha` 或此布尔开关时使用 `false`。
旧安装缺少整个文件时使用默认规则,服务端不会自动写入或覆盖文件。升级服务端时保留服主已经修改的 `game.json`;新发布包中的文件可用于比对新增字段。Docker 部署可将自定义文件只读挂载到 `/app/game.json`,重启容器后生效。
+5 -3
View File
@@ -28,7 +28,7 @@ $env:GOCACHE = (Join-Path $PWD '.cache\go-build')
go run .\cmd\bd2client --dev run
```
服务端开发入口会自动使用仓库根目录的版本、认证、资源、数据和存档配置;客户端开发入口会直接使用仓库版本清单和插件产物,并从本机开发配置读取游戏目录,随后增量构建两个客户端插件。首次开发前创建该配置:
服务端开发入口会自动使用仓库根目录的版本、认证、资源、数据和存档配置;客户端开发入口会直接使用仓库版本清单和插件产物,并从本机开发配置读取游戏目录,随后用 SDK 内嵌的当前版本名字表增量构建两个客户端插件,无需提供官方映射文件。首次开发前创建该配置:
```powershell
Copy-Item .\go\config.example.json .\go\config.json
@@ -44,7 +44,7 @@ Copy-Item .\go\config.example.json .\go\config.json
.\build-release.ps1
```
脚本在 Windows 本机构建版本化归档,例如 `.build\bd2server-2.35.10+server.0.1.0-windows-x64.zip` 和 `.build\bd2client-2.35.10+client.0.1.0-windows-x64.zip`。纯服务端包只包含 `bd2server.exe`、seed、认证/资源策略和服务端数据目录;客户端包只包含 `bd2client.exe`、客户端插件和版本文件。
脚本在 Windows 本机构建版本化归档,例如 `.build\bd2server-2.35.10+server.0.1.0-windows-x64.zip` 和 `.build\bd2client-2.35.10+client.0.1.0-windows-x64.zip`。纯服务端包只包含 `bd2server.exe`、seed、认证/资源策略、游戏规则配置和服务端数据目录;客户端包只包含 `bd2client.exe`、客户端插件和版本文件。
macOS 客户端使用系统 Cocoa / WKWebView,需要 macOS 原生构建环境。本地 Windows 发布脚本不会交叉编译或生成 macOS 发布包;找到 macOS 测试者或准备公开发布时,再通过 GitHub Actions 的 macOS runner 完成原生构建、测试及 `.app` 打包。签名和公证也应在该流程中配置。
@@ -61,7 +61,7 @@ BD2 Client Studio 是使用 Wails 的独立桌面客户端设置工具。Windows
- 选择并验证 Brown Dust II 安装目录。
- 填写服务器 origin 和端口,自动写入游戏目录下的 `BepInEx/config/bd2.client.json`。
- 应用带原文件备份的客户端入口补丁。
- 检查 BepInEx 后安装或更新 `BD2LocalIdentity.dll` 与 `BD2LoginUI.dll`。
- 检查 BepInEx 后安装或更新 `BD2LocalIdentity.dll`、`BD2LoginUI.dll` 及其共享运行时 `BD2.GameNames.dll`。
- 选择官方 CDN、服务器资源源或本地已下载资源。
服主同步官方资源、自建静态 CDN 或配置缓存反代的步骤见 [资源与 CDN 配置指南](RESOURCES.md)。
@@ -77,6 +77,8 @@ BD2 Client Studio 是使用 Wails 的独立桌面客户端设置工具。Windows
认证策略由 `bd2server.exe` 同目录的 `authentication.json` 权威决定。`mode=local`(默认)不依赖第三方登录,单人本地服保持不动,联机服可设为 `oauth` 来让玩家通过 Discord/Google 登录。
游戏规则统一使用服务端同目录的 `game.json`,修改后重启生效;开发入口读取仓库根目录的文件。联动 UR 专武是否加入 UR 必得装备券池及升级保留配置的方法见 [服务端游戏规则配置](GAME_CONFIGURATION.md)。
### 启动客户端
先手动安装 [BepInEx](https://github.com/BepInEx/BepInEx/releases)。然后打开我们的客户端工具 `bd2client.exe`。
+2
View File
@@ -32,4 +32,6 @@
`authentication.json` 和 `resources.json` 必须与 `bd2server.exe` 保持在同一目录。默认 `local` 认证模式继续自动本地登录;公网或联机服可以启用 OAuth,并把资源策略设为官方 CDN 或统一的服务器资源源。服主自建与反代使用相同的 `server` 模式;玩家本地资源目录仅由 `bd2client.exe` 配置。逐步配置方法见发布包内的 [服主第三方登录配置指南](AUTHENTICATION.md) 和 [服主资源与 CDN 配置指南](RESOURCES.md)。
游戏规则使用同目录的 `game.json`,修改后重启服务端生效。默认关闭联动 UR 专武扩展;需要将 14 种联动角色专武加入 UR 必得装备券池时,按 [服务端游戏规则配置](GAME_CONFIGURATION.md) 设置。升级时保留已修改的文件;旧安装缺少文件时使用默认规则。
OAuth access token 只在客户端内存中;Windows refresh credential 使用当前用户 DPAPI 加密后写入 PlayerPrefs 注册表,macOS 使用 Keychain Services。服务端 `auth.db` 只保存 token、device secret、OAuth state 及 provider subject 的 HMAC,临时可恢复材料使用 AES-256-GCM。
+13 -1
View File
@@ -17,6 +17,7 @@ $clientPluginDir = Join-Path $clientPackage 'plugins'
$versionConfig = Join-Path $root 'versions.json'
$authenticationConfig = Join-Path $root 'authentication.json'
$resourceConfig = Join-Path $root 'resources.json'
$gameConfig = Join-Path $root 'game.json'
try {
$releaseVersions = Get-Content -LiteralPath $versionConfig -Raw | ConvertFrom-Json -ErrorAction Stop
@@ -91,20 +92,31 @@ $clientPlugins = @(
@{ Name = 'LoginUI'; Project = Join-Path $root 'plugins\LoginUI\LoginUI.csproj'; Output = Join-Path $root 'plugins\LoginUI\bin\Release\netstandard2.1\BD2LoginUI.dll'; FileName = 'BD2LoginUI.dll' }
)
foreach ($plugin in $clientPlugins) {
dotnet build $plugin.Project -c Release "-p:GameDir=$GameDir" --nologo
$pluginBuildArgs = @('build', $plugin.Project, '-c', 'Release', "-p:GameDir=$GameDir", '--nologo')
dotnet @pluginBuildArgs
if ($LASTEXITCODE -ne 0) { throw "$($plugin.Name) build failed with exit code $LASTEXITCODE" }
if (-not (Test-Path -LiteralPath $plugin.Output -PathType Leaf)) { throw "$($plugin.Name) build output is missing" }
Copy-Item -LiteralPath $plugin.Output -Destination (Join-Path $clientPluginDir $plugin.FileName) -Force
$sharedRuntime = Join-Path (Split-Path -Parent $plugin.Output) 'BD2.GameNames.dll'
if (-not (Test-Path -LiteralPath $sharedRuntime -PathType Leaf)) { throw "Shared game names runtime is missing: $sharedRuntime" }
$packagedRuntime = Join-Path $clientPluginDir 'BD2.GameNames.dll'
if ((Test-Path -LiteralPath $packagedRuntime) -and
(Get-FileHash -LiteralPath $packagedRuntime).Hash -ne (Get-FileHash -LiteralPath $sharedRuntime).Hash) {
throw 'Client plugins were built with different BD2.GameNames libraries.'
}
Copy-Item -LiteralPath $sharedRuntime -Destination $packagedRuntime -Force
}
Copy-Item -LiteralPath (Join-Path $goRoot 'seed') -Destination $serverGoDir -Recurse -Force
Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $serverPackage 'versions.json') -Force
Copy-Item -LiteralPath $authenticationConfig -Destination (Join-Path $serverPackage 'authentication.json') -Force
Copy-Item -LiteralPath $resourceConfig -Destination (Join-Path $serverPackage 'resources.json') -Force
Copy-Item -LiteralPath $gameConfig -Destination (Join-Path $serverPackage 'game.json') -Force
Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $clientPackage 'versions.json') -Force
Copy-Item -LiteralPath (Join-Path $root 'RELEASE.md') -Destination (Join-Path $serverPackage 'README.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'AUTHENTICATION.md') -Destination (Join-Path $serverPackage 'AUTHENTICATION.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'RESOURCES.md') -Destination (Join-Path $serverPackage 'RESOURCES.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'GAME_CONFIGURATION.md') -Destination (Join-Path $serverPackage 'GAME_CONFIGURATION.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'docs\CLIENT.md') -Destination (Join-Path $clientPackage 'README.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $serverPackage 'LICENSE') -Force
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $clientPackage 'LICENSE') -Force
+6
View File
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"gacha": {
"include_collaboration_ur_weapons": false
}
}
@@ -0,0 +1,28 @@
//go:build !release
package main
import (
"os"
"testing"
)
// Opt in on a developer machine with the game installed. Exercise the exact
// --dev run preparation path without starting the interactive Wails window.
func TestDevelopmentRunBuildsPluginsWithEmbeddedNames(t *testing.T) {
if os.Getenv("BD2_TEST_DEVELOPMENT_BUILD") != "1" {
t.Skip("set BD2_TEST_DEVELOPMENT_BUILD=1 to build against go/config.json")
}
args, options, err := developmentRunOptions([]string{"--dev", "run"})
if err != nil {
t.Fatal(err)
}
if len(args) != 0 {
t.Fatalf("unexpected runtime arguments: %v", args)
}
for _, path := range []string{options.localIdentityPlugin, options.loginUIPlugin} {
if !clientDevelopmentFile(path) {
t.Fatalf("development plugin output missing: %s", path)
}
}
}
+1
View File
@@ -25,6 +25,7 @@ func runDevelopmentCommand(args []string) (bool, error) {
serveArgs = appendDefaultFlag(serveArgs, "--version-config", filepath.Join(root, "versions.json"))
serveArgs = appendDefaultFlag(serveArgs, "--authentication-config", filepath.Join(root, "authentication.json"))
serveArgs = appendDefaultFlag(serveArgs, "--resource-config", filepath.Join(root, "resources.json"))
serveArgs = appendDefaultFlag(serveArgs, "--game-config", filepath.Join(root, "game.json"))
serveArgs = appendDefaultFlag(serveArgs, "--data-dir", filepath.Join(root, "data"))
serveArgs = appendDefaultFlag(serveArgs, "--state", filepath.Join(root, "data", "state", "state.db"))
return true, serve(serveArgs)
+28
View File
@@ -24,6 +24,7 @@ import (
"bd2server/internal/server/deck"
"bd2server/internal/server/feature"
"bd2server/internal/server/gacha"
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/lifecycle"
"bd2server/internal/server/mail"
@@ -80,6 +81,7 @@ func serve(args []string) (serveErr error) {
versionConfigPath := fs.String("version-config", "", "repository versions.json override")
authConfigPath := fs.String("authentication-config", "", "authentication.json override for development")
resourceConfigPath := fs.String("resource-config", "", "resources.json override for development")
gameConfigPath := fs.String("game-config", "", "game.json server gameplay configuration override")
listen := fs.String("listen", "127.0.0.1:8080", "local listen address")
dataDir := fs.String("data-dir", "", "server data directory (defaults beside the executable)")
gameDataVersion := fs.String("game-data-version", "", "validated GameData version (defaults to versions.json)")
@@ -118,6 +120,16 @@ func serve(args []string) (serveErr error) {
}
}
versionconfig.Use(versions)
if *gameConfigPath == "" {
*gameConfigPath, err = gameconfig.BesideExecutable()
if err != nil {
return err
}
}
gameRules, err := gameconfig.Load(*gameConfigPath)
if err != nil {
return err
}
if *authConfigPath == "" {
*authConfigPath, err = authconfig.BesideExecutable()
if err != nil {
@@ -220,6 +232,13 @@ func serve(args []string) (serveErr error) {
if err != nil {
return fmt.Errorf("load active gacha GameData: %w", err)
}
if gameRules.Gacha.IncludeCollaborationURWeapons {
if err := equipmentGacha.IncludeCollaborationURWeapons(gameData, *gameDataVersion); err != nil {
return fmt.Errorf("apply collaboration UR weapon game rule: %w", err)
}
}
slog.Info("server gameplay rules loaded", "config", *gameConfigPath,
"include_collaboration_ur_weapons", gameRules.Gacha.IncludeCollaborationURWeapons)
limitedCostumes, err := gamedata.LoadLimitedCostumes(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load limited costume GameData: %w", err)
@@ -519,6 +538,14 @@ func serve(args []string) (serveErr error) {
if err != nil {
return err
}
costumeBurstDesign, err := gamedata.LoadCostumeBurstDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load costume burst GameData: %w", err)
}
costumeBurstService, err := player.NewCostumeBurstService(costumeBurstDesign, collection, ownedItems, wallet)
if err != nil {
return err
}
battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID)
battleService.AttachTutorialWin(func() error {
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113})
@@ -538,6 +565,7 @@ func serve(args []string) (serveErr error) {
inventorySlots,
charAwakeService,
costumePotentialService,
costumeBurstService,
starter,
mailService,
gachaService,
+22 -1
View File
@@ -11,6 +11,7 @@ import (
"bd2server/internal/server/authconfig"
"bd2server/internal/server/deck"
"bd2server/internal/server/gacha"
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
@@ -28,6 +29,7 @@ func preflight(args []string) error {
versionPath := fs.String("version-config", "", "repository versions.json override")
authPath := fs.String("authentication-config", "", "authentication.json override")
resourcePath := fs.String("resource-config", "", "resources.json override")
gamePath := fs.String("game-config", "", "game.json server gameplay configuration override")
if err := fs.Parse(args); err != nil {
return err
}
@@ -39,6 +41,16 @@ func preflight(args []string) error {
return err
}
versionconfig.Use(versions)
if *gamePath == "" {
*gamePath, err = gameconfig.BesideExecutable()
if err != nil {
return err
}
}
gameRules, err := gameconfig.Load(*gamePath)
if err != nil {
return err
}
if *authPath == "" {
*authPath, err = authconfig.BesideExecutable()
if err != nil {
@@ -106,15 +118,24 @@ func preflight(args []string) error {
for _, window := range schedule.StepUps {
steps = append(steps, window.GroupID)
}
if _, _, err := gamedata.LoadActiveGachaForSchedules(gameData, versions.GameDataVersion, groups, steps); err != nil {
_, equipment, err := gamedata.LoadActiveGachaForSchedules(gameData, versions.GameDataVersion, groups, steps)
if err != nil {
return err
}
if gameRules.Gacha.IncludeCollaborationURWeapons {
if err := equipment.IncludeCollaborationURWeapons(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight collaboration UR weapon game rule: %w", err)
}
}
if _, err := gamedata.LoadFirstGacha(gameData, versions.GameDataVersion); err != nil {
return err
}
if _, err := gamedata.LoadLimitedCostumes(gameData, versions.GameDataVersion); err != nil {
return err
}
if _, err := gamedata.LoadCostumeBurstDesign(gameData, versions.GameDataVersion); err != nil {
return err
}
return nil
}
+30 -3
View File
@@ -23,6 +23,7 @@ type Spec struct {
var (
LocalIdentity = Spec{fileName: "BD2LocalIdentity.dll"}
LoginUI = Spec{fileName: "BD2LoginUI.dll"}
GameNames = Spec{fileName: "BD2.GameNames.dll"}
)
func (s Spec) FileName() string { return s.fileName }
@@ -54,8 +55,8 @@ func ResolvePackaged(spec Spec, explicit string) (string, error) {
}
// Install verifies that the user installed BepInEx, then atomically stages the
// packaged plugin into its plugins directory. It never installs or downloads
// BepInEx itself.
// packaged plugin and its adjacent shared game-names runtime into its plugins
// directory. It never installs or downloads BepInEx itself.
func Install(spec Spec, gameDir, source string) (Result, error) {
if err := spec.validate(); err != nil {
return Result{}, err
@@ -80,7 +81,33 @@ func Install(spec Spec, gameDir, source string) (Result, error) {
if len(sourceData) == 0 {
return Result{}, fmt.Errorf("clientplugin: packaged %s is empty", spec.fileName)
}
pluginDir := installation.Plugins
// Read both artifacts before touching the installation. The runtime table
// library is shipped beside every plugin, including explicit development paths.
var runtimeData []byte
if spec == LocalIdentity || spec == LoginUI {
runtimeSource := filepath.Join(filepath.Dir(source), GameNames.fileName)
runtimeData, err = os.ReadFile(runtimeSource)
if err != nil {
return Result{}, fmt.Errorf("clientplugin: read shared runtime %s: %w", runtimeSource, err)
}
if len(runtimeData) == 0 {
return Result{}, fmt.Errorf("clientplugin: packaged %s is empty", GameNames.fileName)
}
}
changed := false
if runtimeData != nil {
runtimeResult, err := installData(GameNames, installation.Plugins, runtimeData)
if err != nil {
return Result{}, err
}
changed = runtimeResult.Changed
}
result, err := installData(spec, installation.Plugins, sourceData)
result.Changed = result.Changed || changed
return result, err
}
func installData(spec Spec, pluginDir string, sourceData []byte) (Result, error) {
destination := filepath.Join(pluginDir, spec.fileName)
if installed, err := os.ReadFile(destination); err == nil {
if bytes.Equal(hash(installed), hash(sourceData)) {
+49
View File
@@ -23,6 +23,9 @@ func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
}
}
source := filepath.Join(t.TempDir(), spec.FileName())
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(source, []byte("plugin"), 0o600); err != nil {
t.Fatal(err)
}
@@ -54,6 +57,9 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
}
}
source := filepath.Join(t.TempDir(), spec.FileName())
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(source, []byte("v1"), 0o600); err != nil {
t.Fatal(err)
}
@@ -65,6 +71,18 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
if err != nil || second.Changed {
t.Fatalf("idempotent install=%+v err=%v", second, err)
}
runtimeSource := filepath.Join(filepath.Dir(source), GameNames.FileName())
if err := os.WriteFile(runtimeSource, []byte("names-v2"), 0o600); err != nil {
t.Fatal(err)
}
runtimeUpdate, err := Install(spec, gameDir, source)
if err != nil || !runtimeUpdate.Changed {
t.Fatalf("runtime-only update=%+v err=%v", runtimeUpdate, err)
}
installedRuntime, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", GameNames.FileName()))
if err != nil || string(installedRuntime) != "names-v2" {
t.Fatalf("installed runtime=%q err=%v", installedRuntime, err)
}
if err := os.WriteFile(source, []byte("v2"), 0o600); err != nil {
t.Fatal(err)
}
@@ -80,6 +98,34 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
}
}
func TestInstallMissingSharedRuntimeDoesNotChangePlugin(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()): []byte("old-plugin"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(t.TempDir(), LocalIdentity.FileName())
if err := os.WriteFile(source, []byte("new-plugin"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Install(LocalIdentity, gameDir, source); err == nil || !strings.Contains(err.Error(), GameNames.FileName()) {
t.Fatalf("missing runtime error=%v", err)
}
installed, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()))
if err != nil || string(installed) != "old-plugin" {
t.Fatalf("installed=%q err=%v", installed, err)
}
}
func TestInstallKeepsPluginsSeparate(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
@@ -96,6 +142,9 @@ func TestInstallKeepsPluginsSeparate(t *testing.T) {
}
for _, spec := range []Spec{LocalIdentity, LoginUI} {
source := filepath.Join(t.TempDir(), spec.FileName())
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(source, []byte(spec.FileName()), 0o600); err != nil {
t.Fatal(err)
}
@@ -0,0 +1,79 @@
package gacha
import (
"bytes"
"fmt"
"os"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestPermanentEquipmentBuyUsesActiveCatalogWithoutSchedule(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
regular, catalog, err := gamedata.LoadActiveGachaForSchedules(root, "20260923193640", nil, nil)
if err != nil {
t.Fatal(err)
}
for _, ticketCount := range []uint64{5, 10} {
t.Run(fmt.Sprintf("tickets_%d", ticketCount), func(t *testing.T) {
storage := stateio.NewMemory()
service := newMultiBuyTestService(t, storage)
service.regular = regular
service.schedule.Schedules = nil
equipment, err := player.OpenEquipmentInventory(storage)
if err != nil {
t.Fatal(err)
}
service.AttachEquipmentGacha(catalog, equipment)
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
items, err := inventory.GrantOnce("tickets", []gamedata.BattleReward{{Type: 8, ID: 1000, Count: ticketCount + 1}})
if err != nil {
t.Fatal(err)
}
service.AttachInventory(inventory)
ticket := items[0]
ticket.Count = ticketCount
request := wire.AppendVarint(nil, 1, 90)
request = wire.AppendVarint(request, 2, 201)
request = wire.AppendVarint(request, 3, 1)
request = wire.AppendBytes(request, 4, player.ItemWire(ticket))
code, response, handled, err := service.Handle("/GachaBuy", request)
if err != nil || !handled || code != 146 {
t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
}
bundle, _, _ := wire.Bytes(response, 1)
if countFields(bundle, 4) != 10 || len(equipment.All()) != 10 {
t.Fatal("ten equipment instances missing")
}
if got := service.wallet.Snapshot().FreeJewelry; got != 1000-(10-ticketCount)*200 {
t.Fatalf("free diamonds=%d", got)
}
user := service.collection.GachaUser(10002)
if user.Point != 10 || user.TotalBuyCount != 10 {
t.Fatalf("user=%+v", user)
}
if countFields(response, 3) != 4 {
t.Fatal("shared pity snapshot missing")
}
for _, item := range inventory.All() {
if item.InvenIndex == ticket.InvenIndex && item.Count != 1 {
t.Fatalf("remaining tickets=%d", item.Count)
}
}
_, replay, _, err := service.Handle("/GachaBuy", request)
if err != nil || !bytes.Equal(response, replay) || len(equipment.All()) != 10 {
t.Fatalf("retry changed draw: %v", err)
}
})
}
}
+95
View File
@@ -0,0 +1,95 @@
// Package gameconfig loads server-authoritative gameplay rules.
package gameconfig
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
const FileName = "game.json"
type Config struct {
SchemaVersion int `json:"schema_version"`
Gacha GachaConfig `json:"gacha"`
}
type GachaConfig struct {
IncludeCollaborationURWeapons bool `json:"include_collaboration_ur_weapons"`
}
func Default() Config { return Config{SchemaVersion: 1} }
// Load reads one explicit path. Older installations without the file retain
// the default rules; an existing invalid file must never silently fall back.
func Load(path string) (Config, error) {
data, err := os.ReadFile(filepath.Clean(path))
if errors.Is(err, os.ErrNotExist) {
return Default(), nil
}
if err != nil {
return Config{}, fmt.Errorf("game configuration: read %s: %w", path, err)
}
var cfg Config
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&cfg); err != nil {
return Config{}, fmt.Errorf("game configuration: decode %s: %w", path, err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return Config{}, fmt.Errorf("game configuration: trailing data in %s", path)
}
// encoding/json accepts null for scalar and struct fields. These rules have
// explicit defaults for omissions, while null is a configuration error.
var document any
if err := json.Unmarshal(data, &document); err != nil {
return Config{}, fmt.Errorf("game configuration: decode %s: %w", path, err)
}
if containsNull(document) {
return Config{}, fmt.Errorf("game configuration: null is not allowed in %s", path)
}
if err := cfg.Validate(); err != nil {
return Config{}, fmt.Errorf("game configuration: %s: %w", path, err)
}
return cfg, nil
}
func containsNull(value any) bool {
switch value := value.(type) {
case nil:
return true
case map[string]any:
for _, child := range value {
if containsNull(child) {
return true
}
}
case []any:
for _, child := range value {
if containsNull(child) {
return true
}
}
}
return false
}
func (c Config) Validate() error {
if c.SchemaVersion != 1 {
return fmt.Errorf("unsupported schema_version %d (expected 1)", c.SchemaVersion)
}
return nil
}
func BesideExecutable() (string, error) {
executable, err := os.Executable()
if err != nil {
return "", fmt.Errorf("game configuration: resolve server executable: %w", err)
}
return filepath.Join(filepath.Dir(executable), FileName), nil
}
@@ -0,0 +1,76 @@
package gameconfig
import (
"os"
"path/filepath"
"testing"
)
func TestLoad(t *testing.T) {
for _, tc := range []struct {
name, text string
wantTrue, wantError bool
}{
{"enabled", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":true}}`, true, false},
{"disabled", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":false}}`, false, false},
{"omitted bool", `{"schema_version":1,"gacha":{}}`, false, false},
{"omitted gacha", `{"schema_version":1}`, false, false},
{"string bool", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":"true"}}`, false, true},
{"unknown root", `{"schema_version":1,"unknown":true}`, false, true},
{"unknown rule", `{"schema_version":1,"gacha":{"typo":true}}`, false, true},
{"trailing object", `{"schema_version":1} {}`, false, true},
{"trailing garbage", `{"schema_version":1} invalid`, false, true},
{"null root", `null`, false, true},
{"null section", `{"schema_version":1,"gacha":null}`, false, true},
{"null bool", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":null}}`, false, true},
{"null schema", `{"schema_version":null}`, false, true},
{"wrong version", `{"schema_version":2}`, false, true},
{"missing version", `{}`, false, true},
{"malformed", `{`, false, true},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), FileName)
if err := os.WriteFile(path, []byte(tc.text), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if (err != nil) != tc.wantError {
t.Fatalf("Load error = %v, wantError %v", err, tc.wantError)
}
if err == nil && (cfg.SchemaVersion != 1 || cfg.Gacha.IncludeCollaborationURWeapons != tc.wantTrue) {
t.Fatalf("unexpected config: %+v", cfg)
}
})
}
}
func TestMissingFileDefaultsWithoutCreating(t *testing.T) {
path := filepath.Join(t.TempDir(), FileName)
cfg, err := Load(path)
if err != nil || cfg != Default() {
t.Fatalf("config = %+v, error = %v", cfg, err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("Load created missing config: %v", err)
}
}
func TestReadError(t *testing.T) {
if _, err := Load(t.TempDir()); err == nil {
t.Fatal("directory must not use defaults")
}
}
func TestBesideExecutable(t *testing.T) {
path, err := BesideExecutable()
if err != nil {
t.Fatal(err)
}
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
if path != filepath.Join(filepath.Dir(executable), FileName) {
t.Fatalf("unexpected path %s", path)
}
}
@@ -0,0 +1,139 @@
package gamedata
import (
"database/sql"
"errors"
"fmt"
)
// CostumeBurstLevel is one row of CostumeBurstTable. Level is the database
// row id (and is the level unlocked by upgrading from Level-1).
type CostumeBurstLevel struct {
CostumeID uint64
Level uint64
Costs []PromotionCost
}
// CostumeBurstUpgradeRule is the exact cost for the next burst level.
type CostumeBurstUpgradeRule struct {
CostumeID uint64
CurrentLevel uint64
NextLevel uint64
MaxLevel uint64
Costs []PromotionCost
}
// CostumeBurstDesign contains only costumes which have a complete, contiguous
// CostumeBurstTable group. GroupId is the CostumeTable id for burst-enabled
// costumes (the table also carries CostumeType for newer costume families).
type CostumeBurstDesign struct {
Levels map[uint64]map[uint64]CostumeBurstLevel
}
func LoadCostumeBurstDesign(root, version string) (*CostumeBurstDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
return loadCostumeBurstDesign(db)
}
func loadCostumeBurstDesign(db *sql.DB) (*CostumeBurstDesign, error) {
if db == nil {
return nil, errors.New("gamedata: nil costume burst database")
}
design := &CostumeBurstDesign{Levels: make(map[uint64]map[uint64]CostumeBurstLevel)}
rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM CostumeBurstTable ORDER BY groupId,id")
if err != nil {
return nil, err
}
for rows.Next() {
var groupID, level uint64
var raw []byte
if err := rows.Scan(&groupID, &level, &raw); err != nil {
rows.Close()
return nil, err
}
if groupID == 0 || level == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid costume burst identity %d/%d", groupID, level)
}
groups, groupErr := packedInts(raw, 8)
ids, idErr := packedInts(raw, 9)
counts, countErr := packedInts(raw, 10)
itemIDs, itemIDErr := packedInts(raw, 11)
types, typeErr := packedInts(raw, 12)
if groupErr != nil || idErr != nil || countErr != nil || itemIDErr != nil || typeErr != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid fields", groupID, level)
}
if len(groups) != 1 || groups[0] != groupID || len(ids) != 1 || ids[0] != level {
rows.Close()
return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched proto identity", groupID, level)
}
if len(counts) == 0 || len(counts) != len(itemIDs) || len(itemIDs) != len(types) {
rows.Close()
return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched costs", groupID, level)
}
entry := CostumeBurstLevel{CostumeID: groupID, Level: level}
for i := range counts {
if counts[i] == 0 || (types[i] == 4 && itemIDs[i] != 0) ||
(types[i] == 8 && itemIDs[i] == 0) || (types[i] != 4 && types[i] != 8) {
rows.Close()
return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid cost", groupID, level)
}
entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: itemIDs[i], Count: counts[i]})
}
if _, exists := design.Levels[groupID][level]; exists {
rows.Close()
return nil, fmt.Errorf("gamedata: duplicate costume burst %d/%d", groupID, level)
}
if design.Levels[groupID] == nil {
design.Levels[groupID] = make(map[uint64]CostumeBurstLevel)
}
design.Levels[groupID][level] = entry
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
if len(design.Levels) == 0 {
return nil, errors.New("gamedata: CostumeBurstTable is empty")
}
for costumeID, levels := range design.Levels {
for level := uint64(1); level <= uint64(len(levels)); level++ {
if _, ok := levels[level]; !ok {
return nil, fmt.Errorf("gamedata: costume burst %d missing level %d", costumeID, level)
}
}
}
return design, nil
}
// UpgradeRule returns the costs for currentLevel -> currentLevel+1.
func (d *CostumeBurstDesign) UpgradeRule(costumeID, currentLevel uint64) (CostumeBurstUpgradeRule, error) {
if d == nil || costumeID == 0 {
return CostumeBurstUpgradeRule{}, errors.New("gamedata: invalid costume burst lookup")
}
levels, ok := d.Levels[costumeID]
if !ok {
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d has no burst design", costumeID)
}
maxLevel := uint64(len(levels))
if currentLevel >= maxLevel {
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d burst is already at maximum level %d", costumeID, maxLevel)
}
next := currentLevel + 1
entry, ok := levels[next]
if !ok {
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d missing burst level %d", costumeID, next)
}
return CostumeBurstUpgradeRule{
CostumeID: costumeID, CurrentLevel: currentLevel, NextLevel: next,
MaxLevel: maxLevel, Costs: append([]PromotionCost(nil), entry.Costs...),
}, nil
}
@@ -0,0 +1,119 @@
package gamedata
import (
"database/sql"
"os"
"strings"
"testing"
)
func TestCostumeBurstDesignAndUpgradeRule(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE CostumeBurstTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))`); err != nil {
t.Fatal(err)
}
for level := 1; level <= 3; level++ {
raw := burstProto(8, 42, 9, uint64(level), 10, 60, 11, 710, 12, 8)
if _, err := db.Exec(`INSERT INTO CostumeBurstTable(groupId,id,ProtoBuf) VALUES(?,?,?)`, 42, level, raw); err != nil {
t.Fatal(err)
}
}
design, err := loadCostumeBurstDesign(db)
if err != nil {
t.Fatal(err)
}
rule, err := design.UpgradeRule(42, 1)
if err != nil {
t.Fatal(err)
}
if rule.NextLevel != 2 || rule.MaxLevel != 3 || len(rule.Costs) != 1 || rule.Costs[0] != (PromotionCost{Type: 8, ID: 710, Count: 60}) {
t.Fatalf("unexpected rule: %+v", rule)
}
if _, err := design.UpgradeRule(42, 3); err == nil {
t.Fatal("expected max-level error")
}
}
func TestCostumeBurstRejectsInvalidIdentityCostAndGap(t *testing.T) {
cases := []struct {
name string
raw []byte
rows [][2]int
}{
{"identity", burstProto(8, 99, 9, 1, 10, 1, 11, 710, 12, 8), [][2]int{{42, 1}}},
{"zero-count", burstProto(8, 42, 9, 1, 10, 0, 11, 710, 12, 8), [][2]int{{42, 1}}},
{"gold-id", burstProto(8, 42, 9, 1, 10, 1, 11, 710, 12, 4), [][2]int{{42, 1}}},
{"gap", burstProto(8, 42, 9, 2, 10, 1, 11, 710, 12, 8), [][2]int{{42, 2}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE CostumeBurstTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))`); err != nil {
t.Fatal(err)
}
for _, row := range tc.rows {
if _, err := db.Exec(`INSERT INTO CostumeBurstTable(groupId,id,ProtoBuf) VALUES(?,?,?)`, row[0], row[1], tc.raw); err != nil {
t.Fatal(err)
}
}
if _, err := loadCostumeBurstDesign(db); err == nil || !strings.Contains(err.Error(), "costume burst") {
t.Fatalf("expected costume burst validation error, got %v", err)
}
})
}
}
func TestCostumeBurstAgainstInstalledCurrentVersion(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
}
design, err := LoadCostumeBurstDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
if len(design.Levels) != 56 {
t.Fatalf("installed burst groups=%d, want 56", len(design.Levels))
}
rows := 0
for _, levels := range design.Levels {
rows += len(levels)
}
if rows != 168 {
t.Fatalf("installed burst rows=%d, want 168", rows)
}
rule, err := design.UpgradeRule(4202, 0)
if err != nil {
t.Fatal(err)
}
want := []PromotionCost{{Type: 4, Count: 1_500_000}, {Type: 8, ID: 710, Count: 60}}
if rule.NextLevel != 1 || rule.MaxLevel != 3 || len(rule.Costs) != len(want) || rule.Costs[0] != want[0] || rule.Costs[1] != want[1] {
t.Fatalf("installed rule=%+v", rule)
}
}
// burstProto builds the small varint-only fields used by the loader tests.
func burstProto(fields ...uint64) []byte {
if len(fields)%2 != 0 {
panic("burstProto requires field/value pairs")
}
var out []byte
for i := 0; i < len(fields); i += 2 {
field, value := fields[i], fields[i+1]
out = append(out, byte(field<<3))
for value >= 0x80 {
out = append(out, byte(value)|0x80)
value >>= 7
}
out = append(out, byte(value))
}
return out
}
@@ -0,0 +1,160 @@
package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
)
// IncludeCollaborationURWeapons extends only the UR equipment ticket pool.
// Limited costumes identify eligible owners; equipment IDs remain version data.
func (c *EquipmentGachaCatalog) IncludeCollaborationURWeapons(root, version string) error {
plain, err := ReadQuestDatabase(root, version)
if err != nil {
return err
}
dir, err := os.MkdirTemp("", "bd2-collaboration-equipment-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return err
}
defer db.Close()
return c.includeCollaborationURWeapons(db)
}
func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error {
if c == nil || c.equipment == nil {
return fmt.Errorf("gamedata: missing equipment catalog")
}
g, ok := c.Gachas[71200001]
if !ok || !g.TicketOnly || len(g.TicketIDs) != 1 || g.TicketIDs[0] != 1104 || len(g.Pool) != 3 {
return fmt.Errorf("gamedata: missing or malformed UR equipment ticket pool")
}
readRows := func(query string) (map[uint64][]byte, error) {
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[uint64][]byte{}
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
return nil, err
}
out[id] = raw
}
return out, rows.Err()
}
scalar := func(raw []byte, field int) (uint64, error) {
v, err := packedInts(raw, field)
if err != nil || len(v) != 1 || v[0] == 0 {
return 0, fmt.Errorf("gamedata: invalid collaboration equipment field %d", field)
}
return v[0], nil
}
owners, err := readRows("SELECT DISTINCT c.useUniqueCharId,c.ProtoBuf FROM LimitedCostumeTable l JOIN CostumeTable c ON c.id=l.id ORDER BY c.useUniqueCharId")
if err != nil {
return err
}
chars, err := readRows("SELECT id,ProtoBuf FROM CharTable WHERE uniqueCharId IN (SELECT privateUniqueCharId FROM EquipmentTable WHERE privateUniqueCharId>0) ORDER BY id")
if err != nil {
return err
}
grades := map[uint64]uint64{}
for _, raw := range chars {
owner, err := scalar(raw, 20)
if err != nil {
return err
}
grade, err := scalar(raw, 9)
if err != nil {
return err
}
if previous, exists := grades[owner]; exists && previous != grade {
return fmt.Errorf("gamedata: conflicting character grade for %d", owner)
}
grades[owner] = grade
}
equipment, err := readRows("SELECT id,ProtoBuf FROM EquipmentTable ORDER BY id")
if err != nil {
return err
}
pool := make([]WeightedEquipment, len(g.Pool))
branchForGrade := map[uint64]int{}
seen := map[uint64]bool{}
for index, branch := range g.Pool {
if branch.ID != 0 || branch.Weight == 0 || len(branch.Children) == 0 {
return fmt.Errorf("gamedata: malformed UR ticket branch")
}
pool[index] = WeightedEquipment{Weight: branch.Weight, Children: append([]WeightedEquipment(nil), branch.Children...)}
var branchGrade uint64
for _, item := range branch.Children {
if item.ID == 0 || item.Weight != 1 || len(item.Children) != 0 || seen[item.ID] {
return fmt.Errorf("gamedata: malformed or duplicate UR ticket candidate %d", item.ID)
}
seen[item.ID] = true
owner, err := scalar(equipment[item.ID], 16)
if err != nil {
return err
}
grade := grades[owner]
if grade < 3 || grade > 5 || (branchGrade != 0 && branchGrade != grade) {
return fmt.Errorf("gamedata: unknown or mixed UR ticket character tier")
}
branchGrade = grade
}
if _, exists := branchForGrade[branchGrade]; exists {
return fmt.Errorf("gamedata: duplicate UR ticket tier")
}
branchForGrade[branchGrade] = index
}
// Collect designs before publishing the cloned pool, so a failed load leaves
// the catalog unchanged. Order by ID to keep repeated loads deterministic.
designs := map[uint64]EquipmentDesign{}
ids := make([]uint64, 0, len(equipment))
for id := range equipment {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
raw := equipment[id]
grade, _ := packedInts(raw, 3)
quality, _ := packedInts(raw, 18)
owner, _ := packedInts(raw, 16)
if len(grade) != 1 || grade[0] != 4 || len(quality) != 1 || quality[0] != 3 || len(owner) != 1 {
continue
}
if _, eligible := owners[owner[0]]; !eligible || seen[id] {
continue
}
index, exists := branchForGrade[grades[owner[0]]]
if !exists {
return fmt.Errorf("gamedata: unknown collaboration equipment tier for %d", id)
}
design, err := loadEquipmentDesign(db, id)
if err != nil {
return err
}
designs[id] = design
pool[index].Children = append(pool[index].Children, WeightedEquipment{ID: id, Weight: 1})
seen[id] = true
}
for id, design := range designs {
c.equipment[id] = design
}
g.Pool = pool
c.Gachas[g.ID] = g
return nil
}
@@ -0,0 +1,149 @@
package gamedata
import (
"database/sql"
"encoding/binary"
"os"
"reflect"
"testing"
)
func TestIncludeCollaborationURWeaponsInstalled(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
c, err := LoadEquipmentGacha(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
before := map[uint64]EquipmentGacha{}
for id, g := range c.Gachas {
before[id] = g
}
if err := c.IncludeCollaborationURWeapons(root, "20260923193640"); err != nil {
t.Fatal(err)
}
g := c.Gachas[71200001]
oldIDs := map[uint64]bool{}
for _, branch := range before[g.ID].Pool {
for _, item := range branch.Children {
oldIDs[item.ID] = true
}
}
added := 0
for _, branch := range g.Pool {
for _, item := range branch.Children {
if !oldIDs[item.ID] {
added++
if item.ID < 943200 || item.ID > 943213 {
t.Fatalf("unexpected added collaboration weapon %d", item.ID)
}
}
}
}
if len(oldIDs) != 73 || added != 14 {
t.Fatalf("original=%d added=%d", len(oldIDs), added)
}
for index, want := range []int{64, 9, 14} {
if len(g.Pool[index].Children) != want || g.Pool[index].Weight != before[g.ID].Pool[index].Weight {
t.Fatalf("branch %d: %+v", index, g.Pool[index])
}
}
if len(before[g.ID].Pool[0].Children) != 50 {
t.Fatal("original pool mutated")
}
for id, old := range before {
if id != g.ID && !reflect.DeepEqual(old, c.Gachas[id]) {
t.Fatalf("other product %d changed", id)
}
}
for _, branch := range g.Pool {
for _, item := range branch.Children {
if _, _, _, err := c.RollOptions(item.ID); err != nil {
t.Fatalf("options %d: %v", item.ID, err)
}
}
}
if err := c.IncludeCollaborationURWeapons(root, "20260923193640"); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(g, c.Gachas[g.ID]) {
t.Fatal("second inclusion changed pool")
}
}
func TestIncludeCollaborationURWeaponsSynthetic(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
for _, schema := range []string{"CREATE TABLE LimitedCostumeTable(id INTEGER)", "CREATE TABLE CostumeTable(id INTEGER,useUniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE CharTable(id INTEGER,uniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE EquipmentTable(id INTEGER,privateUniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE EquipmentOptionTable(id INTEGER,GroupId INTEGER,ProtoBuf BLOB)"} {
if _, err := db.Exec(schema); err != nil {
t.Fatal(err)
}
}
proto := func(values ...uint64) []byte {
var b []byte
for i := 0; i < len(values); i += 2 {
b = binary.AppendUvarint(b, values[i]<<3)
b = binary.AppendUvarint(b, values[i+1])
}
return b
}
for i := uint64(1); i <= 4; i++ {
grade := uint64(6 - i)
if i == 4 {
grade = 5
}
if _, err := db.Exec("INSERT INTO CharTable VALUES(?,?,?)", i, i, proto(20, i, 9, grade)); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO EquipmentTable VALUES(?,?,?)", 100+i, i, proto(6, 100+i, 16, i, 3, 4, 18, 3)); err != nil {
t.Fatal(err)
}
}
if _, err := db.Exec("INSERT INTO LimitedCostumeTable VALUES(40),(41)"); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO CostumeTable VALUES(40,4,?),(41,4,?)", proto(27, 4), proto(27, 4)); err != nil {
t.Fatal(err)
}
pool := []WeightedEquipment{{Weight: 150, Children: []WeightedEquipment{{ID: 101, Weight: 1}}}, {Weight: 350, Children: []WeightedEquipment{{ID: 102, Weight: 1}}}, {Weight: 500, Children: []WeightedEquipment{{ID: 103, Weight: 1}}}}
c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{71200001: {ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, Pool: pool}, 77: {ID: 77, Pool: pool}}, equipment: map[uint64]EquipmentDesign{}}
if err := c.includeCollaborationURWeapons(db); err != nil {
t.Fatal(err)
}
if len(c.Gachas[71200001].Pool[0].Children) != 2 || len(c.Gachas[77].Pool[0].Children) != 1 {
t.Fatal("extension or isolation failed")
}
if _, ok := c.equipment[104]; !ok {
t.Fatal("new equipment design not loaded")
}
if err := c.includeCollaborationURWeapons(db); err != nil {
t.Fatal(err)
}
if len(c.Gachas[71200001].Pool[0].Children) != 2 {
t.Fatal("duplicate owner or repeated inclusion duplicated equipment")
}
if _, err := db.Exec("UPDATE CharTable SET ProtoBuf=? WHERE id=4", proto(20, 4, 9, 2)); err != nil {
t.Fatal(err)
}
delete(c.Gachas, 71200001)
c.Gachas[71200001] = EquipmentGacha{ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, Pool: pool}
if err := c.includeCollaborationURWeapons(db); err == nil {
t.Fatal("unknown tier accepted")
}
if len(c.Gachas[71200001].Pool[0].Children) != 1 {
t.Fatal("failed inclusion published partial pool")
}
invalid := c.Gachas[71200001]
invalid.Pool = append([]WeightedEquipment(nil), pool...)
invalid.Pool[0].Children = []WeightedEquipment{{ID: 101, Weight: 1}, {ID: 101, Weight: 1}}
c.Gachas[71200001] = invalid
if err := c.includeCollaborationURWeapons(db); err == nil {
t.Fatal("duplicate existing candidate accepted")
}
}
+24 -5
View File
@@ -917,17 +917,17 @@ func LoadActiveGachaForSchedules(root, version string, scheduleGroupIDs, stepUpG
return LoadActiveGacha(root, version, costume, equipment, stepUpGroupIDs)
}
// permanentGachaGroupIDs returns static selection groups. ScheduleType=0
// permanentGachaGroupIDs returns static selection and ordinary equipment groups. ScheduleType=0
// groups are deliberately absent from the dynamic schedule response; the
// selection metadata is what distinguishes permanent 12PICK-style banners
// from standalone content-ticket banners handled by other flows.
// selection or shared pity/point metadata distinguishes these banners from
// content-ticket banners handled by other flows.
func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable WHERE scheduleType=0 AND gachaType=1 ORDER BY id")
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable WHERE scheduleType=0 AND gachaType IN (1,2) ORDER BY id")
if err != nil {
return nil, fmt.Errorf("gamedata: list permanent gacha groups: %w", err)
}
@@ -941,7 +941,10 @@ func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
}
subTypes, _ := packedInts(raw, 16)
selectCounts, _ := packedInts(raw, 29)
if len(subTypes) == 1 && subTypes[0] == 1 && len(selectCounts) == 1 && selectCounts[0] != 0 {
types, _ := packedInts(raw, 17)
if len(types) == 1 && types[0] == 1 && len(subTypes) == 1 && subTypes[0] == 1 && len(selectCounts) == 1 && selectCounts[0] != 0 {
result = append(result, id)
} else if isPermanentEquipmentGachaGroup(raw) {
result = append(result, id)
}
}
@@ -951,6 +954,22 @@ func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
return result, nil
}
func isPermanentEquipmentGachaGroup(raw []byte) bool {
types, _ := packedInts(raw, 17)
subTypes, _ := packedInts(raw, 16)
selectCounts, _ := packedInts(raw, 29)
fixed, _ := packedInts(raw, 10)
points, _ := packedInts(raw, 27)
one, _ := packedInts(raw, 24)
ten, _ := packedInts(raw, 33)
// Static content-ticket groups also have one/ten IDs, but have neither
// shared pity nor draw points. They must not enter the ordinary catalog.
// LoadEquipmentGachaGroups validates the referenced products and pools.
return len(types) == 1 && types[0] == 2 && (len(subTypes) == 0 || len(subTypes) == 1 && subTypes[0] == 0) && len(selectCounts) == 0 &&
len(fixed) == 1 && fixed[0] != 0 && len(points) == 1 && points[0] != 0 &&
len(one) == 1 && one[0] != 0 && len(ten) == 1 && ten[0] != 0
}
// ClassifyActiveGachaGroups reads static type metadata for groups already
// selected by a captured schedule. Resemara is owned by LoadInfiniteGacha and
// is intentionally omitted from both ordinary catalogs.
@@ -3,8 +3,29 @@ package gamedata
import (
"os"
"testing"
"bd2server/internal/server/wire"
)
func TestPermanentEquipmentGroupRequiresPityAndPoints(t *testing.T) {
// Content-ticket groups have both products too; those alone do not make
// a group an ordinary permanent equipment draw.
content := wire.AppendVarint(nil, 17, 2)
content = wire.AppendVarint(content, 24, 70100001)
content = wire.AppendVarint(content, 33, 71000001)
if isPermanentEquipmentGachaGroup(content) {
t.Fatal("content-ticket group classified as ordinary equipment")
}
ordinary := wire.AppendVarint(content, 10, 1)
ordinary = wire.AppendVarint(ordinary, 27, 1)
if !isPermanentEquipmentGachaGroup(ordinary) {
t.Fatal("permanent equipment group with shared pity and points omitted")
}
if isPermanentEquipmentGachaGroup(wire.AppendVarint(ordinary, 16, 1)) {
t.Fatal("equipment selection group classified as ordinary equipment")
}
}
func TestSingleSidedCostumeFixedThresholdsDoNotTriggerZeroSide(t *testing.T) {
pool := []WeightedCostume{
{Weight: 150, ID: 5001}, {Weight: 150, ID: 5002},
@@ -162,11 +183,26 @@ func TestActiveGachaAgainstInstalledVersion23510(t *testing.T) {
t.Errorf("step-up %d=%+v ok=%v", groupID, stepUp, ok)
}
}
for _, id := range []uint64{20100082, 21000082, 20100034, 21000034, 20100070, 21000070, 20100083, 21000083} {
for _, id := range []uint64{200, 201, 20100082, 21000082, 20100034, 21000034, 20100070, 21000070, 20100083, 21000083} {
if _, ok := equipment.Gacha(id); !ok {
t.Errorf("equipment gacha %d missing", id)
}
}
permanentEquipment, ok := equipment.Group(10002)
if !ok || permanentEquipment.OneTimeGachaID != 200 || permanentEquipment.TenTimeGachaID != 201 || permanentEquipment.FixedID != 1 || permanentEquipment.PointCount != 1 {
t.Fatalf("permanent equipment group=%+v ok=%v", permanentEquipment, ok)
}
for _, id := range []uint64{200, 201} {
product, _ := equipment.Gacha(id)
if product.TicketOnly || product.PriceType != 3 || product.Price != uint64(product.Count)*200 || len(product.TicketIDs) != 1 || product.TicketIDs[0] != 1000 || len(product.Pool) != 8 {
t.Fatalf("permanent equipment product %d=%+v", id, product)
}
}
for _, groupID := range []uint64{1003, 1004, 1005, 1006} {
if _, ok := equipment.Group(groupID); ok {
t.Errorf("content-ticket equipment group %d became ordinary permanent draw", groupID)
}
}
}
func TestLegacyRegularGachaLoaderAgainstInstalledVersion23510(t *testing.T) {
+231 -32
View File
@@ -1,17 +1,21 @@
package player
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
type CostumeUpgrade struct {
@@ -106,32 +110,46 @@ type CharAwakeProgress struct {
IsAwake bool `json:"is_awake"`
}
// CostumeBurstUpgradeRecord is the durable result of one costume burst level
// transition. Digest identifies the exact protobuf request whose successful
// response is stored in Body, allowing a committed request to be replayed
// after a process restart without charging its materials again.
type CostumeBurstUpgradeRecord struct {
CostumeID uint64 `json:"costume_id"`
Level uint64 `json:"level"`
Digest string `json:"digest"`
Code int `json:"code"`
Body []byte `json:"body"`
}
// FirstGachaCompletedIdentity is the persisted account flag represented in
// the existing collection grant ledger. It is written atomically with the
// official GachaSubType=3 first-pick transaction.
const FirstGachaCompletedIdentity = "account:first-gacha-completed"
type collectionSnapshot struct {
Version string `json:"version"`
NextCharacterIndex uint64 `json:"next_character_index"`
NextCostumeIndex uint64 `json:"next_costume_index"`
LatestPreview []uint64 `json:"latest_preview"`
PreviewEventIndex uint64 `json:"preview_event_index"`
PreviewLocked bool `json:"preview_locked"`
Characters []Character `json:"characters,omitempty"`
Costumes []Costume `json:"costumes,omitempty"`
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"`
CostumePotential map[string][]uint64 `json:"costume_potential,omitempty"`
CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
GachaCountCorrected bool `json:"gacha_count_corrected"`
Grants map[string]CollectionGrant `json:"grants,omitempty"`
Version string `json:"version"`
NextCharacterIndex uint64 `json:"next_character_index"`
NextCostumeIndex uint64 `json:"next_costume_index"`
LatestPreview []uint64 `json:"latest_preview"`
PreviewEventIndex uint64 `json:"preview_event_index"`
PreviewLocked bool `json:"preview_locked"`
Characters []Character `json:"characters,omitempty"`
Costumes []Costume `json:"costumes,omitempty"`
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"`
CostumePotential map[string][]uint64 `json:"costume_potential,omitempty"`
CostumeBurstLevels map[string]uint64 `json:"-"`
CostumeBurstUpgrades map[string]CostumeBurstUpgradeRecord `json:"-"`
CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
GachaCountCorrected bool `json:"gacha_count_corrected"`
Grants map[string]CollectionGrant `json:"grants,omitempty"`
}
// CollectionStore owns non-stackable character/costume rewards as one atomic
@@ -157,9 +175,10 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
s := &CollectionStore{store: entries, base: append([]Costume(nil), base...), data: collectionSnapshot{
Version: versionconfig.State(), NextCharacterIndex: 920000001, NextCostumeIndex: 930000001,
BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{}, GachaSelectionChanges: map[string]uint64{},
CostumePotential: map[string][]uint64{},
CharAwake: map[string]CharAwakeProgress{},
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
CostumePotential: map[string][]uint64{},
CostumeBurstLevels: map[string]uint64{}, CostumeBurstUpgrades: map[string]CostumeBurstUpgradeRecord{},
CharAwake: map[string]CharAwakeProgress{},
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{},
}}
b, err := store.Load("collection")
@@ -193,6 +212,9 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
if s.data.CharAwake == nil {
return nil, errors.New("player: collection save requires char_awake; migrate the development save")
}
if err := s.validateCostumeBurstStorage(); err != nil {
return nil, err
}
for key, progress := range s.data.CharAwake {
uniqueID, parseErr := strconv.ParseUint(key, 10, 64)
if parseErr != nil || uniqueID == 0 || (progress.ImprintLevels == [3]uint64{} && !progress.IsAwake) {
@@ -811,20 +833,191 @@ func (s *CollectionStore) UpdateCharacter(oldID uint64, character Character) err
func (s *CollectionStore) Costumes() []Costume {
s.mu.Lock()
defer s.mu.Unlock()
return s.costumesLocked()
}
func (s *CollectionStore) costumesLocked() []Costume {
result := append([]Costume(nil), s.base...)
for i := range result {
if level := s.data.BaseCostumeLevels[strconv.FormatUint(result[i].InvenIndex, 10)]; level > result[i].Level {
key := strconv.FormatUint(result[i].InvenIndex, 10)
if level := s.data.BaseCostumeLevels[key]; level > result[i].Level {
result[i].Level = level
}
result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(result[i].InvenIndex, 10)]...)
if level, found := s.data.CostumeBurstLevels[key]; found {
result[i].BurstLevel = level
}
result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[key]...)
}
collection := append([]Costume(nil), s.data.Costumes...)
for i := range collection {
collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(collection[i].InvenIndex, 10)]...)
key := strconv.FormatUint(collection[i].InvenIndex, 10)
if level, found := s.data.CostumeBurstLevels[key]; found {
collection[i].BurstLevel = level
}
collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[key]...)
}
return append(result, collection...)
}
// CostumeByID finds the single owned costume with the logical CostumeTable ID.
// A duplicate logical ID is invalid ownership state and is deliberately not
// resolved by choosing an arbitrary inventory instance.
func (s *CollectionStore) CostumeByID(id uint64) (Costume, bool) {
if id == 0 {
return Costume{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
var result Costume
found := false
for _, costume := range s.costumesLocked() {
if costume.ID != id {
continue
}
if found {
return Costume{}, false
}
result, found = costume, true
}
return result, found
}
// CostumeBurstReplay returns an owned copy of the durable response recorded
// for one inventory instance and target burst level.
func (s *CollectionStore) CostumeBurstReplay(invenIndex, target uint64) (CostumeBurstUpgradeRecord, bool) {
if invenIndex == 0 || target == 0 {
return CostumeBurstUpgradeRecord{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
record, found := s.data.CostumeBurstUpgrades[costumeBurstUpgradeKey(invenIndex, target)]
record.Body = append([]byte(nil), record.Body...)
return record, found
}
// ApplyCostumeBurst atomically advances one owned costume and stores the exact
// successful reply. expectedCurrent is a compare-and-swap guard against a
// stale request overwriting a newer level.
func (s *CollectionStore) ApplyCostumeBurst(invenIndex, expectedCurrent, target uint64, record CostumeBurstUpgradeRecord) error {
if invenIndex == 0 || expectedCurrent == math.MaxUint64 || target != expectedCurrent+1 {
return errors.New("player: invalid costume burst transition")
}
if record.Level != target || record.CostumeID == 0 {
return errors.New("player: inconsistent costume burst record")
}
if err := validateCostumeBurstRecord(record); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
costume, found := s.costumeByIndexLocked(invenIndex)
if !found {
return fmt.Errorf("player: costume %d not found", invenIndex)
}
if costume.ID != record.CostumeID {
return errors.New("player: costume burst record design mismatch")
}
if costume.BurstLevel != expectedCurrent {
return errors.New("player: stale costume burst level")
}
ledgerKey := costumeBurstUpgradeKey(invenIndex, target)
if _, exists := s.data.CostumeBurstUpgrades[ledgerKey]; exists {
return errors.New("player: costume burst transition already recorded")
}
next := cloneCollection(s.data)
next.CostumeBurstLevels[strconv.FormatUint(invenIndex, 10)] = target
record.Body = append([]byte(nil), record.Body...)
next.CostumeBurstUpgrades[ledgerKey] = record
return s.commit(next)
}
func (s *CollectionStore) costumeByIndexLocked(index uint64) (Costume, bool) {
for _, costume := range s.costumesLocked() {
if costume.InvenIndex == index {
return costume, true
}
}
return Costume{}, false
}
func costumeBurstUpgradeKey(invenIndex, target uint64) string {
return strconv.FormatUint(invenIndex, 10) + ":" + strconv.FormatUint(target, 10)
}
func parseCostumeBurstUpgradeKey(key string) (uint64, uint64, bool) {
left, right, found := strings.Cut(key, ":")
if !found || strings.Contains(right, ":") {
return 0, 0, false
}
index, indexErr := strconv.ParseUint(left, 10, 64)
level, levelErr := strconv.ParseUint(right, 10, 64)
return index, level, indexErr == nil && levelErr == nil && index != 0 && level != 0 && key == costumeBurstUpgradeKey(index, level)
}
func validateCostumeBurstRecord(record CostumeBurstUpgradeRecord) error {
if record.CostumeID == 0 || record.Level == 0 || record.Code != 578 || len(record.Digest) != costumeBurstDigestHexSize {
return errors.New("player: invalid costume burst upgrade record")
}
if _, err := hex.DecodeString(record.Digest); err != nil {
return errors.New("player: invalid costume burst upgrade digest")
}
level, found, err := wire.Varint(record.Body, 1)
if err != nil || !found || level != record.Level {
return errors.New("player: invalid costume burst upgrade response")
}
fieldCount := 0
if err := wire.Walk(record.Body, func(field wire.Field) error {
if field.Number != 1 || field.Type != 0 {
return errors.New("unexpected costume burst response field")
}
fieldCount++
return nil
}); err != nil || fieldCount != 1 {
return errors.New("player: invalid costume burst upgrade response")
}
return nil
}
const costumeBurstDigestHexSize = 64
func (s *CollectionStore) validateCostumeBurstStorage() error {
if s.data.CostumeBurstLevels == nil || s.data.CostumeBurstUpgrades == nil {
return errors.New("player: collection save requires costume burst ledgers")
}
owned := make(map[uint64]Costume, len(s.base)+len(s.data.Costumes))
for _, costume := range append(append([]Costume(nil), s.base...), s.data.Costumes...) {
if costume.InvenIndex == 0 || costume.ID == 0 {
return errors.New("player: invalid costume burst ownership")
}
if _, exists := owned[costume.InvenIndex]; exists {
return errors.New("player: duplicate costume burst inventory index")
}
owned[costume.InvenIndex] = costume
}
for key, level := range s.data.CostumeBurstLevels {
index, err := strconv.ParseUint(key, 10, 64)
costume, found := owned[index]
if err != nil || index == 0 || key != strconv.FormatUint(index, 10) || level == 0 || !found || level < costume.BurstLevel {
return fmt.Errorf("player: invalid costume burst level entry %q", key)
}
}
for key, record := range s.data.CostumeBurstUpgrades {
index, level, valid := parseCostumeBurstUpgradeKey(key)
costume, found := owned[index]
current := costume.BurstLevel
if overlay, exists := s.data.CostumeBurstLevels[strconv.FormatUint(index, 10)]; exists {
current = overlay
}
if !valid || !found || record.CostumeID != costume.ID || record.Level != level || level > current {
return fmt.Errorf("player: invalid costume burst upgrade entry %q", key)
}
if err := validateCostumeBurstRecord(record); err != nil {
return fmt.Errorf("player: invalid costume burst upgrade entry %q: %w", key, err)
}
}
return nil
}
func (s *CollectionStore) ValidateCostumePotentialActivation(costumeIndex uint64, nodes []uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -948,12 +1141,9 @@ func (s *CollectionStore) CharacterByIndex(index uint64) (Character, bool) {
}
func (s *CollectionStore) CostumeByIndex(index uint64) (Costume, bool) {
for _, costume := range s.Costumes() {
if costume.InvenIndex == index {
return costume, true
}
}
return Costume{}, false
s.mu.Lock()
defer s.mu.Unlock()
return s.costumeByIndexLocked(index)
}
func findCostume(costumes []Costume, id uint64) (int, bool) {
@@ -1005,6 +1195,15 @@ func cloneCollection(in collectionSnapshot) collectionSnapshot {
for k, v := range in.CostumePotential {
out.CostumePotential[k] = append([]uint64(nil), v...)
}
out.CostumeBurstLevels = make(map[string]uint64, len(in.CostumeBurstLevels))
for k, v := range in.CostumeBurstLevels {
out.CostumeBurstLevels[k] = v
}
out.CostumeBurstUpgrades = make(map[string]CostumeBurstUpgradeRecord, len(in.CostumeBurstUpgrades))
for k, v := range in.CostumeBurstUpgrades {
v.Body = append([]byte(nil), v.Body...)
out.CostumeBurstUpgrades[k] = v
}
out.CharAwake = make(map[string]CharAwakeProgress, len(in.CharAwake))
for k, v := range in.CharAwake {
out.CharAwake[k] = v
@@ -15,7 +15,8 @@ const collectionDomain = "collection"
var collectionEntryBuckets = [...]string{
"characters", "costumes", "grants", "gacha_applied", "gacha_users",
"gacha_fixed", "step_up_progress", "gacha_point_exchanges",
"gacha_selections", "gacha_selection_changes", "costume_potential", "char_awake",
"gacha_selections", "gacha_selection_changes", "costume_potential",
"costume_burst_levels", "costume_burst_upgrades", "char_awake",
}
func rejectInlineCollectionEntries(raw []byte) error {
@@ -88,6 +89,12 @@ func loadCollectionEntries(store stateio.EntryStore, data *collectionSnapshot) e
if data.CostumePotential, err = loadEntryMap[[]uint64](store, "costume_potential"); err != nil {
return err
}
if data.CostumeBurstLevels, err = loadEntryMap[uint64](store, "costume_burst_levels"); err != nil {
return err
}
if data.CostumeBurstUpgrades, err = loadEntryMap[CostumeBurstUpgradeRecord](store, "costume_burst_upgrades"); err != nil {
return err
}
if data.CharAwake, err = loadEntryMap[CharAwakeProgress](store, "char_awake"); err != nil {
return err
}
@@ -127,6 +134,8 @@ func collectionCore(in collectionSnapshot) collectionSnapshot {
in.GachaSelections = nil
in.GachaSelectionChanges = nil
in.CostumePotential = nil
in.CostumeBurstLevels = nil
in.CostumeBurstUpgrades = nil
in.CharAwake = nil
return in
}
@@ -226,6 +235,12 @@ func diffCollectionEntries(before, after collectionSnapshot) ([]stateio.EntryMut
if err := diffEntryMap("costume_potential", before.CostumePotential, after.CostumePotential, &changes); err != nil {
return nil, err
}
if err := diffEntryMap("costume_burst_levels", before.CostumeBurstLevels, after.CostumeBurstLevels, &changes); err != nil {
return nil, err
}
if err := diffEntryMap("costume_burst_upgrades", before.CostumeBurstUpgrades, after.CostumeBurstUpgrades, &changes); err != nil {
return nil, err
}
if err := diffEntryMap("char_awake", before.CharAwake, after.CharAwake, &changes); err != nil {
return nil, err
}
+206
View File
@@ -0,0 +1,206 @@
package player
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math"
"strconv"
"sync"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
const costumeBurstPacketCode = 578
type costumeBurstReply struct {
Digest string
Code int
Body []byte
}
// CostumeBurstService applies one adjacent costume-burst level from the
// authoritative GameData row. The account request transaction makes the
// wallet, inventory, and collection writes one atomic SQLite operation.
type CostumeBurstService struct {
mu sync.Mutex
design *gamedata.CostumeBurstDesign
collection *CollectionStore
inventory *Inventory
wallet *Wallet
sessionID string
replies map[string]map[uint64]costumeBurstReply
}
func NewCostumeBurstService(design *gamedata.CostumeBurstDesign, collection *CollectionStore, inventory *Inventory, wallet *Wallet) (*CostumeBurstService, error) {
if design == nil || collection == nil || inventory == nil || wallet == nil {
return nil, errors.New("player: incomplete costume burst service")
}
return &CostumeBurstService{
design: design, collection: collection, inventory: inventory, wallet: wallet,
replies: make(map[string]map[uint64]costumeBurstReply),
}, nil
}
// BeginSession scopes protobuf sequence replay without forgetting older live
// sessions. Session dispatch calls it before each authenticated request.
func (s *CostumeBurstService) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if id == "" || id == s.sessionID {
return
}
s.sessionID = id
if s.replies[id] != nil {
return
}
if len(s.replies) >= 1024 {
for oldID := range s.replies {
if oldID != id {
delete(s.replies, oldID)
break
}
}
}
s.replies[id] = make(map[uint64]costumeBurstReply)
}
func (s *CostumeBurstService) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/CostumeBurst" {
return 0, nil, false, nil
}
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
return 0, nil, true, errors.New("player: CostumeBurst missing sequence")
}
costumeID, found, err := wire.Varint(request, 2)
if err != nil || !found || costumeID == 0 || costumeID > math.MaxInt32 {
return 0, nil, true, errors.New("player: CostumeBurst missing costume")
}
materials, err := equipmentRequestItems(request, 3, "CostumeBurst")
if err != nil {
return 0, nil, true, err
}
for _, material := range materials {
if material.InvenIndex > math.MaxInt64 || material.ID > math.MaxInt32 || material.Type > math.MaxInt32 || material.Count > math.MaxInt32 ||
material.KeepFlag > math.MaxInt32 || material.TimeValue > math.MaxInt64 || material.ExpiryTime > math.MaxInt64 ||
material.SortID > math.MaxInt32 || material.UseCount > math.MaxInt32 {
return 0, nil, true, errors.New("player: CostumeBurst material exceeds protocol range")
}
}
digestBytes := sha256.Sum256(request)
digest := hex.EncodeToString(digestBytes[:])
s.mu.Lock()
defer s.mu.Unlock()
sessionID := s.sessionID
if sessionID == "" {
sessionID = "__direct__"
}
if s.replies[sessionID] == nil {
s.replies[sessionID] = make(map[uint64]costumeBurstReply)
}
if reply, ok := s.replies[sessionID][seq]; ok {
if reply.Digest != digest {
return 0, nil, true, errors.New("player: CostumeBurst sequence reused with different request")
}
return reply.Code, append([]byte(nil), reply.Body...), true, nil
}
costume, found := s.collection.CostumeByID(costumeID)
if !found {
return 0, nil, true, fmt.Errorf("player: CostumeBurst costume %d is not owned", costumeID)
}
if costume.BurstLevel != 0 {
if applied, ok := s.collection.CostumeBurstReplay(costume.InvenIndex, costume.BurstLevel); ok && applied.Digest == digest {
reply := costumeBurstReply{Digest: digest, Code: applied.Code, Body: append([]byte(nil), applied.Body...)}
s.replies[sessionID][seq] = reply
return reply.Code, append([]byte(nil), reply.Body...), true, nil
}
}
rule, err := s.design.UpgradeRule(costume.ID, costume.BurstLevel)
if err != nil {
return 0, nil, true, fmt.Errorf("player: resolve CostumeBurst rule: %w", err)
}
items, gold, err := validateCostumeBurstMaterials(rule.Costs, materials)
if err != nil {
return 0, nil, true, err
}
if gold != 0 && !s.wallet.CanSpendGold(gold) {
return 0, nil, true, errors.New("player: insufficient gold for CostumeBurst")
}
if len(items) != 0 {
if err := s.inventory.CanConsume(items); err != nil {
return 0, nil, true, fmt.Errorf("player: validate CostumeBurst items: %w", err)
}
}
identity := "costume-burst:" + strconv.FormatUint(costume.InvenIndex, 10) + ":" + strconv.FormatUint(rule.NextLevel, 10)
if gold != 0 {
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
return 0, nil, true, fmt.Errorf("player: consume CostumeBurst gold: %w", err)
}
}
if len(items) != 0 {
if err := s.inventory.Consume(items); err != nil {
return 0, nil, true, fmt.Errorf("player: consume CostumeBurst items: %w", err)
}
}
body := wire.AppendVarint(nil, 1, rule.NextLevel)
record := CostumeBurstUpgradeRecord{CostumeID: costume.ID, Level: rule.NextLevel, Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)}
if err := s.collection.ApplyCostumeBurst(costume.InvenIndex, costume.BurstLevel, rule.NextLevel, record); err != nil {
return 0, nil, true, fmt.Errorf("player: persist CostumeBurst: %w", err)
}
reply := costumeBurstReply{Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)}
s.replies[sessionID][seq] = reply
return reply.Code, append([]byte(nil), reply.Body...), true, nil
}
func validateCostumeBurstMaterials(costs []gamedata.PromotionCost, materials []Item) ([]Item, uint64, error) {
if len(costs) == 0 || len(materials) == 0 {
return nil, 0, errors.New("player: CostumeBurst has no material")
}
want := make(map[[2]uint64]uint64, len(costs))
for _, cost := range costs {
if cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || (cost.Type == 8 && cost.ID == 0) || (cost.Type != 4 && cost.Type != 8) {
return nil, 0, errors.New("player: invalid CostumeBurst GameData cost")
}
key := [2]uint64{cost.Type, cost.ID}
if want[key] > math.MaxUint64-cost.Count {
return nil, 0, errors.New("player: CostumeBurst GameData cost overflow")
}
want[key] += cost.Count
}
got := make(map[[2]uint64]uint64, len(materials))
items := make([]Item, 0, len(materials))
var gold uint64
for _, material := range materials {
if material.Type != 4 && material.Type != 8 {
return nil, 0, fmt.Errorf("player: unsupported CostumeBurst material type %d", material.Type)
}
key := [2]uint64{material.Type, material.ID}
if got[key] > math.MaxUint64-material.Count {
return nil, 0, errors.New("player: CostumeBurst submitted material overflow")
}
got[key] += material.Count
if material.Type == 4 {
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
return nil, 0, errors.New("player: invalid CostumeBurst currency")
}
gold = material.Count
} else {
items = append(items, material)
}
}
if len(got) != len(want) {
return nil, 0, errors.New("player: CostumeBurst material kinds mismatch")
}
for key, count := range want {
if got[key] != count {
return nil, 0, fmt.Errorf("player: CostumeBurst material %d/%d=%d want %d", key[0], key[1], got[key], count)
}
}
return items, gold, nil
}
@@ -0,0 +1,185 @@
package player
import (
"encoding/json"
"strings"
"testing"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func testCostumeBurstRecord(costumeID, level uint64) CostumeBurstUpgradeRecord {
return CostumeBurstUpgradeRecord{
CostumeID: costumeID,
Level: level,
Digest: strings.Repeat("ab", 32),
Code: 578,
Body: wire.AppendVarint(nil, 1, level),
}
}
func TestCollectionCostumeBurstPersistsOverlayAndReplayForEveryCostumeSource(t *testing.T) {
storage := &collectionWriteSpy{Memory: stateio.NewMemory()}
base := []Costume{{InvenIndex: 11, ID: 101, BurstLevel: 0}}
collection, err := OpenCollectionStore(storage, base)
if err != nil {
t.Fatal(err)
}
if err := collection.EnsurePersisted(); err != nil {
t.Fatal(err)
}
next := cloneCollection(collection.data)
next.Costumes = []Costume{{InvenIndex: 22, ID: 202}}
if err := collection.commit(next); err != nil {
t.Fatal(err)
}
baseRecord := testCostumeBurstRecord(101, 1)
before := len(storage.mutations)
if err := collection.ApplyCostumeBurst(11, 0, 1, baseRecord); err != nil {
t.Fatal(err)
}
if len(storage.mutations) != before+1 || len(storage.mutations[before]) != 2 {
t.Fatalf("burst level and replay were not one collection write: %+v", storage.mutations[before:])
}
if storage.mutations[before][0].Bucket != "costume_burst_levels" || storage.mutations[before][1].Bucket != "costume_burst_upgrades" {
t.Fatalf("unexpected burst mutations: %+v", storage.mutations[before])
}
if err := collection.ApplyCostumeBurst(22, 0, 1, testCostumeBurstRecord(202, 1)); err != nil {
t.Fatal(err)
}
if err := collection.ApplyCostumeBurst(22, 1, 2, testCostumeBurstRecord(202, 2)); err != nil {
t.Fatal(err)
}
if got, found := collection.CostumeByID(101); !found || got.InvenIndex != 11 || got.BurstLevel != 1 {
t.Fatalf("base costume overlay=%+v found=%v", got, found)
}
if got, found := collection.CostumeByIndex(22); !found || got.ID != 202 || got.BurstLevel != 2 {
t.Fatalf("collection costume overlay=%+v found=%v", got, found)
}
replay, found := collection.CostumeBurstReplay(22, 2)
if !found || replay.CostumeID != 202 || replay.Level != 2 || replay.Code != 578 {
t.Fatalf("replay=%+v found=%v", replay, found)
}
replay.Body[0] = 0
if again, _ := collection.CostumeBurstReplay(22, 2); len(again.Body) == 0 || again.Body[0] == 0 {
t.Fatal("CostumeBurstReplay returned an aliased response body")
}
core, err := storage.Load("collection")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(core), "costume_burst") {
t.Fatalf("burst entries leaked into collection core: %s", core)
}
restarted, err := OpenCollectionStore(storage, base)
if err != nil {
t.Fatal(err)
}
if got, _ := restarted.CostumeByIndex(11); got.BurstLevel != 1 {
t.Fatalf("restarted base burst level=%d", got.BurstLevel)
}
if got, _ := restarted.CostumeByID(202); got.BurstLevel != 2 {
t.Fatalf("restarted collection burst level=%d", got.BurstLevel)
}
if got, ok := restarted.CostumeBurstReplay(22, 1); !ok || got.Level != 1 {
t.Fatalf("restarted first replay=%+v found=%v", got, ok)
}
}
func TestCollectionCostumeBurstCASAndRecordValidation(t *testing.T) {
collection, err := OpenCollectionStore(stateio.NewMemory(), []Costume{{InvenIndex: 11, ID: 101}})
if err != nil {
t.Fatal(err)
}
record := testCostumeBurstRecord(101, 1)
if err := collection.ApplyCostumeBurst(11, 0, 1, record); err != nil {
t.Fatal(err)
}
for name, call := range map[string]func() error{
"stale current": func() error { return collection.ApplyCostumeBurst(11, 0, 1, record) },
"skipped level": func() error { return collection.ApplyCostumeBurst(11, 1, 3, testCostumeBurstRecord(101, 3)) },
"wrong costume": func() error { return collection.ApplyCostumeBurst(11, 1, 2, testCostumeBurstRecord(102, 2)) },
"unknown instance": func() error { return collection.ApplyCostumeBurst(99, 0, 1, record) },
} {
t.Run(name, func(t *testing.T) {
if err := call(); err == nil {
t.Fatal("invalid costume burst transition was accepted")
}
})
}
if got, _ := collection.CostumeByIndex(11); got.BurstLevel != 1 {
t.Fatalf("rejected transitions mutated level to %d", got.BurstLevel)
}
if _, found := collection.CostumeBurstReplay(11, 2); found {
t.Fatal("rejected transition wrote a replay")
}
}
func TestCollectionCostumeByIDRejectsAmbiguousOwnership(t *testing.T) {
collection, err := OpenCollectionStore(stateio.NewMemory(), []Costume{{InvenIndex: 11, ID: 101}, {InvenIndex: 12, ID: 101}})
if err != nil {
t.Fatal(err)
}
if costume, found := collection.CostumeByID(101); found || costume.InvenIndex != 0 || costume.ID != 0 {
t.Fatalf("ambiguous logical ID resolved to %+v", costume)
}
}
func TestCollectionRejectsMalformedCostumeBurstEntries(t *testing.T) {
validRecord, err := json.Marshal(testCostumeBurstRecord(101, 1))
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
bucket string
key string
payload []byte
seed Costume
prepare func(*stateio.Memory)
}{
{name: "noncanonical level key", bucket: "costume_burst_levels", key: "011", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101}},
{name: "orphan level", bucket: "costume_burst_levels", key: "99", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101}},
{name: "zero level", bucket: "costume_burst_levels", key: "11", payload: []byte("0"), seed: Costume{InvenIndex: 11, ID: 101}},
{name: "level decreases seed", bucket: "costume_burst_levels", key: "11", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101, BurstLevel: 2}},
{name: "noncanonical replay key", bucket: "costume_burst_upgrades", key: "011:1", payload: validRecord, seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
{name: "replay above current", bucket: "costume_burst_upgrades", key: "11:1", payload: validRecord, seed: Costume{InvenIndex: 11, ID: 101}},
{name: "invalid replay digest", bucket: "costume_burst_upgrades", key: "11:1", payload: func() []byte {
r := testCostumeBurstRecord(101, 1)
r.Digest = strings.Repeat("zz", 32)
b, _ := json.Marshal(r)
return b
}(), seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
{name: "mismatched response", bucket: "costume_burst_upgrades", key: "11:1", payload: func() []byte {
r := testCostumeBurstRecord(101, 1)
r.Body = wire.AppendVarint(nil, 1, 2)
b, _ := json.Marshal(r)
return b
}(), seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
storage := stateio.NewMemory()
collection, err := OpenCollectionStore(storage, []Costume{test.seed})
if err != nil {
t.Fatal(err)
}
if err := collection.EnsurePersisted(); err != nil {
t.Fatal(err)
}
if test.prepare != nil {
test.prepare(storage)
}
if err := storage.PutEntry("collection", test.bucket, test.key, test.payload); err != nil {
t.Fatal(err)
}
if _, err := OpenCollectionStore(storage, []Costume{test.seed}); err == nil {
t.Fatal("malformed costume burst storage was accepted")
}
})
}
}
@@ -0,0 +1,256 @@
package player
import (
"math"
"path/filepath"
"testing"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
func testCostumeBurstDesign() *gamedata.CostumeBurstDesign {
return &gamedata.CostumeBurstDesign{Levels: map[uint64]map[uint64]gamedata.CostumeBurstLevel{
4202: {
1: {CostumeID: 4202, Level: 1, Costs: []gamedata.PromotionCost{{Type: 4, Count: 100}, {Type: 8, ID: 710, Count: 2}}},
2: {CostumeID: 4202, Level: 2, Costs: []gamedata.PromotionCost{{Type: 4, Count: 200}, {Type: 8, ID: 710, Count: 2}}},
},
}}
}
func costumeBurstRequest(seq, costumeID uint64, materials ...Item) []byte {
request := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, costumeID)
for _, material := range materials {
request = wire.AppendBytes(request, 3, ItemWire(material))
}
return request
}
func newCostumeBurstFixture(t *testing.T, storePath string, stacks []gamedata.BattleReward) (*CostumeBurstService, *CollectionStore, *Inventory, *Wallet, []Item) {
t.Helper()
store := testStore(storePath)
starter := &Starter{Version: "2.35.10"}
inventory, err := OpenInventory(store, starter)
if err != nil {
t.Fatal(err)
}
items, err := inventory.GrantOnce("costume-burst-materials", stacks)
if err != nil {
t.Fatal(err)
}
wallet, err := OpenWallet(store, Currency{Gold: 1000})
if err != nil {
t.Fatal(err)
}
collection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 88, ID: 4202, DesignID: 9911}})
if err != nil {
t.Fatal(err)
}
service, err := NewCostumeBurstService(testCostumeBurstDesign(), collection, inventory, wallet)
if err != nil {
t.Fatal(err)
}
return service, collection, inventory, wallet, items
}
func TestCostumeBurstConsumesExactSplitCostsPersistsAndReplays(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, path, []gamedata.BattleReward{
{Type: 8, ID: 710, Count: 1},
{Type: 8, ID: 710, Count: 1},
})
service.BeginSession("login-a")
request := costumeBurstRequest(7, 4202, stacks[1], Item{Type: 4, Count: 100}, stacks[0])
code, body, handled, err := service.Handle("/CostumeBurst", request)
if err != nil || !handled || code != costumeBurstPacketCode {
t.Fatalf("CostumeBurst code=%d handled=%v body=%x err=%v", code, handled, body, err)
}
level, found, err := wire.Varint(body, 1)
if err != nil || !found || level != 1 {
t.Fatalf("response level=%d found=%v err=%v body=%x", level, found, err, body)
}
got, found := collection.CostumeByID(4202)
if !found || got.InvenIndex != 88 || got.DesignID != 9911 || got.BurstLevel != 1 {
t.Fatalf("upgraded costume=%+v found=%v", got, found)
}
if wallet.Snapshot().Gold != 900 {
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
}
for _, stack := range stacks {
if err := inventory.CanConsume([]Item{stack}); err == nil {
t.Fatalf("material stack %d survived", stack.InvenIndex)
}
}
code2, body2, handled, err := service.Handle("/CostumeBurst", request)
if err != nil || !handled || code2 != code || string(body2) != string(body) {
t.Fatalf("same-session replay code=%d handled=%v body=%x err=%v", code2, handled, body2, err)
}
if wallet.Snapshot().Gold != 900 || collection.Costumes()[0].BurstLevel != 1 {
t.Fatal("same-session replay mutated state")
}
store := testStore(path)
reloadedInventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
reloadedWallet, err := OpenWallet(store, Currency{})
if err != nil {
t.Fatal(err)
}
reloadedCollection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 88, ID: 4202, DesignID: 9911}})
if err != nil {
t.Fatal(err)
}
restarted, err := NewCostumeBurstService(testCostumeBurstDesign(), reloadedCollection, reloadedInventory, reloadedWallet)
if err != nil {
t.Fatal(err)
}
restarted.BeginSession("after-restart")
code2, body2, handled, err = restarted.Handle("/CostumeBurst", request)
if err != nil || !handled || code2 != code || string(body2) != string(body) {
t.Fatalf("durable replay code=%d handled=%v body=%x err=%v", code2, handled, body2, err)
}
if reloadedWallet.Snapshot().Gold != 900 || reloadedCollection.Costumes()[0].BurstLevel != 1 {
t.Fatal("durable replay charged or advanced again")
}
}
func TestCostumeBurstRequiresLogicalCostumeIDAndProtectsSessionSequence(t *testing.T) {
service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, filepath.Join(t.TempDir(), "state.json"), []gamedata.BattleReward{{Type: 8, ID: 710, Count: 4}})
material := stacks[0]
material.Count = 2
for _, wrong := range []uint64{88, 9911} {
request := costumeBurstRequest(wrong, wrong, material, Item{Type: 4, Count: 100})
if _, _, handled, err := service.Handle("/CostumeBurst", request); err == nil || !handled {
t.Fatalf("non-logical costume ID %d accepted: handled=%v err=%v", wrong, handled, err)
}
}
service.BeginSession("login-a")
request := costumeBurstRequest(9, 4202, material, Item{Type: 4, Count: 100})
if _, _, _, err := service.Handle("/CostumeBurst", request); err != nil {
t.Fatal(err)
}
different := costumeBurstRequest(9, 4202, material, Item{Type: 4, Count: 200})
if _, _, handled, err := service.Handle("/CostumeBurst", different); err == nil || !handled {
t.Fatalf("sequence reuse accepted: handled=%v err=%v", handled, err)
}
if collection.Costumes()[0].BurstLevel != 1 || wallet.Snapshot().Gold != 900 {
t.Fatal("rejected sequence reuse mutated state")
}
remaining := material
remaining.Count = 2
if err := inventory.CanConsume([]Item{remaining}); err != nil {
t.Fatalf("rejected request consumed remaining items: %v", err)
}
}
func TestCostumeBurstRejectsInvalidMaterialsAndProtocolRanges(t *testing.T) {
tests := []struct {
name string
materials func(Item) []Item
}{
{"short", func(stack Item) []Item { stack.Count = 1; return []Item{stack, {Type: 4, Count: 100}} }},
{"extra", func(stack Item) []Item {
stack.Count = 2
return []Item{stack, {Type: 4, Count: 100}, {InvenIndex: 900, Type: 8, ID: 711, Count: 1}}
}},
{"wrong-gold", func(stack Item) []Item { stack.Count = 2; return []Item{stack, {Type: 4, Count: 99}} }},
{"duplicate-overdraw", func(stack Item) []Item { stack.Count = 1; return []Item{stack, stack, {Type: 4, Count: 100}} }},
{"range", func(stack Item) []Item {
stack.Count = 2
stack.ExpiryTime = math.MaxInt64 + 1
return []Item{stack, {Type: 4, Count: 100}}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
stackCount := uint64(2)
if test.name == "duplicate-overdraw" {
stackCount = 1
}
service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, filepath.Join(t.TempDir(), "state.json"), []gamedata.BattleReward{{Type: 8, ID: 710, Count: stackCount}})
request := costumeBurstRequest(1, 4202, test.materials(stacks[0])...)
if _, _, handled, err := service.Handle("/CostumeBurst", request); err == nil || !handled {
t.Fatalf("invalid request accepted: handled=%v err=%v", handled, err)
}
if collection.Costumes()[0].BurstLevel != 0 || wallet.Snapshot().Gold != 1000 {
t.Fatal("invalid request mutated level or wallet")
}
if err := inventory.CanConsume([]Item{stacks[0]}); err != nil {
t.Fatalf("invalid request consumed material: %v", err)
}
})
}
}
func TestCostumeBurstSQLiteRollbackIsAtomic(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repository, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
starter := &Starter{Version: "2.35.10"}
inventory, err := OpenInventory(repository, starter)
if err != nil {
t.Fatal(err)
}
stacks, err := inventory.GrantOnce("rollback-material", []gamedata.BattleReward{{Type: 8, ID: 710, Count: 2}})
if err != nil {
t.Fatal(err)
}
wallet, err := OpenWallet(repository, Currency{Gold: 1000})
if err != nil {
t.Fatal(err)
}
collection, err := OpenCollectionStore(repository, []Costume{{InvenIndex: 88, ID: 4202}})
if err != nil {
t.Fatal(err)
}
for _, ensure := range []func() error{inventory.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted} {
if err := ensure(); err != nil {
t.Fatal(err)
}
}
service, _ := NewCostumeBurstService(testCostumeBurstDesign(), collection, inventory, wallet)
request := costumeBurstRequest(1, 4202, stacks[0], Item{Type: 4, Count: 100})
operation, err := repository.BeginOperation()
if err != nil {
t.Fatal(err)
}
if code, _, handled, err := service.Handle("/CostumeBurst", request); err != nil || !handled || code != costumeBurstPacketCode {
t.Fatalf("transactional request code=%d handled=%v err=%v", code, handled, err)
}
if err := operation.Rollback(); err == nil {
t.Fatal("dirty request rollback did not fence published in-memory state")
}
if err := repository.Close(); err != nil {
t.Fatal(err)
}
reopened, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
reloadedInventory, err := OpenInventory(reopened, starter)
if err != nil {
t.Fatal(err)
}
reloadedWallet, err := OpenWallet(reopened, Currency{})
if err != nil {
t.Fatal(err)
}
reloadedCollection, err := OpenCollectionStore(reopened, []Costume{{InvenIndex: 88, ID: 4202}})
if err != nil {
t.Fatal(err)
}
if reloadedWallet.Snapshot().Gold != 1000 || reloadedCollection.Costumes()[0].BurstLevel != 0 {
t.Fatalf("rolled back state gold=%d costume=%+v", reloadedWallet.Snapshot().Gold, reloadedCollection.Costumes()[0])
}
if err := reloadedInventory.CanConsume([]Item{stacks[0]}); err != nil {
t.Fatalf("rolled back inventory did not restore materials: %v", err)
}
}
@@ -7,34 +7,30 @@
<RootNamespace>Bd2CaptureEnvironment</RootNamespace>
<BD2PluginVersionKey>capture_environment</BD2PluginVersionKey>
</PropertyGroup>
<Target Name="RequireGameDir" BeforeTargets="ResolveReferences" Condition="'$(GameDir)' == ''">
<Error Text="GameDir is required. Build with -p:GameDir=&lt;official-client-directory&gt;." />
</Target>
<ItemGroup>
<Reference Include="BepInEx">
<HintPath>$(GameDir)\BepInEx\core\BepInEx.dll</HintPath>
<HintPath>$(BD2BepInExDir)/core/BepInEx.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>$(GameDir)\BepInEx\core\0Harmony.dll</HintPath>
<HintPath>$(BD2BepInExDir)/core/0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>$(GameDir)\BrownDust II_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<HintPath>$(BD2ManagedDir)/UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine">
<HintPath>$(GameDir)\BrownDust II_Data\Managed\UnityEngine.dll</HintPath>
<HintPath>$(BD2ManagedDir)/UnityEngine.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestModule">
<HintPath>$(GameDir)\BrownDust II_Data\Managed\UnityEngine.UnityWebRequestModule.dll</HintPath>
<HintPath>$(BD2ManagedDir)/UnityEngine.UnityWebRequestModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Google.Protobuf">
<HintPath>$(GameDir)\BrownDust II_Data\Managed\Google.Protobuf.dll</HintPath>
<HintPath>$(BD2ManagedDir)/Google.Protobuf.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<Import Project="..\VersionSource.targets" />
</Project>
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using BD2.GameNames;
using BepInEx.Logging;
using HarmonyLib;
using UnityEngine;
using static BD2.GameNames.Game;
namespace Bd2CaptureEnvironment;
internal static class CaptureStorageIsolation
{
private const string PlayerPrefsPrefix = "BD2OfficialCapture:" + Bd2Build.Versions.Game + ":";
private static ManualLogSource Log;
internal static string DataDirectory { get; private set; }
internal static string GameDataDirectory { get; private set; }
internal static void Initialize(string root, ManualLogSource log)
{
Log = log;
DataDirectory = Path.GetFullPath(Path.Combine(root, "IsolatedUserData"));
GameDataDirectory = Path.Combine(DataDirectory, "Data", "t");
EnsurePrivateGameDataDirectory();
}
private static void EnsurePrivateGameDataDirectory()
{
foreach (string path in new[]
{
DataDirectory,
Path.Combine(DataDirectory, "Data"),
GameDataDirectory
})
{
if (Directory.Exists(path) &&
(new DirectoryInfo(path).Attributes & FileAttributes.ReparsePoint) != 0)
throw new InvalidOperationException("Isolated GameData path is linked: " + path);
Directory.CreateDirectory(path);
}
}
internal static void Install(Harmony harmony)
{
PropertyInfo persistent = typeof(Application).GetGameProperty(
"persistentDataPath", BindingFlags.Static | BindingFlags.Public);
MethodInfo getter = persistent?.GetGetMethod();
if (getter == null)
throw new MissingMethodException("Application.persistentDataPath getter not found");
harmony.Patch(getter, prefix: new HarmonyMethod(
typeof(CaptureStorageIsolation), nameof(PersistentDataPathPrefix)));
var names = new HashSet<string>(StringComparer.Ordinal)
{
"GetString", "SetString", "GetInt", "SetInt", "GetFloat", "SetFloat",
"HasKey", "DeleteKey"
};
foreach (MethodInfo method in typeof(PlayerPrefs).GetMethods(
BindingFlags.Static | BindingFlags.Public))
{
ParameterInfo[] parameters = method.GetParameters();
if (!names.Contains(method.Name) || parameters.Length == 0 ||
parameters[0].ParameterType != typeof(string))
continue;
harmony.Patch(method, prefix: new HarmonyMethod(
typeof(CaptureStorageIsolation), nameof(PlayerPrefsKeyPrefix)));
}
MethodInfo deleteAll = typeof(PlayerPrefs).GetGameMethod(
"DeleteAll", BindingFlags.Static | BindingFlags.Public,
null, Type.EmptyTypes, null);
if (deleteAll != null)
harmony.Patch(deleteAll, prefix: new HarmonyMethod(
typeof(CaptureStorageIsolation), nameof(BlockPlayerPrefsDeleteAll)));
}
private static bool PersistentDataPathPrefix(ref string __result)
{
__result = DataDirectory;
return false;
}
private static void PlayerPrefsKeyPrefix(ref string key)
{
if (key != null && !key.StartsWith(PlayerPrefsPrefix, StringComparison.Ordinal))
key = PlayerPrefsPrefix + key;
}
private static bool BlockPlayerPrefsDeleteAll()
{
Log?.LogWarning("Blocked PlayerPrefs.DeleteAll to protect the primary client profile");
return false;
}
internal static void InstallGameDataPath(Harmony harmony)
{
MethodInfo gameDataPath = Game.Method<BDNetwork.NetworkManager>(network => network.GetPachedGameDataPath());
if (gameDataPath == null || gameDataPath.ReturnType != typeof(string))
throw new MissingMethodException("NetworkManager.GetPachedGameDataPath not found");
harmony.Patch(gameDataPath, prefix: new HarmonyMethod(
typeof(CaptureStorageIsolation), nameof(GameDataPathPrefix)));
Log?.LogInfo("Private installed GameData path hook installed");
}
private static bool GameDataPathPrefix(ref string __result)
{
__result = GameDataDirectory;
return false;
}
}
+283
View File
@@ -0,0 +1,283 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using BepInEx.Logging;
namespace Bd2CaptureEnvironment;
internal static class CaptureWriter
{
private const int MaxBodyBytes = 16 * 1024 * 1024;
private const int MaxQueuedRecords = 256;
private const int WriterShutdownSeconds = 30;
private static readonly object FailureFileLock = new object();
private static readonly BlockingCollection<CaptureRecord> WriteQueue =
new BlockingCollection<CaptureRecord>(new ConcurrentQueue<CaptureRecord>(), MaxQueuedRecords);
private static ManualLogSource Log;
internal static string DirectoryPath { get; private set; }
private static string JsonlPath;
private static string ReadableLogPath;
private static Thread WriterThread;
private static int WriterFailed;
private static int RejectionLogged;
private static int StopRequested;
internal static void Initialize(string root, ManualLogSource log)
{
Log = log;
DirectoryPath = Path.GetFullPath(Path.Combine(root, "Capture", DateTime.Now.ToString("yyyyMMdd-HHmmss")));
JsonlPath = Path.Combine(DirectoryPath, "capture.jsonl");
ReadableLogPath = Path.Combine(DirectoryPath, "capture.log");
Directory.CreateDirectory(Path.Combine(DirectoryPath, "bodies"));
StartWriter();
}
private sealed class CaptureRecord
{
public DateTime Timestamp;
public long Id;
public string Direction;
public string Path;
public string Type;
public byte[] Body;
public int Length;
public string Note;
}
internal static void QueueCapture(long id, string direction, string path,
string type, byte[] body)
{
if (Volatile.Read(ref WriterFailed) != 0)
{
LogRejectedPacket(id, direction, path,
"capture writer is unavailable");
return;
}
if (WriteQueue.IsAddingCompleted)
{
string closedReason = "capture queue was already closed when packet " +
id + " " + direction + " " + path + " arrived";
MarkIncomplete(closedReason, null);
LogRejectedPacket(id, direction, path, closedReason);
return;
}
int length = body?.Length ?? 0;
string note = body == null ? "protobuf body is null" : null;
byte[] owned = null;
if (length <= MaxBodyBytes)
{
if (length != 0)
{
owned = new byte[length];
Buffer.BlockCopy(body, 0, owned, 0, length);
}
}
else
{
note = "body exceeds " + MaxBodyBytes + " byte capture limit";
}
var record = new CaptureRecord
{
Timestamp = DateTime.Now,
Id = id,
Direction = direction,
Path = path,
Type = type,
Body = owned,
Length = length,
Note = note
};
try
{
if (WriteQueue.TryAdd(record)) return;
}
catch (InvalidOperationException)
{
// CompleteAdding can race a producer during application shutdown.
}
string reason = WriteQueue.IsAddingCompleted
? "capture queue closed before packet " + id + " " + direction +
" " + path + " could be queued"
: "capture queue capacity " + MaxQueuedRecords +
" was exceeded; packet " + id + " " + direction + " " + path +
" was not captured";
MarkIncomplete(reason, null);
LogRejectedPacket(id, direction, path, reason);
}
private static void StartWriter()
{
WriterThread = new Thread(WriterLoop)
{
IsBackground = true,
Name = "BD2 capture writer"
};
WriterThread.Start();
}
internal static void Stop()
{
if (Interlocked.Exchange(ref StopRequested, 1) != 0) return;
CompleteWriterQueue();
if (WriterThread == null || !WriterThread.IsAlive) return;
if (WriterThread.Join(TimeSpan.FromSeconds(WriterShutdownSeconds))) return;
MarkIncomplete(
"capture writer did not drain " + WriteQueue.Count +
" queued records within " + WriterShutdownSeconds +
" seconds during shutdown", null);
}
private static void WriterLoop()
{
try
{
using var json = new StreamWriter(JsonlPath, false, new UTF8Encoding(false));
using var readable = new StreamWriter(
ReadableLogPath, false, new UTF8Encoding(false));
readable.WriteLine("timestamp id dir path protobuf type bytes body");
readable.WriteLine(new string('-', 160));
foreach (CaptureRecord record in WriteQueue.GetConsumingEnumerable())
{
try
{
string bodyFile = WriteBody(record);
json.WriteLine(RecordJson(record, bodyFile));
readable.WriteLine(ReadableLine(record, bodyFile));
json.Flush();
readable.Flush();
}
catch (Exception ex)
{
MarkIncomplete(
"capture writer failed while writing packet " + record.Id +
" " + record.Direction + " " + record.Path, ex);
return;
}
}
}
catch (Exception ex)
{
MarkIncomplete("capture writer failed to initialize or finalize", ex);
}
}
private static void CompleteWriterQueue()
{
if (WriteQueue.IsAddingCompleted) return;
try { WriteQueue.CompleteAdding(); }
catch (InvalidOperationException) { }
}
internal static void MarkIncomplete(string reason, Exception error)
{
if (Interlocked.CompareExchange(ref WriterFailed, 1, 0) != 0) return;
CompleteWriterQueue();
string detail = DateTime.Now.ToString("O") + Environment.NewLine + reason;
if (error != null) detail += Environment.NewLine + error;
detail += Environment.NewLine;
try
{
if (!string.IsNullOrEmpty(DirectoryPath))
{
Directory.CreateDirectory(DirectoryPath);
lock (FailureFileLock)
{
File.WriteAllText(
Path.Combine(DirectoryPath, "INCOMPLETE.txt"),
detail, new UTF8Encoding(false));
}
}
}
catch (Exception markerError)
{
Log?.LogError("could not write capture INCOMPLETE marker: " + markerError);
}
Log?.LogError("CAPTURE IS INCOMPLETE: " + reason +
(error == null ? "" : Environment.NewLine + error));
}
private static void LogRejectedPacket(long id, string direction, string path,
string reason)
{
if (Interlocked.Exchange(ref RejectionLogged, 1) != 0) return;
Log?.LogError("CAPTURE PACKETS ARE BEING REJECTED: packet " + id + " " +
direction + " " + path + "; " + reason);
}
private static string WriteBody(CaptureRecord record)
{
if (record.Body == null || record.Body.Length == 0) return null;
string type = record.Type ?? record.Path ?? "protobuf";
int dot = type.LastIndexOf('.');
if (dot >= 0) type = type.Substring(dot + 1);
string safe = new string(type.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_')
.ToArray()).Trim('_');
if (safe.Length == 0) safe = "protobuf";
string name = record.Id.ToString("D6") + "_" + record.Direction +
"_" + safe + ".pb";
File.WriteAllBytes(Path.Combine(DirectoryPath, "bodies", name), record.Body);
return "bodies/" + name;
}
private static string RecordJson(CaptureRecord record, string bodyFile)
{
return "{" +
"\"timestamp\":\"" + Escape(record.Timestamp.ToString("O")) + "\"," +
"\"id\":" + record.Id + "," +
"\"direction\":\"" + Escape(record.Direction) + "\"," +
"\"path\":\"" + Escape(record.Path) + "\"," +
"\"protobuf_type\":\"" + Escape(record.Type) + "\"," +
"\"length\":" + record.Length + "," +
"\"body\":" + JsonString(bodyFile) + "," +
"\"note\":" + JsonString(record.Note) + "}";
}
private static string ReadableLine(CaptureRecord record, string bodyFile)
{
string direction = record.Direction == "request" ? "REQ" : "RESP";
return string.Format("{0,-33} {1,6:D6} {2,-4} {3,-40} {4,-50} {5,8} {6}",
record.Timestamp.ToString("O"), record.Id, direction,
Truncate(record.Path, 40), Truncate(record.Type, 50), record.Length,
bodyFile ?? record.Note ?? "-");
}
private static string Truncate(string value, int width)
{
value ??= "";
return value.Length <= width ? value : value.Substring(0, width - 1) + "…";
}
private static string JsonString(string value)
{
return value == null ? "null" : "\"" + Escape(value) + "\"";
}
private static string Escape(string value)
{
if (value == null) return "";
var escaped = new StringBuilder(value.Length + 16);
foreach (char character in value)
{
switch (character)
{
case '\"': escaped.Append("\\\""); break;
case '\\': escaped.Append("\\\\"); break;
case '\b': escaped.Append("\\b"); break;
case '\f': escaped.Append("\\f"); break;
case '\n': escaped.Append("\\n"); break;
case '\r': escaped.Append("\\r"); break;
case '\t': escaped.Append("\\t"); break;
default:
if (character <= '\u001f')
escaped.Append("\\u").Append(((int)character).ToString("x4"));
else
escaped.Append(character);
break;
}
}
return escaped.ToString();
}
}
+155
View File
@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using BD2.GameNames;
using BepInEx.Logging;
using Google.Protobuf;
using HarmonyLib;
using static BD2.GameNames.Game;
namespace Bd2CaptureEnvironment;
internal static class PacketCapture
{
private static readonly object CorrelationLock = new object();
private static readonly Dictionary<string, Queue<PendingRequest>> PendingRequests =
new Dictionary<string, Queue<PendingRequest>>(StringComparer.Ordinal);
private static ManualLogSource Log;
private static long Sequence;
private sealed class PendingRequest
{
public long Id;
public string Type;
}
internal static void Install(Harmony harmony, ManualLogSource log)
{
Log = log;
Type manager = typeof(BDNetwork.NetworkManager);
MethodInfo send = manager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.SingleOrDefault(method => method.IsGameMethod("Send") &&
method.ReturnType == typeof(void) &&
method.GetParameters().Length == 6 &&
typeof(IMessage).IsAssignableFrom(method.GetParameters()[0].ParameterType));
if (send != null)
{
harmony.Patch(send, prefix: new HarmonyMethod(
typeof(PacketCapture), nameof(GameSendPrefix)));
Log?.LogInfo("Plaintext request capture installed");
}
else
{
Log?.LogWarning("NetworkManager.Send plaintext hook not found");
}
Type responseData = typeof(BDNetwork.ResponseData);
MethodInfo responseCheck = manager?.GetMethods(
BindingFlags.Instance | BindingFlags.NonPublic)
.SingleOrDefault(method =>
{
ParameterInfo[] parameters = method.GetParameters();
return method.IsGameMethod("ResponseCheck") && method.ReturnType == typeof(bool) && parameters.Length == 4 &&
parameters[0].ParameterType == typeof(string) &&
parameters[2].ParameterType == responseData &&
parameters[3].ParameterType == typeof(byte[]);
});
if (responseCheck != null)
{
harmony.Patch(responseCheck, prefix: new HarmonyMethod(
typeof(PacketCapture), nameof(ResponseCheckPrefix)));
Log?.LogInfo("Path-correlated plaintext response capture installed");
}
else
{
Log?.LogWarning("NetworkManager.ResponseCheck plaintext hook not found");
}
}
private static void GameSendPrefix(IMessage __0)
{
try
{
if (__0 == null) return;
byte[] body = __0.ToByteArray();
string type = __0.GetType().FullName ?? __0.GetType().Name;
string path = RequestPath(__0.GetType().Name);
long id = Interlocked.Increment(ref Sequence);
EnqueueRequest(path, new PendingRequest { Id = id, Type = type });
CaptureWriter.QueueCapture(id, "request", path, type, body);
}
catch (Exception ex)
{
Log?.LogWarning("plaintext request capture failed: " + ex.Message);
}
}
private static void ResponseCheckPrefix(string __0, byte[] __3)
{
try
{
string path = NormalizePath(__0);
PendingRequest request = DequeueRequest(path);
long id = request?.Id ?? Interlocked.Increment(ref Sequence);
string type = ResponseType(request?.Type, path);
CaptureWriter.QueueCapture(id, "response", path, type, __3);
}
catch (Exception ex)
{
Log?.LogWarning("plaintext response capture failed: " + ex.Message);
}
}
private static string RequestPath(string typeName)
{
const string suffix = "Request";
if (typeName != null && typeName.EndsWith(suffix, StringComparison.Ordinal))
typeName = typeName.Substring(0, typeName.Length - suffix.Length);
return NormalizePath(typeName);
}
private static string NormalizePath(string path)
{
if (string.IsNullOrWhiteSpace(path)) return "/unknown";
return path[0] == '/' ? path : "/" + path;
}
private static string ResponseType(string requestType, string path)
{
const string suffix = "Request";
if (!string.IsNullOrEmpty(requestType) &&
requestType.EndsWith(suffix, StringComparison.Ordinal))
return requestType.Substring(0, requestType.Length - suffix.Length) + "Response";
return "Proto.Net." + path.TrimStart('/') + "Response";
}
private static void EnqueueRequest(string path, PendingRequest request)
{
lock (CorrelationLock)
{
if (!PendingRequests.TryGetValue(path, out Queue<PendingRequest> requests))
{
requests = new Queue<PendingRequest>();
PendingRequests.Add(path, requests);
}
requests.Enqueue(request);
}
}
private static PendingRequest DequeueRequest(string path)
{
lock (CorrelationLock)
{
if (PendingRequests.TryGetValue(path, out Queue<PendingRequest> requests) &&
requests.Count != 0)
{
PendingRequest request = requests.Dequeue();
if (requests.Count == 0) PendingRequests.Remove(path);
return request;
}
}
return null;
}
}
+16 -524
View File
@@ -1,14 +1,8 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using BD2.GameNames;
using BepInEx;
using BepInEx.Logging;
using Google.Protobuf;
using HarmonyLib;
using UnityEngine;
@@ -21,548 +15,46 @@ public sealed class Plugin : BaseUnityPlugin
public const string Name = "BD2 Capture Environment";
public const string Version = Bd2Build.Versions.Plugin;
private const int MaxBodyBytes = 16 * 1024 * 1024;
private const int MaxQueuedRecords = 256;
private const int WriterShutdownSeconds = 30;
private const string PlayerPrefsPrefix = "BD2OfficialCapture:" + Bd2Build.Versions.Game + ":";
private static readonly object CorrelationLock = new object();
private static readonly object FailureFileLock = new object();
private static readonly Dictionary<string, Queue<PendingRequest>> PendingRequests =
new Dictionary<string, Queue<PendingRequest>>(StringComparer.Ordinal);
private static readonly BlockingCollection<CaptureRecord> WriteQueue =
new BlockingCollection<CaptureRecord>(
new ConcurrentQueue<CaptureRecord>(), MaxQueuedRecords);
private static ManualLogSource Log;
private static string CaptureDirectory;
private static string JsonlPath;
private static string ReadableLogPath;
private static string IsolatedDataDirectory;
private static string IsolatedGameDataDirectory;
private static long Sequence;
private static Thread WriterThread;
private static int WriterFailed;
private static int RejectionLogged;
private static int StopRequested;
private void Awake()
{
try
{
Log = Logger;
string root = Paths.GameRootPath;
IsolatedDataDirectory = Path.GetFullPath(Path.Combine(root, "IsolatedUserData"));
IsolatedGameDataDirectory = Path.Combine(IsolatedDataDirectory, "Data", "t");
CaptureDirectory = Path.GetFullPath(Path.Combine(
root, "Capture", DateTime.Now.ToString("yyyyMMdd-HHmmss")));
JsonlPath = Path.Combine(CaptureDirectory, "capture.jsonl");
ReadableLogPath = Path.Combine(CaptureDirectory, "capture.log");
EnsurePrivateGameDataDirectory();
Directory.CreateDirectory(Path.Combine(CaptureDirectory, "bodies"));
StartWriter();
Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message));
CaptureStorageIsolation.Initialize(Paths.GameRootPath, Logger);
CaptureWriter.Initialize(Paths.GameRootPath, Logger);
var harmony = new Harmony(Guid);
InstallStorageIsolation(harmony);
CaptureStorageIsolation.Install(harmony);
string effectiveDataPath = Application.persistentDataPath;
if (!string.Equals(
Path.GetFullPath(effectiveDataPath).TrimEnd(Path.DirectorySeparatorChar),
IsolatedDataDirectory.TrimEnd(Path.DirectorySeparatorChar),
CaptureStorageIsolation.DataDirectory.TrimEnd(Path.DirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
"persistentDataPath isolation verification failed: " + effectiveDataPath);
InstallPlaintextCapture(harmony);
CaptureStorageIsolation.InstallGameDataPath(harmony);
PacketCapture.Install(harmony, Logger);
WriteMetadata();
Logger.LogInfo("Official capture environment active");
Logger.LogInfo("persistentDataPath => " + IsolatedDataDirectory);
Logger.LogInfo("isolated GameData => " + IsolatedGameDataDirectory);
Logger.LogInfo("capture => " + CaptureDirectory);
Logger.LogInfo("persistentDataPath => " + CaptureStorageIsolation.DataDirectory);
Logger.LogInfo("isolated GameData => " + CaptureStorageIsolation.GameDataDirectory);
Logger.LogInfo("capture => " + CaptureWriter.DirectoryPath);
}
catch (Exception ex)
{
Logger.LogError("Capture environment setup failed: " + ex);
MarkCaptureIncomplete("capture environment setup failed", ex);
CaptureWriter.MarkIncomplete("capture environment setup failed", ex);
}
}
private void OnApplicationQuit()
{
StopWriter();
}
private sealed class PendingRequest
{
public long Id;
public string Type;
}
private sealed class CaptureRecord
{
public DateTime Timestamp;
public long Id;
public string Direction;
public string Path;
public string Type;
public byte[] Body;
public int Length;
public string Note;
}
private static void EnsurePrivateGameDataDirectory()
{
foreach (string path in new[]
{
IsolatedDataDirectory,
Path.Combine(IsolatedDataDirectory, "Data"),
IsolatedGameDataDirectory
})
{
if (Directory.Exists(path) &&
(new DirectoryInfo(path).Attributes & FileAttributes.ReparsePoint) != 0)
throw new InvalidOperationException("Isolated GameData path is linked: " + path);
Directory.CreateDirectory(path);
}
}
private static void InstallStorageIsolation(Harmony harmony)
{
PropertyInfo persistent = typeof(Application).GetProperty(
"persistentDataPath", BindingFlags.Static | BindingFlags.Public);
MethodInfo getter = persistent?.GetGetMethod();
if (getter == null)
throw new MissingMethodException("Application.persistentDataPath getter not found");
harmony.Patch(getter, prefix: new HarmonyMethod(
typeof(Plugin), nameof(PersistentDataPathPrefix)));
var names = new HashSet<string>(StringComparer.Ordinal)
{
"GetString", "SetString", "GetInt", "SetInt", "GetFloat", "SetFloat",
"HasKey", "DeleteKey"
};
foreach (MethodInfo method in typeof(PlayerPrefs).GetMethods(
BindingFlags.Static | BindingFlags.Public))
{
ParameterInfo[] parameters = method.GetParameters();
if (!names.Contains(method.Name) || parameters.Length == 0 ||
parameters[0].ParameterType != typeof(string))
continue;
harmony.Patch(method, prefix: new HarmonyMethod(
typeof(Plugin), nameof(PlayerPrefsKeyPrefix)));
}
MethodInfo deleteAll = typeof(PlayerPrefs).GetMethod(
"DeleteAll", BindingFlags.Static | BindingFlags.Public,
null, Type.EmptyTypes, null);
if (deleteAll != null)
harmony.Patch(deleteAll, prefix: new HarmonyMethod(
typeof(Plugin), nameof(BlockPlayerPrefsDeleteAll)));
}
private static bool PersistentDataPathPrefix(ref string __result)
{
__result = IsolatedDataDirectory;
return false;
}
private static void PlayerPrefsKeyPrefix(ref string key)
{
if (key != null && !key.StartsWith(PlayerPrefsPrefix, StringComparison.Ordinal))
key = PlayerPrefsPrefix + key;
}
private static bool BlockPlayerPrefsDeleteAll()
{
Log?.LogWarning("Blocked PlayerPrefs.DeleteAll to protect the primary client profile");
return false;
}
private static void InstallPlaintextCapture(Harmony harmony)
{
Type manager = FindType("BDNetwork.NetworkManager");
MethodInfo gameDataPath = manager?.GetMethod("GetPachedGameDataPath",
BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null);
if (gameDataPath == null || gameDataPath.ReturnType != typeof(string))
throw new MissingMethodException("NetworkManager.GetPachedGameDataPath not found");
harmony.Patch(gameDataPath, prefix: new HarmonyMethod(
typeof(Plugin), nameof(GameDataPathPrefix)));
Log?.LogInfo("Private installed GameData path hook installed");
MethodInfo send = manager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.FirstOrDefault(method => method.Name == "Send" &&
method.ReturnType == typeof(void) &&
method.GetParameters().Length == 6 &&
typeof(IMessage).IsAssignableFrom(method.GetParameters()[0].ParameterType));
if (send != null)
{
harmony.Patch(send, prefix: new HarmonyMethod(
typeof(Plugin), nameof(GameSendPrefix)));
Log?.LogInfo("Plaintext request capture installed");
}
else
{
Log?.LogWarning("NetworkManager.Send plaintext hook not found");
}
Type responseData = FindType("BDNetwork.ResponseData");
MethodInfo responseCheck = manager?.GetMethods(
BindingFlags.Instance | BindingFlags.NonPublic)
.SingleOrDefault(method =>
{
ParameterInfo[] parameters = method.GetParameters();
return method.ReturnType == typeof(bool) && parameters.Length == 4 &&
parameters[0].ParameterType == typeof(string) &&
parameters[2].ParameterType == responseData &&
parameters[3].ParameterType == typeof(byte[]);
});
if (responseCheck != null)
{
harmony.Patch(responseCheck, prefix: new HarmonyMethod(
typeof(Plugin), nameof(ResponseCheckPrefix)));
Log?.LogInfo("Path-correlated plaintext response capture installed");
}
else
{
Log?.LogWarning("NetworkManager.ResponseCheck plaintext hook not found");
}
}
private static bool GameDataPathPrefix(ref string __result)
{
__result = IsolatedGameDataDirectory;
return false;
}
private static void GameSendPrefix(IMessage __0)
{
try
{
if (__0 == null) return;
byte[] body = __0.ToByteArray();
string type = __0.GetType().FullName ?? __0.GetType().Name;
string path = RequestPath(__0.GetType().Name);
long id = Interlocked.Increment(ref Sequence);
EnqueueRequest(path, new PendingRequest { Id = id, Type = type });
QueueCapture(id, "request", path, type, body);
}
catch (Exception ex)
{
Log?.LogWarning("plaintext request capture failed: " + ex.Message);
}
}
private static void ResponseCheckPrefix(string __0, byte[] __3)
{
try
{
string path = NormalizePath(__0);
PendingRequest request = DequeueRequest(path);
long id = request?.Id ?? Interlocked.Increment(ref Sequence);
string type = ResponseType(request?.Type, path);
QueueCapture(id, "response", path, type, __3);
}
catch (Exception ex)
{
Log?.LogWarning("plaintext response capture failed: " + ex.Message);
}
}
private static string RequestPath(string typeName)
{
const string suffix = "Request";
if (typeName != null && typeName.EndsWith(suffix, StringComparison.Ordinal))
typeName = typeName.Substring(0, typeName.Length - suffix.Length);
return NormalizePath(typeName);
}
private static string NormalizePath(string path)
{
if (string.IsNullOrWhiteSpace(path)) return "/unknown";
return path[0] == '/' ? path : "/" + path;
}
private static string ResponseType(string requestType, string path)
{
const string suffix = "Request";
if (!string.IsNullOrEmpty(requestType) &&
requestType.EndsWith(suffix, StringComparison.Ordinal))
return requestType.Substring(0, requestType.Length - suffix.Length) + "Response";
return "Proto.Net." + path.TrimStart('/') + "Response";
}
private static void EnqueueRequest(string path, PendingRequest request)
{
lock (CorrelationLock)
{
if (!PendingRequests.TryGetValue(path, out Queue<PendingRequest> requests))
{
requests = new Queue<PendingRequest>();
PendingRequests.Add(path, requests);
}
requests.Enqueue(request);
}
}
private static PendingRequest DequeueRequest(string path)
{
lock (CorrelationLock)
{
if (PendingRequests.TryGetValue(path, out Queue<PendingRequest> requests) &&
requests.Count != 0)
{
PendingRequest request = requests.Dequeue();
if (requests.Count == 0) PendingRequests.Remove(path);
return request;
}
}
return null;
}
private static void QueueCapture(long id, string direction, string path,
string type, byte[] body)
{
if (Volatile.Read(ref WriterFailed) != 0)
{
LogRejectedPacket(id, direction, path,
"capture writer is unavailable");
return;
}
if (WriteQueue.IsAddingCompleted)
{
string closedReason = "capture queue was already closed when packet " +
id + " " + direction + " " + path + " arrived";
MarkCaptureIncomplete(closedReason, null);
LogRejectedPacket(id, direction, path, closedReason);
return;
}
int length = body?.Length ?? 0;
string note = body == null ? "protobuf body is null" : null;
byte[] owned = null;
if (length <= MaxBodyBytes)
{
if (length != 0)
{
owned = new byte[length];
Buffer.BlockCopy(body, 0, owned, 0, length);
}
}
else
{
note = "body exceeds " + MaxBodyBytes + " byte capture limit";
}
var record = new CaptureRecord
{
Timestamp = DateTime.Now,
Id = id,
Direction = direction,
Path = path,
Type = type,
Body = owned,
Length = length,
Note = note
};
try
{
if (WriteQueue.TryAdd(record)) return;
}
catch (InvalidOperationException)
{
// CompleteAdding can race a producer during application shutdown.
}
string reason = WriteQueue.IsAddingCompleted
? "capture queue closed before packet " + id + " " + direction +
" " + path + " could be queued"
: "capture queue capacity " + MaxQueuedRecords +
" was exceeded; packet " + id + " " + direction + " " + path +
" was not captured";
MarkCaptureIncomplete(reason, null);
LogRejectedPacket(id, direction, path, reason);
}
private static void StartWriter()
{
WriterThread = new Thread(WriterLoop)
{
IsBackground = true,
Name = "BD2 capture writer"
};
WriterThread.Start();
}
private static void StopWriter()
{
if (Interlocked.Exchange(ref StopRequested, 1) != 0) return;
CompleteWriterQueue();
if (WriterThread == null || !WriterThread.IsAlive) return;
if (WriterThread.Join(TimeSpan.FromSeconds(WriterShutdownSeconds))) return;
MarkCaptureIncomplete(
"capture writer did not drain " + WriteQueue.Count +
" queued records within " + WriterShutdownSeconds +
" seconds during shutdown", null);
}
private static void WriterLoop()
{
try
{
using var json = new StreamWriter(JsonlPath, false, new UTF8Encoding(false));
using var readable = new StreamWriter(
ReadableLogPath, false, new UTF8Encoding(false));
readable.WriteLine("timestamp id dir path protobuf type bytes body");
readable.WriteLine(new string('-', 160));
foreach (CaptureRecord record in WriteQueue.GetConsumingEnumerable())
{
try
{
string bodyFile = WriteBody(record);
json.WriteLine(RecordJson(record, bodyFile));
readable.WriteLine(ReadableLine(record, bodyFile));
json.Flush();
readable.Flush();
}
catch (Exception ex)
{
MarkCaptureIncomplete(
"capture writer failed while writing packet " + record.Id +
" " + record.Direction + " " + record.Path, ex);
return;
}
}
}
catch (Exception ex)
{
MarkCaptureIncomplete("capture writer failed to initialize or finalize", ex);
}
}
private static void CompleteWriterQueue()
{
if (WriteQueue.IsAddingCompleted) return;
try { WriteQueue.CompleteAdding(); }
catch (InvalidOperationException) { }
}
private static void MarkCaptureIncomplete(string reason, Exception error)
{
if (Interlocked.CompareExchange(ref WriterFailed, 1, 0) != 0) return;
CompleteWriterQueue();
string detail = DateTime.Now.ToString("O") + Environment.NewLine + reason;
if (error != null) detail += Environment.NewLine + error;
detail += Environment.NewLine;
try
{
if (!string.IsNullOrEmpty(CaptureDirectory))
{
Directory.CreateDirectory(CaptureDirectory);
lock (FailureFileLock)
{
File.WriteAllText(
Path.Combine(CaptureDirectory, "INCOMPLETE.txt"),
detail, new UTF8Encoding(false));
}
}
}
catch (Exception markerError)
{
Log?.LogError("could not write capture INCOMPLETE marker: " + markerError);
}
Log?.LogError("CAPTURE IS INCOMPLETE: " + reason +
(error == null ? "" : Environment.NewLine + error));
}
private static void LogRejectedPacket(long id, string direction, string path,
string reason)
{
if (Interlocked.Exchange(ref RejectionLogged, 1) != 0) return;
Log?.LogError("CAPTURE PACKETS ARE BEING REJECTED: packet " + id + " " +
direction + " " + path + "; " + reason);
}
private static string WriteBody(CaptureRecord record)
{
if (record.Body == null || record.Body.Length == 0) return null;
string type = record.Type ?? record.Path ?? "protobuf";
int dot = type.LastIndexOf('.');
if (dot >= 0) type = type.Substring(dot + 1);
string safe = new string(type.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_')
.ToArray()).Trim('_');
if (safe.Length == 0) safe = "protobuf";
string name = record.Id.ToString("D6") + "_" + record.Direction +
"_" + safe + ".pb";
File.WriteAllBytes(Path.Combine(CaptureDirectory, "bodies", name), record.Body);
return "bodies/" + name;
}
private static string RecordJson(CaptureRecord record, string bodyFile)
{
return "{" +
"\"timestamp\":\"" + Escape(record.Timestamp.ToString("O")) + "\"," +
"\"id\":" + record.Id + "," +
"\"direction\":\"" + Escape(record.Direction) + "\"," +
"\"path\":\"" + Escape(record.Path) + "\"," +
"\"protobuf_type\":\"" + Escape(record.Type) + "\"," +
"\"length\":" + record.Length + "," +
"\"body\":" + JsonString(bodyFile) + "," +
"\"note\":" + JsonString(record.Note) + "}";
}
private static string ReadableLine(CaptureRecord record, string bodyFile)
{
string direction = record.Direction == "request" ? "REQ" : "RESP";
return string.Format("{0,-33} {1,6:D6} {2,-4} {3,-40} {4,-50} {5,8} {6}",
record.Timestamp.ToString("O"), record.Id, direction,
Truncate(record.Path, 40), Truncate(record.Type, 50), record.Length,
bodyFile ?? record.Note ?? "-");
}
private static string Truncate(string value, int width)
{
value ??= "";
return value.Length <= width ? value : value.Substring(0, width - 1) + "…";
}
private static string JsonString(string value)
{
return value == null ? "null" : "\"" + Escape(value) + "\"";
}
private static string Escape(string value)
{
if (value == null) return "";
var escaped = new StringBuilder(value.Length + 16);
foreach (char character in value)
{
switch (character)
{
case '\"': escaped.Append("\\\""); break;
case '\\': escaped.Append("\\\\"); break;
case '\b': escaped.Append("\\b"); break;
case '\f': escaped.Append("\\f"); break;
case '\n': escaped.Append("\\n"); break;
case '\r': escaped.Append("\\r"); break;
case '\t': escaped.Append("\\t"); break;
default:
if (character <= '\u001f')
escaped.Append("\\u").Append(((int)character).ToString("x4"));
else
escaped.Append(character);
break;
}
}
return escaped.ToString();
}
private static Type FindType(string fullName)
{
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
{
Type type = assembly.GetType(fullName, false);
if (type != null) return type;
}
try { return Assembly.Load("Assembly-CSharp")?.GetType(fullName, false); }
catch { return null; }
CaptureWriter.Stop();
}
private static void WriteMetadata()
{
File.WriteAllText(Path.Combine(CaptureDirectory, "README.txt"),
File.WriteAllText(Path.Combine(CaptureWriter.DirectoryPath, "README.txt"),
"BD2 " + Bd2Build.Versions.Game + " official API capture.\r\n" +
"All NetworkManager plaintext protobuf requests and responses are recorded.\r\n" +
"capture.jsonl is machine-readable; capture.log is the aligned human-readable index.\r\n" +
@@ -570,8 +62,8 @@ public sealed class Plugin : BaseUnityPlugin
"No Cookie/Authorization headers or URL query strings are recorded.\r\n" +
"Raw protobuf/account bodies can still contain private account data. Do not share this directory.\r\n" +
"clientVersion=" + Application.version + "\r\n" +
"persistentDataPath=" + IsolatedDataDirectory + "\r\n" +
"gameDataPath=" + IsolatedGameDataDirectory + "\r\n",
"persistentDataPath=" + CaptureStorageIsolation.DataDirectory + "\r\n" +
"gameDataPath=" + CaptureStorageIsolation.GameDataDirectory + "\r\n",
new UTF8Encoding(false));
}
}
+13
View File
@@ -2,6 +2,19 @@
This BepInEx plugin is only for the separately extracted official client selected by the repository-root `versions.json`.
The SDK embeds the version-locked names table. Build with the game directory; the build generates the readable reference assembly and reobfuscates the plugin automatically:
```powershell
dotnet build plugins/CaptureEnvironment/CaptureEnvironment.csproj -c Release `
'-p:GameDir=<official-client-directory>'
```
Deploy the runtime `BD2CaptureEnvironment.dll` together with the adjacent
`BD2.GameNames.dll`. The launcher copies both from the staged build and requires
both to exist. The shared library validates the plugin/table version and game
binary before game patches are installed. See [the GameSdk guide](../GameSdk/README.md)
for the compiler/runtime name contract; the readable shell stays in `obj`.
- redirects `Application.persistentDataPath` to `<game>/IsolatedUserData`;
- prefixes game `PlayerPrefs` keys with a namespace derived from the configured client version;
- keeps account/profile/Neo/Intro and the installed GameData working set in
@@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<BD2LocalGameDir>E:\Games\BrownDustII</BD2LocalGameDir>
<!-- Optional separate installation for CaptureEnvironment. -->
<BD2CaptureGameDir>E:\Games\BrownDustII-Capture</BD2CaptureGameDir>
<!-- Optional shared navigation cache; omit to use the SDK default. -->
<!-- <BD2GameSdkCache>D:\Caches\BD2.GameSdk</BD2GameSdkCache> -->
</PropertyGroup>
</Project>
+21
View File
@@ -0,0 +1,21 @@
<Project>
<!-- Shared repository settings. Machine paths belong in the local import. -->
<PropertyGroup>
<BD2PluginsDir>$(MSBuildThisFileDirectory)</BD2PluginsDir>
<BD2RepositoryDir>$([System.IO.Path]::GetFullPath('$(BD2PluginsDir)..'))/</BD2RepositoryDir>
</PropertyGroup>
<Import Project="$(BD2PluginsDir)Directory.Build.local.props" Condition="Exists('$(BD2PluginsDir)Directory.Build.local.props')" />
<PropertyGroup>
<GameDir Condition="'$(GameDir)' == '' and '$(MSBuildProjectName)' == 'CaptureEnvironment' and '$(BD2CaptureGameDir)' != ''">$(BD2CaptureGameDir)</GameDir>
<GameDir Condition="'$(GameDir)' == ''">$(BD2LocalGameDir)</GameDir>
<BD2ManagedDir Condition="'$(BD2ManagedDir)' == '' and '$(GameDir)' != ''">$(GameDir)/BrownDust II_Data/Managed</BD2ManagedDir>
<BD2BepInExDir Condition="'$(BD2BepInExDir)' == '' and '$(GameDir)' != ''">$(GameDir)/BepInEx</BD2BepInExDir>
<BD2VersionConfig Condition="'$(BD2VersionConfig)' == ''">$(BD2RepositoryDir)versions.json</BD2VersionConfig>
<BD2SdkProject Condition="'$(BD2SdkProject)' == ''">$(BD2PluginsDir)GameSdk/GameSdk.csproj</BD2SdkProject>
<BD2GameNamesProject Condition="'$(BD2GameNamesProject)' == ''">$(BD2PluginsDir)GameNames/GameNames.csproj</BD2GameNamesProject>
<BD2SdkConfiguration Condition="'$(BD2SdkConfiguration)' == ''">Release</BD2SdkConfiguration>
<BD2SdkTool Condition="'$(BD2SdkTool)' == ''">$(BD2PluginsDir)GameSdk/bin/$(BD2SdkConfiguration)/net8.0/GameSdk.dll</BD2SdkTool>
<BD2SdkIntermediateFolder Condition="'$(BD2SdkIntermediateFolder)' == ''">game-sdk</BD2SdkIntermediateFolder>
<BD2GameSdkCache Condition="'$(BD2GameSdkCache)' == ''">$(BD2_GAME_SDK_CACHE)</BD2GameSdkCache>
</PropertyGroup>
</Project>
+9
View File
@@ -0,0 +1,9 @@
<Project>
<!-- Framework/configuration/output paths are available only after the project. -->
<PropertyGroup Condition="'$(BD2PluginVersionKey)' != ''">
<BD2SdkDir Condition="'$(BD2SdkDir)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)$(BD2SdkIntermediateFolder)'))</BD2SdkDir>
<BD2GeneratedVersionSource Condition="'$(BD2GeneratedVersionSource)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)Bd2Versions.g.cs'))</BD2GeneratedVersionSource>
</PropertyGroup>
<Import Project="$(BD2PluginsDir)VersionSource.targets" Condition="'$(BD2PluginVersionKey)' != ''" />
<Import Project="$(BD2PluginsDir)GameSdk.targets" Condition="'$(BD2PluginVersionKey)' != ''" />
</Project>
+243
View File
@@ -0,0 +1,243 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.Serialization.Json;
using System.Security.Cryptography;
using BD2.GameNames.Internal;
namespace BD2.GameNames;
/// <summary>Readable names for string-based reflection. Unknown names retain their literal spelling.</summary>
public static class Game
{
private sealed class Index
{
internal readonly NameTable Table;
internal readonly Dictionary<string, TypeName> Types;
internal readonly Dictionary<int, MemberName> Tokens;
internal Index()
{
using (var stream = typeof(Game).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz"))
using (var gzip = new GZipStream(stream ?? throw new InvalidDataException("BD2.GameNames: embedded table missing"), CompressionMode.Decompress))
Table = (NameTable)new DataContractJsonSerializer(typeof(NameTable)).ReadObject(gzip);
if (Table.schema_version != 1) throw new InvalidDataException("BD2.GameNames: unsupported table schema");
Types = Table.types.ToDictionary(t => t.readable, StringComparer.Ordinal);
Tokens = Table.members.ToDictionary(m => m.token);
}
}
private static readonly Lazy<Index> Names = new Lazy<Index>(() => new Index());
private static readonly ConcurrentDictionary<string, Type> TypeCache = new ConcurrentDictionary<string, Type>();
private static readonly ConcurrentDictionary<string, MemberInfo> MemberCache = new ConcurrentDictionary<string, MemberInfo>();
private static readonly object ValidationLock = new object();
private static string ValidatedStamp;
/// <summary>Gets the game version embedded in this package.</summary>
public static string GameVersion => Names.Value.Table.game_version;
/// <summary>Translates a full readable type name. Unknown names retain their literal spelling.</summary>
public static string TypeName(string readableFullName)
{
if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName));
return Names.Value.Types.TryGetValue(readableFullName.Replace('/', '+'), out var entry) ? entry.original : readableFullName;
}
/// <summary>Finds a readable type in Assembly-CSharp or loaded assemblies; returns null when absent.</summary>
public static Type FindType(string readableFullName)
{
if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName));
if (TypeCache.TryGetValue(readableFullName, out var cached)) return cached;
string original = TypeName(readableFullName);
Type found = GameAssembly().GetType(original, false);
if (found == null)
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
found = assembly.GetType(original, false);
if (found != null) break;
}
if (found != null) TypeCache.TryAdd(readableFullName, found);
return found;
}
private static Assembly GameAssembly() => Assembly.Load("Assembly-CSharp");
private static bool Matches(MemberInfo member, string kind, string readable)
{
if (member == null) throw new ArgumentNullException(nameof(member));
if (readable == null) throw new ArgumentNullException(nameof(readable));
// Metadata tokens retain declaration identity through inheritance and closed generics.
// A hidden literal member must not be mistaken for a renamed member of a base class.
if (member.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(member.MetadataToken, out var entry))
return entry.kind == kind && entry.readable == readable;
return member.Name == readable;
}
/// <summary>Translate coroutine/member strings. Overloads with different runtime names require Method/MethodName(MethodInfo).</summary>
public static string MemberName(Type type, string readable, GameMemberKind kind = GameMemberKind.Method)
{
if (type == null) throw new ArgumentNullException(nameof(type));
if (readable == null) throw new ArgumentNullException(nameof(readable));
string key = kind switch
{
GameMemberKind.Method => "method",
GameMemberKind.Field => "field",
GameMemberKind.Property => "property",
GameMemberKind.Event => "event",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
var reflected = kind switch
{
GameMemberKind.Method => type.GetMethods(all).Cast<MemberInfo>(),
GameMemberKind.Field => type.GetFields(all).Cast<MemberInfo>(),
GameMemberKind.Property => type.GetProperties(all).Cast<MemberInfo>(),
GameMemberKind.Event => type.GetEvents(all).Cast<MemberInfo>(),
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
var names = reflected.Where(m => Matches(m, key, readable)).Select(m => m.Name).Distinct().ToArray();
if (names.Length == 0) return readable;
if (names.Length != 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature");
return names[0];
}
/// <summary>Returns the actual CLR method name for APIs that accept strings.</summary>
public static string MethodName(MethodInfo method) => method?.Name ?? throw new ArgumentNullException(nameof(method));
/// <summary>Tests a reflected method against its readable name, preserving overload identity.</summary>
public static bool IsGameMethod(this MethodInfo method, string readable) => Matches(method, "method", readable);
/// <summary>Translates a readable parameter name using the method declaration; unknown names remain unchanged.</summary>
public static string ParameterName(MethodBase method, string readable)
{
if (method == null) throw new ArgumentNullException(nameof(method));
if (readable == null) throw new ArgumentNullException(nameof(readable));
if (method.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(method.MetadataToken, out var entry))
{
var p = entry.parameters.SingleOrDefault(n => n.readable == readable);
if (p != null) return p.original;
}
return readable;
}
/// <summary>Extracts a MethodInfo from a call expression without executing the expression.</summary>
public static MethodInfo Method<T>(Expression<Action<T>> expression) => Call(expression);
/// <summary>Extracts a MethodInfo from a call expression without executing the expression.</summary>
public static MethodInfo Method(Expression<Action> expression) => Call(expression);
/// <summary>Extracts a property getter from an expression without evaluating the property.</summary>
public static MethodInfo Getter<T, TResult>(Expression<Func<T, TResult>> expression) => PropertyGetter(expression);
/// <summary>Extracts a property getter from an expression without evaluating the property.</summary>
public static MethodInfo Getter<TResult>(Expression<Func<TResult>> expression) => PropertyGetter(expression);
private static MethodInfo Call(LambdaExpression expression)
{
if (expression == null) throw new ArgumentNullException(nameof(expression));
return expression.Body is MethodCallExpression call ? call.Method : throw new ArgumentException("Expected a method call", nameof(expression));
}
private static MethodInfo PropertyGetter(LambdaExpression expression)
{
if (expression == null) throw new ArgumentNullException(nameof(expression));
return expression.Body is MemberExpression member && member.Member is PropertyInfo property
? property.GetGetMethod(true) : throw new ArgumentException("Expected a property access", nameof(expression));
}
private static T Cached<T>(Type type, string kind, string readable, BindingFlags flags, Type[] parameters, Func<T> resolve) where T : MemberInfo
{
if (readable == null) throw new ArgumentNullException(nameof(readable));
if (type == null) return null;
string key = type.AssemblyQualifiedName + "|" + kind + "|" + readable + "|" + (int)flags + "|" + (parameters == null ? "*" : string.Join(";", parameters.Select(p => p.AssemblyQualifiedName)));
if (MemberCache.TryGetValue(key, out var cached)) return (T)cached;
T found = resolve();
if (found != null) MemberCache.TryAdd(key, found);
return found;
}
/// <summary>Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw.</summary>
public static MethodInfo GetGameMethod(this Type type, string readable, BindingFlags flags) => Cached(type, "method", readable, flags, null,
() => Single(type.GetMethods(flags).Where(m => Matches(m, "method", readable)), type, readable));
/// <summary>Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw.</summary>
public static MethodInfo GetGameMethod(this Type type, string readable, BindingFlags flags, Binder binder, Type[] parameters, ParameterModifier[] modifiers)
{
if (readable == null) throw new ArgumentNullException(nameof(readable));
if (parameters == null) throw new ArgumentNullException(nameof(parameters));
if (parameters.Any(p => p == null)) throw new ArgumentException("Parameter types cannot contain null", nameof(parameters));
if (type == null) return null;
MethodInfo Resolve()
{
var candidates = type.GetMethods(flags).Where(m => Matches(m, "method", readable)).ToArray();
if (candidates.Length == 0) return null;
return (MethodInfo)(binder ?? Type.DefaultBinder).SelectMethod(flags, candidates, parameters, modifiers);
}
// Custom binders/modifiers can affect resolution; do not cache those calls.
if (binder != null || modifiers != null) return Resolve();
return Cached(type, "method", readable, flags, parameters, Resolve);
}
private static T Single<T>(IEnumerable<T> members, Type type, string readable) where T : MemberInfo
{
var candidates = members.Take(2).ToArray();
if (candidates.Length > 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature");
return candidates.FirstOrDefault();
}
/// <summary>Finds a field by readable name and binding flags. Returns null when absent.</summary>
public static FieldInfo GetGameField(this Type type, string readable, BindingFlags flags) => Cached(type, "field", readable, flags, null,
() => Single(type.GetFields(flags).Where(m => Matches(m, "field", readable)), type, readable));
/// <summary>Finds a property by readable name and binding flags. Returns null when absent; ambiguous matches throw.</summary>
public static PropertyInfo GetGameProperty(this Type type, string readable, BindingFlags flags) => Cached(type, "property", readable, flags, null,
() => Single(type.GetProperties(flags).Where(m => Matches(m, "property", readable)), type, readable));
/// <summary>Finds an event by readable name and binding flags. Returns null when absent.</summary>
public static EventInfo GetGameEvent(this Type type, string readable, BindingFlags flags) => Cached(type, "event", readable, flags, null,
() => Single(type.GetEvents(flags).Where(m => Matches(m, "event", readable)), type, readable));
/// <summary>Validate plugin/table version, binary fingerprint and known metadata entries before installing patches.</summary>
public static void Validate(Assembly plugin, Action<string> log = null) => Validate(plugin, GameVersion, log);
/// <summary>Validates only the game binary against the embedded table for plugins that do not use the compiler SDK.</summary>
public static void ValidateGame(Action<string> log = null) => ValidateCore(null, GameVersion, log);
/// <summary>Validates a compiler SDK plugin, its expected game version, the table and the game binary. Throws on mismatch.</summary>
public static void Validate(Assembly plugin, string expectedVersion, Action<string> log)
{
if (plugin == null) throw new ArgumentNullException(nameof(plugin));
if (expectedVersion == null) throw new ArgumentNullException(nameof(expectedVersion));
ValidateCore(plugin, expectedVersion, log);
}
private static void ValidateCore(Assembly plugin, string expectedVersion, Action<string> log)
{
try
{
var table = Names.Value.Table;
string stamp = table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256;
var metadata = plugin?.GetCustomAttributes<AssemblyMetadataAttribute>().SingleOrDefault(a => a.Key == "BD2.GameNames");
if (expectedVersion != table.game_version || plugin != null && metadata?.Value != stamp)
throw new InvalidDataException("plugin/table mismatch; rebuild the plugin and BD2.GameNames together");
lock (ValidationLock)
{
if (ValidatedStamp != stamp)
{
var assembly = GameAssembly();
if (assembly.ManifestModule.ModuleVersionId.ToString() != table.assembly_mvid)
throw new InvalidDataException("Assembly-CSharp MVID mismatch");
using (var sha = SHA256.Create())
using (var stream = File.OpenRead(assembly.Location))
{
string actual = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant();
if (actual != table.assembly_sha256) throw new InvalidDataException("Assembly-CSharp SHA-256 mismatch");
}
const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
var app = FindType("AppManager") ?? throw new TypeLoadException("AppManager");
var intro = FindType("IntroUI") ?? throw new TypeLoadException("IntroUI");
var network = FindType("BDNetwork.NetworkManager") ?? throw new TypeLoadException("BDNetwork.NetworkManager");
if (app.GetGameProperty("IsPlatformLogin", all)?.PropertyType != typeof(bool) ||
intro.GetGameMethod("SendMaintenanceInfo", all, null, new[] { typeof(bool) }, null) == null ||
network.GetGameMethod("GetPachedGameDataPath", all, null, Type.EmptyTypes, null)?.ReturnType != typeof(string))
throw new MissingMemberException("known game-name probes failed");
ValidatedStamp = stamp;
}
}
log?.Invoke("BD2.GameNames self-check passed: game=" + table.game_version + ", MVID=" + table.assembly_mvid);
}
catch (Exception ex)
{
log?.Invoke("BD2.GameNames SELF-CHECK FAILED: expected game=" + expectedVersion + "; " + ex.Message + "; game patches will not be installed");
throw;
}
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace BD2.GameNames;
/// <summary>Specifies the kind of game member whose readable name should be resolved.</summary>
public enum GameMemberKind
{
/// <summary>A method, including a coroutine entry point.</summary>
Method,
/// <summary>A field.</summary>
Field,
/// <summary>A property.</summary>
Property,
/// <summary>An event.</summary>
Event
}
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../PackageMetadata.props" />
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<AssemblyName>BD2.GameNames</AssemblyName>
<Nullable>disable</Nullable>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<Deterministic>true</Deterministic>
<DebugType>none</DebugType>
<PathMap>$(BaseIntermediateOutputPath)=/_/BD2.GameNames/obj,$(MSBuildProjectDirectory)=/_/BD2.GameNames</PathMap>
<PackageId>BD2.GameNames</PackageId>
<Description>Readable game-name resolution, reflection helpers and compatibility validation for Brown Dust II plugins. Includes a version-locked names table.</Description>
<PackageTags>BD2;BrownDust;Unity;BepInEx;Harmony;modding</PackageTags>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<GameNamesTable Condition="'$(GameNamesTable)' == ''">$(MSBuildThisFileDirectory)Mappings/names.json</GameNamesTable>
</PropertyGroup>
<Target Name="RequireNameTable" BeforeTargets="PrepareForBuild">
<Error Condition="!Exists('$(GameNamesTable).gz')" Text="Build a plugin with GameSdk.targets, or pass GameNamesTable pointing to generated names.json." />
</Target>
<ItemGroup>
<Compile Include="Game.cs;GameMemberKind.cs;NameTable.cs" />
<EmbeddedResource Include="$(GameNamesTable).gz" LogicalName="BD2.GameNames.names.json.gz" />
<None Include="README.md" Pack="true" PackagePath="README.md" />
</ItemGroup>
</Project>
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
using System.Collections.Generic;
using System.Runtime.Serialization;
// DataContractJsonSerializer populates these internal fields from the embedded table.
#pragma warning disable CS0649
namespace BD2.GameNames.Internal;
[DataContract]
internal sealed class NameTable
{
[DataMember] public int schema_version = 1;
[DataMember] public string game_version;
[DataMember] public string assembly_name;
[DataMember] public string assembly_mvid;
[DataMember] public string assembly_sha256;
[DataMember] public string mapping_sha256;
[DataMember] public string generator_sha256;
[DataMember] public List<TypeName> types = new List<TypeName>();
[DataMember] public List<MemberName> members = new List<MemberName>();
}
[DataContract]
internal sealed class TypeName
{
[DataMember] public int token;
[DataMember] public string readable;
[DataMember] public string original;
}
[DataContract]
internal sealed class MemberName
{
[DataMember] public int token;
[DataMember] public string kind;
[DataMember] public string declaring_type;
[DataMember] public string readable;
[DataMember] public string original;
[DataMember] public string signature;
[DataMember] public List<ParameterName> parameters = new List<ParameterName>();
}
[DataContract]
internal sealed class ParameterName
{
[DataMember] public int position;
[DataMember] public string readable;
[DataMember] public string original;
}
+29
View File
@@ -0,0 +1,29 @@
# BD2.GameNames
Brown Dust II 插件的共享名字解析运行时,目标框架为 `.NET Standard 2.0`。内嵌当前游戏版本的官方名字表,支持字符串反射、Harmony 目标表达式、缓存和插件启动自检。
开发插件建议引用配套的 `BD2.GameSdk` NuGet 包,它会自动引入本包并接入可读引用程序集和构建期 reobf。只有需要字符串反射、完全不引用游戏类型的插件才单独引用本包。
```xml
<PackageReference Include="BD2.GameSdk" Version="0.2.0-game.2.35.10" PrivateAssets="all" />
```
```csharp
using BD2.GameNames;
using System.Reflection;
Game.Validate(typeof(MyPlugin).Assembly, message => Logger.LogInfo(message));
var intro = Game.FindType("IntroUI");
var enter = intro?.GetGameMethod("Enter", BindingFlags.Instance | BindingFlags.NonPublic,
null, Type.EmptyTypes, null);
```
未知名字保留字面名;查不到成员返回 null,歧义抛出 `AmbiguousMatchException`。表中私有/编译器生成成员可通过字符串通道解析,公开调用可使用 `BD2.GameSdk` 生成的壳接受编译器检查。`nameof` 和协程字符串需显式经过名字表,普通字符串不会被 reobf 改写。
单独引用本包的插件使用 `Game.ValidateGame` 校验游戏和内嵌表;`Game.Validate` 还会校验 SDK 在插件中写入的编译指纹。运行环境只需要共享 DLL,无需 .NET SDK、Mono.Cecil 或构建工具。同一客户端只安装一份 `BD2.GameNames.dll`,不同表指纹的插件会在启动时明确报错。
单独引用本包的 SDK-style 类库在 `.csproj` 中设置 `<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>`,让运行时 DLL 复制到 `bin`;`BD2.GameSdk` 会自动设置此默认值。
包版本使用 `工具语义版本-game.游戏版本`,例如 `0.2.0-game.2.35.10`;游戏版本由 `Game.GameVersion` 获取。游戏版本变化后必须更新配套 SDK/运行时并重新构建插件。遵循包中附带的 LICENSE;本包并非 MIT 或其他开源许可。
完整导入和构建说明:[BD2.GameSdk 文档](https://github.com/Flechazo098/bd2/blob/main/plugins/GameSdk/README.md)。包目前由仓库打包脚本生成,通过维护者提供的 NuGet 源或本地目录安装。
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<!-- Exercise Visual Studio's actual metadata-as-source provider, not a mock. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.11.0" />
</ItemGroup>
</Project>
+116
View File
@@ -0,0 +1,116 @@
using System.Reflection;
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.CSharp.Syntax;
if (args.Length == 0)
throw new ArgumentException("Usage: GameSdk.NavigationTests <sdk-directory> [game-managed-directory] [--embedded-only]");
var hiddenSources = new List<(string Original, string Hidden)>();
try
{
// Missing local source proves that navigation uses the embedded documents.
if (args.Contains("--embedded-only"))
{
using var manifest = JsonDocument.Parse(File.ReadAllText(Path.Combine(args[0], "navigation.json")));
string root = manifest.RootElement.GetProperty("SourceRoot").GetString()!;
string fixtures = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, ".build", "game-sdk-tests")) + Path.DirectorySeparatorChar;
if (!Path.GetFullPath(root).StartsWith(fixtures, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Embedded-only tests may only move generated synthetic fixture sources.");
foreach (string source in Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories))
{
string hidden = source + ".embedded-test";
File.Move(source, hidden);
hiddenSources.Add((source, hidden));
}
}
// Reflection accesses Visual Studio's internal Roslyn service. A mock would
// miss the reference-assembly lookup and embedded-document loader failures.
var features = Assembly.Load("Microsoft.CodeAnalysis.Features");
var host = MefHostServices.Create(MefHostServices.DefaultAssemblies
.Add(features).Add(Assembly.Load("Microsoft.CodeAnalysis.CSharp.Features")));
var exportProvider = host.GetType().GetInterfaces().Single(t => t.Name == "IMefHostExportProvider");
var getExports = exportProvider.GetMethods().Single(m => m.Name == "GetExports" && m.GetGenericArguments().Length == 1);
var contract = features.GetType("Microsoft.CodeAnalysis.MetadataAsSource.IMetadataAsSourceFileService")!;
var exports = (System.Collections.IEnumerable)getExports.MakeGenericMethod(contract).Invoke(host, null)!;
object export = exports.Cast<object>().First();
object service = export.GetType().GetProperty("Value")!.GetValue(export)!;
using var workspace = new AdhocWorkspace(host);
string reference = Path.Combine(Path.GetFullPath(args[0]), "ref", "Assembly-CSharp.Readable.dll");
IEnumerable<string> referencePaths = args.Length > 1 && !args[1].StartsWith("--")
? Directory.GetFiles(Path.GetFullPath(args[1]), "*.dll").Where(p => Path.GetFileName(p) != "Assembly-CSharp.dll")
: ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator);
var references = referencePaths.Select(p => MetadataReference.CreateFromFile(p))
.Append(MetadataReference.CreateFromFile(reference));
var project = workspace.AddProject("Consumer", LanguageNames.CSharp)
.WithMetadataReferences(references)
.WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
if (!workspace.TryApplyChanges(project.Solution)) throw new InvalidOperationException("Cannot create navigation consumer project.");
project = workspace.CurrentSolution.GetProject(project.Id)!;
var compilation = (await project.GetCompilationAsync())!;
bool fixture = compilation.GetTypeByMetadataName("Readable.Agent") is not null;
var type = compilation.GetTypeByMetadataName(fixture ? "Readable.Agent" : "BDNetwork.NetworkManager")
?? throw new InvalidOperationException("Navigation test type missing.");
var optionsType = features.GetType("Microsoft.CodeAnalysis.MetadataAsSource.MetadataAsSourceOptions")!;
object options = optionsType.GetMethod("GetDefault", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!
.Invoke(null, new object[] { project.Services })!;
optionsType.GetProperty("NavigateToSourceLinkAndEmbeddedSources")!.SetValue(options, true);
optionsType.GetProperty("NavigateToDecompiledSources")!.SetValue(options, false);
var symbols = new List<ISymbol> { type };
if (fixture)
{
var ping = type.GetMembers("Ping").OfType<IMethodSymbol>().Single(m => m.Parameters[0].Type.SpecialType == SpecialType.System_Int32);
symbols.AddRange(new ISymbol[] {
ping, type.GetMembers("Ping").OfType<IMethodSymbol>().Single(m => m.Parameters[0].Type.SpecialType == SpecialType.System_String),
ping.Parameters[0], type.GetMembers("Echo").Single(), type.GetMembers("Value").Single(), type.GetMembers("Changed").Single()
});
var box = compilation.GetTypeByMetadataName("Readable.Box`1")!;
symbols.AddRange(new ISymbol[] { box, box.GetMembers("Echo").Single(), box.GetTypeMembers("Nested").Single() });
}
else
{
symbols.AddRange(new ISymbol[] {
type.GetMembers("Send").First(), type.GetMembers("Refresh").Single(), type.GetMembers("BatchCount").Single(),
type.GetMembers("UpdateNowFunc").Single(), type.GetMembers("OnRequestComplete").Single(), type.GetTypeMembers("BatchObject").Single()
});
}
foreach (var symbol in symbols)
{
var task = (Task)contract.GetMethod("GetGeneratedFileAsync")!
.Invoke(service, new object[] { workspace, project, symbol, false, options, CancellationToken.None })!;
await task;
object result = task.GetType().GetProperty("Result")!.GetValue(task)!;
string path = (string)result.GetType().GetProperty("FilePath")!.GetValue(result)!;
string source = File.ReadAllText(path);
if (!path.Contains("PdbSourceDocumentMetadataAsSourceFileProvider", StringComparison.Ordinal))
throw new InvalidDataException("Navigation fell back from PDB source: " + path);
if (!source.Contains(fixture ? "return value;" : "Path.Combine", StringComparison.Ordinal))
throw new InvalidDataException("Navigation did not load complete method bodies: " + path);
var location = (Location)result.GetType().GetProperty("IdentifierLocation")!.GetValue(result)!;
if (!location.IsInSource) throw new InvalidDataException("Navigation has no source declaration.");
string identifier = source.Substring(location.SourceSpan.Start, location.SourceSpan.Length);
if (identifier != symbol.Name)
throw new InvalidDataException($"Navigation selected '{identifier}' instead of '{symbol.Name}'.");
if (fixture && symbol is IMethodSymbol { Name: "Ping" } method)
{
var syntax = CSharpSyntaxTree.ParseText(source).GetRoot().FindToken(location.SourceSpan.Start)
.Parent!.AncestorsAndSelf().OfType<MethodDeclarationSyntax>().First();
string expected = method.Parameters[0].Type.SpecialType == SpecialType.System_Int32 ? "int" : "string";
if (syntax.ParameterList.Parameters[0].Type!.ToString() != expected)
throw new InvalidDataException("Navigation selected the wrong Ping overload.");
}
Console.WriteLine($"{symbol.ToDisplayString()} => {path}");
}
Console.WriteLine($"Visual Studio Roslyn PDB-source navigation: {symbols.Count} declarations resolved to exact identifiers and complete source; embedded-only={hiddenSources.Count > 0}.");
}
finally
{
foreach (var source in hiddenSources) File.Move(source.Hidden, source.Original);
}
+21
View File
@@ -0,0 +1,21 @@
<Project>
<ItemGroup>
<Compile Include="$(BD2SdkDir)\GameSdkIdentity.g.cs" />
<ProjectReference Include="$(BD2GameNamesProject)" />
<Reference Include="Assembly-CSharp.Readable">
<HintPath>$(BD2SdkDir)\ref\Assembly-CSharp.Readable.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<Import Project="$(BD2SdkDir)/GameSourceNavigation.props" Condition="Exists('$(BD2SdkDir)/GameSourceNavigation.props')" />
<Target Name="PrepareBD2GameSdk" BeforeTargets="ResolveProjectReferences;ResolveAssemblyReferences">
<Error Condition="'$(GameDir)' == '' and '$(BD2ManagedDir)' == ''" Text="Set BD2LocalGameDir in plugins/Directory.Build.local.props, or pass -p:GameDir=&lt;game-directory&gt;." />
<Error Condition="!Exists('$(BD2ManagedDir)/Assembly-CSharp.dll')" Text="Assembly-CSharp.dll missing in $(BD2ManagedDir). Set GameDir or BD2ManagedDir." />
<Exec Condition="'$(DesignTimeBuild)' != 'true' or !Exists('$(BD2SdkTool)')" Command="dotnet build &quot;$(BD2SdkProject)&quot; -c $(BD2SdkConfiguration) --nologo" />
<Exec EnvironmentVariables="BD2_GAME_SDK_CACHE=$(BD2GameSdkCache)" Command="dotnet &quot;$(BD2SdkTool)&quot; prepare-embedded &quot;$(BD2ManagedDir)/Assembly-CSharp.dll&quot; &quot;$(BD2SdkDir)&quot; &quot;$(BD2VersionConfig)&quot;" />
</Target>
<Target Name="ReobfuscateBD2Plugin" AfterTargets="Build" Condition="'$(DesignTimeBuild)' != 'true' and '$(SkipCompilerExecution)' != 'true'">
<Exec Command="dotnet &quot;$(BD2SdkTool)&quot; reobf &quot;$(BD2SdkDir)\names.json&quot; &quot;$(IntermediateOutputPath)$(TargetFileName)&quot; &quot;$(TargetPath)&quot; &quot;$(BD2ManagedDir)\Assembly-CSharp.dll&quot; &quot;$(BD2BepInExDir)\core&quot;" />
<Delete Files="$(TargetDir)$(TargetName).pdb" />
</Target>
</Project>
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../PackageMetadata.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ICSharpCode.Decompiler" Version="9.1.0.7988" />
<PackageReference Include="Mono.Cecil" Version="0.11.6" />
<Compile Include="../GameNames/NameTable.cs" Link="NameTable.cs" />
<Compile Remove="samples/**/*.cs;Package/**/*.cs" />
<EmbeddedResource Include="../GameNames/Mappings/names.json.gz" LogicalName="BD2.GameNames.names.json.gz" />
</ItemGroup>
</Project>
+55
View File
@@ -0,0 +1,55 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$GameDir,
[string]$VersionConfig,
[string]$PackageVersion,
[string]$OutputDirectory
)
$ErrorActionPreference = 'Stop'
if (-not $VersionConfig) { $VersionConfig = Join-Path $PSScriptRoot '..\..\versions.json' }
if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot '..\..\.build\nuget' }
$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$versions = Get-Content -LiteralPath $VersionConfig -Raw | ConvertFrom-Json
if (-not $PackageVersion) {
[xml]$metadata = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\PackageMetadata.props') -Raw
$PackageVersion = "$($metadata.Project.PropertyGroup.BD2PackageVersion)-game.$($versions.game_version)"
}
if ($PackageVersion -notmatch '^\d+\.\d+\.\d+-game\.\d+\.\d+\.\d+(?:\.[A-Za-z0-9-]+)*$') {
throw 'PackageVersion must be SemVer with game version, for example 0.2.1-game.2.35.10.'
}
if ($PackageVersion -notmatch ('-game\.' + [regex]::Escape([string]$versions.game_version) + '(?:\.|$)')) {
throw 'PackageVersion game suffix must match VersionConfig game_version.'
}
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
$staging = Join-Path $repository ".build\nuget-staging\$PackageVersion"
$sdk = Join-Path $staging 'sdk'
$tool = Join-Path $staging 'tool'
$runtimeObj = Join-Path $staging 'runtime-obj'
$runtimeBin = Join-Path $staging 'runtime-bin'
$packageObj = Join-Path $staging 'package-obj'
$packageBin = Join-Path $staging 'package-bin'
New-Item -ItemType Directory -Force -Path $OutputDirectory, $staging | Out-Null
function Invoke-Dotnet([string[]]$Arguments) {
& dotnet @Arguments
if ($LASTEXITCODE -ne 0) { throw "dotnet failed with exit code $LASTEXITCODE" }
}
$toolVersion = $PackageVersion.Split('-')[0]
Invoke-Dotnet @('publish', (Join-Path $PSScriptRoot 'GameSdk.csproj'), '-c', 'Release', '--nologo', '-o', $tool, "-p:Version=$toolVersion", '-p:UseAppHost=false')
$assembly = Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll'
$toolDll = Join-Path $tool 'GameSdk.dll'
Invoke-Dotnet @($toolDll, 'prepare-embedded', $assembly, $sdk, $VersionConfig)
$table = Join-Path $sdk 'names.json'
$runtimeProject = Join-Path $PSScriptRoot '..\GameNames\GameNames.csproj'
Invoke-Dotnet @('pack', $runtimeProject, '-c', 'Release', '--nologo', '-o', $OutputDirectory,
"-p:Version=$PackageVersion", "-p:GameNamesTable=$table", "-p:BaseIntermediateOutputPath=$runtimeObj/", "-p:OutputPath=$runtimeBin/")
Invoke-Dotnet @($toolDll, 'verify-runtime', $table, (Join-Path $runtimeBin 'BD2.GameNames.dll'))
$packageProject = Join-Path $PSScriptRoot 'Package\BD2.GameSdk.Package.csproj'
$nugetConfig = Join-Path $staging 'NuGet.Config'
$escapedSource = [Security.SecurityElement]::Escape($OutputDirectory)
[IO.File]::WriteAllText($nugetConfig, "<configuration><packageSources><clear/><add key=`"bd2-local`" value=`"$escapedSource`"/><add key=`"nuget.org`" value=`"https://api.nuget.org/v3/index.json`"/></packageSources></configuration>")
Invoke-Dotnet @('restore', $packageProject, '--configfile', $nugetConfig, "-p:Version=$PackageVersion",
'-p:BD2Packaging=true', "-p:BaseIntermediateOutputPath=$packageObj/", "-p:OutputPath=$packageBin/")
Invoke-Dotnet @('pack', $packageProject, '-c', 'Release', '--no-restore', '--nologo', '-o', $OutputDirectory,
"-p:Version=$PackageVersion", "-p:BD2ToolPublishDir=$tool", '-p:BD2Packaging=true',
"-p:BaseIntermediateOutputPath=$packageObj/", "-p:OutputPath=$packageBin/")
Write-Host "Created BD2.GameNames and BD2.GameSdk $PackageVersion in $OutputDirectory"
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../../PackageMetadata.props" />
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<PackageId>BD2.GameSdk</PackageId>
<Description>Build integration for Brown Dust II plugins: readable game references, complete decompiled source navigation with embedded Portable PDB, and reobfuscation. Requires .NET 8 SDK and a matching local game installation.</Description>
<PackageTags>BD2;BrownDust;Unity;BepInEx;MSBuild;modding</PackageTags>
<IncludeBuildOutput>false</IncludeBuildOutput>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<!-- IDE restore must work before any local NuGet package has been produced. -->
<ProjectReference Include="../../GameNames/GameNames.csproj" Condition="'$(BD2Packaging)' != 'true'" />
<PackageReference Include="BD2.GameNames" Version="[$(Version)]" Condition="'$(BD2Packaging)' == 'true'" />
<None Include="../README.md" Pack="true" PackagePath="README.md" />
<None Include="../THIRD-PARTY-NOTICES.txt" Pack="true" PackagePath="" />
<None Include="build/*" Pack="true" PackagePath="build/" />
<None Include="_._" Pack="true" PackagePath="lib/netstandard2.0/" />
<None Include="$(BD2ToolPublishDir)/**/*" Exclude="$(BD2ToolPublishDir)/**/*.exe;$(BD2ToolPublishDir)/**/*.pdb" Condition="'$(BD2ToolPublishDir)' != ''" Pack="true" PackagePath="tools/net8.0/%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<Target Name="RequirePackageInputs" BeforeTargets="GenerateNuspec">
<Error Condition="'$(BD2Packaging)' != 'true'" Text="Pack with ../Pack.ps1; BD2Packaging=true is required for the version-locked runtime dependency." />
<Error Condition="!Exists('$(BD2ToolPublishDir)/GameSdk.dll')" Text="Pack with ../Pack.ps1: published tool missing." />
</Target>
</Project>
View File
@@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<BD2GameSdkEnabled Condition="'$(BD2GameSdkEnabled)' == ''">true</BD2GameSdkEnabled>
<BD2GameSdkPackageRoot>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../'))</BD2GameSdkPackageRoot>
<BD2GameSdkCache Condition="'$(BD2GameSdkCache)' == ''">$(BD2_GAME_SDK_CACHE)</BD2GameSdkCache>
</PropertyGroup>
</Project>
@@ -0,0 +1,34 @@
<Project>
<PropertyGroup Condition="'$(BD2GameSdkEnabled)' == 'true'">
<!-- SDK-style class libraries otherwise omit NuGet runtime assemblies from bin. -->
<CopyLocalLockFileAssemblies Condition="'$(CopyLocalLockFileAssemblies)' == ''">true</CopyLocalLockFileAssemblies>
<BD2ManagedDir Condition="'$(BD2ManagedDir)' == ''">$(GameDir)/BrownDust II_Data/Managed</BD2ManagedDir>
<BD2BepInExDir Condition="'$(BD2BepInExDir)' == ''">$(GameDir)/BepInEx</BD2BepInExDir>
<BD2SdkDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)bd2-game-sdk'))</BD2SdkDir>
<BD2SdkTool>$(BD2GameSdkPackageRoot)tools/net8.0/GameSdk.dll</BD2SdkTool>
</PropertyGroup>
<ItemGroup Condition="'$(BD2GameSdkEnabled)' == 'true'">
<Compile Include="$(BD2SdkDir)/GameSdkIdentity.g.cs" />
<Reference Include="Assembly-CSharp.Readable">
<HintPath>$(BD2SdkDir)/ref/Assembly-CSharp.Readable.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="BepInEx" Condition="'$(BD2AddBepInExReferences)' != 'false'"><HintPath>$(BD2BepInExDir)/core/BepInEx.dll</HintPath><Private>false</Private></Reference>
<Reference Include="0Harmony" Condition="'$(BD2AddBepInExReferences)' != 'false'"><HintPath>$(BD2BepInExDir)/core/0Harmony.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine" Condition="'$(BD2AddUnityReferences)' != 'false'"><HintPath>$(BD2ManagedDir)/UnityEngine.dll</HintPath><Private>false</Private></Reference>
<Reference Include="UnityEngine.CoreModule" Condition="'$(BD2AddUnityReferences)' != 'false'"><HintPath>$(BD2ManagedDir)/UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
</ItemGroup>
<Import Project="$(BD2SdkDir)/GameSourceNavigation.props" Condition="Exists('$(BD2SdkDir)/GameSourceNavigation.props') and '$(BD2GameSdkEnabled)' == 'true'" />
<Target Name="PrepareBD2GameSdk" BeforeTargets="ResolveAssemblyReferences;CoreCompile"
Condition="'$(BD2GameSdkEnabled)' == 'true' and '$(IsCrossTargetingBuild)' != 'true'">
<Error Condition="'$(GameDir)' == '' and '$(BD2ManagedDir)' == '/BrownDust II_Data/Managed'" Text="BD2.GameSdk: set GameDir in Directory.Build.props or pass -p:GameDir=&lt;game-directory&gt;." />
<Error Condition="!Exists('$(BD2ManagedDir)/Assembly-CSharp.dll')" Text="BD2.GameSdk: Assembly-CSharp.dll missing in $(BD2ManagedDir). Set GameDir or BD2ManagedDir." />
<Error Condition="'$(BD2AddBepInExReferences)' != 'false' and !Exists('$(BD2BepInExDir)/core/BepInEx.dll')" Text="BD2.GameSdk: install BepInEx or set BD2BepInExDir. Custom hosts can disable BD2AddBepInExReferences." />
<Exec EnvironmentVariables="BD2_GAME_SDK_CACHE=$(BD2GameSdkCache)" Command="dotnet &quot;$(BD2SdkTool)&quot; prepare-embedded &quot;$(BD2ManagedDir)/Assembly-CSharp.dll&quot; &quot;$(BD2SdkDir)&quot;" />
</Target>
<Target Name="ReobfuscateBD2Plugin" AfterTargets="Build"
Condition="'$(BD2GameSdkEnabled)' == 'true' and '$(DesignTimeBuild)' != 'true' and '$(IsCrossTargetingBuild)' != 'true'">
<Exec Command="dotnet &quot;$(BD2SdkTool)&quot; reobf &quot;$(BD2SdkDir)/names.json&quot; &quot;$(IntermediateOutputPath)$(TargetFileName)&quot; &quot;$(TargetPath)&quot; &quot;$(BD2ManagedDir)/Assembly-CSharp.dll&quot; &quot;$(BD2BepInExDir)/core&quot;" />
<Delete Files="$(TargetDir)$(TargetName).pdb" />
</Target>
</Project>
+573
View File
@@ -0,0 +1,573 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using BD2.GameNames.Internal;
using Mono.Cecil;
using Mono.Cecil.Cil;
namespace BD2.GameSdk;
internal static class Program
{
internal const string ShellName = "Assembly-CSharp.Readable";
internal static readonly JsonSerializerOptions Json = new() { IncludeFields = true };
private static int Main(string[] args)
{
try
{
switch (args.FirstOrDefault())
{
case "prepare" when args.Length == 5: Prepare(args[1], args[2], args[3], args[4]); break;
case "prepare-embedded" when args.Length is 3 or 4: PrepareEmbedded(args[1], args[2], args.Length == 4 ? args[3] : null); break;
case "export-names" when args.Length == 2: ExportNames(args[1]); break;
case "names" when args.Length == 5: GenerateNames(args[1], args[2], args[3], args[4]); break;
case "shell" when args.Length == 4: GenerateShell(args[1], args[2], args[3]); break;
case "source-navigation" when args.Length == 3: SourceNavigation.Generate(args[1], args[2]); break;
case "verify-navigation" when args.Length == 2: SourceNavigation.Verify(args[1]); break;
case "reobf" when args.Length is 4 or 5 or 6: Reobfuscate(args[1], args[2], args[3], args.Length >= 5 ? args[4] : null, args.Length == 6 ? args[5] : null); break;
case "verify" when args.Length is 3 or 4: Verify(args[1], args[2], args.Length == 4 ? args[3] : null); break;
case "verify-runtime" when args.Length == 3: SelfTest.VerifyRuntime(args[1], args[2]); break;
case "self-test": SelfTest.Run(); break;
default: throw new ArgumentException("Usage: prepare-embedded <Assembly-CSharp.dll> <output-dir> [versions.json] | export-names <names.json> | names <Assembly-CSharp.dll> <mapping.obfuscate> <versions.json> <names.json> | shell <names.json> <Assembly-CSharp.dll> <output.dll> | source-navigation <readable-implementation.dll> <dependency-directory> | verify-navigation <sdk-directory> | reobf <names.json> <input.dll> <output.dll> [Assembly-CSharp.dll] [dependency-directory] | verify <names.json> <plugin.dll> [Assembly-CSharp.dll] | verify-runtime <names.json> <BD2.GameNames.dll> | self-test");
}
return 0;
}
catch (Exception ex) { Console.Error.WriteLine("BD2 GameSdk: " + ex); return 1; }
}
internal static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant();
private static string GeneratorStamp() => Hash(typeof(Program).Assembly.Location) + "|" +
Hash(typeof(ICSharpCode.Decompiler.CSharp.CSharpDecompiler).Assembly.Location) + "|" + Hash(typeof(ModuleDefinition).Assembly.Location);
private static string DependencyStamp(string assembly) => string.Join("|",
Directory.EnumerateFiles(Path.GetDirectoryName(Path.GetFullPath(assembly)), "*.dll").OrderBy(p => p, StringComparer.Ordinal)
.Select(p => Path.GetFileName(p) + ":" + Hash(p)));
internal static NameTable ReadTable(string path) => JsonSerializer.Deserialize<NameTable>(File.ReadAllText(path), Json);
internal static string Stamp(NameTable table) => table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256;
internal static byte[] EmbeddedNames()
{
using var stream = typeof(Program).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz")
?? throw new InvalidDataException("SDK embedded names table is missing; rebuild the SDK");
using var output = new MemoryStream();
stream.CopyTo(output);
return output.ToArray();
}
internal static void ExportNames(string tablePath)
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(tablePath)));
byte[] compressed = EmbeddedNames();
File.WriteAllBytes(tablePath + ".gz", compressed);
using var gzip = new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress);
using var destination = File.Create(tablePath);
gzip.CopyTo(destination);
}
internal static void PrepareEmbedded(string assembly, string output, string versions = null)
{
byte[] compressed = EmbeddedNames();
Directory.CreateDirectory(output);
// IDE loads and command-line builds may prepare the same obj directory.
// Serialize the whole transaction, including the exported table and props.
using var outputLock = AcquireCacheLock(Path.Combine(output, "prepare.lock"), "Waiting for this project's game SDK preparation to finish...");
string tablePath = Path.Combine(output, "names.json"), compressedPath = tablePath + ".gz";
// Keep the cache copy exact; the only authoritative source is the embedded resource.
if (!File.Exists(compressedPath) || !File.ReadAllBytes(compressedPath).SequenceEqual(compressed))
File.WriteAllBytes(compressedPath, compressed);
PreparePackage(compressedPath, assembly, output);
if (versions != null)
{
using var config = JsonDocument.Parse(File.ReadAllText(versions));
string expected = config.RootElement.GetProperty("game_version").GetString();
string actual = ReadTable(tablePath).game_version;
if (expected != actual) throw new InvalidDataException($"Repository game_version {expected} does not match SDK embedded names {actual}; update the SDK names table for this version");
}
}
internal static void PreparePackage(string compressedTable, string assembly, string output)
{
Directory.CreateDirectory(output);
string tablePath = Path.Combine(output, "names.json"), shellPath = Path.Combine(output, ShellName + ".dll");
string identityPath = Path.Combine(output, "GameSdkIdentity.g.cs"), readyPath = Path.Combine(output, "package-ready.txt");
string pdbPath = Path.Combine(output, "lib", ShellName + ".pdb"), navigationPath = Path.Combine(output, "navigation.json");
string inputs = Hash(compressedTable) + "|" + GeneratorStamp() + "|" + DependencyStamp(assembly);
string[] required = { tablePath, shellPath, identityPath, pdbPath, navigationPath,
Path.Combine(output, "ref", ShellName + ".dll"), Path.Combine(output, "ref", ShellName + ".xml"),
Path.Combine(output, "lib", ShellName + ".dll"), Path.Combine(output, "lib", ShellName + ".xml") };
string Outputs() => string.Join("|", required.Select(Hash));
if (File.Exists(readyPath) && required.All(File.Exists) &&
File.ReadAllText(readyPath) == inputs + "|" + Outputs())
{
WriteNavigationItems(output);
return;
}
File.Delete(readyPath);
using (var gzip = new GZipStream(File.OpenRead(compressedTable), CompressionMode.Decompress))
using (var destination = File.Create(tablePath)) gzip.CopyTo(destination);
GenerateCachedShell(tablePath, assembly, shellPath);
WriteIdentity(tablePath, identityPath);
WriteNavigationItems(output);
File.WriteAllText(readyPath, inputs + "|" + Outputs());
}
private static void GenerateCachedShell(string tablePath, string assembly, string destination)
{
string cacheRoot = Environment.GetEnvironmentVariable("BD2_GAME_SDK_CACHE");
if (string.IsNullOrEmpty(cacheRoot)) cacheRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BD2", "GameSdk", "navigation");
// Changes to any managed dependency can affect decompilation and semantic references.
string dependencyDirectory = Path.GetDirectoryName(Path.GetFullPath(assembly));
string key = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Hash(tablePath) + "|" + GeneratorStamp() + "|" + DependencyStamp(assembly)))).ToLowerInvariant();
string cache = Path.Combine(Path.GetFullPath(cacheRoot), key);
Directory.CreateDirectory(cache);
string cachedShell = Path.Combine(cache, ShellName + ".dll"), ready = Path.Combine(cache, "ready.txt");
using (AcquireCacheLock(Path.Combine(cache, "generation.lock")))
{
string[] relativeFiles = { ShellName + ".dll", ShellName + ".xml", "navigation.json", "ref/" + ShellName + ".dll", "ref/" + ShellName + ".xml",
"lib/" + ShellName + ".dll", "lib/" + ShellName + ".pdb", "lib/" + ShellName + ".xml", "lib/navigation.json" };
string[] files = relativeFiles.Select(p => Path.Combine(cache, p)).ToArray();
bool complete = File.Exists(ready) && files.All(File.Exists) && File.ReadAllText(ready) == string.Join("|", files.Select(Hash));
if (!complete)
{
File.Delete(ready);
GenerateShell(tablePath, assembly, cachedShell);
File.WriteAllText(ready, string.Join("|", files.Select(Hash)));
}
string destinationDirectory = Path.GetDirectoryName(Path.GetFullPath(destination));
foreach (string relative in relativeFiles)
{
if (relative == ShellName + ".xml" || relative == "lib/navigation.json") continue;
string target = Path.Combine(destinationDirectory, relative);
Directory.CreateDirectory(Path.GetDirectoryName(target));
File.Copy(Path.Combine(cache, relative), target, overwrite: true);
}
File.Delete(Path.ChangeExtension(destination, ".xml"));
File.Delete(Path.Combine(destinationDirectory, "lib", "navigation.json"));
}
}
private static FileStream AcquireCacheLock(string path, string message = "Waiting for the shared game source-navigation cache to finish generating...")
{
var elapsed = System.Diagnostics.Stopwatch.StartNew();
bool announced = false;
while (true)
{
try { return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); }
catch (IOException) when (elapsed.Elapsed < TimeSpan.FromMinutes(30))
{
if (!announced) { Console.WriteLine(message); announced = true; }
Thread.Sleep(500);
}
}
}
private static void WriteNavigationItems(string output)
{
var manifest = JsonSerializer.Deserialize<SourceNavigation.Manifest>(File.ReadAllText(Path.Combine(output, "navigation.json")));
if (!Directory.Exists(manifest.SourceRoot) || Directory.EnumerateFiles(manifest.SourceRoot, "*.cs", SearchOption.AllDirectories).Count() != manifest.Documents)
SourceNavigation.RestoreSources(output);
var items = new System.Xml.Linq.XElement("ItemGroup");
foreach (var path in Directory.EnumerateFiles(manifest.SourceRoot, "*.cs", SearchOption.AllDirectories).OrderBy(p => p, StringComparer.Ordinal))
items.Add(new System.Xml.Linq.XElement("None", new System.Xml.Linq.XAttribute("Include", path),
new System.Xml.Linq.XElement("Link", "Game Sources/" + Path.GetRelativePath(manifest.SourceRoot, path)),
new System.Xml.Linq.XElement("CopyToOutputDirectory", "Never")));
using var stream = new MemoryStream();
new System.Xml.Linq.XDocument(new System.Xml.Linq.XElement("Project", items)).Save(stream);
WriteGeneratedFile(Path.Combine(output, "GameSourceNavigation.props"), stream.ToArray());
}
private static void WriteGeneratedFile(string path, byte[] content)
{
// Rewriting an imported props file triggers another IDE project reload.
// Keep its timestamp stable when the source list has not changed.
if (File.Exists(path) && File.ReadAllBytes(path).SequenceEqual(content)) return;
string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.WriteAllBytes(temporary, content);
var elapsed = System.Diagnostics.Stopwatch.StartNew();
while (true)
{
try { File.Move(temporary, path, overwrite: true); break; }
catch (IOException ex) when (ex.HResult == unchecked((int)0x80070020) && elapsed.Elapsed < TimeSpan.FromSeconds(10))
{
Thread.Sleep(100);
}
}
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
private static void WriteIdentity(string tablePath, string identityPath) =>
File.WriteAllText(identityPath, "// Generated from the shared names table.\n[assembly: System.Reflection.AssemblyMetadataAttribute(\"BD2.GameNames\", " + JsonSerializer.Serialize(Stamp(ReadTable(tablePath))) + ")]\n");
internal static IEnumerable<TypeDefinition> Types(ModuleDefinition module) => module.GetTypes();
internal static string TypeKey(TypeReference type) => type switch
{
GenericParameter p => (p.Type == GenericParameterType.Method ? "!!" : "!") + p.Position,
GenericInstanceType g => TypeKey(g.ElementType) + "<" + string.Join(",", g.GenericArguments.Select(TypeKey)) + ">",
ArrayType a => TypeKey(a.ElementType) + "[" + new string(',', a.Rank - 1) + "]",
ByReferenceType b => TypeKey(b.ElementType) + "&",
PointerType p => TypeKey(p.ElementType) + "*",
OptionalModifierType m => TypeKey(m.ElementType) + " modopt(" + TypeKey(m.ModifierType) + ")",
RequiredModifierType m => TypeKey(m.ElementType) + " modreq(" + TypeKey(m.ModifierType) + ")",
FunctionPointerType f => "fnptr(" + TypeKey(f.ReturnType) + ":" + string.Join(",", f.Parameters.Select(p => TypeKey(p.ParameterType))) + ")",
_ => type.FullName.Replace('/', '+')
};
internal static string Signature(MemberReference member) => member switch
{
MethodReference m => $"{m.GenericParameters.Count}:{TypeKey(m.ReturnType)}({string.Join(",", m.Parameters.Select(p => TypeKey(p.ParameterType)))})",
FieldReference f => TypeKey(f.FieldType),
_ => throw new ArgumentException("Unsupported member " + member)
};
internal static void Prepare(string assembly, string mapping, string versions, string output)
{
var version = JsonDocument.Parse(File.ReadAllText(versions)).RootElement.GetProperty("game_version").GetString();
if (!Path.GetFileName(mapping).Contains(version, StringComparison.Ordinal))
throw new InvalidDataException("Mapping filename must identify game_version " + version);
Directory.CreateDirectory(output);
string tablePath = Path.Combine(output, "names.json");
string shellPath = Path.Combine(output, ShellName + ".dll");
string assemblyHash = Hash(assembly), mappingHash = Hash(mapping), generatorHash = Hash(typeof(Program).Assembly.Location);
string identityPath = Path.Combine(output, "GameSdkIdentity.g.cs"), readyPath = Path.Combine(output, "ready.txt");
string inputStamp = version + "|" + assemblyHash + "|" + mappingHash + "|" + generatorHash;
if (File.Exists(readyPath) && File.Exists(tablePath) && File.Exists(shellPath) && File.Exists(tablePath + ".gz") && File.Exists(identityPath))
{
string completeStamp = inputStamp + "|" + Hash(shellPath) + "|" + Hash(tablePath) + "|" + Hash(tablePath + ".gz") + "|" + Hash(identityPath);
if (File.ReadAllText(readyPath) == completeStamp) return;
}
File.Delete(readyPath);
GenerateNames(assembly, mapping, versions, tablePath);
GenerateShell(tablePath, assembly, shellPath);
WriteIdentity(tablePath, identityPath);
File.WriteAllText(readyPath, inputStamp + "|" + Hash(shellPath) + "|" + Hash(tablePath) + "|" + Hash(tablePath + ".gz") + "|" + Hash(identityPath));
}
internal static void GenerateNames(string assembly, string mapping, string versions, string tablePath)
{
using var config = JsonDocument.Parse(File.ReadAllText(versions));
string version = config.RootElement.GetProperty("game_version").GetString();
if (string.IsNullOrWhiteSpace(version) || !Path.GetFileName(mapping).Contains(version, StringComparison.Ordinal))
throw new InvalidDataException("Mapping filename must identify game_version " + version);
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var raw in File.ReadLines(mapping))
{
string line = raw.Trim().TrimStart('\ufeff');
if (line.Length == 0 || line.StartsWith('#') || line.StartsWith("//")) continue;
var parts = line.Split('⇨');
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
throw new InvalidDataException("Invalid mapping row: " + line);
string key = parts[0].Trim(), value = parts[1].Trim();
if (map.TryGetValue(key, out var old) && old != value) throw new InvalidDataException("Ambiguous mapping: " + key);
map[key] = value;
}
string Translate(string name)
{
// Dots in explicit interface members are part of the CLR name, not a scope prefix.
if (map.TryGetValue(name, out var value)) return value.Split('/').Last();
// Accessors can retain their CLR prefix while the property/event is renamed.
foreach (string prefix in new[] { "get_", "set_", "add_", "remove_" })
if (name.StartsWith(prefix) && map.TryGetValue(name[prefix.Length..], out value))
{
string property = value.Split('/').Last();
int dot = property.LastIndexOf('.');
return property[..(dot + 1)] + prefix + property[(dot + 1)..];
}
return name;
}
using var module = ReadModule(assembly);
if (module.Assembly.Name.Name != "Assembly-CSharp") throw new InvalidDataException("Expected Assembly-CSharp");
var table = new NameTable { game_version = version, assembly_name = module.Assembly.Name.Name,
assembly_mvid = module.Mvid.ToString(), assembly_sha256 = Hash(assembly), mapping_sha256 = Hash(mapping), generator_sha256 = Hash(typeof(Program).Assembly.Location) };
var definitions = Types(module).ToArray();
var originalTypes = definitions.ToDictionary(t => t, TypeKey);
string ReadableType(TypeDefinition t)
{
if (t.DeclaringType != null) return ReadableType(t.DeclaringType) + "+" + Translate(t.Name);
if (map.TryGetValue(t.Name, out string full)) return full.Replace('/', '+');
return string.IsNullOrEmpty(t.Namespace) ? Translate(t.Name) : t.Namespace + "." + Translate(t.Name);
}
foreach (var type in definitions)
table.types.Add(new TypeName { token = type.MetadataToken.ToInt32(), original = originalTypes[type], readable = ReadableType(type) });
if (table.types.GroupBy(t => t.readable).Any(g => g.Count() > 1)) throw new InvalidDataException("Readable type collision");
var memberKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var type in definitions)
{
void Add(IMemberDefinition member, string kind, string signature = null, MethodDefinition method = null)
{
var entry = new MemberName { token = member.MetadataToken.ToInt32(), kind = kind,
declaring_type = originalTypes[type], original = member.Name, readable = Translate(member.Name), signature = signature };
if (!memberKeys.Add(originalTypes[type] + "|" + kind + "|" + entry.readable + "|" + signature))
throw new InvalidDataException("Readable member collision: " + originalTypes[type] + "." + entry.readable + " " + signature);
if (method != null)
foreach (var p in method.Parameters)
if (Translate(p.Name) != p.Name) entry.parameters.Add(new ParameterName { position = p.Index, original = p.Name, readable = Translate(p.Name) });
table.members.Add(entry);
}
foreach (var m in type.Methods) Add(m, "method", Signature(m), m);
foreach (var f in type.Fields) Add(f, "field", Signature(f));
foreach (var p in type.Properties) Add(p, "property", TypeKey(p.PropertyType) + "(" + string.Join(",", p.Parameters.Select(a => TypeKey(a.ParameterType))) + ")");
foreach (var e in type.Events) Add(e, "event", TypeKey(e.EventType));
}
// Keep unrenamed overloads that share a readable name with a renamed member.
// Otherwise runtime lookup would accidentally omit the literal overload.
var renamedGroups = table.members.Where(m => m.readable != m.original)
.Select(m => m.declaring_type + "|" + m.kind + "|" + m.readable).ToHashSet(StringComparer.Ordinal);
table.members = table.members.Where(m => m.readable != m.original || m.parameters.Count != 0 || renamedGroups.Contains(m.declaring_type + "|" + m.kind + "|" + m.readable)).ToList();
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(tablePath)));
File.WriteAllText(tablePath, JsonSerializer.Serialize(table, Json));
using (var gz = new GZipStream(File.Create(tablePath + ".gz"), CompressionLevel.SmallestSize))
gz.Write(File.ReadAllBytes(tablePath));
Console.WriteLine($"Game names {version}: {table.types.Count} types, {table.members.Count} member deltas => {tablePath}");
}
internal static void GenerateShell(string tablePath, string assembly, string shellPath)
{
var table = ReadTable(tablePath);
if (Hash(assembly) != table.assembly_sha256) throw new InvalidDataException($"Assembly-CSharp does not match SDK names for game {table.game_version}; install the matching client or update the SDK names table");
using var module = ReadModule(assembly);
var definitions = Types(module).ToArray();
// Rename references while their declaring types still have original names.
Rewrite(module, table, toReadable: true);
var membersByToken = table.members.ToDictionary(m => m.token);
var typesByToken = table.types.ToDictionary(t => t.token);
foreach (var type in definitions)
{
foreach (var member in type.Methods.Cast<IMemberDefinition>().Concat(type.Fields).Concat(type.Properties).Concat(type.Events))
{
membersByToken.TryGetValue(member.MetadataToken.ToInt32(), out var entry);
if (entry != null) member.Name = entry.readable;
if (member is MethodDefinition m)
{
if (entry != null) foreach (var p in entry.parameters) m.Parameters[p.position].Name = p.readable;
// Retain IL only for offline source navigation/decompilation.
// ReferenceAssemblyAttribute prevents this assembly from executing.
}
}
SetTypeName(type, typesByToken[type.MetadataToken.ToInt32()].readable);
}
module.Assembly.Name.Name = ShellName;
module.Name = ShellName + ".dll";
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(shellPath)));
// Visual Studio resolves reference-source navigation via the standard ref -> lib
// layout. The companion is never a compiler/runtime dependency; it is only for IDEs.
string root = Path.GetDirectoryName(Path.GetFullPath(shellPath));
string implementation = Path.Combine(root, "lib", ShellName + ".dll");
Directory.CreateDirectory(Path.GetDirectoryName(implementation));
module.Write(implementation);
SourceNavigation.Generate(implementation, Path.GetDirectoryName(Path.GetFullPath(assembly)));
// The CLR refuses to execute a reference assembly even if it is copied accidentally.
var marker = new TypeReference("System.Runtime.CompilerServices", "ReferenceAssemblyAttribute", module, module.TypeSystem.CoreLibrary);
module.Assembly.CustomAttributes.Add(new CustomAttribute(new MethodReference(".ctor", module.TypeSystem.Void, marker) { HasThis = true }));
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(shellPath)));
module.Write(shellPath);
string reference = Path.Combine(root, "ref", ShellName + ".dll");
Directory.CreateDirectory(Path.GetDirectoryName(reference));
File.Copy(shellPath, reference, overwrite: true);
File.Copy(Path.ChangeExtension(implementation, ".xml"), Path.ChangeExtension(reference, ".xml"), overwrite: true);
File.Copy(Path.ChangeExtension(implementation, ".xml"), Path.ChangeExtension(shellPath, ".xml"), overwrite: true);
File.Copy(Path.Combine(root, "lib", "navigation.json"), Path.Combine(root, "navigation.json"), overwrite: true);
Console.WriteLine($"Readable reference assembly {table.game_version} => {shellPath}");
}
internal static ModuleDefinition ReadModule(string path, params string[] searchDirectories)
{
var resolver = new DefaultAssemblyResolver();
resolver.AddSearchDirectory(Path.GetDirectoryName(Path.GetFullPath(path)));
foreach (var directory in searchDirectories) resolver.AddSearchDirectory(directory);
return ModuleDefinition.ReadModule(path, new ReaderParameters { InMemory = true, AssemblyResolver = resolver });
}
internal static void SetTypeName(TypeReference type, string full)
{
if (type.DeclaringType != null) { type.Name = full[(full.LastIndexOf('+') + 1)..]; return; }
int dot = full.LastIndexOf('.');
type.Namespace = dot < 0 ? "" : full[..dot];
type.Name = full[(dot + 1)..];
}
internal static void Rewrite(ModuleDefinition module, NameTable table, bool toReadable)
{
var types = table.types.ToDictionary(t => toReadable ? t.original : t.readable);
var readableByOriginal = table.types.ToDictionary(t => t.original, t => t.readable);
string TranslateSignature(string sig)
{
// Type references in signatures are tokenized, so substring collisions are avoided.
return System.Text.RegularExpressions.Regex.Replace(sig, @"[^,:()\[\]&*<>\s]+", m => readableByOriginal.TryGetValue(m.Value, out var v) ? v : m.Value);
}
var members = table.members.Where(m => m.kind is "method" or "field").ToDictionary(
m => (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) + "|" + m.kind + "|" + (toReadable ? m.original : m.readable) + "|" + (toReadable ? m.signature : TranslateSignature(m.signature)));
var namedMembers = table.members.Where(m => m.original != m.readable && m.kind is "method" or "field")
.Select(m => (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) + "|" + m.kind + "|" + (toReadable ? m.original : m.readable)).ToHashSet(StringComparer.Ordinal);
bool IsGame(TypeReference type)
{
while (type is TypeSpecification s) type = s.ElementType;
while (type.DeclaringType != null) type = type.DeclaringType;
return type.Scope is AssemblyNameReference a ? a.Name == (toReadable ? table.assembly_name : ShellName) : toReadable && type.Scope == module;
}
var memberRefs = module.GetMemberReferences().ToList();
var attributes = new List<CustomAttribute>();
void Attributes(ICustomAttributeProvider provider)
{
if (provider.HasCustomAttributes) attributes.AddRange(provider.CustomAttributes);
}
Attributes(module); Attributes(module.Assembly);
foreach (var t in Types(module))
{
Attributes(t);
foreach (var gp in t.GenericParameters) Attributes(gp);
foreach (var i in t.Interfaces) Attributes(i);
foreach (var f in t.Fields) Attributes(f);
foreach (var p in t.Properties) Attributes(p);
foreach (var e in t.Events) Attributes(e);
foreach (var m in t.Methods)
{
Attributes(m); Attributes(m.MethodReturnType);
foreach (var p in m.Parameters) Attributes(p);
foreach (var gp in m.GenericParameters) Attributes(gp);
}
}
foreach (var attribute in attributes) memberRefs.Add(attribute.Constructor);
foreach (var type in Types(module))
foreach (var method in type.Methods)
{
foreach (var ov in method.Overrides) memberRefs.Add(ov);
if (method.HasBody) foreach (var instruction in method.Body.Instructions)
if (instruction.Operand is MemberReference mr) memberRefs.Add(mr);
}
foreach (var reference in memberRefs.Distinct())
{
MemberReference member = reference is GenericInstanceMethod gm ? gm.ElementMethod : reference;
if (member is not MethodReference && member is not FieldReference || member is IMemberDefinition || !IsGame(member.DeclaringType)) continue;
var declaring = member.DeclaringType is GenericInstanceType gi ? gi.ElementType : member.DeclaringType;
// CLR array Get/Set/Address pseudo-methods have no metadata definitions.
if (declaring is ArrayType) continue;
string key = TypeKey(declaring) + "|" + (member is MethodReference ? "method" : "field") + "|" + member.Name + "|" + Signature(member);
if (members.TryGetValue(key, out var entry)) member.Name = toReadable ? entry.readable : entry.original;
else if (namedMembers.Contains(key[..key.LastIndexOf('|')])) throw new InvalidDataException("Game member signature does not match the names table: " + key);
}
// Snapshot names before renaming a parent of a nested type.
var renames = new Dictionary<TypeReference, string>();
void Visit(TypeReference type)
{
if (type == null || type is GenericParameter) return;
if (type is FunctionPointerType fp) { Visit(fp.ReturnType); foreach (var p in fp.Parameters) Visit(p.ParameterType); return; }
if (type is TypeSpecification spec)
{
Visit(spec.ElementType);
if (type is GenericInstanceType gi) foreach (var a in gi.GenericArguments) Visit(a);
if (type is IModifierType modifier) Visit(modifier.ModifierType);
return;
}
if (!renames.ContainsKey(type) && IsGame(type))
{
if (!types.TryGetValue(TypeKey(type), out var entry)) throw new InvalidDataException("Unmapped game type: " + TypeKey(type));
renames[type] = toReadable ? entry.readable : entry.original;
}
Visit(type.DeclaringType);
}
void VisitArgument(CustomAttributeArgument argument)
{
Visit(argument.Type);
if (argument.Value is TypeReference type) Visit(type);
if (argument.Value is CustomAttributeArgument boxed) VisitArgument(boxed);
if (argument.Value is CustomAttributeArgument[] array) foreach (var item in array) VisitArgument(item);
}
foreach (var attribute in attributes)
{
// Decode blobs before changing assembly scopes. System.Type arguments are typed
// metadata even though the ECMA-335 blob stores assembly-qualified text.
foreach (var argument in attribute.ConstructorArguments) VisitArgument(argument);
void NamedArguments(Mono.Collections.Generic.Collection<CustomAttributeNamedArgument> arguments, string kind)
{
for (int i = 0; i < arguments.Count; i++)
{
var argument = arguments[i]; VisitArgument(argument.Argument);
if (!IsGame(attribute.AttributeType)) continue;
string declaring = TypeKey(attribute.AttributeType);
var entry = table.members.SingleOrDefault(m => m.kind == kind &&
(toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) == declaring &&
(toReadable ? m.original : m.readable) == argument.Name);
if (entry != null) arguments[i] = new CustomAttributeNamedArgument(toReadable ? entry.readable : entry.original, argument.Argument);
}
}
NamedArguments(attribute.Fields, "field"); NamedArguments(attribute.Properties, "property");
}
foreach (var t in module.GetTypeReferences()) Visit(t);
foreach (var member in memberRefs)
{
Visit(member.DeclaringType);
if (member is MethodReference m) { Visit(m.ReturnType); foreach (var p in m.Parameters) Visit(p.ParameterType); if (m is GenericInstanceMethod gm) foreach (var a in gm.GenericArguments) Visit(a); }
if (member is FieldReference f) Visit(f.FieldType);
}
foreach (var type in Types(module))
{
Visit(type.BaseType);
foreach (var i in type.Interfaces) Visit(i.InterfaceType);
foreach (var f in type.Fields) Visit(f.FieldType);
foreach (var p in type.Properties) { Visit(p.PropertyType); foreach (var a in p.Parameters) Visit(a.ParameterType); }
foreach (var e in type.Events) Visit(e.EventType);
foreach (var gp in type.GenericParameters) foreach (var c in gp.Constraints) Visit(c.ConstraintType);
foreach (var m in type.Methods)
{
Visit(m.ReturnType);
foreach (var p in m.Parameters) Visit(p.ParameterType);
foreach (var gp in m.GenericParameters) foreach (var c in gp.Constraints) Visit(c.ConstraintType);
if (m.HasBody) { foreach (var v in m.Body.Variables) Visit(v.VariableType); foreach (var h in m.Body.ExceptionHandlers) Visit(h.CatchType); foreach (var i in m.Body.Instructions) if (i.Operand is TypeReference t) Visit(t); }
}
}
foreach (var pair in renames) SetTypeName(pair.Key, pair.Value);
}
internal static void Reobfuscate(string tablePath, string input, string output, string originalAssembly = null, string dependencyDirectory = null)
{
var table = ReadTable(tablePath);
var dependencies = new List<string> { Path.GetDirectoryName(Path.GetFullPath(tablePath)), Path.GetDirectoryName(Path.GetFullPath(output)) };
if (originalAssembly != null) dependencies.Add(Path.GetDirectoryName(Path.GetFullPath(originalAssembly)));
if (dependencyDirectory != null) dependencies.Add(Path.GetFullPath(dependencyDirectory));
using var module = ReadModule(input, dependencies.ToArray());
if (module.Assembly.Name.HasPublicKey) throw new InvalidDataException("Signed plugins require an explicit signing workflow");
VerifyStamp(module, table);
Rewrite(module, table, false);
foreach (var a in module.AssemblyReferences.Where(a => a.Name == ShellName)) a.Name = table.assembly_name;
// Preserve compiler PDBs in obj; rewritten runtime DLL deliberately has no stale symbols.
string absoluteOutput = Path.GetFullPath(output);
Directory.CreateDirectory(Path.GetDirectoryName(absoluteOutput));
string temporary = absoluteOutput + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
module.Write(temporary);
Verify(tablePath, temporary, originalAssembly);
File.Move(temporary, absoluteOutput, overwrite: true);
}
finally { File.Delete(temporary); }
Console.WriteLine("Reobfuscated runtime plugin: " + output);
}
internal static void Verify(string tablePath, string plugin, string originalAssembly = null)
{
var table = ReadTable(tablePath);
using var module = originalAssembly == null ? ReadModule(plugin) : ReadModule(plugin, Path.GetDirectoryName(Path.GetFullPath(originalAssembly)));
if (module.AssemblyReferences.Any(a => a.Name == ShellName)) throw new InvalidDataException("Runtime DLL still references the readable shell");
VerifyStamp(module, table);
if (originalAssembly != null)
{
if (Hash(originalAssembly) != table.assembly_sha256) throw new InvalidDataException("Verification game binary does not match the names table");
bool IsGame(TypeReference t) => t.GetElementType().Scope is AssemblyNameReference a && a.Name == table.assembly_name;
foreach (var type in module.GetTypeReferences().Where(IsGame))
if (type.Resolve() == null) throw new InvalidDataException("Unresolvable game type reference: " + type.FullName);
foreach (var member in module.GetMemberReferences().Where(m => IsGame(m.DeclaringType)))
{
if (member.DeclaringType is ArrayType) continue;
if (member is MethodReference method && method.Resolve() == null || member is FieldReference field && field.Resolve() == null)
throw new InvalidDataException("Unresolvable game member reference: " + member.FullName);
}
}
}
private static void VerifyStamp(ModuleDefinition module, NameTable table)
{
var markers = module.Assembly.CustomAttributes.Where(a => a.AttributeType.FullName == "System.Reflection.AssemblyMetadataAttribute" && a.ConstructorArguments.Count == 2 && a.ConstructorArguments[0].Value as string == "BD2.GameNames").ToArray();
if (markers.Length != 1 || markers[0].ConstructorArguments[1].Value as string != Stamp(table))
throw new InvalidDataException("Missing/mismatched SDK stamp: compile with GameSdkIdentity.g.cs from this names table");
}
}
+257
View File
@@ -0,0 +1,257 @@
# BD2.GameSdk
给 Brown Dust II 插件作者使用的 C# 开发包。通过标准 NuGet `PackageReference` 导入,自动完成 **生成可读引用程序集和全量源码导航 → 编译插件 → reobf → 验证运行 DLL**。源码可以使用可读类型和成员,字符串反射由配套的 `BD2.GameNames` 处理。转到定义可查看完整反编译方法体。
## 包与版本
| NuGet 包 | 内容 | 使用方式 |
| --- | --- | --- |
| `BD2.GameSdk` | .NET 8 构建工具、`build/*.props/targets`、压缩名字表 | 插件项目直接引用,`PrivateAssets="all"` |
| `BD2.GameNames` | `lib/netstandard2.0/BD2.GameNames.dll`、XML API 文档、同一份内嵌表 | SDK 固定依赖对应版本,自动引入;DLL 随插件部署 |
当前包版本为 `0.2.1-game.2.35.10`:`0.2.1` 是开发工具/API 的语义版本,`game.2.35.10` 指定游戏版本。采用 SemVer 的 prerelease 段,安装时明确指定版本。升级游戏后更新包并重新构建插件;相同游戏版本下的不同官方 DLL 也会因指纹不同而被拒绝。
包不包含游戏 DLL 或可读壳。维护者用官方映射生成名字表,提交为 `plugins/GameNames/Mappings/names.json.gz`,SDK 和运行时从同一文件嵌入,插件作者只需要对应游戏客户端和 BepInEx,无需官方映射、Python、仓库 `versions.json` 或本仓库源码。
包当前由维护者提供 `.nupkg` 或 NuGet 源,**尚未发布到 nuget.org**。包内包含仓库 LICENSE,许可条款沿用项目现有授权,不声明为开源许可。
## 导入到自己的插件项目
前提:安装 **.NET 8 SDK**,准备与包对应的游戏目录,并在该目录安装 BepInEx。推荐 SDK-style `.csproj` 与 `netstandard2.1`;运行时库本身兼容 `.NET Standard 2.0`。旧式 `packages.config` 不受支持。
将维护者提供的两个 `.nupkg` 放在一个目录,例如 `D:\NuGet\BD2`,添加为本地 NuGet 源:
```powershell
dotnet nuget add source 'D:\NuGet\BD2' --name BD2
dotnet add MyPlugin.csproj package BD2.GameSdk --version 0.2.1-game.2.35.10
```
Visual Studio / Rider 也可在 NuGet 包管理界面添加该源,打开“包含预发布版本”,安装指定版本。若维护者提供远程 NuGet 源,把上面的目录换成该源 URL。
最小项目文件:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>latest</LangVersion>
<AssemblyName>MyPlugin</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BD2.GameSdk"
Version="0.2.1-game.2.35.10"
PrivateAssets="all" />
</ItemGroup>
</Project>
```
`PrivateAssets="all"` 让构建步骤只用于当前插件项目;它不会阻止运行时 DLL 复制到输出目录。SDK 自动引入 `BD2.GameNames`,以及游戏目录中的 `BepInEx`、`0Harmony`、`UnityEngine`、`UnityEngine.CoreModule` 引用,无需手写 `<Import>`。**不要再引用真实的 `Assembly-CSharp.dll`**,游戏 API 编译引用由 SDK 提供。
在项目旁创建仅保存在本机的 `Directory.Build.props`,并加入自己的 `.gitignore`:
```xml
<Project>
<PropertyGroup>
<GameDir>E:\Games\BrownDustII</GameDir>
</PropertyGroup>
</Project>
```
或者构建时传入目录:
```powershell
dotnet build MyPlugin.csproj -c Release '-p:GameDir=E:\Games\BrownDustII'
```
IDE 项目加载/设计时构建会准备可读引用;CLI 首次构建同样会自动生成。使用 SDK 不需要运行额外的生成命令。完整可复制示例在 [samples/ExamplePlugin](samples/ExamplePlugin),包含 `.csproj`、插件源码和本机配置示例。
## 完整源码导航
导入包、设置 `GameDir` 后,先完成一次构建。SDK 使用 ILSpy 从匹配版本的真实游戏 DLL 生成**全部类型的可读 C# 源码**,保留方法体、私有成员、嵌套类型和编译器生成实现;同步生成 Portable PDB,将源码内嵌到 PDB。名字仍来自同一份内嵌表,没有另一份源码映射。
在支持外部源码导航的 IDE 中,对 `NetworkManager.Send` 等可读 API 使用“转到定义”(Visual Studio 的 F12),即可打开完整方法体。重载、泛型、参数、属性、字段、事件和嵌套类型使用真实元数据及 PDB 定位,不靠搜索同名字符串。
- **Visual Studio 2022**:在“工具 → 选项 → 文本编辑器 → C# → 高级”中启用“导航到 Source Link 和嵌入源”(不同语言/版本的名称可能略有差异)。如果仍打开旧的签名视图,关闭原外部源码页并重新加载项目后再 F12。只阅读源码不需要关闭“仅我的代码”调试设置。
- **Rider**:启用外部代码的源文件/PDB 或反编译导航。可读引用保留完整 IL,IDE 也可直接反编译出方法体;本项目的自动测试验证了 Visual Studio 的 Roslyn PDB 导航引擎,尚未手动验证 Rider 各版本界面。
- 项目中还会链接一个 **`Game Sources`** 文件夹,支持直接浏览和文本搜索。首次生成后若文件夹未出现,重新加载项目使生成的 MSBuild 导入生效。这些 `.cs` 是 `None` 项,**不参与插件编译,也不复制到部署目录**。
普通视图恢复 `async/await`、迭代器和 lambda,便于阅读;`generated` 视图补全被这些语法隐藏的状态机、访问器等,`metadata` 视图补全其他隐藏声明。无法作为 C# 标识符的 CLR 生成名在源码中显示为 `__generated_...`,`navigation.json` 和名字表仍保留真实元数据身份。
首次全量生成会花费数分钟,日志持续报告已处理的类型数。本机 2.35.10 的一次验证生成了 **24,856 个源码文件、654,265 个声明、319,350 个带方法体的符号**。生成结果按名字表、生成器及其依赖、全部游戏 Managed DLL 的指纹缓存,同一台机器上的插件项目共用;并行构建会等待同一个缓存生成完成。后续构建不再执行全量反编译。
默认共享缓存路径为 `%LOCALAPPDATA%\BD2\GameSdk\navigation`。可用环境变量改变位置:
```powershell
$env:BD2_GAME_SDK_CACHE = 'D:\Caches\BD2.GameSdk'
dotnet build MyPlugin.csproj -c Release '-p:GameDir=E:\Games\BrownDustII'
```
保持这个路径稳定;更改路径、工具或游戏 DLL 后首次构建会重新准备缓存。删除 `obj` 不影响共享源码缓存;缺失的缓存源码可在下次准备时从 PDB 恢复。可删除共享缓存来强制重新生成,它不存放手写源码。
插件 `obj/<配置>/<框架>/bd2-game-sdk` 中的布局遵循 .NET 的引用程序集查找约定(仓库插件使用 `game-sdk` 子目录):
| 路径 | 用途 |
| --- | --- |
| `ref/Assembly-CSharp.Readable.dll` | 编译器引用,带引用程序集标记,禁止执行 |
| `lib/Assembly-CSharp.Readable.dll` + `.pdb` | IDE 对应实现和导航符号;源码内嵌,不依赖联网下载 |
| `GameSourceNavigation.props` | 把共享源码链接到项目浏览器 |
| `navigation.json` | 元数据 token 到文件/行列的全量索引、覆盖统计与 DLL/PDB 指纹;schema 2 的文件路径相对于 `SourceRoot` |
`lib` 中的程序集仅供开发导航,**不要部署或执行**。这里展示的是从当前 DLL 重建的源码,局部变量名和语法可能与开发商原始工程不同。导航 PDB 对应可读程序集;它不能用来在真实混淆游戏 DLL 上逐行调试。
## 插件代码
```csharp
using System;
using BD2.GameNames;
using BepInEx;
using HarmonyLib;
[BepInPlugin("example.my-plugin", "My Plugin", "1.0.0")]
public sealed class MyPlugin : BaseUnityPlugin
{
private void Awake()
{
try
{
Game.Validate(typeof(MyPlugin).Assembly,
message => Logger.LogInfo(message));
var target = Game.Method<IntroUI>(ui => ui.SendMaintenanceInfo(false));
new Harmony("example.my-plugin").Patch(target,
prefix: new HarmonyMethod(typeof(MyPlugin), nameof(BeforeMaintenance)));
}
catch (Exception exception)
{
Logger.LogError("My Plugin initialization failed: " + exception);
}
}
private static void BeforeMaintenance() { }
}
```
表达式只取得 `MethodInfo`,不会执行游戏调用;签名由 C# 编译器检查。已知游戏类型优先使用 `typeof`,公开成员优先使用强类型表达式或 `nameof`,可以获得 IDE 补全和编译检查。实际公开调用和字段访问也能直接使用可读名字。私有成员保持原访问性,通过已知类型和映射反射 API 查询:
```csharp
using System.Reflection;
var intro = typeof(IntroUI);
var enter = intro.GetGameMethod("Enter",
BindingFlags.Instance | BindingFlags.NonPublic,
null, Type.EmptyTypes, null);
var field = typeof(IntroUI).GetGameField("_maintenanceTimeoutCts",
BindingFlags.Instance | BindingFlags.NonPublic);
// nameof 的结果仍是可读字符串,必须传给 GetGameMethod 等运行时 API。
var maintenance = typeof(IntroUI).GetGameMethod(nameof(IntroUI.SendMaintenanceInfo),
BindingFlags.Instance | BindingFlags.Public, null, new[] { typeof(bool) }, null);
// 对实际协程可读名调用 MemberName,再交给 Unity 的字符串 API。
owner.StartCoroutine(Game.MemberName(owner.GetType(), "ReadableCoroutineName"));
```
`GetGameMethod/GetGameField/GetGameProperty/GetGameEvent` 采用 .NET 反射约定:查不到返回 null,重载歧义抛出 `AmbiguousMatchException`,null 名字参数抛出 `ArgumentNullException`。`MemberName` 的成员种类使用 `GameMemberKind` 枚举。未知映射保留字面名;普通 UI 文案、`nameof` 字符串和协程常量都不会被 reobf 自动替换。
`Game.FindType` 保留给运行时才知道类型名称的查询。只做字符串反射的项目可单独安装 `BD2.GameNames`,启动时用 `Game.ValidateGame(...)`。这种方式没有可读游戏类型引用,也没有编译指纹;使用 SDK 的项目必须用 `Game.Validate(typeof(MyPlugin).Assembly, ...)`。
## 构建与部署
构建成功后,将 `bin/Release/netstandard2.1/MyPlugin.dll` 和相邻的 **`BD2.GameNames.dll`** 安装到游戏 `BepInEx/plugins`。客户端只安装一份共享库;多个插件应使用同一游戏表。不要部署 `obj`、可读壳、源码、导航 PDB、`GameSdk.dll`、`Mono.Cecil.dll`、`ICSharpCode.Decompiler.dll` 或其他构建工具。
最终插件 DLL 已回映射,`obj` 保留可读编译产物和 PDB,最终目录删除改写前的旧 PDB。SDK 包是构建依赖;玩家电脑不需要 .NET 8 SDK 或 NuGet。这里支持 Unity 的 Mono/Managed 客户端,要求存在真实 `Assembly-CSharp.dll`;IL2CPP/AOT 客户端不受支持。
| MSBuild 属性 | 用途 |
| --- | --- |
| `GameDir` | 游戏根目录,常规项目唯一必填配置 |
| `BD2ManagedDir` | 自定义 Managed 路径,默认 `GameDir/BrownDust II_Data/Managed` |
| `BD2BepInExDir` | 自定义 BepInEx 路径,默认 `GameDir/BepInEx` |
| `BD2GameSdkCache` | 共享导航缓存目录,可在 `Directory.Build.props` 配置;默认取 `BD2_GAME_SDK_CACHE` 环境变量或用户缓存目录 |
| `BD2AddBepInExReferences=false` | 使用其他宿主或自己提供 BepInEx/Harmony 引用时关闭自动引用 |
| `BD2AddUnityReferences=false` | 自己提供 Unity 引用时关闭自动引用 |
| `BD2GameSdkEnabled=false` | 暂时关闭壳生成与 reobf;只适用于不依赖可读游戏 API 的项目 |
额外 Unity 模块或第三方游戏依赖仍以普通 `<Reference>` 添加,并设 `<Private>false</Private>`。强签名插件需要重新签名流程,当前 reobf 明确拒绝。CI 用相同的 PackageReference 和 `GameDir` 参数构建,直接分发 Build 的 DLL 产物;不要把原始 `obj` DLL 放进发布包。
## 维护者打包
在本仓库运行:
```powershell
.\plugins\GameSdk\Pack.ps1 `
-GameDir '<当前客户端目录>'
```
默认生成 `.build/nuget/BD2.GameSdk.<版本>.nupkg` 和 `BD2.GameNames.<版本>.nupkg`。工具版本来自 `plugins/PackageMetadata.props`,游戏版本来自根目录 `versions.json`,组合成 `工具版本-game.游戏版本`。可传 `-PackageVersion` 和 `-OutputDirectory`;发布后的同一包版本必须保持内容不可变,有任何变更都递增工具版本。
SDK 精确依赖同版本运行时包;两个包中的压缩表都直接来自同一生成结果。工具通过 `dotnet publish` 打包自带 Mono.Cecil,不把 Cecil 作为插件的 NuGet 依赖。包内携带 README、LICENSE、仓库地址和作者信息。本脚本只生成本地包,不上传到任何 NuGet 服务。
工具包也包含 Mono.Cecil、ILSpy 和相关 .NET 组件的 MIT 授权原文 `THIRD-PARTY-NOTICES.txt`;第三方组件保持其自身许可。打包后运行 `VerifyPackages.ps1 -GameDir '<客户端目录>'`,它在仓库外的全新目录和 NuGet 缓存中构建示例插件,检查运行时复制、禁止部署的文件、回映射引用、完整内嵌表、源码/PDB 覆盖和重复构建。
打包工程在普通 IDE 加载/源码构建时直接引用 `GameNames.csproj`,无需先生成包,也不会到 nuget.org 查找未发布的 `BD2.GameNames`。`Pack.ps1` 用 `BD2Packaging=true` 切换到同版本的精确 NuGet 依赖,并把打包用 `obj/bin` 放在独立 staging 目录,避免 IDE restore 与 pack 互相覆盖依赖资产。不要直接对打包 `.csproj` 执行 `dotnet pack`,使用脚本才能保证两个包版本配套。
仓库中的 `samples/ExamplePlugin/NuGet.Config` 指向 `.build/nuget`,示例保持真正的 NuGet 消费方式;先运行 `Pack.ps1` 再构建示例。复制示例到其他目录时按前文配置自己的包源,不要照搬仓库相对路径。
## 仓库源码构建与内嵌表
仓库的三个插件与 NuGet 消费项目采用同样的内嵌表流程。开发启动命令保持不变:
公共配置见 `plugins/Directory.Build.props`,本机游戏安装位置见不提交的 `plugins/Directory.Build.local.props`。可从 `Directory.Build.local.props.example` 复制并设置 `BD2LocalGameDir`、`BD2CaptureGameDir`、可选的 `BD2GameSdkCache`。配置和目标框架确定后,由 `Directory.Build.targets` 计算中间目录并导入 SDK/版本构建步骤。
仓库插件通过普通 `ProjectReference` 使用共享运行时;IDE 设计时加载不会嵌套构建三份运行时。SDK 准备过程按输出目录加锁,导航 `.props` 只在内容改变时原子替换,重复设计时构建不会因重写该文件触发连续项目加载。设计时构建不执行 reobf。
```powershell
# 从 go 目录运行;游戏目录来自 go/config.json
go run .\cmd\bd2client --dev run
```
直接构建源码插件也只需要游戏目录:
```powershell
dotnet build plugins/LocalIdentity/LocalIdentity.csproj -c Release '-p:GameDir=<客户端目录>'
```
构建工具从自身资源导出表到 `obj` 缓存,用本机真实 DLL 的元数据生成壳,再做回映射。`GameSdk` 和 `BD2.GameNames` 的唯一名字数据源是仓库内的 `GameNames/Mappings/names.json.gz`。两份程序集内嵌的是同一份压缩字节,不维护第二份映射;`obj` 里的表只是可以删除重建的缓存。NuGet 包同样不再包含单独的 `tools/data` 表文件。
表包含 `game_version`、完整类型名、成员声明类型/签名/metadata token、参数映射、真实 DLL 的 MVID/SHA-256 和官方映射 SHA-256。构建时先验证 DLL 指纹,仓库构建还核对 `versions.json`;不匹配会要求更新 SDK,不会尝试使用其他版本或猜名字。插件启动时核对表指纹、游戏 DLL 和少量已知条目。
## 更新游戏版本的名字表
只有维护者更新 SDK 名字数据时需要官方 `.obfuscate`。先更新仓库 `versions.json`,然后执行:
```powershell
.\plugins\GameSdk\UpdateNames.ps1 `
-GameDir '<新版本客户端目录>' `
-GameMapping '<ObfuscationTranslation_新版本.obfuscate>'
```
脚本从官方映射和真实元数据生成表,完成全量壳转换验证后才替换 `plugins/GameNames/Mappings/names.json.gz`。提交这份表与对应版本变更;重新构建插件并发布新版本配套包。更新过程中产生的明文表和壳留在 `.build/names-update`,不作为源码提交。
`tools/python/deobfuscate_client_source.py` 继续生成阅读镜像;程序集映射使用这里的元数据名字表,保留作用域、泛型位置和重载签名。官方映射冲突或可读签名冲突会报错。
工具也提供独立命令:
```powershell
dotnet build plugins/GameSdk/GameSdk.csproj -c Release
$tool = 'plugins/GameSdk/bin/Release/net8.0/GameSdk.dll'
dotnet $tool prepare-embedded '<Assembly-CSharp.dll>' '<输出目录>'
dotnet $tool export-names '<导出的 names.json>'
dotnet $tool reobf '<names.json>' '<可读插件.dll>' '<运行插件.dll>' '<Assembly-CSharp.dll>' '<BepInEx/core>'
dotnet $tool verify '<names.json>' '<运行插件.dll>' '<Assembly-CSharp.dll>'
dotnet $tool verify-runtime '<names.json>' '<BD2.GameNames.dll>'
dotnet $tool verify-navigation '<生成的 SDK 目录>'
dotnet $tool self-test
```
手动编译须引用 `ref/Assembly-CSharp.Readable.dll` 并编译同目录 `GameSdkIdentity.g.cs`,保留相邻 `lib` 以供 IDE 查找。`verify-navigation` 全量检查 PE/PDB 身份、内嵌/本地源码校验和、类型文档和全部方法体的符号。
`self-test` 生成合成游戏 DLL,验证重载、泛型、继承、嵌套/编译器生成类型、私有成员、事件、参数、表达式、字符串不变和版本拒绝;它会打印合成 SDK 目录。使用实际 Roslyn 引擎验证该目录的导航:
```powershell
dotnet run --project plugins/GameSdk.NavigationTests -- '<合成 SDK 目录>' --embedded-only
# 或验证完整游戏导航:
dotnet run --project plugins/GameSdk.NavigationTests -- '<插件 obj 中的 SDK 目录>' '<游戏 Managed 目录>'
```
嵌入源码测试临时移走合成 fixture 的 `.cs`,结束时恢复;它限制在 `.build/game-sdk-tests` 下运行。测试断言类型、重载、泛型、参数、字段、事件和嵌套类型跳到准确的源码标识符,并拒绝签名/反编译回退。`VerifyPackages.ps1` 验证仓库外 NuGet 项目。实际 Unity/Harmony 行为需在游戏启动后检查日志。
+273
View File
@@ -0,0 +1,273 @@
using System.Diagnostics;
using System.Runtime.Loader;
using Mono.Cecil;
using Mono.Cecil.Cil;
namespace BD2.GameSdk;
/// <summary>End-to-end tests with synthetic binaries. Never execute the production game or readable shell.</summary>
internal static class SelfTest
{
private static void Assert(bool condition, string message)
{
if (!condition) throw new InvalidOperationException("Self-test: " + message);
}
private static void Build(string project, params string[] properties)
{
var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true };
foreach (string arg in new[] { "build", project, "--nologo", "-c", "Release" }.Concat(properties)) start.ArgumentList.Add(arg);
using var process = Process.Start(start);
var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync();
process.WaitForExit();
string output = stdout.GetAwaiter().GetResult() + stderr.GetAwaiter().GetResult();
if (process.ExitCode != 0) throw new InvalidOperationException(output);
}
private static string Project(string name, string references = "") => $"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>netstandard2.1</TargetFramework><LangVersion>latest</LangVersion><AssemblyName>{name}</AssemblyName></PropertyGroup>
<ItemGroup>{references}</ItemGroup>
</Project>
""";
internal static void Run()
{
string repository = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../"));
Assert(File.Exists(Path.Combine(repository, "versions.json")), "run from a built GameSdk project");
string root = Path.Combine(repository, ".build", "game-sdk-tests", Guid.NewGuid().ToString("N"));
string gameDir = Path.Combine(root, "game"), sdkDir = Path.Combine(root, "sdk"), probeDir = Path.Combine(root, "probe");
Directory.CreateDirectory(gameDir); Directory.CreateDirectory(probeDir);
string exported = Path.Combine(root, "embedded", "names.json");
Program.ExportNames(exported);
Assert(File.ReadAllBytes(exported + ".gz").SequenceEqual(Program.EmbeddedNames()), "SDK exports the exact embedded table");
Assert(Program.ReadTable(exported).schema_version == 1, "embedded names have supported schema");
string gameProject = Path.Combine(gameDir, "Game.csproj");
File.WriteAllText(gameProject, Project("Assembly-CSharp"));
File.WriteAllText(Path.Combine(gameDir, "Game.cs"), """
using System;
public class AppManager { public bool IsPlatformLogin { get { return true; } } }
public class IntroUI { public void SendMaintenanceInfo(bool force) {} }
namespace BDNetwork { public class NetworkManager { public string GetPachedGameDataPath() { return "path"; } } }
namespace Readable {
public interface IThing { string Name { get; } }
public class Agent : IThing {
string IThing.Name { get { return "agent"; } }
public event EventHandler Changed;
public int Value;
public int Ping(int value) { return value; }
public string Ping(string value) { return value; }
public void Act() {}
public void Awake() {}
private void PauseRoutine() {}
private int secret = 3;
public class State {}
public T Echo<T>(T value) { return value; }
public Agent[,] Grid(Agent[,] value) { return value; }
public void Ref(ref Agent value) {}
}
public class Box<T> { public T Item; public T Echo(T value) { return value; } public class Nested<U> { public U Item; } }
public class Derived : Box<Agent> {}
}
""");
Build(gameProject);
string gamePath = Path.Combine(gameDir, "bin/Release/netstandard2.1/Assembly-CSharp.dll");
string mappingPath = Path.Combine(root, "ObfuscationTranslation_9.8.7.obfuscate"), versionPath = Path.Combine(root, "versions.json");
File.WriteAllText(versionPath, "{\"game_version\":\"9.8.7\"}");
var rows = new List<string> { "#ReverseOrder", "#Classes", "#Methods", "#Fields", "#Properties", "#Events", "#Parameters" };
using (var game = Program.ReadModule(gamePath))
{
var table = new BD2.GameNames.Internal.NameTable { assembly_name = "Assembly-CSharp" };
var defs = game.GetTypes().ToArray(); int counter = 0;
string Obfuscated() => "ὠ" + string.Concat((counter++).ToString().Select(c => (char)('ὠ' + c - '0')));
var newNames = new Dictionary<TypeDefinition, string>();
string Full(TypeDefinition t) => t.DeclaringType != null ? Full(t.DeclaringType) + "+" + newNames[t] : newNames.TryGetValue(t, out var n) ? n : Program.TypeKey(t);
foreach (var t in defs)
if (t.Namespace == "Readable" || t.DeclaringType?.Namespace == "Readable")
{
newNames[t] = Obfuscated();
string meaning = t.DeclaringType == null ? Program.TypeKey(t) : newNames[t.DeclaringType] + "/" + (t.Name == "State" ? "<Run>d__0" : t.Name);
rows.Add(newNames[t] + "⇨" + meaning);
}
foreach (var t in defs) table.types.Add(new BD2.GameNames.Internal.TypeName { token = t.MetadataToken.ToInt32(), original = Program.TypeKey(t), readable = Full(t) });
foreach (var t in defs)
{
void Rename(IMemberDefinition member, string kind, string signature = null, MethodDefinition method = null)
{
bool renamed = member.Name != ".ctor" && member.Name != "Awake" && t.Name != "<Module>";
string original = member.Name, target = renamed ? Obfuscated() : original;
if (renamed) rows.Add(target + "⇨" + original);
var entry = new BD2.GameNames.Internal.MemberName { token = member.MetadataToken.ToInt32(), declaring_type = Program.TypeKey(t), original = original, readable = target, kind = kind, signature = signature };
if (method != null) foreach (var p in method.Parameters)
{
if (string.IsNullOrEmpty(p.Name)) continue;
string name = Obfuscated(); rows.Add(name + "⇨" + p.Name);
entry.parameters.Add(new BD2.GameNames.Internal.ParameterName { position = p.Index, original = p.Name, readable = name });
}
table.members.Add(entry);
}
foreach (var m in t.Methods) Rename(m, "method", Program.Signature(m), m);
foreach (var f in t.Fields) Rename(f, "field", Program.Signature(f));
foreach (var p in t.Properties) Rename(p, "property");
foreach (var e in t.Events) Rename(e, "event", Program.TypeKey(e.EventType));
}
Program.Rewrite(game, table, true);
var entries = table.members.ToDictionary(m => m.token);
foreach (var t in defs)
{
foreach (var m in t.Methods)
{
var entry = entries[m.MetadataToken.ToInt32()]; m.Name = entry.readable;
foreach (var p in entry.parameters) m.Parameters[p.position].Name = p.readable;
}
foreach (var f in t.Fields) f.Name = entries[f.MetadataToken.ToInt32()].readable;
foreach (var p in t.Properties) p.Name = entries[p.MetadataToken.ToInt32()].readable;
foreach (var e in t.Events) e.Name = entries[e.MetadataToken.ToInt32()].readable;
Program.SetTypeName(t, table.types.Single(e => e.token == t.MetadataToken.ToInt32()).readable);
}
game.Write(gamePath);
}
File.WriteAllLines(mappingPath, rows);
bool embeddedMismatchRejected = false;
try { Program.PrepareEmbedded(gamePath, Path.Combine(root, "mismatched-embedded")); }
catch (InvalidDataException ex) { embeddedMismatchRejected = ex.Message.Contains("does not match SDK names for game"); }
Assert(embeddedMismatchRejected, "embedded table rejects another game binary without seeking an external mapping");
Program.Prepare(gamePath, mappingPath, versionPath, sdkDir);
SourceNavigation.Verify(sdkDir);
string tablePath = Path.Combine(sdkDir, "names.json");
var generated = Program.ReadTable(tablePath);
Assert(generated.types.Any(t => t.readable == "Readable.Agent+<Run>d__0"), "compiler-generated scoped type");
using (var shell = Program.ReadModule(Path.Combine(sdkDir, Program.ShellName + ".dll")))
{
Assert(shell.Assembly.CustomAttributes.Any(a => a.AttributeType.Name == "ReferenceAssemblyAttribute"), "reference assembly marker");
Assert(shell.GetType("Readable.Agent").Methods.First(m => m.Name == "Ping").Body.Instructions.Any(i => i.OpCode == OpCodes.Ret), "readable IL retained for offline decompiler fallback");
Assert(shell.GetType("Readable.Agent").Methods.Count(m => m.Name == "Ping") == 2, "overload names restored");
}
string runtimeProject = Path.Combine(repository, "plugins/GameNames/GameNames.csproj"), runtimeBin = Path.Combine(root, "runtime-bin") + Path.DirectorySeparatorChar;
Build(runtimeProject, "-p:GameNamesTable=" + tablePath, "-p:BaseIntermediateOutputPath=" + Path.Combine(root, "runtime-obj") + Path.DirectorySeparatorChar, "-p:OutputPath=" + runtimeBin);
string runtimePath = Path.Combine(runtimeBin, "BD2.GameNames.dll");
string references = $"<Compile Include=\"{System.Security.SecurityElement.Escape(Path.Combine(sdkDir, "GameSdkIdentity.g.cs"))}\" /><Reference Include=\"{Program.ShellName}\"><HintPath>{System.Security.SecurityElement.Escape(Path.Combine(sdkDir, "ref", Program.ShellName + ".dll"))}</HintPath><Private>false</Private></Reference><Reference Include=\"BD2.GameNames\"><HintPath>{System.Security.SecurityElement.Escape(runtimePath)}</HintPath></Reference>";
string probeProject = Path.Combine(probeDir, "Probe.csproj");
File.WriteAllText(probeProject, Project("Probe", references));
File.WriteAllText(Path.Combine(probeDir, "Probe.cs"), """
using System;
using System.Collections.Generic;
using System.Reflection;
using BD2.GameNames;
using Readable;
public sealed class MarkerAttribute : Attribute { public Type Target; public MarkerAttribute(Type target) { Target = target; } }
[Marker(typeof(Box<Agent>))]
public class Probe {
private static void Check(bool condition, string name) { if (!condition) throw new Exception(name); }
public static string Run() {
var log = new List<string>();
Game.Validate(typeof(Probe).Assembly, "9.8.7", log.Add);
Game.Validate(typeof(Probe).Assembly, log.Add);
Game.ValidateGame(log.Add);
var a = new Agent(); a.Value = 42;
Check(a.Value == 42 && a.Ping(7) == 7 && a.Ping("ok") == "ok", "fields and overloads");
Check(((IThing)a).Name == "agent", "explicit interface property");
a.Changed += (s, e) => {};
Check(typeof(Probe).GetCustomAttribute<MarkerAttribute>().Target == typeof(Box<Agent>), "custom attribute type arguments");
Check(a.Echo(a) == a && new Box<Agent>().Echo(a) == a, "generic methods/types");
var box = new Box<Agent>(); box.Item = a; Check(box.Item == a, "generic field");
var derived = new Derived(); derived.Item = a; Check(derived.Echo(a) == a && derived.Item == a, "inherited generic members");
var nested = new Box<Agent>.Nested<string>(); nested.Item = "nested"; Check(nested.Item == "nested", "nested generic field");
var grid = new Agent[1,1]; grid[0,0] = a; Check(a.Grid(grid)[0,0] == a, "array pseudo methods");
a.Ref(ref a);
Check(typeof(Derived).BaseType == typeof(Box<Agent>), "generic base");
Check(Game.Getter<AppManager, bool>(x => x.IsPlatformLogin).Invoke(new AppManager(), null) is true, "property expression");
Check(Game.Method<Agent>(x => x.Act()).Name != "Act", "Harmony method expression");
Check(nameof(Agent.Ping) == "Ping", "nameof is unchanged");
const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
var ping = typeof(Agent).GetGameMethod(nameof(Agent.Ping), all, null, new[] { typeof(int) }, null);
Check((int)ping.Invoke(a, new object[] { 12 }) == 12, "overloaded runtime lookup");
Check(Game.ParameterName(ping, "value") == ping.GetParameters()[0].Name, "parameter mapping");
Check(typeof(Derived).GetGameMethod("Echo", all, null, new[] { typeof(Agent) }, null).Invoke(derived, new object[] { a }) == a, "inherited generic runtime method");
Check(typeof(Derived).GetGameField("Item", all).GetValue(derived) == a, "inherited generic runtime field");
bool ambiguous = false;
try { typeof(Agent).GetGameMethod("Ping", all); } catch (AmbiguousMatchException) { ambiguous = true; }
Check(ambiguous, "ambiguous overloads require a signature");
Check(Game.MemberName(typeof(Agent), "PauseRoutine") != "PauseRoutine", "coroutine string mapping");
Check(typeof(Agent).GetGameField("secret", all).GetValue(a) is 3, "private field");
Check(Game.MemberName(typeof(Agent), "secret", GameMemberKind.Field) != "secret", "typed member kind");
bool nullRejected = false;
try { Game.TypeName(null); } catch (ArgumentNullException) { nullRejected = true; }
Check(nullRejected, "null input uses .NET exception contract");
Check(typeof(Agent).GetGameEvent("Changed", all).Name != "Changed", "event string mapping");
Check(Game.FindType("Readable.Agent+<Run>d__0").DeclaringType == typeof(Agent), "generated nested type");
Check(typeof(Agent).GetGameMethod("Awake", all).Name == "Awake", "Unity literal fallback");
Check(Game.TypeName("Unmapped.Type") == "Unmapped.Type" && Game.MemberName(typeof(Agent), "Missing") == "Missing", "unknown literal fallback");
Check(typeof(string).GetGameMethod("Contains", all, null, new[] { typeof(string) }, null) != null, "external member unaffected");
bool rejected = false;
try { Game.Validate(typeof(Probe).Assembly, "wrong", log.Add); } catch { rejected = true; }
Check(rejected && log.Exists(s => s.Contains("SELF-CHECK FAILED")), "version mismatch diagnosed");
return "All SDK/runtime integration probes passed; UI Ping remains Ping";
}
public static Agent[] Signature(Agent[] value) { return value; }
}
""");
Build(probeProject);
string readablePlugin = Path.Combine(probeDir, "bin/Release/netstandard2.1/Probe.dll"), pluginPath = Path.Combine(root, "Probe.dll");
using (var plugin = Program.ReadModule(readablePlugin))
{
var foreign = new TypeReference("Readable", "Agent", plugin, new AssemblyNameReference("Foreign", new Version(1, 0)));
var holder = new TypeDefinition("", "ExternalHolder", TypeAttributes.Public, plugin.TypeSystem.Object);
holder.Fields.Add(new FieldDefinition("Other", FieldAttributes.Public, foreign)); plugin.Types.Add(holder);
plugin.Write(readablePlugin);
}
Program.Reobfuscate(tablePath, readablePlugin, pluginPath, gamePath);
using (var rewritten = Program.ReadModule(pluginPath))
{
Assert(rewritten.GetType("ExternalHolder").Fields[0].FieldType.FullName == "Readable.Agent", "external assembly scope isolation");
Assert(!rewritten.AssemblyReferences.Any(a => a.Name == "System.Private.CoreLib"), "target framework preserved");
Assert(Strings(readablePlugin).SequenceEqual(Strings(pluginPath)), "all ldstr operands preserved byte-for-byte");
}
var context = new AssemblyLoadContext("BD2 GameSdk self-test", isCollectible: true);
context.Resolving += (_, name) => name.Name switch
{
"Assembly-CSharp" => context.LoadFromAssemblyPath(gamePath),
"BD2.GameNames" => context.LoadFromAssemblyPath(runtimePath),
_ => null
};
try { Console.WriteLine(context.LoadFromAssemblyPath(pluginPath).GetType("Probe").GetMethod("Run").Invoke(null, null)); }
finally { context.Unload(); }
string wrongTable = Path.Combine(root, "wrong-names.json");
generated.game_version = "wrong";
File.WriteAllText(wrongTable, System.Text.Json.JsonSerializer.Serialize(generated, Program.Json));
bool mismatchRejected = false;
string protectedOutput = Path.Combine(root, "protected-output.dll");
File.WriteAllText(protectedOutput, "untouched");
try { Program.Reobfuscate(wrongTable, readablePlugin, protectedOutput); } catch (InvalidDataException) { mismatchRejected = true; }
Assert(mismatchRejected && File.ReadAllText(protectedOutput) == "untouched", "mismatched table rejected before publishing");
File.AppendAllText(mappingPath, "\nὠὡ⇨Conflicting.Name\n");
bool conflictRejected = false;
try { Program.Prepare(gamePath, mappingPath, versionPath, sdkDir); } catch (InvalidDataException) { conflictRejected = true; }
Assert(conflictRejected, "ambiguous mapping rejected");
Console.WriteLine("Self-test artifacts: " + root);
}
private static string[] Strings(string path)
{
using var module = Program.ReadModule(path);
return module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody).SelectMany(m => m.Body.Instructions).Where(i => i.OpCode == OpCodes.Ldstr).Select(i => (string)i.Operand).ToArray();
}
internal static void VerifyRuntime(string tablePath, string runtimePath)
{
var table = Program.ReadTable(tablePath);
using (var runtime = Program.ReadModule(runtimePath))
{
var resource = (EmbeddedResource)runtime.Resources.Single(r => r.Name == "BD2.GameNames.names.json.gz");
Assert(resource.GetResourceData().SequenceEqual(File.ReadAllBytes(tablePath + ".gz")), "embedded runtime table equals the shared table");
}
var context = new AssemblyLoadContext("BD2 full runtime-table check", isCollectible: true);
try
{
var runtime = context.LoadFromAssemblyPath(Path.GetFullPath(runtimePath));
Assert(runtime.GetExportedTypes().Select(t => t.FullName).OrderBy(n => n).SequenceEqual(new[] { "BD2.GameNames.Game", "BD2.GameNames.GameMemberKind" }), "serialization models are not public API");
var game = runtime.GetType("BD2.GameNames.Game");
Assert((string)game.GetProperty("GameVersion").GetValue(null) == table.game_version, "runtime deserializes the full table");
var translate = game.GetMethod("TypeName").CreateDelegate<Func<string, string>>();
foreach (var type in table.types) Assert(translate(type.readable) == type.original, "runtime type translation: " + type.readable);
Assert(translate("Unknown.Type") == "Unknown.Type", "runtime literal fallback");
Console.WriteLine($"Verified embedded runtime table: game={table.game_version}, {table.types.Count} type lookups; {runtimePath}");
}
finally { context.Unload(); }
}
}
+464
View File
@@ -0,0 +1,464 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using System.Reflection.PortableExecutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.CSharp.OutputVisitor;
using ICSharpCode.Decompiler.CSharp.Syntax;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.TypeSystem;
using Mono.Cecil;
using Mono.Cecil.Cil;
using EntityHandle = System.Reflection.Metadata.EntityHandle;
using ModuleDefinition = Mono.Cecil.ModuleDefinition;
using MethodDefinition = Mono.Cecil.MethodDefinition;
using MethodDebugInformation = Mono.Cecil.Cil.MethodDebugInformation;
namespace BD2.GameSdk;
/// <summary>Build local, offline external-source navigation from the readable implementation.</summary>
internal static class SourceNavigation
{
internal static readonly Guid EmbeddedSource = new("0E8A571B-6926-466E-B4AD-8AB04611F5FE");
internal static readonly Guid TypeDocuments = new("932E74BC-DBA9-4478-8D46-0F32A7BAB3D3");
private static readonly Guid CompilationOptions = new("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8");
private static readonly Guid CompilationReferences = new("7E4D4708-096E-4C5C-AEDA-CB10BA6A740D");
private static readonly Guid CSharpLanguage = new("3F5162F8-07C6-11D3-9053-00C04FA302A1");
internal sealed record Declaration(int Token, string Name, string Kind, string File, int Line, int Column, int EndLine, int EndColumn, bool GeneratedView);
internal sealed record Point(int Offset, int Line, int Column, int EndLine, int EndColumn);
private sealed record Source(string Path, byte[] Content, Dictionary<int, Declaration> Declarations, Dictionary<int, List<Point>> Methods);
internal sealed record Manifest(int SchemaVersion, string AssemblySha256, string PdbSha256, string DecompilerVersion, string SourceRoot,
int Documents, int Declarations, int MethodsWithBodies, int MethodsWithSymbols, List<string> Diagnostics, List<Declaration> Symbols);
internal static void Generate(string shellPath, string dependencyDirectory)
{
string directory = Path.GetDirectoryName(Path.GetFullPath(shellPath));
string sourceRoot = Path.Combine(directory, "sources");
Directory.CreateDirectory(sourceRoot);
using var module = Program.ReadModule(shellPath, dependencyDirectory);
using var pe = new PEFile(shellPath, new MemoryStream(File.ReadAllBytes(shellPath)), PEStreamOptions.PrefetchEntireImage);
var reader = pe.Metadata;
var types = reader.TypeDefinitions.Where(h => reader.GetTypeDefinition(h).GetDeclaringType().IsNil && reader.GetString(reader.GetTypeDefinition(h).Name) != "<Module>").ToArray();
var sources = new ConcurrentBag<Source>();
var timer = Stopwatch.StartNew();
int completed = 0;
var expected = module.GetTypes().ToDictionary(t => t.MetadataToken.ToInt32(), t =>
t.Methods.Cast<IMemberDefinition>().Concat(t.Fields).Concat(t.Properties).Concat(t.Events).Select(m => m.MetadataToken.ToInt32()).Append(t.MetadataToken.ToInt32()).ToHashSet());
var definitionsByToken = module.GetTypes().ToDictionary(t => t.MetadataToken.ToInt32());
// Each worker owns its decompiler and resolver. PE metadata is immutable;
// mutable decompiler state is never shared between workers.
int workers = Math.Clamp(Environment.ProcessorCount / 2, 1, 4);
Parallel.ForEach(types, new ParallelOptions { MaxDegreeOfParallelism = workers },
() => new Worker(shellPath, dependencyDirectory), (handle, _, worker) =>
{
int token = MetadataTokens.GetToken(handle);
string relative = PathFor(reader, handle);
var primary = worker.Decompile(handle, Path.Combine(sourceRoot, relative), raw: false);
ApplyImplicitDeclarations(primary.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]);
sources.Add(primary);
var typeTokens = Descendants(reader, handle).Select(h => MetadataTokens.GetToken(h)).ToArray();
var wanted = typeTokens.SelectMany(t => expected[t]).ToHashSet();
var covered = primary.Declarations.Keys.ToHashSet();
if (wanted.Except(covered).Any())
{
// Keep idiomatic async/iterator/lambda code in the primary view, and
// expose the compiler-generated implementation in a companion view.
// Roslyn materializes embedded documents by basename, so primary
// and generated views must have distinct filenames as well as paths.
string generatedPath = Path.Combine(Path.GetDirectoryName(relative), Path.GetFileNameWithoutExtension(relative) + ".generated.cs");
var raw = worker.Decompile(handle, Path.Combine(sourceRoot, "generated", generatedPath), raw: true);
ApplyImplicitDeclarations(raw.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]);
sources.Add(raw);
covered.UnionWith(raw.Declarations.Keys);
}
foreach (int missing in wanted.Except(covered))
{
// ILSpy hides some runtime-only definitions even with transformations
// disabled. Decompile them explicitly, retaining exact token identity.
var supplemental = worker.DecompileMember(MetadataTokens.EntityHandle(missing),
Path.Combine(sourceRoot, "metadata", missing.ToString("X8") + ".cs"));
sources.Add(supplemental);
if (!supplemental.Declarations.ContainsKey(missing))
throw new InvalidDataException($"Decompiler omitted declaration 0x{missing:X8}; complete navigation cannot be published");
}
int done = Interlocked.Increment(ref completed);
if (done % 500 == 0 || done == types.Length)
Console.WriteLine($"Game source navigation: {done}/{types.Length} top-level types ({timer.Elapsed.TotalSeconds:F0}s)");
return worker;
}, worker => worker.Dispose());
var ordered = sources.OrderBy(s => s.Path, StringComparer.Ordinal).ToArray();
var sourceByPath = ordered.ToDictionary(s => s.Path, StringComparer.Ordinal);
var declarations = new Dictionary<int, Declaration>();
var methodPoints = new Dictionary<int, (Source Source, List<Point> Points)>();
foreach (var source in ordered.OrderBy(s => s.Declarations.Values.FirstOrDefault()?.GeneratedView == true))
{
foreach (var declaration in source.Declarations) declarations.TryAdd(declaration.Key, declaration.Value);
foreach (var method in source.Methods) methodPoints.TryAdd(method.Key, (source, method.Value));
}
// Accessors may be folded into properties/events. Their declaration navigation
// maps to the exact accessor when present, otherwise its owning declaration.
foreach (var type in module.GetTypes())
{
foreach (var property in type.Properties)
Alias(property, property.GetMethod, property.SetMethod);
foreach (var @event in type.Events)
Alias(@event, @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod);
}
void Alias(IMemberDefinition owner, params MethodDefinition[] accessors)
{
if (!declarations.TryGetValue(owner.MetadataToken.ToInt32(), out var location)) return;
foreach (var method in accessors.Where(m => m != null))
declarations.TryAdd(method.MetadataToken.ToInt32(), location with { Token = method.MetadataToken.ToInt32(), Name = method.FullName, Kind = "Method" });
}
foreach (var method in module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody))
{
int token = method.MetadataToken.ToInt32();
if (!methodPoints.ContainsKey(token) && declarations.TryGetValue(token, out var declaration))
{
var source = sourceByPath[declaration.File];
methodPoints[token] = (source, new List<Point> { new(0, declaration.Line, declaration.Column, declaration.EndLine, declaration.EndColumn) });
}
}
int bodies = module.GetTypes().Sum(t => t.Methods.Count(m => m.HasBody));
if (methodPoints.Count < bodies) throw new InvalidDataException($"Navigation coverage incomplete: {methodPoints.Count} symbols for {bodies} method bodies");
string pdbPath = Path.ChangeExtension(shellPath, ".pdb");
var pdb = BuildPdb(pe, module, ordered, declarations, methodPoints, dependencyDirectory);
File.WriteAllBytes(pdbPath, pdb.Bytes);
// Attach exactly the PDB we generated to this PE. No implementation is executed.
module.Write(shellPath, new WriterParameters { WriteSymbols = true, SymbolWriterProvider = new NavigationSymbolWriterProvider(pdb.Id, pdbPath, pdb.Bytes) });
// Store the source root once instead of repeating a long machine-specific
// cache path for hundreds of thousands of declarations.
var manifest = new Manifest(2, Program.Hash(shellPath), Program.Hash(pdbPath), typeof(CSharpDecompiler).Assembly.GetName().Version.ToString(), sourceRoot,
ordered.Length, declarations.Count, bodies, methodPoints.Count, new List<string>(),
declarations.Values.OrderBy(d => d.Token).Select(d => d with { File = Path.GetRelativePath(sourceRoot, d.File) }).ToList());
File.WriteAllText(Path.Combine(directory, "navigation.json"), JsonSerializer.Serialize(manifest));
WriteXmlDocumentation(shellPath, pe, declarations);
Console.WriteLine($"Game source navigation ready: {ordered.Length} source files, {declarations.Count} declarations, {methodPoints.Count} method symbols");
}
internal static void Verify(string sdkDirectory)
{
string library = Path.Combine(sdkDirectory, "lib", Program.ShellName + ".dll"), pdbPath = Path.ChangeExtension(library, ".pdb");
var manifest = JsonSerializer.Deserialize<Manifest>(File.ReadAllText(Path.Combine(sdkDirectory, "navigation.json")));
if (Program.Hash(library) != manifest.AssemblySha256 || Program.Hash(pdbPath) != manifest.PdbSha256)
throw new InvalidDataException("Source navigation manifest, DLL and PDB do not match");
using var peStream = File.OpenRead(library); using var pe = new PEReader(peStream);
using var pdbStream = File.OpenRead(pdbPath); using var provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream);
var pdb = provider.GetMetadataReader(); var dll = pe.GetMetadataReader();
var id = new BlobContentId(pdb.DebugMetadataHeader.Id);
var entry = pe.ReadDebugDirectory().Single(d => d.Type == DebugDirectoryEntryType.CodeView);
var codeview = pe.ReadCodeViewDebugDirectoryData(entry);
if (codeview.Guid != id.Guid || entry.Stamp != id.Stamp || codeview.Age != 1) throw new InvalidDataException("PDB identity does not match navigation assembly");
var checksum = pe.ReadDebugDirectory().Single(d => d.Type == DebugDirectoryEntryType.PdbChecksum);
if (!pe.ReadPdbChecksumDebugDirectoryData(checksum).Checksum.SequenceEqual(SHA256.HashData(File.ReadAllBytes(pdbPath))))
throw new InvalidDataException("PE/PDB checksum mismatch");
int documents = 0, methods = 0;
foreach (var documentHandle in pdb.Documents)
{
var document = pdb.GetDocument(documentHandle);
if (pdb.GetGuid(document.Language) != CSharpLanguage) throw new InvalidDataException("Navigation document language is not C#");
var embedded = pdb.GetCustomDebugInformation(documentHandle).Select(h => pdb.GetCustomDebugInformation(h)).Single(c => pdb.GetGuid(c.Kind) == EmbeddedSource);
byte[] content = DecodeEmbedded(pdb.GetBlobBytes(embedded.Value));
if (!SHA256.HashData(content).SequenceEqual(pdb.GetBlobBytes(document.Hash))) throw new InvalidDataException("Embedded-source checksum mismatch");
string path = pdb.GetString(document.Name);
if (!File.Exists(path) || !File.ReadAllBytes(path).SequenceEqual(content)) throw new InvalidDataException("Local source does not match embedded source: " + path);
documents++;
}
foreach (var method in dll.MethodDefinitions)
{
var definition = dll.GetMethodDefinition(method);
if (definition.RelativeVirtualAddress == 0) continue;
var debug = pdb.GetMethodDebugInformation(method);
var points = debug.GetSequencePoints().ToArray();
if (debug.Document.IsNil || points.Length == 0) throw new InvalidDataException("Method lacks navigation symbols: 0x" + MetadataTokens.GetToken(method).ToString("X8"));
int size = pe.GetMethodBody(definition.RelativeVirtualAddress).GetILContent().Length;
if (points.Any(p => !p.IsHidden && (p.Offset >= size || p.StartLine <= 0 || p.EndLine < p.StartLine))) throw new InvalidDataException("Invalid navigation sequence point");
methods++;
}
foreach (var type in dll.TypeDefinitions)
{
if (dll.GetString(dll.GetTypeDefinition(type).Name) == "<Module>") continue;
var custom = pdb.GetCustomDebugInformation(type).Select(h => pdb.GetCustomDebugInformation(h)).SingleOrDefault(c => pdb.GetGuid(c.Kind) == TypeDocuments);
if (custom.Value.IsNil) throw new InvalidDataException("Type lacks source documents: 0x" + MetadataTokens.GetToken(type).ToString("X8"));
var blob = pdb.GetBlobReader(custom.Value);
while (blob.RemainingBytes > 0) _ = pdb.GetDocument(MetadataTokens.DocumentHandle(blob.ReadCompressedInteger()));
}
if (documents != manifest.Documents || methods != manifest.MethodsWithBodies || manifest.Symbols.Select(s => s.Token).Distinct().Count() != manifest.Declarations)
throw new InvalidDataException("Navigation coverage counts do not match manifest");
Console.WriteLine($"Verified source navigation: {documents} embedded documents, {manifest.Declarations} declarations, {methods} method bodies; PE/PDB identity, source checksums and type documents match");
}
internal static void RestoreSources(string sdkDirectory)
{
using var stream = File.OpenRead(Path.Combine(sdkDirectory, "lib", Program.ShellName + ".pdb"));
using var provider = MetadataReaderProvider.FromPortablePdbStream(stream);
var reader = provider.GetMetadataReader();
foreach (var handle in reader.Documents)
{
string path = reader.GetString(reader.GetDocument(handle).Name);
if (File.Exists(path)) continue;
var info = reader.GetCustomDebugInformation(handle).Select(h => reader.GetCustomDebugInformation(h)).Single(c => reader.GetGuid(c.Kind) == EmbeddedSource);
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllBytes(path, DecodeEmbedded(reader.GetBlobBytes(info.Value)));
}
}
private static byte[] DecodeEmbedded(byte[] blob)
{
int length = BitConverter.ToInt32(blob, 0);
if (length == 0) return blob[4..];
using var input = new MemoryStream(blob, 4, blob.Length - 4);
using var gzip = new System.IO.Compression.DeflateStream(input, System.IO.Compression.CompressionMode.Decompress);
using var output = new MemoryStream(); gzip.CopyTo(output);
if (output.Length != length) throw new InvalidDataException("Embedded-source length mismatch");
return output.ToArray();
}
private static void ApplyImplicitDeclarations(Dictionary<int, Declaration> declarations, Mono.Cecil.TypeDefinition type)
{
foreach (var property in type.Properties)
if (declarations.TryGetValue(property.MetadataToken.ToInt32(), out var location))
foreach (var accessor in new[] { property.GetMethod, property.SetMethod }.Where(m => m != null))
declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" });
foreach (var @event in type.Events)
if (declarations.TryGetValue(@event.MetadataToken.ToInt32(), out var location))
foreach (var accessor in new[] { @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod }.Where(m => m != null))
declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" });
if (declarations.TryGetValue(type.MetadataToken.ToInt32(), out var declaration))
{
foreach (var constructor in type.Methods.Where(m => m.IsConstructor && !declarations.ContainsKey(m.MetadataToken.ToInt32())))
declarations.TryAdd(constructor.MetadataToken.ToInt32(), declaration with { Token = constructor.MetadataToken.ToInt32(), Name = constructor.FullName, Kind = "Method" });
foreach (var field in type.Fields.Where(f => f.IsRuntimeSpecialName))
declarations.TryAdd(field.MetadataToken.ToInt32(), declaration with { Token = field.MetadataToken.ToInt32(), Name = field.FullName, Kind = "Field" });
}
foreach (var nested in type.NestedTypes) ApplyImplicitDeclarations(declarations, nested);
}
private static IEnumerable<TypeDefinitionHandle> Descendants(MetadataReader reader, TypeDefinitionHandle handle)
{
yield return handle;
foreach (var child in reader.GetTypeDefinition(handle).GetNestedTypes())
foreach (var nested in Descendants(reader, child)) yield return nested;
}
private static string PathFor(MetadataReader reader, TypeDefinitionHandle handle)
{
var type = reader.GetTypeDefinition(handle);
static string Safe(string name) => string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' ? c : '_'));
string ns = reader.GetString(type.Namespace), name = reader.GetString(type.Name);
// Token suffix prevents collisions from generic arity, case-insensitive filesystems,
// and names whose invalid filename characters normalize to the same spelling.
return Path.Combine(Safe(ns), Safe(name) + "." + MetadataTokens.GetToken(handle).ToString("X8") + ".cs");
}
private sealed class Worker : IDisposable
{
private readonly PEFile file;
private readonly UniversalAssemblyResolver resolver;
private readonly CSharpDecompiler primary;
private readonly CSharpDecompiler raw;
private readonly DecompilerSettings primarySettings;
private readonly DecompilerSettings rawSettings;
internal Worker(string path, string dependencies)
{
file = new PEFile(path, new MemoryStream(File.ReadAllBytes(path)), PEStreamOptions.PrefetchEntireImage);
resolver = new UniversalAssemblyResolver(path, true, file.DetectTargetFrameworkId());
resolver.AddSearchDirectory(dependencies);
primarySettings = Settings(false); rawSettings = Settings(true);
primary = new CSharpDecompiler(file, resolver, primarySettings);
raw = new CSharpDecompiler((DecompilerTypeSystem)primary.TypeSystem, rawSettings);
}
private static DecompilerSettings Settings(bool raw) => new()
{
ThrowOnAssemblyResolveErrors = true, UseDebugSymbols = false, ShowXmlDocumentation = false,
UseNestedDirectoriesForNamespaces = true, AnonymousMethods = !raw, AnonymousTypes = !raw,
AsyncAwait = !raw, YieldReturn = !raw, AutomaticProperties = !raw, AutomaticEvents = !raw,
GetterOnlyAutomaticProperties = !raw, UseExpressionBodyForCalculatedGetterOnlyProperties = !raw
};
internal Source Decompile(TypeDefinitionHandle type, string path, bool raw)
{
var decompiler = raw ? this.raw : primary;
return Render(decompiler, decompiler.DecompileTypes(new[] { type }), path, raw);
}
internal Source DecompileMember(EntityHandle member, string path)
{
var source = Render(raw, raw.Decompile(member), path, true);
int token = MetadataTokens.GetToken(member);
if (!source.Declarations.ContainsKey(token))
{
var text = Encoding.UTF8.GetString(source.Content);
source.Declarations[token] = new Declaration(token, "Metadata declaration 0x" + token.ToString("X8"), member.Kind.ToString(), path, 2, 1, text.Count(c => c == '\n') + 1, 1, true);
}
return source;
}
private Source Render(CSharpDecompiler decompiler, SyntaxTree tree, string path, bool generated)
{
// CLR compiler-generated names such as <Run>d__0 are not legal C#.
// Keep token identity in the index/PDB while spelling those identifiers
// legally in the generated view, so one state machine cannot break the
// semantic parser and navigation for all ordinary members in its file.
foreach (var identifier in tree.DescendantsAndSelf.OfType<Identifier>())
{
string name = identifier.Name;
if (name.Any(c => !(char.IsLetterOrDigit(c) || c == '_')))
identifier.Name = "__generated_" + string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c.ToString() : "u" + ((int)c).ToString("X4")));
}
tree.InsertChildAfter(null, new Comment(" Decompiled from the matching game DLL using the shared BD2 names table. For navigation; not compiled or executed."), Roles.Comment);
using var text = new StringWriter(System.Globalization.CultureInfo.InvariantCulture);
var writer = TokenWriter.WrapInWriterThatSetsLocationsInAST(new TextWriterTokenWriter(text));
tree.AcceptVisitor(new CSharpOutputVisitor(writer, (generated ? rawSettings : primarySettings).CSharpFormattingOptions));
byte[] content = new UTF8Encoding(false).GetBytes(text.ToString());
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllBytes(path, content);
var declarations = new Dictionary<int, Declaration>();
foreach (var node in tree.DescendantsAndSelf)
{
if (node is not EntityDeclaration && node is not VariableInitializer && node is not Accessor) continue;
if (node.GetSymbol() is not IEntity entity || entity.MetadataToken.IsNil) continue;
int token = MetadataTokens.GetToken(entity.MetadataToken);
var start = node is EntityDeclaration declaration && !declaration.NameToken.IsNull ? declaration.NameToken.StartLocation : node.StartLocation;
var end = node.EndLocation;
if (start.Line <= 0 || end.Line < start.Line) continue;
declarations.TryAdd(token, new Declaration(token, entity.ReflectionName, entity.SymbolKind.ToString(), path, start.Line, start.Column, end.Line, end.Column, generated));
}
var points = new Dictionary<int, List<Point>>();
foreach (var function in decompiler.CreateSequencePoints(tree))
{
var method = function.Key.MoveNextMethod ?? function.Key.Method;
if (method == null || method.MetadataToken.IsNil) continue;
int token = MetadataTokens.GetToken(method.MetadataToken);
var sequence = function.Value.Where(p => !p.IsHidden).Select(p => new Point(p.Offset, p.StartLine, p.StartColumn, p.EndLine, p.EndColumn)).ToList();
if (sequence.Count > 0) points.TryAdd(token, sequence);
}
return new Source(path, content, declarations, points);
}
public void Dispose() => file.Dispose();
}
private static (byte[] Bytes, BlobContentId Id) BuildPdb(PEFile pe, ModuleDefinition module, Source[] sources,
Dictionary<int, Declaration> declarations, Dictionary<int, (Source Source, List<Point> Points)> methods, string dependencies)
{
var metadata = new MetadataBuilder();
var documents = new Dictionary<string, DocumentHandle>(StringComparer.Ordinal);
var custom = new List<(EntityHandle Parent, Guid Kind, byte[] Bytes)>();
var csharp = metadata.GetOrAddGuid(CSharpLanguage);
var sha256 = metadata.GetOrAddGuid(new Guid("8829D00F-11B8-4213-878B-770E8597AC16"));
foreach (var source in sources)
{
var document = metadata.AddDocument(metadata.GetOrAddDocumentName(source.Path), sha256, metadata.GetOrAddBlob(SHA256.HashData(source.Content)), csharp);
documents.Add(source.Path, document);
using var stream = new MemoryStream();
using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) writer.Write(source.Content.Length);
using (var deflate = new System.IO.Compression.DeflateStream(stream, System.IO.Compression.CompressionLevel.Fastest, leaveOpen: true)) deflate.Write(source.Content);
custom.Add((document, EmbeddedSource, stream.ToArray()));
}
var methodDefs = module.GetTypes().SelectMany(t => t.Methods).ToDictionary(m => m.MetadataToken.ToInt32());
foreach (var handle in pe.Metadata.MethodDefinitions)
{
int token = MetadataTokens.GetToken(handle);
if (!methods.TryGetValue(token, out var mapped)) { metadata.AddMethodDebugInformation(default, default); continue; }
var points = mapped.Points.Where(p => p.Offset >= 0 && p.Offset < methodDefs[token].Body.CodeSize && p.Line > 0 && p.EndLine >= p.Line)
.GroupBy(p => p.Offset).Select(g => g.First()).OrderBy(p => p.Offset).ToArray();
var blob = new BlobBuilder(); blob.WriteCompressedInteger(0); // no local signature is needed for navigation
int previousOffset = 0, previousLine = 0, previousColumn = 0;
for (int i = 0; i < points.Length; i++)
{
var p = points[i]; blob.WriteCompressedInteger(i == 0 ? p.Offset : p.Offset - previousOffset);
int lines = p.EndLine - p.Line; blob.WriteCompressedInteger(lines);
int columns = p.EndColumn - p.Column;
if (lines == 0) blob.WriteCompressedInteger(Math.Max(1, columns)); else blob.WriteCompressedSignedInteger(columns);
if (i == 0) { blob.WriteCompressedInteger(p.Line); blob.WriteCompressedInteger(p.Column); }
else { blob.WriteCompressedSignedInteger(p.Line - previousLine); blob.WriteCompressedSignedInteger(p.Column - previousColumn); }
previousOffset = p.Offset; previousLine = p.Line; previousColumn = p.Column;
}
metadata.AddMethodDebugInformation(documents[mapped.Source.Path], metadata.GetOrAddBlob(blob));
}
foreach (var type in module.GetTypes())
{
if (!declarations.TryGetValue(type.MetadataToken.ToInt32(), out var d)) continue;
var docs = new BlobBuilder(); docs.WriteCompressedInteger(MetadataTokens.GetRowNumber(documents[d.File]));
custom.Add((MetadataTokens.EntityHandle(type.MetadataToken.ToInt32()), TypeDocuments, docs.ToArray()));
}
var options = Encoding.UTF8.GetBytes("language\0C#\0language-version\0" + "12.0\0compiler-version\0BD2.GameSdk\0output-kind\0DynamicallyLinkedLibrary\0optimization\0debug\0");
custom.Add((MetadataTokens.EntityHandle(1), CompilationOptions, options));
var references = new BlobBuilder();
foreach (var reference in module.AssemblyReferences)
{
string path = Path.Combine(dependencies, reference.Name + ".dll");
if (!File.Exists(path)) continue;
using var stream = File.OpenRead(path); using var dependency = new PEReader(stream);
var reader = dependency.GetMetadataReader();
references.WriteBytes(Encoding.UTF8.GetBytes(Path.GetFileName(path))); references.WriteByte(0);
references.WriteByte(0); // global alias
references.WriteByte(1); // combined embedInteropTypes=false, image kind=assembly
references.WriteUInt32((uint)dependency.PEHeaders.CoffHeader.TimeDateStamp);
references.WriteUInt32((uint)dependency.PEHeaders.PEHeader.SizeOfImage);
references.WriteBytes(reader.GetGuid(reader.GetModuleDefinition().Mvid).ToByteArray());
}
custom.Add((MetadataTokens.EntityHandle(1), CompilationReferences, references.ToArray()));
foreach (var info in custom.OrderBy(c => CodedIndex.HasCustomDebugInformation(c.Parent)))
metadata.AddCustomDebugInformation(info.Parent, metadata.GetOrAddGuid(info.Kind), metadata.GetOrAddBlob(info.Bytes));
var counts = Enumerable.Range(0, 64).Select(i => pe.Metadata.GetTableRowCount((TableIndex)i)).ToImmutableArray();
var builder = new PortablePdbBuilder(metadata, counts, default, blobs =>
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var blob in blobs) hash.AppendData(blob.GetBytes());
return BlobContentId.FromHash(hash.GetHashAndReset());
});
var output = new BlobBuilder(); var id = builder.Serialize(output);
return (output.ToArray(), id);
}
private static void WriteXmlDocumentation(string assembly, PEFile pe, Dictionary<int, Declaration> declarations)
{
var settings = new DecompilerSettings();
var resolver = new UniversalAssemblyResolver(assembly, false, pe.DetectTargetFrameworkId());
var decompiler = new CSharpDecompiler(pe, resolver, settings);
using var writer = System.Xml.XmlWriter.Create(Path.ChangeExtension(assembly, ".xml"), new System.Xml.XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false) });
writer.WriteStartElement("doc"); writer.WriteStartElement("assembly"); writer.WriteElementString("name", Program.ShellName); writer.WriteEndElement(); writer.WriteStartElement("members");
// Method signatures already provide IntelliSense, and PDB supplies exact
// navigation. Repeating the same boilerplate for every member inflated
// each XML file to hundreds of MB without adding API documentation.
foreach (IEntity entity in decompiler.TypeSystem.MainModule.TypeDefinitions)
{
if (entity.MetadataToken.IsNil || !declarations.TryGetValue(MetadataTokens.GetToken(entity.MetadataToken), out var location)) continue;
string id = ICSharpCode.Decompiler.Documentation.IdStringProvider.GetIdString(entity);
writer.WriteStartElement("member"); writer.WriteAttributeString("name", id);
writer.WriteElementString("summary", "Readable game type. Decompiled source: " + Path.GetRelativePath(Path.Combine(Path.GetDirectoryName(assembly), "sources"), location.File) + ":" + location.Line);
writer.WriteElementString("remarks", "Navigation-only reference; the runtime implementation is in the matching Assembly-CSharp.");
writer.WriteEndElement();
}
writer.WriteEndElement(); writer.WriteEndElement();
}
private sealed class NavigationSymbolWriterProvider(BlobContentId id, string pdbPath, byte[] pdb) : ISymbolWriterProvider
{
public ISymbolWriter GetSymbolWriter(ModuleDefinition module, string fileName) => new Writer(id, pdbPath, pdb);
public ISymbolWriter GetSymbolWriter(ModuleDefinition module, Stream symbolStream) => throw new NotSupportedException();
private sealed class Writer(BlobContentId id, string path, byte[] pdb) : ISymbolWriter
{
public ISymbolReaderProvider GetReaderProvider() => new PortablePdbReaderProvider();
public void Write(MethodDebugInformation info) { }
public void Write(ICustomDebugInformationProvider provider) { }
public void Write() { }
public void Dispose() { }
public ImageDebugHeader GetDebugHeader()
{
using var stream = new MemoryStream(); using var writer = new BinaryWriter(stream);
writer.Write(0x53445352); writer.Write(id.Guid.ToByteArray()); writer.Write(1); writer.Write(Encoding.UTF8.GetBytes(path)); writer.Write((byte)0);
byte[] codeview = stream.ToArray();
return new ImageDebugHeader(new[] {
new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.CodeView, MajorVersion = 0x100, MinorVersion = 0x504d, TimeDateStamp = (int)id.Stamp, SizeOfData = codeview.Length }, codeview),
new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.PdbChecksum, SizeOfData = 39 }, Encoding.UTF8.GetBytes("SHA256\0").Concat(SHA256.HashData(pdb)).ToArray())
});
}
}
}
}
+75
View File
@@ -0,0 +1,75 @@
Mono.Cecil 0.11.6
https://github.com/jbevain/cecil
Copyright (c) 2008 - 2015 Jb Evain
Copyright (c) 2008 - 2011 Novell, Inc.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
ICSharpCode.Decompiler 9.1.0.7988
https://github.com/icsharpcode/ILSpy
MIT license
Copyright (c) 2011-2025 AlphaSierraPapa for the ILSpy team
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
System.Collections.Immutable 6.0.0, System.Reflection.Metadata 6.0.0, System.Runtime.CompilerServices.Unsafe 6.0.0
https://github.com/dotnet/runtime
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$GameDir,
[Parameter(Mandatory)] [string]$GameMapping,
[string]$VersionConfig
)
$ErrorActionPreference = 'Stop'
if (-not $VersionConfig) { $VersionConfig = Join-Path $PSScriptRoot '..\..\versions.json' }
$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$staging = Join-Path $repository ('.build\names-update\' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $staging | Out-Null
function Invoke-Dotnet([string[]]$Arguments) {
& dotnet @Arguments
if ($LASTEXITCODE -ne 0) { throw "dotnet failed with exit code $LASTEXITCODE" }
}
Invoke-Dotnet @('build', (Join-Path $PSScriptRoot 'GameSdk.csproj'), '-c', 'Release', '--nologo')
$tool = Join-Path $PSScriptRoot 'bin\Release\net8.0\GameSdk.dll'
$assembly = Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll'
$table = Join-Path $staging 'names.json'
Invoke-Dotnet @($tool, 'names', $assembly, $GameMapping, $VersionConfig, $table)
# Validate the full metadata transformation before replacing the checked-in table.
Invoke-Dotnet @($tool, 'shell', $table, $assembly, (Join-Path $staging 'Assembly-CSharp.Readable.dll'))
$destination = Join-Path $PSScriptRoot '..\GameNames\Mappings\names.json.gz'
Copy-Item -LiteralPath "$table.gz" -Destination $destination -Force
Write-Host "Updated SDK/runtime shared table: $destination. Rebuild SDK and plugins, then pack a new version."
+53
View File
@@ -0,0 +1,53 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$GameDir,
[string]$PackageVersion,
[string]$PackageDirectory
)
$ErrorActionPreference = 'Stop'
if (-not $PackageDirectory) { $PackageDirectory = Join-Path $PSScriptRoot '..\..\.build\nuget' }
if (-not $PackageVersion) {
[xml]$metadata = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\PackageMetadata.props') -Raw
$versions = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\versions.json') -Raw | ConvertFrom-Json
$PackageVersion = "$($metadata.Project.PropertyGroup.BD2PackageVersion)-game.$($versions.game_version)"
}
$PackageDirectory = [IO.Path]::GetFullPath($PackageDirectory)
# A fresh external directory and package cache prevent repository imports and stale
# same-version development packages from making an invalid package appear to work.
$workspace = Join-Path ([IO.Path]::GetTempPath()) ('BD2 NuGet Test ' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $workspace | Out-Null
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'samples\ExamplePlugin\ExamplePlugin.csproj'), (Join-Path $PSScriptRoot 'samples\ExamplePlugin\Plugin.cs') -Destination $workspace
$project = Join-Path $workspace 'ExamplePlugin.csproj'
$projectText = [IO.File]::ReadAllText($project).Replace('Version="0.2.1-game.2.35.10"', "Version=`"$PackageVersion`"")
[IO.File]::WriteAllText($project, $projectText)
$feed = [Security.SecurityElement]::Escape($PackageDirectory)
$cache = [Security.SecurityElement]::Escape((Join-Path $workspace 'packages'))
[IO.File]::WriteAllText((Join-Path $workspace 'NuGet.Config'), "<configuration><packageSources><clear/><add key=`"bd2`" value=`"$feed`"/><add key=`"nuget.org`" value=`"https://api.nuget.org/v3/index.json`"/></packageSources><config><add key=`"globalPackagesFolder`" value=`"$cache`"/></config></configuration>")
function Invoke-Dotnet([string[]]$Arguments) {
& dotnet @Arguments
if ($LASTEXITCODE -ne 0) { throw "dotnet failed with exit code $LASTEXITCODE" }
}
Invoke-Dotnet @('build', $project, '-c', 'Release', '--nologo', "-p:GameDir=$GameDir")
$output = Join-Path $workspace 'bin\Release\netstandard2.1'
$runtime = Join-Path $output 'BD2.GameNames.dll'
foreach ($required in @('BD2.GameNames.dll', 'ExamplePlugin.dll')) {
if (-not (Test-Path -LiteralPath (Join-Path $output $required))) { throw "Missing output: $required" }
}
foreach ($forbidden in @('Assembly-CSharp.Readable.dll', 'Assembly-CSharp.Readable.pdb', 'Assembly-CSharp.dll', 'GameSdk.dll', 'Mono.Cecil.dll', 'ICSharpCode.Decompiler.dll', 'BepInEx.dll', '0Harmony.dll', 'UnityEngine.dll', 'ExamplePlugin.pdb', 'navigation.json', 'sources', 'ref', 'lib')) {
if (Test-Path -LiteralPath (Join-Path $output $forbidden)) { throw "Unexpected deployment artifact: $forbidden" }
}
$tool = Join-Path $workspace "packages\bd2.gamesdk\$PackageVersion\tools\net8.0\GameSdk.dll"
$sdk = Join-Path $workspace 'obj\Release\netstandard2.1\bd2-game-sdk'
$table = Join-Path $sdk 'names.json'
Invoke-Dotnet @($tool, 'verify-runtime', $table, $runtime)
Invoke-Dotnet @($tool, 'verify-navigation', $sdk)
Invoke-Dotnet @($tool, 'verify', $table, (Join-Path $output 'ExamplePlugin.dll'), (Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll'))
# Repeated builds must always transform the readable obj DLL, never reprocess bin.
$navigationProps = Join-Path $sdk 'GameSourceNavigation.props'
$navigationTimestamp = (Get-Item -LiteralPath $navigationProps).LastWriteTimeUtc
Invoke-Dotnet @('build', $project, '-c', 'Release', '--no-restore', '--nologo', "-p:GameDir=$GameDir")
if ((Get-Item -LiteralPath $navigationProps).LastWriteTimeUtc -ne $navigationTimestamp) {
throw 'Unchanged navigation props were rewritten; this triggers repeated IDE reloads.'
}
Invoke-Dotnet @($tool, 'verify', $table, (Join-Path $output 'ExamplePlugin.dll'), (Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll'))
Write-Host "Verified external PackageReference consumer, complete source navigation, shared runtime and incremental rebuild: $workspace"
@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<GameDir>E:\Games\BrownDustII</GameDir>
</PropertyGroup>
</Project>
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>latest</LangVersion>
<AssemblyName>ExamplePlugin</AssemblyName>
<!-- GameDir belongs in local Directory.Build.props or on the command line. -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BD2.GameSdk" Version="0.2.1-game.2.35.10" PrivateAssets="all" />
</ItemGroup>
</Project>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<!-- Repository sample consumes the packages produced by GameSdk/Pack.ps1. -->
<add key="bd2-local" value="../../../../.build/nuget" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
@@ -0,0 +1,38 @@
using System;
using System.Reflection;
using BD2.GameNames;
using BepInEx;
using HarmonyLib;
namespace ExamplePlugin;
[BepInPlugin("example.readable-names", "Readable Names Example", "1.0.0")]
public sealed class Plugin : BaseUnityPlugin
{
private Harmony harmony;
private void Awake()
{
try
{
// Fail before installing any game patches when binary/table/plugin versions differ.
Game.Validate(typeof(Plugin).Assembly, message => Logger.LogInfo(message));
MethodInfo target = Game.Method<IntroUI>(ui => ui.SendMaintenanceInfo(false));
harmony = new Harmony("example.readable-names");
harmony.Patch(target, prefix: new HarmonyMethod(typeof(Plugin), nameof(BeforeMaintenance)));
// Private members require the runtime string channel. The prefix expression above
// and the Unity callback name below belong to separate channels.
var awake = typeof(IntroUI).GetGameMethod("Awake",
BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null);
Logger.LogInfo("Resolved IntroUI.Awake: " + (awake != null));
}
catch (Exception exception)
{
Logger.LogError("Readable Names Example initialization failed: " + exception);
}
}
private static void BeforeMaintenance() { }
private void OnDestroy() => harmony?.UnpatchSelf();
}
+141
View File
@@ -0,0 +1,141 @@
using System;
using BD2.GameNames;
using static BD2.GameNames.Game;
using System.Reflection;
using BepInEx.Logging;
using HarmonyLib;
using UnityEngine;
using System.Diagnostics;
using System.Threading;
namespace Bd2LocalIdentity;
internal static class ClientDiagnostics
{
private static ManualLogSource Log;
internal static void Initialize(ManualLogSource logger) => Log = logger;
internal static void InstallMaintenanceTimeoutGuard(Harmony harmony, Type introUI)
{
MethodInfo finishMaintenance = introUI?.GetGameMethod(
nameof(IntroUI.OnFinishMaintenanceRequest),
BindingFlags.Instance | BindingFlags.Public,
null,
Type.EmptyTypes,
null);
MethodInfo falseTimeoutTelemetry = introUI?.GetGameMethod(
"CheckMaintenanceTimeout",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
if (finishMaintenance == null || falseTimeoutTelemetry == null)
{
throw new MissingMethodException("IntroUI maintenance timeout methods were not found");
}
harmony.Patch(
finishMaintenance,
postfix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(OnFinishMaintenancePostfix)));
harmony.Patch(
falseTimeoutTelemetry,
prefix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(SkipLocalTimeoutTelemetry)));
}
private static void OnFinishMaintenancePostfix(object __instance)
{
// CancelMaintenanceTimeout cancels the active CTS and then
// immediately stores a fresh CTS. Depending on async scheduling, the
// timeout task can capture that fresh token after the successful
// response and emit a false 10-second timeout. Cancel the replacement
// token only after OnFinishMaintenanceRequest has completed.
FieldInfo timeout = __instance.GetType().GetGameField(
"_maintenanceTimeoutCts",
BindingFlags.Instance | BindingFlags.NonPublic);
CancellationTokenSource source = timeout?.GetValue(__instance) as CancellationTokenSource;
source?.Cancel();
Log?.LogInfo("Maintenance timeout guard cancelled after successful response");
}
private static bool SkipLocalTimeoutTelemetry()
{
// This method only emits intro_server_info_timeout telemetry after ten
// seconds. Network request failures have their own callbacks. In
// In the configured client it can outlive a successful local maintenance response.
return false;
}
internal static void InstallDatabaseDiagnostics(Harmony harmony)
{
Type rawDataManager = typeof(RawDataManager);
MethodInfo dbLoad = rawDataManager?.GetGameMethod(
nameof(RawDataManager.DBLoad),
BindingFlags.Instance | BindingFlags.Public);
if (dbLoad == null)
{
throw new MissingMethodException("RawDataManager.DBLoad was not found");
}
harmony.Patch(
dbLoad,
prefix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(DBLoadPrefix)));
Type clientLocalInfo = typeof(Proto.Local.ClientLocalInfo);
MethodInfo loadDB = clientLocalInfo?.GetGameMethod(
nameof(Proto.Local.ClientLocalInfo.LoadDB),
BindingFlags.Static | BindingFlags.Public);
if (loadDB == null)
{
throw new MissingMethodException("ClientLocalInfo.LoadDB was not found");
}
harmony.Patch(
loadDB,
prefix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(ClientLocalLoadPrefix)),
postfix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(ClientLocalLoadPostfix)),
finalizer: new HarmonyMethod(typeof(ClientDiagnostics), nameof(ClientLocalLoadFinalizer)));
Log?.LogInfo("Database diagnostics active");
}
private static void DBLoadPrefix(RawDataManager.DBOption __0, ref Action __1)
{
string dbName = __0.dbName ?? "<unknown>";
Action original = __1;
Stopwatch elapsed = Stopwatch.StartNew();
Log?.LogInfo("DBLoad start: " + dbName);
__1 = delegate
{
Log?.LogInfo("DBLoad callback enter: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)");
try
{
original?.Invoke();
}
finally
{
Log?.LogInfo("DBLoad callback exit: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)");
}
};
}
private static void ClientLocalLoadPrefix(out Stopwatch __state)
{
__state = Stopwatch.StartNew();
Log?.LogInfo("ClientLocalInfo.LoadDB enter");
}
private static void ClientLocalLoadPostfix(Stopwatch __state)
{
Log?.LogInfo("ClientLocalInfo.LoadDB exit (" + __state.ElapsedMilliseconds + " ms)");
}
private static Exception ClientLocalLoadFinalizer(Exception __exception, Stopwatch __state)
{
if (__exception != null)
{
Log?.LogError(
"ClientLocalInfo.LoadDB threw after " + __state.ElapsedMilliseconds + " ms: " + __exception);
}
return __exception;
}
}
+147
View File
@@ -0,0 +1,147 @@
using System;
using BD2.GameNames;
using static BD2.GameNames.Game;
using System.Reflection;
using BepInEx.Logging;
using HarmonyLib;
using UnityEngine;
namespace Bd2LocalIdentity;
internal static class ClientPresentation
{
private static ManualLogSource Log;
private static MethodInfo SwitchToFullScreenMethod;
internal static void Initialize(ManualLogSource logger) => Log = logger;
internal static void InstallStartupFullscreen(Harmony harmony, Type appManager)
{
MethodInfo initializeResolution = appManager?.GetGameMethod(
"InitPCResolution",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
SwitchToFullScreenMethod = appManager?.GetGameMethod(
nameof(AppManager.SwitchToFullScreen),
BindingFlags.Instance | BindingFlags.Public,
null,
new[] { typeof(bool) },
null);
if (initializeResolution == null || SwitchToFullScreenMethod == null)
{
throw new MissingMethodException("AppManager fullscreen methods were not found (client version mismatch)");
}
harmony.Patch(
initializeResolution,
postfix: new HarmonyMethod(typeof(ClientPresentation), nameof(InitializeResolutionPostfix)));
Log?.LogInfo("Native startup fullscreen patch installed");
}
private static void InitializeResolutionPostfix(object __instance)
{
try
{
// Use the game's own FullScreenWindow path so the behaviour is
// identical on Windows and macOS. This runs once during startup;
// later user-initiated switches to windowed mode remain intact.
SwitchToFullScreenMethod?.Invoke(__instance, new object[] { false });
}
catch (Exception ex)
{
Log?.LogError("Could not apply native startup fullscreen: " + ex);
}
}
internal static void InstallPerformanceOverlaySuppression(Harmony harmony)
{
Type fpsCheck = typeof(FPS_Check);
MethodInfo start = fpsCheck?.GetGameMethod(
nameof(FPS_Check.StartFPS),
BindingFlags.Instance | BindingFlags.Public,
null,
Type.EmptyTypes,
null);
MethodInfo render = fpsCheck?.GetGameMethod(
"TextRender",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
MethodInfo onEnable = fpsCheck?.GetGameMethod(
"OnEnable",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
MethodInfo onInitialize = fpsCheck?.GetGameMethod(
nameof(FPS_Check.OnInitialize),
BindingFlags.Instance | BindingFlags.Public,
null,
Type.EmptyTypes,
null);
if (start == null || render == null || onEnable == null || onInitialize == null)
{
throw new MissingMethodException("FPS_Check overlay methods were not found (client version mismatch)");
}
HarmonyMethod skip = new HarmonyMethod(typeof(ClientPresentation), nameof(SkipPerformanceOverlay));
HarmonyMethod hide = new HarmonyMethod(typeof(ClientPresentation), nameof(HidePerformanceOverlayPostfix));
harmony.Patch(start, prefix: skip);
harmony.Patch(render, prefix: skip);
harmony.Patch(onEnable, postfix: hide);
harmony.Patch(onInitialize, postfix: hide);
Log?.LogInfo("FPS_Check performance overlay disabled");
}
private static bool SkipPerformanceOverlay(object __instance)
{
HidePerformanceOverlay(__instance);
return false;
}
private static void HidePerformanceOverlayPostfix(object __instance)
{
HidePerformanceOverlay(__instance);
}
private static void HidePerformanceOverlay(object instance)
{
if (instance == null)
{
return;
}
try
{
MethodInfo disposeRecorders = instance.GetType().GetGameMethod(
nameof(FPS_Check.DisposeRecorders),
BindingFlags.Instance | BindingFlags.Public,
null,
Type.EmptyTypes,
null);
disposeRecorders?.Invoke(instance, null);
FieldInfo textField = instance.GetType().GetGameField(
"_text",
BindingFlags.Instance | BindingFlags.Public);
object text = textField?.GetValue(instance);
PropertyInfo textProperty = text?.GetType().GetGameProperty(
"text",
BindingFlags.Instance | BindingFlags.Public);
if (textProperty?.CanWrite == true)
{
textProperty.SetValue(text, string.Empty);
}
if (text is Component component && component != null)
{
component.gameObject.SetActive(false);
}
}
catch (Exception ex)
{
Log?.LogError("Could not suppress FPS_Check overlay: " + ex);
}
}
}
+54 -37
View File
@@ -1,4 +1,8 @@
using System;
using System.Reflection;
using BD2.GameNames;
using static BD2.GameNames.Game;
using HarmonyLib;
using System.IO;
using System.Net;
using System.Text;
@@ -11,6 +15,49 @@ namespace Bd2LocalIdentity;
internal sealed class ClientRouting : IDisposable
{
private static ClientRouting installedRouting;
private static ManualLogSource routingLog;
internal void Install(Harmony harmony)
{
installedRouting = this;
routingLog = log;
MethodInfo load = Game.Method(() => BDNetwork.ServerURLInfo.Load());
MethodInfo sendMaintenance = Game.Method<IntroUI>(ui => ui.SendMaintenanceInfo(false));
MethodInfo makeCDNInfo = Game.Method(() => BDNetwork.CommonPacket.MakeCDNInfo(null));
if (load == null || sendMaintenance == null || makeCDNInfo == null)
{
throw new MissingMethodException("2.35.10 server/resource routing methods were not found");
}
harmony.Patch(load, postfix: new HarmonyMethod(typeof(ClientRouting), nameof(ApplyServerOriginHook)));
harmony.Patch(sendMaintenance, prefix: new HarmonyMethod(typeof(ClientRouting), nameof(ApplyServerOriginHook)));
harmony.Patch(makeCDNInfo, postfix: new HarmonyMethod(typeof(ClientRouting), nameof(ApplyResourcesHook)));
routingLog?.LogInfo("Server origin and native CdnInfo routing patches installed");
}
private static void ApplyServerOriginHook()
{
ClientRouting routing = installedRouting;
if (routing == null)
{
routingLog?.LogError("Client routing is unavailable while applying the server origin");
return;
}
routing.ApplyServerOrigin();
}
private static void ApplyResourcesHook()
{
ClientRouting routing = installedRouting;
if (routing == null)
{
routingLog?.LogError("Client routing is unavailable while applying resources");
return;
}
routing.ApplyResources();
}
private const string ConfigFileName = "bd2.client.json";
private const string OfficialServerData = "https://bd2-cdn.akamaized.net/ServerData";
private const string OfficialGameData = "https://bd2-cdn.akamaized.net/GameData";
@@ -90,44 +137,24 @@ internal sealed class ClientRouting : IDisposable
public void Dispose()
{
if (ReferenceEquals(installedRouting, this)) installedRouting = null;
localResourceServer?.Dispose();
}
internal void ApplyServerOrigin()
{
Type serverURLInfo = Plugin.FindType("ὫὡὩὤὣὨὯὭὥὠὩ") ?? Plugin.FindType("BDNetwork.ServerURLInfo");
var setUriDirectly = serverURLInfo?.GetMethod(
"ὬὠὢὮὪὮὭὫὮὡὥ",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public,
null,
new[] { typeof(Uri) },
null) ?? serverURLInfo?.GetMethod(
"SetUriDirectly",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public,
null,
new[] { typeof(Uri) },
null);
if (setUriDirectly == null)
{
throw new MissingMethodException("BDNetwork.ServerURLInfo.SetUriDirectly(Uri) was not found");
}
Uri gameEndpoint = new Uri(new Uri(config.server_origin + "/"), "game/");
setUriDirectly.Invoke(null, new object[] { gameEndpoint });
BDNetwork.ServerURLInfo.SetUriDirectly(gameEndpoint);
log.LogInfo("Client server origin applied: " + config.server_origin);
}
internal void ApplyResources()
{
Type cdnInfo = Plugin.FindType("BDNetwork.CdnInfo");
if (cdnInfo == null)
{
throw new TypeLoadException("BDNetwork.CdnInfo was not found");
}
SetStaticString(cdnInfo, "Info", resources.server_data_url);
SetStaticString(cdnInfo, "Version", resources.bundle_version);
SetStaticString(cdnInfo, "SoundVersion", resources.bundle_version);
SetStaticString(cdnInfo, "GameDataInfo", resources.game_data_url);
SetStaticString(cdnInfo, "GameDataVersion", resources.game_data_version);
BDNetwork.CdnInfo.Info = resources.server_data_url;
BDNetwork.CdnInfo.Version = resources.bundle_version;
BDNetwork.CdnInfo.SoundVersion = resources.bundle_version;
BDNetwork.CdnInfo.GameDataInfo = resources.game_data_url;
BDNetwork.CdnInfo.GameDataVersion = resources.game_data_version;
log.LogInfo("Client resources applied: mode=" + resources.mode +
" server_data=" + resources.server_data_url +
" game_data=" + resources.game_data_url +
@@ -370,16 +397,6 @@ internal sealed class ClientRouting : IDisposable
return (string)token;
}
private static void SetStaticString(Type type, string propertyName, string value)
{
var property = type.GetProperty(propertyName, System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public);
if (property?.PropertyType != typeof(string) || property.GetSetMethod() == null)
{
throw new MissingMemberException(type.FullName + "." + propertyName + " was not found");
}
property.SetValue(null, value, null);
}
private sealed class ClientConfig
{
public int schema_version;
+163
View File
@@ -0,0 +1,163 @@
using System;
using BD2.GameNames;
using static BD2.GameNames.Game;
using System.Reflection;
using BepInEx.Logging;
using HarmonyLib;
using UnityEngine;
using System.Linq;
namespace Bd2LocalIdentity;
internal static class LocalAccountPolicy
{
private static ManualLogSource Log;
internal static void Initialize(ManualLogSource logger) => Log = logger;
internal static void InstallAgeGatePersistence(Harmony harmony)
{
// LoginUserResponse field 13 is the client's sole gate for opening
// AgeGatePopupUI. Retain the original first-run UI and request; only
// change a later LoginUser parse after its successful local state has
// been read from disk.
Type commonPacket = typeof(BDNetwork.CommonPacket);
MethodInfo updateAgeGate = commonPacket?.GetGameMethod(
nameof(BDNetwork.CommonPacket.SendUpdateAgeGateRequest),
BindingFlags.Static | BindingFlags.Public,
null,
new[] { typeof(bool), typeof(int), typeof(int), typeof(int), typeof(Action) },
null);
if (updateAgeGate == null)
{
throw new MissingMethodException("CommonPacket.SendUpdateAgeGateRequest(bool, int, int, int, Action) was not found");
}
Type loginUserResponse = typeof(Proto.Net.LoginUserResponse);
MethodInfo needsAgeVerificationSetter = loginUserResponse?.GetGameProperty(
nameof(Proto.Net.LoginUserResponse.NeedsAgeVerification),
BindingFlags.Instance | BindingFlags.Public)?.GetSetMethod();
if (needsAgeVerificationSetter == null)
{
throw new MissingMethodException("LoginUserResponse.NeedsAgeVerification setter was not found");
}
harmony.Patch(
updateAgeGate,
prefix: new HarmonyMethod(typeof(LocalAccountPolicy), nameof(UpdateAgeGateRequestPrefix)));
harmony.Patch(
needsAgeVerificationSetter,
prefix: new HarmonyMethod(typeof(LocalAccountPolicy), nameof(NeedsAgeVerificationSetterPrefix)));
Log?.LogInfo("Local age-gate confirmation persistence active (confirmed=" + AgeGateState.IsConfirmed() + ")");
}
private static void NeedsAgeVerificationSetterPrefix(ref bool value)
{
if (value && AgeGateState.IsConfirmed())
{
value = false;
Log?.LogInfo("Used persisted local age-gate confirmation for LoginUser");
}
}
private static void UpdateAgeGateRequestPrefix(ref Action __4)
{
// CommonPacket invokes this callback only after it has parsed the
// empty UpdateAgeGateResponse and accepted errorType == 0. Wrapping
// it therefore never records failed/cancelled submissions.
Action continuation = __4;
__4 = delegate
{
try
{
AgeGateState.MarkConfirmed();
Log?.LogInfo("Stored successful local age-gate confirmation");
}
catch (Exception ex)
{
// Preserve the original continuation: inability to persist
// should not break a successfully completed first login.
Log?.LogWarning("Could not persist local age-gate confirmation: " + ex.Message);
}
continuation?.Invoke();
};
}
internal static void InstallLocalPurchaseBypass(Harmony harmony)
{
Type platformRuler = typeof(gamfs.Platform.PlatformRuler);
MethodInfo getProducts = platformRuler?
.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.SingleOrDefault(method =>
method.IsGameMethod(nameof(gamfs.Platform.PlatformRuler.GetProductAsync)) &&
method.GetParameters().Length == 6 &&
method.GetParameters()[0].ParameterType == typeof(string[]) &&
method.GetParameters()[3].ParameterType == typeof(Action) &&
method.GetParameters()[4].ParameterType == typeof(Action<int, int, string>) &&
method.GetParameters()[5].ParameterType == typeof(bool));
if (getProducts == null)
{
throw new MissingMethodException("PlatformRuler.GetProductAsync was not found");
}
harmony.Patch(
getProducts,
prefix: new HarmonyMethod(typeof(LocalAccountPolicy), nameof(GetProductsPrefix)));
Type platformManager = typeof(gamfs.Platform.PlatformManager);
MethodInfo purchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.SingleOrDefault(method => method.IsGameMethod(nameof(gamfs.Platform.PlatformManager.Purchase)) &&
method.GetParameters().Length == 3 &&
method.GetParameters()[0].ParameterType == typeof(string) &&
method.GetParameters()[2].ParameterType == typeof(Action));
MethodInfo finishPurchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.SingleOrDefault(method => method.IsGameMethod(nameof(gamfs.Platform.PlatformManager.FinishPurchase)) &&
method.GetParameters().Length == 2 &&
method.GetParameters()[0].ParameterType == typeof(string) &&
method.GetParameters()[1].ParameterType == typeof(long));
if (purchase == null || finishPurchase == null)
throw new MissingMethodException("PlatformManager purchase methods were not found");
harmony.Patch(purchase, prefix: new HarmonyMethod(
typeof(LocalAccountPolicy), nameof(LocalPurchasePrefix)));
harmony.Patch(finishPurchase, prefix: new HarmonyMethod(
typeof(LocalAccountPolicy), nameof(FinishLocalPurchasePrefix)));
Log?.LogInfo("Local purchase price lookup disabled");
Log?.LogInfo("Infinite reroll confirmation is local/free; all other paid purchases are blocked");
}
private static bool GetProductsPrefix(Action __3)
{
// A private server has no Neon/GPG commerce identity. Treat price
// prefetch as complete so startup can continue without contacting the
// production payment API. No purchase result or currency is forged.
__3?.Invoke();
return false;
}
private static bool LocalPurchasePrefix(string __0, object __1, Action __2)
{
// Product 9100033 is the infinite-reroll confirmation. The
// local server grants the last preview through CashShopBuy without
// contacting Neon/GPG. No other real-money product is authorized.
if (__0 == "brd2_limited_pack_660" || __0 == "brd2_limited_pack_660_ios")
{
object result = new gamfs.Platform.PurchaseData(__0, 1L,
"bd2-local-free-infinite", "bd2-local-free-receipt");
Log?.LogInfo("Approved local/free infinite-reroll confirmation");
(__1 as Delegate)?.DynamicInvoke(result);
return false;
}
Log?.LogWarning("Blocked unsupported paid product: " + (__0 ?? "<null>"));
__2?.Invoke();
return false;
}
private static bool FinishLocalPurchasePrefix(string __0)
{
if (__0 == "bd2-local-free-infinite")
{
Log?.LogInfo("Finished local/free infinite-reroll confirmation");
return false;
}
return true;
}
}
@@ -6,12 +6,7 @@
<AssemblyName>BD2LocalIdentity</AssemblyName>
<RootNamespace>Bd2LocalIdentity</RootNamespace>
<BD2PluginVersionKey>local_identity</BD2PluginVersionKey>
<BD2ManagedDir Condition="'$(BD2ManagedDir)' == ''">$(GameDir)\BrownDust II_Data\Managed</BD2ManagedDir>
<BD2BepInExDir Condition="'$(BD2BepInExDir)' == ''">$(GameDir)\BepInEx</BD2BepInExDir>
</PropertyGroup>
<Target Name="RequireGameDir" BeforeTargets="ResolveReferences" Condition="'$(GameDir)' == ''">
<Error Text="GameDir is required. Build with -p:GameDir=&lt;local-client-directory&gt;." />
</Target>
<ItemGroup>
<Reference Include="BepInEx">
<HintPath>$(BD2BepInExDir)\core\BepInEx.dll</HintPath>
@@ -34,5 +29,4 @@
<Private>false</Private>
</Reference>
</ItemGroup>
<Import Project="..\VersionSource.targets" />
</Project>
+20 -510
View File
@@ -1,6 +1,6 @@
using System;
using System.Diagnostics;
using System.Linq;
using BD2.GameNames;
using static BD2.GameNames.Game;
using System.Reflection;
using System.Threading;
using BepInEx;
@@ -18,7 +18,6 @@ public sealed class Plugin : BaseUnityPlugin
public const string Version = Bd2Build.Versions.Plugin;
private static ManualLogSource Log;
private static ClientRouting Routing;
private static MethodInfo SwitchToFullScreenMethod;
private static int ShutdownHooksInstalled;
internal static void LogWarning(string message)
@@ -31,6 +30,7 @@ public sealed class Plugin : BaseUnityPlugin
try
{
Log = Logger;
Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message));
// The non-SDK branch still needs a local bootstrap identity for
// MaintenanceInfo. OAuth LoginUI replaces this value after its
// browser/device transaction completes.
@@ -42,31 +42,32 @@ public sealed class Plugin : BaseUnityPlugin
AppDomain.CurrentDomain.ProcessExit += delegate { DisposeRouting(); };
AppDomain.CurrentDomain.DomainUnload += delegate { DisposeRouting(); };
}
Type appManager = FindType("AppManager");
PropertyInfo useSdk = appManager?.GetProperty(
"ὬὦὠὫὡὥὥὦὠὠὠ",
BindingFlags.Instance | BindingFlags.Public);
MethodInfo getter = useSdk?.GetGetMethod();
Type appManager = typeof(AppManager);
MethodInfo getter = Game.Getter<AppManager, bool>(app => app.IsPlatformLogin);
if (getter == null || getter.ReturnType != typeof(bool) || getter.GetParameters().Length != 0)
{
throw new MissingMethodException("AppManager.UseSdk getter was not found (client version mismatch)");
throw new MissingMethodException("AppManager.IsPlatformLogin getter was not found (client version mismatch)");
}
MethodInfo prefix = typeof(Plugin).GetMethod(
MethodInfo prefix = typeof(Plugin).GetGameMethod(
nameof(UseSdkPrefix),
BindingFlags.Static | BindingFlags.NonPublic);
Harmony harmony = new Harmony(Guid);
harmony.Patch(getter, prefix: new HarmonyMethod(prefix));
TryInstall("OS time zone device country", () => SystemTimeZoneRegion.Install(harmony, Logger));
Type introUI = FindType("IntroUI");
InstallClientRouting(harmony, introUI);
TryInstall("startup fullscreen", () => InstallStartupFullscreen(harmony, appManager));
TryInstall("performance overlay suppression", () => InstallPerformanceOverlaySuppression(harmony));
TryInstall("maintenance timeout guard", () => InstallMaintenanceTimeoutGuard(harmony, introUI));
TryInstall("age-gate persistence", () => InstallAgeGatePersistence(harmony));
TryInstall("local purchase bypass", () => InstallLocalPurchaseBypass(harmony));
TryInstall("database diagnostics", () => InstallDatabaseDiagnostics(harmony));
Logger.LogInfo("Local identity active: AppManager.UseSdk => false");
ClientPresentation.Initialize(Logger);
LocalAccountPolicy.Initialize(Logger);
ClientDiagnostics.Initialize(Logger);
Type introUI = typeof(IntroUI);
Routing.Install(harmony);
TryInstall("startup fullscreen", () => ClientPresentation.InstallStartupFullscreen(harmony, appManager));
TryInstall("performance overlay suppression", () => ClientPresentation.InstallPerformanceOverlaySuppression(harmony));
TryInstall("maintenance timeout guard", () => ClientDiagnostics.InstallMaintenanceTimeoutGuard(harmony, introUI));
TryInstall("age-gate persistence", () => LocalAccountPolicy.InstallAgeGatePersistence(harmony));
TryInstall("local purchase bypass", () => LocalAccountPolicy.InstallLocalPurchaseBypass(harmony));
TryInstall("database diagnostics", () => ClientDiagnostics.InstallDatabaseDiagnostics(harmony));
Logger.LogInfo("Local identity active: AppManager.IsPlatformLogin => false");
}
catch (Exception ex)
{
@@ -103,496 +104,5 @@ public sealed class Plugin : BaseUnityPlugin
return false;
}
private static void InstallStartupFullscreen(Harmony harmony, Type appManager)
{
MethodInfo initializeResolution = appManager?.GetMethod(
"ὪὣὬὤὪὥὣὨὤὦὧ",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
SwitchToFullScreenMethod = appManager?.GetMethod(
"SwitchToFullScreen",
BindingFlags.Instance | BindingFlags.Public,
null,
new[] { typeof(bool) },
null);
if (initializeResolution == null || SwitchToFullScreenMethod == null)
{
throw new MissingMethodException("AppManager fullscreen methods were not found (client version mismatch)");
}
harmony.Patch(
initializeResolution,
postfix: new HarmonyMethod(typeof(Plugin), nameof(InitializeResolutionPostfix)));
Log?.LogInfo("Native startup fullscreen patch installed");
}
private static void InitializeResolutionPostfix(object __instance)
{
try
{
// Use the game's own FullScreenWindow path so the behaviour is
// identical on Windows and macOS. This runs once during startup;
// later user-initiated switches to windowed mode remain intact.
SwitchToFullScreenMethod?.Invoke(__instance, new object[] { false });
}
catch (Exception ex)
{
Log?.LogError("Could not apply native startup fullscreen: " + ex);
}
}
private static void InstallPerformanceOverlaySuppression(Harmony harmony)
{
Type fpsCheck = FindType("FPS_Check");
MethodInfo start = fpsCheck?.GetMethod(
"StartFPS",
BindingFlags.Instance | BindingFlags.Public,
null,
Type.EmptyTypes,
null);
MethodInfo render = fpsCheck?.GetMethod(
"ὬὡὡὬὢὭὦὣὥὬὯ",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
MethodInfo onEnable = fpsCheck?.GetMethod(
"OnEnable",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
MethodInfo onInitialize = fpsCheck?.GetMethod(
"OnInitialize",
BindingFlags.Instance | BindingFlags.Public,
null,
Type.EmptyTypes,
null);
if (start == null || render == null || onEnable == null || onInitialize == null)
{
throw new MissingMethodException("FPS_Check overlay methods were not found (client version mismatch)");
}
HarmonyMethod skip = new HarmonyMethod(typeof(Plugin), nameof(SkipPerformanceOverlay));
HarmonyMethod hide = new HarmonyMethod(typeof(Plugin), nameof(HidePerformanceOverlayPostfix));
harmony.Patch(start, prefix: skip);
harmony.Patch(render, prefix: skip);
harmony.Patch(onEnable, postfix: hide);
harmony.Patch(onInitialize, postfix: hide);
Log?.LogInfo("FPS_Check performance overlay disabled");
}
private static bool SkipPerformanceOverlay(object __instance)
{
HidePerformanceOverlay(__instance);
return false;
}
private static void HidePerformanceOverlayPostfix(object __instance)
{
HidePerformanceOverlay(__instance);
}
private static void HidePerformanceOverlay(object instance)
{
if (instance == null)
{
return;
}
try
{
MethodInfo disposeRecorders = instance.GetType().GetMethod(
"DisposeRecorders",
BindingFlags.Instance | BindingFlags.Public,
null,
Type.EmptyTypes,
null);
disposeRecorders?.Invoke(instance, null);
FieldInfo textField = instance.GetType().GetField(
"_text",
BindingFlags.Instance | BindingFlags.Public);
object text = textField?.GetValue(instance);
PropertyInfo textProperty = text?.GetType().GetProperty(
"text",
BindingFlags.Instance | BindingFlags.Public);
if (textProperty?.CanWrite == true)
{
textProperty.SetValue(text, string.Empty);
}
if (text is Component component && component != null)
{
component.gameObject.SetActive(false);
}
}
catch (Exception ex)
{
Log?.LogError("Could not suppress FPS_Check overlay: " + ex);
}
}
private static void InstallClientRouting(Harmony harmony, Type introUI)
{
Type serverURLInfo = FindType("ὫὡὩὤὣὨὯὭὥὠὩ") ?? FindType("BDNetwork.ServerURLInfo");
MethodInfo load = serverURLInfo?.GetMethod(
"ὣὪὦὮὦὠὪὯὧὯὭ",
BindingFlags.Static | BindingFlags.Public,
null,
Type.EmptyTypes,
null) ?? serverURLInfo?.GetMethod(
"Load",
BindingFlags.Static | BindingFlags.Public,
null,
Type.EmptyTypes,
null);
MethodInfo sendMaintenance = introUI?.GetMethod(
"SendMaintenanceInfo",
BindingFlags.Instance | BindingFlags.Public,
null,
new[] { typeof(bool) },
null);
Type commonPacket = FindType("ὣὡὧὡὦὣὣὬὨὪὫ");
MethodInfo makeCDNInfo = commonPacket?.GetMethod(
"ὥὢὨὡὪὨὥὥὪὩὠ",
BindingFlags.Static | BindingFlags.Public,
null,
new[] { typeof(string) },
null);
if (load == null || sendMaintenance == null || makeCDNInfo == null)
{
throw new MissingMethodException("2.35.10 server/resource routing methods were not found");
}
harmony.Patch(load, postfix: new HarmonyMethod(typeof(Plugin), nameof(ApplyServerOrigin)));
harmony.Patch(sendMaintenance, prefix: new HarmonyMethod(typeof(Plugin), nameof(ApplyServerOrigin)));
harmony.Patch(makeCDNInfo, postfix: new HarmonyMethod(typeof(Plugin), nameof(ApplyResources)));
Log?.LogInfo("Server origin and native CdnInfo routing patches installed");
}
private static void ApplyServerOrigin()
{
ClientRouting routing = Routing;
if (routing == null)
{
Log?.LogError("Client routing is unavailable while applying the server origin");
return;
}
routing.ApplyServerOrigin();
}
private static void ApplyResources()
{
ClientRouting routing = Routing;
if (routing == null)
{
Log?.LogError("Client routing is unavailable while applying resources");
return;
}
routing.ApplyResources();
}
private static void InstallMaintenanceTimeoutGuard(Harmony harmony, Type introUI)
{
MethodInfo finishMaintenance = introUI?.GetMethod(
"OnFinishMaintenanceRequest",
BindingFlags.Instance | BindingFlags.Public,
null,
Type.EmptyTypes,
null);
MethodInfo falseTimeoutTelemetry = introUI?.GetMethod(
"ὡὡὤὧὫὥὬὯὣὠὬ",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Type.EmptyTypes,
null);
if (finishMaintenance == null || falseTimeoutTelemetry == null)
{
throw new MissingMethodException("IntroUI maintenance timeout methods were not found");
}
harmony.Patch(
finishMaintenance,
postfix: new HarmonyMethod(typeof(Plugin), nameof(OnFinishMaintenancePostfix)));
harmony.Patch(
falseTimeoutTelemetry,
prefix: new HarmonyMethod(typeof(Plugin), nameof(SkipLocalTimeoutTelemetry)));
}
private static void OnFinishMaintenancePostfix(object __instance)
{
// CancelMaintenanceTimeout cancels the active CTS and then
// immediately stores a fresh CTS. Depending on async scheduling, the
// timeout task can capture that fresh token after the successful
// response and emit a false 10-second timeout. Cancel the replacement
// token only after OnFinishMaintenanceRequest has completed.
FieldInfo timeout = __instance.GetType().GetField(
"ὫὥὨὨὠὯὥὭὨὨὪ",
BindingFlags.Instance | BindingFlags.NonPublic);
CancellationTokenSource source = timeout?.GetValue(__instance) as CancellationTokenSource;
source?.Cancel();
Log?.LogInfo("Maintenance timeout guard cancelled after successful response");
}
private static bool SkipLocalTimeoutTelemetry()
{
// This method only emits intro_server_info_timeout telemetry after ten
// seconds. Network request failures have their own callbacks. In
// In the configured client it can outlive a successful local maintenance response.
return false;
}
private static void InstallAgeGatePersistence(Harmony harmony)
{
// LoginUserResponse field 13 is the client's sole gate for opening
// AgeGatePopupUI. Retain the original first-run UI and request; only
// change a later LoginUser parse after its successful local state has
// been read from disk.
Type commonPacket = FindType("ὣὡὧὡὦὣὣὬὨὪὫ");
MethodInfo updateAgeGate = commonPacket?.GetMethod(
"ὪὯὭὣὨὡὬὪὭὨὡ",
BindingFlags.Static | BindingFlags.Public,
null,
new[] { typeof(bool), typeof(int), typeof(int), typeof(int), typeof(Action) },
null);
if (updateAgeGate == null)
{
throw new MissingMethodException("CommonPacket.SendUpdateAgeGateRequest(bool, int, int, int, Action) was not found");
}
Type loginUserResponse = FindType("Proto.Net.LoginUserResponse");
MethodInfo needsAgeVerificationSetter = loginUserResponse?.GetProperty(
"NeedsAgeVerification",
BindingFlags.Instance | BindingFlags.Public)?.GetSetMethod();
if (needsAgeVerificationSetter == null)
{
throw new MissingMethodException("LoginUserResponse.NeedsAgeVerification setter was not found");
}
harmony.Patch(
updateAgeGate,
prefix: new HarmonyMethod(typeof(Plugin), nameof(UpdateAgeGateRequestPrefix)));
harmony.Patch(
needsAgeVerificationSetter,
prefix: new HarmonyMethod(typeof(Plugin), nameof(NeedsAgeVerificationSetterPrefix)));
Log?.LogInfo("Local age-gate confirmation persistence active (confirmed=" + AgeGateState.IsConfirmed() + ")");
}
private static void NeedsAgeVerificationSetterPrefix(ref bool value)
{
if (value && AgeGateState.IsConfirmed())
{
value = false;
Log?.LogInfo("Used persisted local age-gate confirmation for LoginUser");
}
}
private static void UpdateAgeGateRequestPrefix(ref Action __4)
{
// CommonPacket invokes this callback only after it has parsed the
// empty UpdateAgeGateResponse and accepted errorType == 0. Wrapping
// it therefore never records failed/cancelled submissions.
Action continuation = __4;
__4 = delegate
{
try
{
AgeGateState.MarkConfirmed();
Log?.LogInfo("Stored successful local age-gate confirmation");
}
catch (Exception ex)
{
// Preserve the original continuation: inability to persist
// should not break a successfully completed first login.
Log?.LogWarning("Could not persist local age-gate confirmation: " + ex.Message);
}
continuation?.Invoke();
};
}
private static void InstallLocalPurchaseBypass(Harmony harmony)
{
Type platformRuler = FindType("ὧὩὬὦὤὥὦὢὤὠὡ");
MethodInfo getProducts = platformRuler?
.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.FirstOrDefault(method =>
method.Name == "ὮὨὡὩὠὭὧὦὭὥὮ" &&
method.GetParameters().Length == 6 &&
method.GetParameters()[0].ParameterType == typeof(string[]) &&
method.GetParameters()[3].ParameterType == typeof(Action) &&
method.GetParameters()[4].ParameterType == typeof(Action<int, int, string>) &&
method.GetParameters()[5].ParameterType == typeof(bool));
if (getProducts == null)
{
throw new MissingMethodException("PlatformRuler.GetProductAsync was not found");
}
harmony.Patch(
getProducts,
prefix: new HarmonyMethod(typeof(Plugin), nameof(GetProductsPrefix)));
Type platformManager = FindType("gamfs.Platform.PlatformManager");
MethodInfo purchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.FirstOrDefault(method => method.Name == "Purchase" &&
method.GetParameters().Length == 3 &&
method.GetParameters()[0].ParameterType == typeof(string) &&
method.GetParameters()[2].ParameterType == typeof(Action));
MethodInfo finishPurchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.FirstOrDefault(method => method.Name == "FinishPurchase" &&
method.GetParameters().Length == 2 &&
method.GetParameters()[0].ParameterType == typeof(string) &&
method.GetParameters()[1].ParameterType == typeof(long));
if (purchase == null || finishPurchase == null)
throw new MissingMethodException("PlatformManager purchase methods were not found");
harmony.Patch(purchase, prefix: new HarmonyMethod(
typeof(Plugin), nameof(LocalPurchasePrefix)));
harmony.Patch(finishPurchase, prefix: new HarmonyMethod(
typeof(Plugin), nameof(FinishLocalPurchasePrefix)));
Log?.LogInfo("Local purchase price lookup disabled");
Log?.LogInfo("Infinite reroll confirmation is local/free; all other paid purchases are blocked");
}
private static bool GetProductsPrefix(Action __3)
{
// A private server has no Neon/GPG commerce identity. Treat price
// prefetch as complete so startup can continue without contacting the
// production payment API. No purchase result or currency is forged.
__3?.Invoke();
return false;
}
private static bool LocalPurchasePrefix(string __0, object __1, Action __2)
{
// Product 9100033 is the infinite-reroll confirmation. The
// local server grants the last preview through CashShopBuy without
// contacting Neon/GPG. No other real-money product is authorized.
if (__0 == "brd2_limited_pack_660" || __0 == "brd2_limited_pack_660_ios")
{
Type purchaseData = FindType("ὦὯὢὡὥὨὦὯὤὯὩ");
object result = Activator.CreateInstance(purchaseData, new object[]
{
__0,
1L,
"bd2-local-free-infinite",
"bd2-local-free-receipt"
});
Log?.LogInfo("Approved local/free infinite-reroll confirmation");
(__1 as Delegate)?.DynamicInvoke(result);
return false;
}
Log?.LogWarning("Blocked unsupported paid product: " + (__0 ?? "<null>"));
__2?.Invoke();
return false;
}
private static bool FinishLocalPurchasePrefix(string __0)
{
if (__0 == "bd2-local-free-infinite")
{
Log?.LogInfo("Finished local/free infinite-reroll confirmation");
return false;
}
return true;
}
private static void InstallDatabaseDiagnostics(Harmony harmony)
{
Type rawDataManager = FindType("RawDataManager");
MethodInfo dbLoad = rawDataManager?.GetMethod(
"DBLoad",
BindingFlags.Instance | BindingFlags.Public);
if (dbLoad == null)
{
throw new MissingMethodException("RawDataManager.DBLoad was not found");
}
harmony.Patch(
dbLoad,
prefix: new HarmonyMethod(typeof(Plugin), nameof(DBLoadPrefix)));
Type clientLocalInfo = FindType("Proto.Local.ClientLocalInfo");
MethodInfo loadDB = clientLocalInfo?.GetMethod(
"LoadDB",
BindingFlags.Static | BindingFlags.Public);
if (loadDB == null)
{
throw new MissingMethodException("ClientLocalInfo.LoadDB was not found");
}
harmony.Patch(
loadDB,
prefix: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadPrefix)),
postfix: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadPostfix)),
finalizer: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadFinalizer)));
Log?.LogInfo("Database diagnostics active");
}
private static void DBLoadPrefix(object __0, ref Action __1)
{
string dbName = ReadFirstStringField(__0) ?? "<unknown>";
Action original = __1;
Stopwatch elapsed = Stopwatch.StartNew();
Log?.LogInfo("DBLoad start: " + dbName);
__1 = delegate
{
Log?.LogInfo("DBLoad callback enter: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)");
try
{
original?.Invoke();
}
finally
{
Log?.LogInfo("DBLoad callback exit: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)");
}
};
}
private static void ClientLocalLoadPrefix(out Stopwatch __state)
{
__state = Stopwatch.StartNew();
Log?.LogInfo("ClientLocalInfo.LoadDB enter");
}
private static void ClientLocalLoadPostfix(Stopwatch __state)
{
Log?.LogInfo("ClientLocalInfo.LoadDB exit (" + __state.ElapsedMilliseconds + " ms)");
}
private static Exception ClientLocalLoadFinalizer(Exception __exception, Stopwatch __state)
{
if (__exception != null)
{
Log?.LogError(
"ClientLocalInfo.LoadDB threw after " + __state.ElapsedMilliseconds + " ms: " + __exception);
}
return __exception;
}
private static string ReadFirstStringField(object value)
{
if (value == null)
{
return null;
}
FieldInfo field = value.GetType()
.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
.FirstOrDefault(candidate => candidate.FieldType == typeof(string));
return field?.GetValue(value) as string;
}
internal static Type FindType(string name)
{
Assembly assembly = Assembly.Load("Assembly-CSharp");
Type type = assembly?.GetType(name);
if (type != null)
{
return type;
}
foreach (Assembly loaded in AppDomain.CurrentDomain.GetAssemblies())
{
type = loaded.GetType(name);
if (type != null)
{
return type;
}
}
return null;
}
}
@@ -0,0 +1,707 @@
using System;
using System.Collections.Generic;
namespace Bd2LocalIdentity;
// Time zones describe a representative territory, not a player's physical location.
// Windows mappings: Unicode CLDR release-48 windowsZones.xml (territory 001).
// https://github.com/unicode-org/cldr/blob/release-48/common/supplemental/windowsZones.xml
// CLDR data copyright Unicode, Inc.; Unicode License v3: https://www.unicode.org/license.txt
// IANA mappings and aliases: tzdata 2025b zone.tab / backward (public-domain data).
// https://github.com/eggert/tz/tree/2025b
// UTC, offset-only zones and unknown identifiers intentionally have no territory.
internal static class SystemTimeZoneCountries
{
private static readonly Dictionary<string, string> Countries = new Dictionary<string, string>(StringComparer.Ordinal)
{
{ "AUS Central Standard Time", "AU" },
{ "AUS Eastern Standard Time", "AU" },
{ "Afghanistan Standard Time", "AF" },
{ "Africa/Abidjan", "CI" },
{ "Africa/Accra", "GH" },
{ "Africa/Addis_Ababa", "ET" },
{ "Africa/Algiers", "DZ" },
{ "Africa/Asmara", "ER" },
{ "Africa/Asmera", "KE" },
{ "Africa/Bamako", "ML" },
{ "Africa/Bangui", "CF" },
{ "Africa/Banjul", "GM" },
{ "Africa/Bissau", "GW" },
{ "Africa/Blantyre", "MW" },
{ "Africa/Brazzaville", "CG" },
{ "Africa/Bujumbura", "BI" },
{ "Africa/Cairo", "EG" },
{ "Africa/Casablanca", "MA" },
{ "Africa/Ceuta", "ES" },
{ "Africa/Conakry", "GN" },
{ "Africa/Dakar", "SN" },
{ "Africa/Dar_es_Salaam", "TZ" },
{ "Africa/Djibouti", "DJ" },
{ "Africa/Douala", "CM" },
{ "Africa/El_Aaiun", "EH" },
{ "Africa/Freetown", "SL" },
{ "Africa/Gaborone", "BW" },
{ "Africa/Harare", "ZW" },
{ "Africa/Johannesburg", "ZA" },
{ "Africa/Juba", "SS" },
{ "Africa/Kampala", "UG" },
{ "Africa/Khartoum", "SD" },
{ "Africa/Kigali", "RW" },
{ "Africa/Kinshasa", "CD" },
{ "Africa/Lagos", "NG" },
{ "Africa/Libreville", "GA" },
{ "Africa/Lome", "TG" },
{ "Africa/Luanda", "AO" },
{ "Africa/Lubumbashi", "CD" },
{ "Africa/Lusaka", "ZM" },
{ "Africa/Malabo", "GQ" },
{ "Africa/Maputo", "MZ" },
{ "Africa/Maseru", "LS" },
{ "Africa/Mbabane", "SZ" },
{ "Africa/Mogadishu", "SO" },
{ "Africa/Monrovia", "LR" },
{ "Africa/Nairobi", "KE" },
{ "Africa/Ndjamena", "TD" },
{ "Africa/Niamey", "NE" },
{ "Africa/Nouakchott", "MR" },
{ "Africa/Ouagadougou", "BF" },
{ "Africa/Porto-Novo", "BJ" },
{ "Africa/Sao_Tome", "ST" },
{ "Africa/Timbuktu", "CI" },
{ "Africa/Tripoli", "LY" },
{ "Africa/Tunis", "TN" },
{ "Africa/Windhoek", "NA" },
{ "Alaskan Standard Time", "US" },
{ "Aleutian Standard Time", "US" },
{ "Altai Standard Time", "RU" },
{ "America/Adak", "US" },
{ "America/Anchorage", "US" },
{ "America/Anguilla", "AI" },
{ "America/Antigua", "AG" },
{ "America/Araguaina", "BR" },
{ "America/Argentina/Buenos_Aires", "AR" },
{ "America/Argentina/Catamarca", "AR" },
{ "America/Argentina/ComodRivadavia", "AR" },
{ "America/Argentina/Cordoba", "AR" },
{ "America/Argentina/Jujuy", "AR" },
{ "America/Argentina/La_Rioja", "AR" },
{ "America/Argentina/Mendoza", "AR" },
{ "America/Argentina/Rio_Gallegos", "AR" },
{ "America/Argentina/Salta", "AR" },
{ "America/Argentina/San_Juan", "AR" },
{ "America/Argentina/San_Luis", "AR" },
{ "America/Argentina/Tucuman", "AR" },
{ "America/Argentina/Ushuaia", "AR" },
{ "America/Aruba", "AW" },
{ "America/Asuncion", "PY" },
{ "America/Atikokan", "CA" },
{ "America/Atka", "US" },
{ "America/Bahia", "BR" },
{ "America/Bahia_Banderas", "MX" },
{ "America/Barbados", "BB" },
{ "America/Belem", "BR" },
{ "America/Belize", "BZ" },
{ "America/Blanc-Sablon", "CA" },
{ "America/Boa_Vista", "BR" },
{ "America/Bogota", "CO" },
{ "America/Boise", "US" },
{ "America/Buenos_Aires", "AR" },
{ "America/Cambridge_Bay", "CA" },
{ "America/Campo_Grande", "BR" },
{ "America/Cancun", "MX" },
{ "America/Caracas", "VE" },
{ "America/Catamarca", "AR" },
{ "America/Cayenne", "GF" },
{ "America/Cayman", "KY" },
{ "America/Chicago", "US" },
{ "America/Chihuahua", "MX" },
{ "America/Ciudad_Juarez", "MX" },
{ "America/Coral_Harbour", "PA" },
{ "America/Cordoba", "AR" },
{ "America/Costa_Rica", "CR" },
{ "America/Coyhaique", "CL" },
{ "America/Creston", "CA" },
{ "America/Cuiaba", "BR" },
{ "America/Curacao", "CW" },
{ "America/Danmarkshavn", "GL" },
{ "America/Dawson", "CA" },
{ "America/Dawson_Creek", "CA" },
{ "America/Denver", "US" },
{ "America/Detroit", "US" },
{ "America/Dominica", "DM" },
{ "America/Edmonton", "CA" },
{ "America/Eirunepe", "BR" },
{ "America/El_Salvador", "SV" },
{ "America/Ensenada", "MX" },
{ "America/Fort_Nelson", "CA" },
{ "America/Fort_Wayne", "US" },
{ "America/Fortaleza", "BR" },
{ "America/Glace_Bay", "CA" },
{ "America/Godthab", "GL" },
{ "America/Goose_Bay", "CA" },
{ "America/Grand_Turk", "TC" },
{ "America/Grenada", "GD" },
{ "America/Guadeloupe", "GP" },
{ "America/Guatemala", "GT" },
{ "America/Guayaquil", "EC" },
{ "America/Guyana", "GY" },
{ "America/Halifax", "CA" },
{ "America/Havana", "CU" },
{ "America/Hermosillo", "MX" },
{ "America/Indiana/Indianapolis", "US" },
{ "America/Indiana/Knox", "US" },
{ "America/Indiana/Marengo", "US" },
{ "America/Indiana/Petersburg", "US" },
{ "America/Indiana/Tell_City", "US" },
{ "America/Indiana/Vevay", "US" },
{ "America/Indiana/Vincennes", "US" },
{ "America/Indiana/Winamac", "US" },
{ "America/Indianapolis", "US" },
{ "America/Inuvik", "CA" },
{ "America/Iqaluit", "CA" },
{ "America/Jamaica", "JM" },
{ "America/Jujuy", "AR" },
{ "America/Juneau", "US" },
{ "America/Kentucky/Louisville", "US" },
{ "America/Kentucky/Monticello", "US" },
{ "America/Knox_IN", "US" },
{ "America/Kralendijk", "BQ" },
{ "America/La_Paz", "BO" },
{ "America/Lima", "PE" },
{ "America/Los_Angeles", "US" },
{ "America/Louisville", "US" },
{ "America/Lower_Princes", "SX" },
{ "America/Maceio", "BR" },
{ "America/Managua", "NI" },
{ "America/Manaus", "BR" },
{ "America/Marigot", "MF" },
{ "America/Martinique", "MQ" },
{ "America/Matamoros", "MX" },
{ "America/Mazatlan", "MX" },
{ "America/Mendoza", "AR" },
{ "America/Menominee", "US" },
{ "America/Merida", "MX" },
{ "America/Metlakatla", "US" },
{ "America/Mexico_City", "MX" },
{ "America/Miquelon", "PM" },
{ "America/Moncton", "CA" },
{ "America/Monterrey", "MX" },
{ "America/Montevideo", "UY" },
{ "America/Montreal", "CA" },
{ "America/Montserrat", "MS" },
{ "America/Nassau", "BS" },
{ "America/New_York", "US" },
{ "America/Nipigon", "CA" },
{ "America/Nome", "US" },
{ "America/Noronha", "BR" },
{ "America/North_Dakota/Beulah", "US" },
{ "America/North_Dakota/Center", "US" },
{ "America/North_Dakota/New_Salem", "US" },
{ "America/Nuuk", "GL" },
{ "America/Ojinaga", "MX" },
{ "America/Panama", "PA" },
{ "America/Pangnirtung", "CA" },
{ "America/Paramaribo", "SR" },
{ "America/Phoenix", "US" },
{ "America/Port-au-Prince", "HT" },
{ "America/Port_of_Spain", "TT" },
{ "America/Porto_Acre", "BR" },
{ "America/Porto_Velho", "BR" },
{ "America/Puerto_Rico", "PR" },
{ "America/Punta_Arenas", "CL" },
{ "America/Rainy_River", "CA" },
{ "America/Rankin_Inlet", "CA" },
{ "America/Recife", "BR" },
{ "America/Regina", "CA" },
{ "America/Resolute", "CA" },
{ "America/Rio_Branco", "BR" },
{ "America/Rosario", "AR" },
{ "America/Santa_Isabel", "MX" },
{ "America/Santarem", "BR" },
{ "America/Santiago", "CL" },
{ "America/Santo_Domingo", "DO" },
{ "America/Sao_Paulo", "BR" },
{ "America/Scoresbysund", "GL" },
{ "America/Shiprock", "US" },
{ "America/Sitka", "US" },
{ "America/St_Barthelemy", "BL" },
{ "America/St_Johns", "CA" },
{ "America/St_Kitts", "KN" },
{ "America/St_Lucia", "LC" },
{ "America/St_Thomas", "VI" },
{ "America/St_Vincent", "VC" },
{ "America/Swift_Current", "CA" },
{ "America/Tegucigalpa", "HN" },
{ "America/Thule", "GL" },
{ "America/Thunder_Bay", "CA" },
{ "America/Tijuana", "MX" },
{ "America/Toronto", "CA" },
{ "America/Tortola", "VG" },
{ "America/Vancouver", "CA" },
{ "America/Virgin", "PR" },
{ "America/Whitehorse", "CA" },
{ "America/Winnipeg", "CA" },
{ "America/Yakutat", "US" },
{ "America/Yellowknife", "CA" },
{ "Antarctica/Casey", "AQ" },
{ "Antarctica/Davis", "AQ" },
{ "Antarctica/DumontDUrville", "AQ" },
{ "Antarctica/Macquarie", "AU" },
{ "Antarctica/Mawson", "AQ" },
{ "Antarctica/McMurdo", "AQ" },
{ "Antarctica/Palmer", "AQ" },
{ "Antarctica/Rothera", "AQ" },
{ "Antarctica/South_Pole", "NZ" },
{ "Antarctica/Syowa", "AQ" },
{ "Antarctica/Troll", "AQ" },
{ "Antarctica/Vostok", "AQ" },
{ "Arab Standard Time", "SA" },
{ "Arabian Standard Time", "AE" },
{ "Arabic Standard Time", "IQ" },
{ "Arctic/Longyearbyen", "SJ" },
{ "Argentina Standard Time", "AR" },
{ "Asia/Aden", "YE" },
{ "Asia/Almaty", "KZ" },
{ "Asia/Amman", "JO" },
{ "Asia/Anadyr", "RU" },
{ "Asia/Aqtau", "KZ" },
{ "Asia/Aqtobe", "KZ" },
{ "Asia/Ashgabat", "TM" },
{ "Asia/Ashkhabad", "TM" },
{ "Asia/Atyrau", "KZ" },
{ "Asia/Baghdad", "IQ" },
{ "Asia/Bahrain", "BH" },
{ "Asia/Baku", "AZ" },
{ "Asia/Bangkok", "TH" },
{ "Asia/Barnaul", "RU" },
{ "Asia/Beirut", "LB" },
{ "Asia/Bishkek", "KG" },
{ "Asia/Brunei", "BN" },
{ "Asia/Calcutta", "IN" },
{ "Asia/Chita", "RU" },
{ "Asia/Choibalsan", "MN" },
{ "Asia/Chongqing", "CN" },
{ "Asia/Chungking", "CN" },
{ "Asia/Colombo", "LK" },
{ "Asia/Dacca", "BD" },
{ "Asia/Damascus", "SY" },
{ "Asia/Dhaka", "BD" },
{ "Asia/Dili", "TL" },
{ "Asia/Dubai", "AE" },
{ "Asia/Dushanbe", "TJ" },
{ "Asia/Famagusta", "CY" },
{ "Asia/Gaza", "PS" },
{ "Asia/Harbin", "CN" },
{ "Asia/Hebron", "PS" },
{ "Asia/Ho_Chi_Minh", "VN" },
{ "Asia/Hong_Kong", "HK" },
{ "Asia/Hovd", "MN" },
{ "Asia/Irkutsk", "RU" },
{ "Asia/Istanbul", "TR" },
{ "Asia/Jakarta", "ID" },
{ "Asia/Jayapura", "ID" },
{ "Asia/Jerusalem", "IL" },
{ "Asia/Kabul", "AF" },
{ "Asia/Kamchatka", "RU" },
{ "Asia/Karachi", "PK" },
{ "Asia/Kashgar", "CN" },
{ "Asia/Kathmandu", "NP" },
{ "Asia/Katmandu", "NP" },
{ "Asia/Khandyga", "RU" },
{ "Asia/Kolkata", "IN" },
{ "Asia/Krasnoyarsk", "RU" },
{ "Asia/Kuala_Lumpur", "MY" },
{ "Asia/Kuching", "MY" },
{ "Asia/Kuwait", "KW" },
{ "Asia/Macao", "MO" },
{ "Asia/Macau", "MO" },
{ "Asia/Magadan", "RU" },
{ "Asia/Makassar", "ID" },
{ "Asia/Manila", "PH" },
{ "Asia/Muscat", "OM" },
{ "Asia/Nicosia", "CY" },
{ "Asia/Novokuznetsk", "RU" },
{ "Asia/Novosibirsk", "RU" },
{ "Asia/Omsk", "RU" },
{ "Asia/Oral", "KZ" },
{ "Asia/Phnom_Penh", "KH" },
{ "Asia/Pontianak", "ID" },
{ "Asia/Pyongyang", "KP" },
{ "Asia/Qatar", "QA" },
{ "Asia/Qostanay", "KZ" },
{ "Asia/Qyzylorda", "KZ" },
{ "Asia/Rangoon", "MM" },
{ "Asia/Riyadh", "SA" },
{ "Asia/Saigon", "VN" },
{ "Asia/Sakhalin", "RU" },
{ "Asia/Samarkand", "UZ" },
{ "Asia/Seoul", "KR" },
{ "Asia/Shanghai", "CN" },
{ "Asia/Singapore", "SG" },
{ "Asia/Srednekolymsk", "RU" },
{ "Asia/Taipei", "TW" },
{ "Asia/Tashkent", "UZ" },
{ "Asia/Tbilisi", "GE" },
{ "Asia/Tehran", "IR" },
{ "Asia/Tel_Aviv", "IL" },
{ "Asia/Thimbu", "BT" },
{ "Asia/Thimphu", "BT" },
{ "Asia/Tokyo", "JP" },
{ "Asia/Tomsk", "RU" },
{ "Asia/Ujung_Pandang", "ID" },
{ "Asia/Ulaanbaatar", "MN" },
{ "Asia/Ulan_Bator", "MN" },
{ "Asia/Urumqi", "CN" },
{ "Asia/Ust-Nera", "RU" },
{ "Asia/Vientiane", "LA" },
{ "Asia/Vladivostok", "RU" },
{ "Asia/Yakutsk", "RU" },
{ "Asia/Yangon", "MM" },
{ "Asia/Yekaterinburg", "RU" },
{ "Asia/Yerevan", "AM" },
{ "Astrakhan Standard Time", "RU" },
{ "Atlantic Standard Time", "CA" },
{ "Atlantic/Azores", "PT" },
{ "Atlantic/Bermuda", "BM" },
{ "Atlantic/Canary", "ES" },
{ "Atlantic/Cape_Verde", "CV" },
{ "Atlantic/Faeroe", "FO" },
{ "Atlantic/Faroe", "FO" },
{ "Atlantic/Jan_Mayen", "DE" },
{ "Atlantic/Madeira", "PT" },
{ "Atlantic/Reykjavik", "IS" },
{ "Atlantic/South_Georgia", "GS" },
{ "Atlantic/St_Helena", "SH" },
{ "Atlantic/Stanley", "FK" },
{ "Aus Central W. Standard Time", "AU" },
{ "Australia/ACT", "AU" },
{ "Australia/Adelaide", "AU" },
{ "Australia/Brisbane", "AU" },
{ "Australia/Broken_Hill", "AU" },
{ "Australia/Canberra", "AU" },
{ "Australia/Currie", "AU" },
{ "Australia/Darwin", "AU" },
{ "Australia/Eucla", "AU" },
{ "Australia/Hobart", "AU" },
{ "Australia/LHI", "AU" },
{ "Australia/Lindeman", "AU" },
{ "Australia/Lord_Howe", "AU" },
{ "Australia/Melbourne", "AU" },
{ "Australia/NSW", "AU" },
{ "Australia/North", "AU" },
{ "Australia/Perth", "AU" },
{ "Australia/Queensland", "AU" },
{ "Australia/South", "AU" },
{ "Australia/Sydney", "AU" },
{ "Australia/Tasmania", "AU" },
{ "Australia/Victoria", "AU" },
{ "Australia/West", "AU" },
{ "Australia/Yancowinna", "AU" },
{ "Azerbaijan Standard Time", "AZ" },
{ "Azores Standard Time", "PT" },
{ "Bahia Standard Time", "BR" },
{ "Bangladesh Standard Time", "BD" },
{ "Belarus Standard Time", "BY" },
{ "Bougainville Standard Time", "PG" },
{ "Brazil/Acre", "BR" },
{ "Brazil/DeNoronha", "BR" },
{ "Brazil/East", "BR" },
{ "Brazil/West", "BR" },
{ "CET", "BE" },
{ "CST6CDT", "US" },
{ "Canada Central Standard Time", "CA" },
{ "Canada/Atlantic", "CA" },
{ "Canada/Central", "CA" },
{ "Canada/Eastern", "CA" },
{ "Canada/Mountain", "CA" },
{ "Canada/Newfoundland", "CA" },
{ "Canada/Pacific", "CA" },
{ "Canada/Saskatchewan", "CA" },
{ "Canada/Yukon", "CA" },
{ "Cape Verde Standard Time", "CV" },
{ "Caucasus Standard Time", "AM" },
{ "Cen. Australia Standard Time", "AU" },
{ "Central America Standard Time", "GT" },
{ "Central Asia Standard Time", "KG" },
{ "Central Brazilian Standard Time", "BR" },
{ "Central Europe Standard Time", "HU" },
{ "Central European Standard Time", "PL" },
{ "Central Pacific Standard Time", "SB" },
{ "Central Standard Time", "US" },
{ "Central Standard Time (Mexico)", "MX" },
{ "Chatham Islands Standard Time", "NZ" },
{ "Chile/Continental", "CL" },
{ "Chile/EasterIsland", "CL" },
{ "China Standard Time", "CN" },
{ "Cuba", "CU" },
{ "Cuba Standard Time", "CU" },
{ "E. Africa Standard Time", "KE" },
{ "E. Australia Standard Time", "AU" },
{ "E. Europe Standard Time", "MD" },
{ "E. South America Standard Time", "BR" },
{ "EET", "GR" },
{ "EST", "PA" },
{ "EST5EDT", "US" },
{ "Easter Island Standard Time", "CL" },
{ "Eastern Standard Time", "US" },
{ "Eastern Standard Time (Mexico)", "MX" },
{ "Egypt", "EG" },
{ "Egypt Standard Time", "EG" },
{ "Eire", "IE" },
{ "Ekaterinburg Standard Time", "RU" },
{ "Europe/Amsterdam", "NL" },
{ "Europe/Andorra", "AD" },
{ "Europe/Astrakhan", "RU" },
{ "Europe/Athens", "GR" },
{ "Europe/Belfast", "GB" },
{ "Europe/Belgrade", "RS" },
{ "Europe/Berlin", "DE" },
{ "Europe/Bratislava", "SK" },
{ "Europe/Brussels", "BE" },
{ "Europe/Bucharest", "RO" },
{ "Europe/Budapest", "HU" },
{ "Europe/Busingen", "DE" },
{ "Europe/Chisinau", "MD" },
{ "Europe/Copenhagen", "DK" },
{ "Europe/Dublin", "IE" },
{ "Europe/Gibraltar", "GI" },
{ "Europe/Guernsey", "GG" },
{ "Europe/Helsinki", "FI" },
{ "Europe/Isle_of_Man", "IM" },
{ "Europe/Istanbul", "TR" },
{ "Europe/Jersey", "JE" },
{ "Europe/Kaliningrad", "RU" },
{ "Europe/Kiev", "UA" },
{ "Europe/Kirov", "RU" },
{ "Europe/Kyiv", "UA" },
{ "Europe/Lisbon", "PT" },
{ "Europe/Ljubljana", "SI" },
{ "Europe/London", "GB" },
{ "Europe/Luxembourg", "LU" },
{ "Europe/Madrid", "ES" },
{ "Europe/Malta", "MT" },
{ "Europe/Mariehamn", "AX" },
{ "Europe/Minsk", "BY" },
{ "Europe/Monaco", "MC" },
{ "Europe/Moscow", "RU" },
{ "Europe/Nicosia", "CY" },
{ "Europe/Oslo", "NO" },
{ "Europe/Paris", "FR" },
{ "Europe/Podgorica", "ME" },
{ "Europe/Prague", "CZ" },
{ "Europe/Riga", "LV" },
{ "Europe/Rome", "IT" },
{ "Europe/Samara", "RU" },
{ "Europe/San_Marino", "SM" },
{ "Europe/Sarajevo", "BA" },
{ "Europe/Saratov", "RU" },
{ "Europe/Simferopol", "UA" },
{ "Europe/Skopje", "MK" },
{ "Europe/Sofia", "BG" },
{ "Europe/Stockholm", "SE" },
{ "Europe/Tallinn", "EE" },
{ "Europe/Tirane", "AL" },
{ "Europe/Tiraspol", "MD" },
{ "Europe/Ulyanovsk", "RU" },
{ "Europe/Uzhgorod", "UA" },
{ "Europe/Vaduz", "LI" },
{ "Europe/Vatican", "VA" },
{ "Europe/Vienna", "AT" },
{ "Europe/Vilnius", "LT" },
{ "Europe/Volgograd", "RU" },
{ "Europe/Warsaw", "PL" },
{ "Europe/Zagreb", "HR" },
{ "Europe/Zaporozhye", "UA" },
{ "Europe/Zurich", "CH" },
{ "FLE Standard Time", "UA" },
{ "Fiji Standard Time", "FJ" },
{ "GB", "GB" },
{ "GB-Eire", "GB" },
{ "GMT Standard Time", "GB" },
{ "GTB Standard Time", "RO" },
{ "Georgian Standard Time", "GE" },
{ "Greenland Standard Time", "GL" },
{ "Greenwich Standard Time", "IS" },
{ "HST", "US" },
{ "Haiti Standard Time", "HT" },
{ "Hawaiian Standard Time", "US" },
{ "Hongkong", "HK" },
{ "Iceland", "CI" },
{ "India Standard Time", "IN" },
{ "Indian/Antananarivo", "MG" },
{ "Indian/Chagos", "IO" },
{ "Indian/Christmas", "CX" },
{ "Indian/Cocos", "CC" },
{ "Indian/Comoro", "KM" },
{ "Indian/Kerguelen", "TF" },
{ "Indian/Mahe", "SC" },
{ "Indian/Maldives", "MV" },
{ "Indian/Mauritius", "MU" },
{ "Indian/Mayotte", "YT" },
{ "Indian/Reunion", "RE" },
{ "Iran", "IR" },
{ "Iran Standard Time", "IR" },
{ "Israel", "IL" },
{ "Israel Standard Time", "IL" },
{ "Jamaica", "JM" },
{ "Japan", "JP" },
{ "Jordan Standard Time", "JO" },
{ "Kaliningrad Standard Time", "RU" },
{ "Korea Standard Time", "KR" },
{ "Kwajalein", "MH" },
{ "Libya", "LY" },
{ "Libya Standard Time", "LY" },
{ "Line Islands Standard Time", "KI" },
{ "Lord Howe Standard Time", "AU" },
{ "MET", "BE" },
{ "MST", "US" },
{ "MST7MDT", "US" },
{ "Magadan Standard Time", "RU" },
{ "Magallanes Standard Time", "CL" },
{ "Marquesas Standard Time", "PF" },
{ "Mauritius Standard Time", "MU" },
{ "Mexico/BajaNorte", "MX" },
{ "Mexico/BajaSur", "MX" },
{ "Mexico/General", "MX" },
{ "Middle East Standard Time", "LB" },
{ "Montevideo Standard Time", "UY" },
{ "Morocco Standard Time", "MA" },
{ "Mountain Standard Time", "US" },
{ "Mountain Standard Time (Mexico)", "MX" },
{ "Myanmar Standard Time", "MM" },
{ "N. Central Asia Standard Time", "RU" },
{ "NZ", "NZ" },
{ "NZ-CHAT", "NZ" },
{ "Namibia Standard Time", "NA" },
{ "Navajo", "US" },
{ "Nepal Standard Time", "NP" },
{ "New Zealand Standard Time", "NZ" },
{ "Newfoundland Standard Time", "CA" },
{ "Norfolk Standard Time", "NF" },
{ "North Asia East Standard Time", "RU" },
{ "North Asia Standard Time", "RU" },
{ "North Korea Standard Time", "KP" },
{ "Omsk Standard Time", "RU" },
{ "PRC", "CN" },
{ "PST8PDT", "US" },
{ "Pacific SA Standard Time", "CL" },
{ "Pacific Standard Time", "US" },
{ "Pacific Standard Time (Mexico)", "MX" },
{ "Pacific/Apia", "WS" },
{ "Pacific/Auckland", "NZ" },
{ "Pacific/Bougainville", "PG" },
{ "Pacific/Chatham", "NZ" },
{ "Pacific/Chuuk", "FM" },
{ "Pacific/Easter", "CL" },
{ "Pacific/Efate", "VU" },
{ "Pacific/Enderbury", "KI" },
{ "Pacific/Fakaofo", "TK" },
{ "Pacific/Fiji", "FJ" },
{ "Pacific/Funafuti", "TV" },
{ "Pacific/Galapagos", "EC" },
{ "Pacific/Gambier", "PF" },
{ "Pacific/Guadalcanal", "SB" },
{ "Pacific/Guam", "GU" },
{ "Pacific/Honolulu", "US" },
{ "Pacific/Johnston", "US" },
{ "Pacific/Kanton", "KI" },
{ "Pacific/Kiritimati", "KI" },
{ "Pacific/Kosrae", "FM" },
{ "Pacific/Kwajalein", "MH" },
{ "Pacific/Majuro", "MH" },
{ "Pacific/Marquesas", "PF" },
{ "Pacific/Midway", "UM" },
{ "Pacific/Nauru", "NR" },
{ "Pacific/Niue", "NU" },
{ "Pacific/Norfolk", "NF" },
{ "Pacific/Noumea", "NC" },
{ "Pacific/Pago_Pago", "AS" },
{ "Pacific/Palau", "PW" },
{ "Pacific/Pitcairn", "PN" },
{ "Pacific/Pohnpei", "FM" },
{ "Pacific/Ponape", "SB" },
{ "Pacific/Port_Moresby", "PG" },
{ "Pacific/Rarotonga", "CK" },
{ "Pacific/Saipan", "MP" },
{ "Pacific/Samoa", "AS" },
{ "Pacific/Tahiti", "PF" },
{ "Pacific/Tarawa", "KI" },
{ "Pacific/Tongatapu", "TO" },
{ "Pacific/Truk", "PG" },
{ "Pacific/Wake", "UM" },
{ "Pacific/Wallis", "WF" },
{ "Pacific/Yap", "PG" },
{ "Pakistan Standard Time", "PK" },
{ "Paraguay Standard Time", "PY" },
{ "Poland", "PL" },
{ "Portugal", "PT" },
{ "Qyzylorda Standard Time", "KZ" },
{ "ROC", "TW" },
{ "ROK", "KR" },
{ "Romance Standard Time", "FR" },
{ "Russia Time Zone 10", "RU" },
{ "Russia Time Zone 11", "RU" },
{ "Russia Time Zone 3", "RU" },
{ "Russian Standard Time", "RU" },
{ "SA Eastern Standard Time", "GF" },
{ "SA Pacific Standard Time", "CO" },
{ "SA Western Standard Time", "BO" },
{ "SE Asia Standard Time", "TH" },
{ "Saint Pierre Standard Time", "PM" },
{ "Sakhalin Standard Time", "RU" },
{ "Samoa Standard Time", "WS" },
{ "Sao Tome Standard Time", "ST" },
{ "Saratov Standard Time", "RU" },
{ "Singapore", "SG" },
{ "Singapore Standard Time", "SG" },
{ "South Africa Standard Time", "ZA" },
{ "South Sudan Standard Time", "SS" },
{ "Sri Lanka Standard Time", "LK" },
{ "Sudan Standard Time", "SD" },
{ "Syria Standard Time", "SY" },
{ "Taipei Standard Time", "TW" },
{ "Tasmania Standard Time", "AU" },
{ "Tocantins Standard Time", "BR" },
{ "Tokyo Standard Time", "JP" },
{ "Tomsk Standard Time", "RU" },
{ "Tonga Standard Time", "TO" },
{ "Transbaikal Standard Time", "RU" },
{ "Turkey", "TR" },
{ "Turkey Standard Time", "TR" },
{ "Turks And Caicos Standard Time", "TC" },
{ "US Eastern Standard Time", "US" },
{ "US Mountain Standard Time", "US" },
{ "US/Alaska", "US" },
{ "US/Aleutian", "US" },
{ "US/Arizona", "US" },
{ "US/Central", "US" },
{ "US/East-Indiana", "US" },
{ "US/Eastern", "US" },
{ "US/Hawaii", "US" },
{ "US/Indiana-Starke", "US" },
{ "US/Michigan", "US" },
{ "US/Mountain", "US" },
{ "US/Pacific", "US" },
{ "US/Samoa", "AS" },
{ "Ulaanbaatar Standard Time", "MN" },
{ "Venezuela Standard Time", "VE" },
{ "Vladivostok Standard Time", "RU" },
{ "Volgograd Standard Time", "RU" },
{ "W-SU", "RU" },
{ "W. Australia Standard Time", "AU" },
{ "W. Central Africa Standard Time", "NG" },
{ "W. Europe Standard Time", "DE" },
{ "W. Mongolia Standard Time", "MN" },
{ "WET", "PT" },
{ "West Asia Standard Time", "UZ" },
{ "West Bank Standard Time", "PS" },
{ "West Pacific Standard Time", "PG" },
{ "Yakutsk Standard Time", "RU" },
{ "Yukon Standard Time", "CA" },
};
internal static string Resolve(string timeZoneId)
{
return timeZoneId != null && Countries.TryGetValue(timeZoneId, out string country) ? country : string.Empty;
}
}
@@ -0,0 +1,106 @@
using System;
using System.Reflection;
using System.Runtime.InteropServices;
using BepInEx.Logging;
using HarmonyLib;
namespace Bd2LocalIdentity;
// Neo's device country is derived directly from the OS time-zone identifier.
// Maps an explicit zone identifier to its representative territory, never a UTC offset.
// Unmapped identifiers have no inferred country.
internal static class SystemTimeZoneRegion
{
private static ManualLogSource log;
private static string lastLoggedIdentifier;
internal static void Install(Harmony harmony, ManualLogSource logger)
{
log = logger;
// NeoMobilePlatform is internal in the original SDK; typeof cannot reference it.
Type platform = Assembly.Load("Neo.Unity.Device").GetType("Neo.Unity.Platform.NeoMobilePlatform", false);
MethodInfo method = platform?.GetMethod("GetDeviceCountryIso", BindingFlags.Instance | BindingFlags.Public,
null, Type.EmptyTypes, null);
if (method == null || method.ReturnType != typeof(string))
throw new MissingMethodException("NeoMobilePlatform.GetDeviceCountryIso was not found");
harmony.Patch(method, prefix: new HarmonyMethod(typeof(SystemTimeZoneRegion), nameof(GetDeviceCountryPrefix)));
logger.LogInfo("Neo device country uses the operating system time zone");
}
private static bool GetDeviceCountryPrefix(ref string __result)
{
string identifier = ReadId();
__result = SystemTimeZoneCountries.Resolve(identifier);
if (!string.Equals(lastLoggedIdentifier, identifier, StringComparison.Ordinal))
{
lastLoggedIdentifier = identifier;
if (__result.Length == 0)
log?.LogWarning("OS time zone country is unknown: source=OS, timeZone=" + identifier);
else
log?.LogInfo("OS time zone country: source=OS, timeZone=" + identifier + ", country=" + __result);
}
return false;
}
public static string ReadId()
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
DynamicTimeZoneInformation information;
if (GetDynamicTimeZoneInformation(out information) == uint.MaxValue) return string.Empty;
return information.TimeZoneKeyName ?? string.Empty;
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
IntPtr zone = CFTimeZoneCopySystem();
if (zone == IntPtr.Zero) return string.Empty;
try
{
IntPtr name = CFTimeZoneGetName(zone);
if (name == IntPtr.Zero) return string.Empty;
long length = CFStringGetLength(name).ToInt64();
if (length <= 0 || length > 1024) return string.Empty;
ushort[] characters = new ushort[(int)length];
CFStringGetCharacters(name, new CFRange { Length = new IntPtr(length) }, characters);
char[] text = new char[characters.Length];
for (int i = 0; i < text.Length; i++) text[i] = (char)characters[i];
return new string(text);
}
finally { CFRelease(zone); }
}
}
catch (Exception)
{
log?.LogWarning("Could not read the operating system time zone");
}
return string.Empty;
}
[StructLayout(LayoutKind.Sequential)]
private struct SystemTime { public ushort Year, Month, DayOfWeek, Day, Hour, Minute, Second, Milliseconds; }
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct DynamicTimeZoneInformation
{
public int Bias;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string StandardName;
public SystemTime StandardDate;
public int StandardBias;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string DaylightName;
public SystemTime DaylightDate;
public int DaylightBias;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string TimeZoneKeyName;
public byte DynamicDaylightTimeDisabled;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern uint GetDynamicTimeZoneInformation(out DynamicTimeZoneInformation information);
[StructLayout(LayoutKind.Sequential)]
private struct CFRange { public IntPtr Location, Length; }
private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
[DllImport(CoreFoundation)] private static extern IntPtr CFTimeZoneCopySystem();
[DllImport(CoreFoundation)] private static extern IntPtr CFTimeZoneGetName(IntPtr zone);
[DllImport(CoreFoundation)] private static extern IntPtr CFStringGetLength(IntPtr value);
[DllImport(CoreFoundation)] private static extern void CFStringGetCharacters(IntPtr value, CFRange range, [Out] ushort[] characters);
[DllImport(CoreFoundation)] private static extern void CFRelease(IntPtr value);
}
+133
View File
@@ -0,0 +1,133 @@
using System;
using System.Collections;
using System.Threading;
using UnityEngine;
using static Bd2LoginUI.LoginRuntime;
using static Bd2LoginUI.SessionRecovery;
namespace Bd2LoginUI;
// Runs native HTTP work and delivers results to Unity coroutines with owner cancellation.
internal static class ControlRequests
{
internal sealed class ControlProbeResult
{
public bool Success;
public string Body;
public string Error;
public int StatusCode;
public bool RefreshInvalid;
}
// Native HTTP avoids UnityTls for owned auth/control endpoints. The coroutine
// applies completed results on the Unity thread and rejects cancelled generations.
internal static IEnumerator RequestControlEndpoint(Uri uri, string method, byte[] body,
string authorization, int timeoutSeconds, CancellationToken lifetime, CancellationToken generation,
Action<ControlProbeResult> completed)
{
try { ValidateOAuthTransport(uri); }
catch
{
if (body != null) Array.Clear(body, 0, body.Length);
throw;
}
using (CancellationTokenSource cancel = CancellationTokenSource.CreateLinkedTokenSource(lifetime, generation))
{
byte[] payload = body == null ? null : (byte[])body.Clone();
if (body != null) Array.Clear(body, 0, body.Length);
System.Threading.Tasks.Task<PlatformControlHttp.Response> task = null;
bool responseConsumed = false;
try
{
task = PlatformControlHttp.Send(uri, method, payload, authorization, timeoutSeconds, cancel.Token, generation);
// Native work may finish after its Unity owner is destroyed. Observe it
// and clear the upload clone only when transport no longer reads it.
task.ContinueWith(finished =>
{
if (payload != null) Array.Clear(payload, 0, payload.Length);
if (finished.IsFaulted) { _ = finished.Exception; }
}, System.Threading.Tasks.TaskScheduler.Default);
while (!task.IsCompleted && !cancel.IsCancellationRequested)
{
yield return null;
}
if (cancel.IsCancellationRequested) yield break;
if (task.IsFaulted || task.IsCanceled)
{
completed(new ControlProbeResult { Error = task.IsCanceled ? "Cancelled" : "NativeTransportFailure" });
yield break;
}
PlatformControlHttp.Response result = task.GetAwaiter().GetResult();
try
{
completed(new ControlProbeResult
{
Success = result.Success,
StatusCode = result.StatusCode,
Body = result.Body,
RefreshInvalid = result.RefreshInvalid,
Error = result.Error
});
}
finally
{
ClearNativeResponse(result);
responseConsumed = true;
}
}
finally
{
cancel.Cancel();
if (task == null && payload != null) Array.Clear(payload, 0, payload.Length);
if (task != null && !responseConsumed)
{
task.ContinueWith(finished =>
{
if (finished.Status == System.Threading.Tasks.TaskStatus.RanToCompletion) ClearNativeResponse(finished.Result);
if (finished.IsFaulted) { _ = finished.Exception; }
}, System.Threading.Tasks.TaskScheduler.Default);
}
}
}
}
private static void ClearNativeResponse(PlatformControlHttp.Response response)
{
if (response == null) return;
if (response.Data != null) Array.Clear(response.Data, 0, response.Data.Length);
response.Data = null;
response.Body = null;
}
public sealed class AuthenticationRequestLifetime : MonoBehaviour
{
internal readonly CancellationTokenSource Lifetime = new CancellationTokenSource();
private void OnDestroy()
{
Lifetime.Cancel();
Lifetime.Dispose();
}
}
internal static IEnumerator AuthRequest(object introUI, Uri uri, string method, byte[] body,
string authorization, Action<ControlProbeResult> completed)
{
MonoBehaviour intro = introUI as MonoBehaviour;
if (intro == null)
{
if (body != null) Array.Clear(body, 0, body.Length);
yield break;
}
AuthenticationRequestLifetime owner = intro.GetComponent<AuthenticationRequestLifetime>() ??
intro.gameObject.AddComponent<AuthenticationRequestLifetime>();
int expectedGeneration = Volatile.Read(ref RecoveryGeneration);
CancellationToken lifetime = owner.Lifetime.Token;
ControlProbeResult result = null;
yield return RequestControlEndpoint(uri, method, body, authorization, 15, lifetime, ControlProbeCancellation.Token,
response => result = response);
if (result != null && intro != null && !lifetime.IsCancellationRequested &&
expectedGeneration == Volatile.Read(ref RecoveryGeneration)) completed(result);
}
}
+801
View File
@@ -0,0 +1,801 @@
using System;
using System.Collections;
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading;
using BD2.GameNames;
using UnityEngine;
using UnityEngine.Networking;
using static BD2.GameNames.Game;
using static Bd2LoginUI.LoginPanel;
using static Bd2LoginUI.LoginRuntime;
using static Bd2LoginUI.ControlRequests;
using static Bd2LoginUI.SessionRecovery;
namespace Bd2LoginUI;
// Owns server authentication policy, browser/device login and refresh rotation state.
internal static class LoginController
{
internal static void InitializeCredentials()
{
AccessTokens = new MemoryAccessTokenStore();
RefreshCredentials = PlatformRefreshCredentialStore.Create();
}
private const string LocalAccessToken = "bd2-local-development-user";
internal static ServerAuthentication Authentication;
internal static string AuthenticationLoadingOrigin;
internal static bool ContinueMaintenance;
internal static bool LoginInProgress;
internal static MemoryAccessTokenStore AccessTokens;
internal static IRefreshCredentialStore RefreshCredentials;
internal static bool AccessTokenPrefix(ref string __result)
{
if (Authentication != null && Authentication.mode == "oauth")
{
__result = AccessTokens.Get();
return false;
}
__result = LocalAccessToken;
return false;
}
internal static void ClearPCLocalDataPostfix()
{
AccessTokens.Clear();
EstablishedGameSession = false;
RuntimeProbeFailures = 0;
ServerInstanceID = null;
PlayerPrefs.DeleteKey("AccessToken");
DeleteCurrentRefresh();
PlayerPrefs.Save();
}
internal static bool SendMaintenancePrefix(object __instance, bool __0)
{
if (!__0 || ContinueMaintenance)
{
return true;
}
try
{
if (!(__instance is Component))
{
throw new InvalidOperationException("IntroUI is not a Unity component");
}
Uri maintenance = CurrentMaintenanceUri();
Uri currentRoot = new Uri(maintenance, "/");
if (ServerRoot == null || !SameOrigin(ServerRoot, currentRoot))
{
AccessTokens.Clear();
Authentication = null;
LoginInProgress = false;
EstablishedGameSession = false;
RuntimeProbeFailures = 0;
ServerInstanceID = null;
DisposeGameRelay();
ServerRoot = currentRoot;
EnsureGameRelay();
PlayerPrefs.DeleteKey("AccessToken");
PlayerPrefs.Save();
}
if (Volatile.Read(ref SessionRecoveryInProgress) != 0 && Authentication != null &&
Authentication.mode == "oauth" && AccessTokens.IsUsable(NormalizedServerOrigin()))
{
ContinueWithMaintenance(__instance, true);
return false;
}
EnsureGameRelay();
if (Authentication != null)
{
ApplyAuthenticationPolicy(__instance);
return false;
}
string origin = NormalizedServerOrigin();
if (AuthenticationLoadingOrigin == origin)
{
return false;
}
AuthenticationLoadingOrigin = origin;
StartIntroCoroutine(__instance, LoadAuthenticationPolicy(__instance, currentRoot, origin));
return false;
}
catch (Exception ex)
{
Log?.LogError("Could not request the server authentication policy: " + ex);
return false;
}
}
private static IEnumerator LoadAuthenticationPolicy(object introUI, Uri expectedRoot, string expectedOrigin)
{
Uri endpoint = new Uri(expectedRoot, "auth/config");
int generation = Volatile.Read(ref RecoveryGeneration);
for (int attempt = 0; attempt < 3; attempt++)
{
if (generation != Volatile.Read(ref RecoveryGeneration)) yield break;
ControlProbeResult request = null;
yield return AuthRequest(introUI, endpoint, UnityWebRequest.kHttpVerbGET, null, null, result => request = result);
if (request == null || ServerRoot == null || !SameOrigin(ServerRoot, expectedRoot))
{
yield break;
}
if (!request.Success)
{
Log?.LogWarning("Authentication policy request failed: " + request.Error);
bool transient = request.StatusCode == 0 || request.StatusCode == 429 || request.StatusCode >= 500;
if (transient && attempt < 2)
{
yield return new WaitForSecondsRealtime(attempt + 1);
continue;
}
if (AuthenticationLoadingOrigin == expectedOrigin) AuthenticationLoadingOrigin = null;
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
{
BeginSessionRecovery("authentication policy request failed");
}
yield break;
}
if (AuthenticationLoadingOrigin == expectedOrigin) AuthenticationLoadingOrigin = null;
try
{
ServerAuthentication policy = JsonUtility.FromJson<ServerAuthentication>(request.Body);
ValidateAuthentication(policy);
Authentication = policy;
Log?.LogInfo("Control HTTP /auth/config succeeded (native platform HTTP with certificate validation)");
Log?.LogInfo("Server authentication mode: " + policy.mode);
ApplyAuthenticationPolicy(introUI);
}
catch (Exception ex)
{
Log?.LogError("Server returned an invalid authentication policy: " + ex.GetType().Name);
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
{
BeginSessionRecovery("authentication policy response was invalid");
}
}
finally { request.Body = null; }
yield break;
}
}
private static void ApplyAuthenticationPolicy(object introUI)
{
if (Authentication.mode == "local")
{
try
{
AccessTokens.Clear();
PlayerPrefs.DeleteKey("AccessToken");
PlayerPrefs.Save();
ContinueMaintenance = true;
SendMaintenance.Invoke(introUI, new object[] { true });
}
finally
{
ContinueMaintenance = false;
}
return;
}
ValidateOAuthTransport(ServerRoot);
// Earlier development builds stored both local identifiers and OAuth
// access credentials in this key. OAuth credentials now live only in
// process memory, so remove any legacy plaintext before proceeding.
PlayerPrefs.DeleteKey("AccessToken");
if (!RefreshCredentials.IsSupported)
{
PlayerPrefs.SetInt("IsAutoLogin", 0);
PlayerPrefs.SetInt("StandaloneAutoLogin", 0);
Log?.LogWarning("Secure refresh credential storage is unavailable; automatic login is disabled on this platform");
}
PlayerPrefs.Save();
if (LoginInProgress)
{
return;
}
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
{
if (AccessTokens.IsUsable(NormalizedServerOrigin()))
{
ContinueWithMaintenance(introUI, true);
return;
}
if (RefreshCredentials.IsSupported)
{
LoginInProgress = true;
StartIntroCoroutine(introUI, RefreshSession(introUI));
return;
}
FinishRecovery(false, "no usable credential is available");
ShowLoginPanel(introUI);
return;
}
if (PlayerPrefs.GetInt("IsAutoLogin", 0) != 0 &&
PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0 &&
CanAttemptAutomaticLogin())
{
LoginInProgress = true;
StartIntroCoroutine(introUI, RefreshSession(introUI));
}
else
{
ShowLoginPanel(introUI);
}
}
private static void ShowLoginPanel(object introUI)
{
AccessTokens.Clear();
LoginInProgress = false;
EstablishedGameSession = false;
ConfigureLoginPanel(introUI);
Type stateType = SetIntroState.GetParameters()[0].ParameterType;
SetIntroState.Invoke(introUI, new[] { Enum.ToObject(stateType, 1) });
Log?.LogInfo("Waiting for server-authorized third-party authentication");
}
internal static void OpenLogin(object introUI, string provider)
{
try
{
if (LoginInProgress)
{
return;
}
PropertyInfo canInteraction = introUI?.GetType().GetGameProperty(
"CanInteraction",
BindingFlags.Instance | BindingFlags.Public);
if (canInteraction != null && canInteraction.PropertyType == typeof(bool) &&
!(bool)canInteraction.GetValue(introUI, null))
{
return;
}
if (!ProviderEnabled(provider))
{
throw new InvalidOperationException("Provider is not enabled by this server");
}
LoginInProgress = true;
StartIntroCoroutine(introUI, DeviceLogin(introUI, provider));
}
catch (Exception ex)
{
Log?.LogError("Could not start " + provider + " authentication: " + ex.Message);
}
}
private static IEnumerator DeviceLogin(object introUI, string provider)
{
string endpoint = new Uri(ServerRoot, "auth/device").AbsoluteUri;
byte[] body = Encoding.UTF8.GetBytes(JsonUtility.ToJson(new DeviceRequest { provider = provider }));
{
ControlProbeResult request = null;
yield return AuthRequest(introUI, new Uri(endpoint), UnityWebRequest.kHttpVerbPOST, body, null, result => request = result);
if (request == null) yield break;
if (!request.Success)
{
LoginInProgress = false;
Log?.LogError("Could not create login transaction: " + request.Error);
yield break;
}
DeviceStart start;
try
{
start = JsonUtility.FromJson<DeviceStart>(request.Body);
request.Body = null;
if (start == null || string.IsNullOrEmpty(start.transaction_id) || string.IsNullOrEmpty(start.device_secret) || string.IsNullOrEmpty(start.start_url))
{
throw new InvalidDataException("incomplete transaction response");
}
ValidateBrowserURL(start.start_url);
}
catch (Exception ex)
{
LoginInProgress = false;
Log?.LogError("Invalid login transaction: " + ex.GetType().Name);
yield break;
}
Application.OpenURL(start.start_url);
yield return PollDevice(introUI, start);
}
}
private static IEnumerator PollDevice(object introUI, DeviceStart start)
{
int generation = Volatile.Read(ref RecoveryGeneration);
int delay = Math.Max(1, start.poll_interval);
float deadline = Time.realtimeSinceStartup + Math.Max(30, start.expires_in);
string endpoint = new Uri(ServerRoot, "auth/device/" + Uri.EscapeDataString(start.transaction_id) + "/poll").AbsoluteUri;
while (Time.realtimeSinceStartup < deadline)
{
yield return new WaitForSecondsRealtime(delay);
if (generation != Volatile.Read(ref RecoveryGeneration)) yield break;
{
ControlProbeResult request = null;
yield return AuthRequest(introUI, new Uri(endpoint), UnityWebRequest.kHttpVerbPOST, Array.Empty<byte>(), "Device " + start.device_secret, result => request = result);
if (request == null) yield break;
if (request.StatusCode == 202)
{
continue;
}
if (!request.Success)
{
LoginInProgress = false;
Log?.LogError("Login transaction failed: " + request.Error);
yield break;
}
TokenResult result;
try
{
result = JsonUtility.FromJson<TokenResult>(request.Body);
request.Body = null;
}
catch (Exception ex)
{
LoginInProgress = false;
Log?.LogError("Login transaction returned invalid credentials: " + ex.GetType().Name);
yield break;
}
if (!ValidTokenResult(result) || !ProviderEnabled(result.provider))
{
LoginInProgress = false;
Log?.LogError("Login transaction returned incomplete credentials");
yield break;
}
CompleteInteractiveLogin(introUI, result);
yield break;
}
}
LoginInProgress = false;
Log?.LogError("Login transaction expired");
}
private static void CompleteInteractiveLogin(object introUI, TokenResult result)
{
if (!RefreshCredentials.IsSupported)
{
AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in);
result.access_token = null;
result.refresh_token = null;
PlayerPrefs.SetInt("IsAutoLogin", 0);
PlayerPrefs.SetInt("StandaloneAutoLogin", 0);
PlayerPrefs.DeleteKey("AccessToken");
PlayerPrefs.Save();
Log?.LogWarning("Login succeeded, but automatic login remains disabled because this platform has no supported secure credential store");
ContinueWithMaintenance(introUI, false);
return;
}
Action confirmed = delegate
{
try
{
bool autoLogin = PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0;
if (autoLogin)
{
StoreRefresh(result);
}
else
{
DeleteCurrentRefresh();
result.refresh_token = null;
}
AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in);
result.access_token = null;
PlayerPrefs.SetInt("IsAutoLogin", autoLogin ? 1 : 0);
PlayerPrefs.DeleteKey("AccessToken");
PlayerPrefs.Save();
ContinueWithMaintenance(introUI, false);
}
catch (Exception ex)
{
result.access_token = null;
result.refresh_token = null;
Log?.LogError("Could not finish interactive login: " + ex.Message);
AccessTokens.Clear();
LoginInProgress = false;
ShowLoginPanel(introUI);
}
};
try
{
OpenPCLoginPopup.Invoke(null, new object[] { confirmed });
}
catch
{
result.access_token = null;
result.refresh_token = null;
LoginInProgress = false;
throw;
}
}
private static IEnumerator RefreshSession(object introUI)
{
int generation = Volatile.Read(ref RecoveryGeneration);
RefreshCredential saved = null;
while (saved == null)
{
if (generation != Volatile.Read(ref RecoveryGeneration))
{
yield break;
}
InvalidDataException invalid = null;
Exception transient = null;
try
{
saved = PrepareRefreshAttempt();
}
catch (InvalidDataException ex)
{
invalid = ex;
}
catch (FileNotFoundException ex)
{
invalid = new InvalidDataException("saved automatic-login credential was not found", ex);
}
catch (Exception ex)
{
transient = ex;
}
if (invalid != null)
{
Log?.LogError("Saved automatic login is invalid: " + invalid.Message);
ClearSavedLogin();
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
{
FinishRecovery(false, "saved automatic-login credential is invalid", generation);
}
ShowLoginPanel(introUI);
yield break;
}
if (transient != null)
{
Log?.LogWarning("Secure automatic-login storage is temporarily unavailable: " + transient.Message);
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
{
LoginInProgress = false;
ShowLoginPanel(introUI);
yield break;
}
yield return new WaitForSecondsRealtime(2f);
}
}
string refreshToken = saved.pending_refresh_token;
string attemptID = saved.pending_attempt_id;
saved.refresh_token = null;
saved.pending_refresh_token = null;
saved.pending_attempt_id = null;
float retryDelay = 1f;
while (true)
{
byte[] body = BuildRefreshRequest(refreshToken, attemptID);
{
ControlProbeResult request = null;
yield return AuthRequest(introUI, new Uri(ServerRoot, "auth/session/refresh"), UnityWebRequest.kHttpVerbPOST, body, null, result => request = result);
if (request == null) yield break;
if (generation != Volatile.Read(ref RecoveryGeneration))
{
yield break;
}
bool credentialRejected = (request.StatusCode == 401 || request.StatusCode == 409) && request.RefreshInvalid;
if (credentialRejected)
{
refreshToken = null;
attemptID = null;
ClearSavedLogin();
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
{
FinishRecovery(false, "saved automatic-login credential was rejected", generation);
}
ShowLoginPanel(introUI);
yield break;
}
if (!request.Success)
{
Log?.LogWarning("Automatic login temporarily unavailable; the same refresh attempt will be retried: " + request.Error);
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
{
LoginInProgress = false;
ShowLoginPanel(introUI);
yield break;
}
}
else
{
TokenResult result = null;
try
{
result = JsonUtility.FromJson<TokenResult>(request.Body);
request.Body = null;
}
catch (Exception ex)
{
Log?.LogWarning("Automatic login returned an unreadable response; the same refresh attempt will be retried: " + ex.GetType().Name);
}
if (ValidRefreshResult(result) && ProviderEnabled(result.provider))
{
try
{
StoreRefresh(result);
if (result.access_expires_in <= 30)
{
result.access_token = null;
saved = PrepareRefreshAttempt();
refreshToken = saved.pending_refresh_token;
attemptID = saved.pending_attempt_id;
saved.refresh_token = null;
saved.pending_refresh_token = null;
saved.pending_attempt_id = null;
retryDelay = 1f;
continue;
}
AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in);
result.access_token = null;
PlayerPrefs.DeleteKey("AccessToken");
PlayerPrefs.Save();
refreshToken = null;
attemptID = null;
}
catch (Exception ex)
{
result.access_token = null;
result.refresh_token = null;
Log?.LogWarning("Could not persist the rotated automatic-login credential; the committed attempt will be retrieved again: " + ex.Message);
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
{
LoginInProgress = false;
ShowLoginPanel(introUI);
yield break;
}
}
if (AccessTokens.IsUsable(NormalizedServerOrigin()))
{
ContinueWithMaintenance(introUI, true);
yield break;
}
}
Log?.LogWarning("Automatic login returned incomplete credentials; the same refresh attempt will be retried");
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
{
LoginInProgress = false;
ShowLoginPanel(introUI);
yield break;
}
}
}
yield return new WaitForSecondsRealtime(retryDelay);
retryDelay = Math.Min(retryDelay * 2f, 5f);
}
}
private static void ContinueWithMaintenance(object introUI, bool automatic)
{
try
{
ContinueMaintenance = true;
SendMaintenance.Invoke(introUI, new object[] { automatic });
}
finally
{
ContinueMaintenance = false;
LoginInProgress = false;
}
}
private static void StartIntroCoroutine(object introUI, IEnumerator routine)
{
if (!(introUI is MonoBehaviour owner) || owner == null)
{
throw new InvalidOperationException("IntroUI coroutine owner is unavailable");
}
owner.StartCoroutine(routine ?? throw new ArgumentNullException(nameof(routine)));
}
private static bool CanAttemptAutomaticLogin()
{
if (!RefreshCredentials.IsSupported)
{
return false;
}
try
{
return RefreshCredentials.Contains(NormalizedServerOrigin());
}
catch (Exception ex)
{
Log?.LogWarning("Could not inspect the secure automatic-login credential: " + ex.Message);
return false;
}
}
private static void StoreRefresh(TokenResult result)
{
string origin = NormalizedServerOrigin();
RefreshCredential credential = new RefreshCredential
{
version = 2,
origin = origin,
provider = result.provider,
refresh_token = result.refresh_token,
expires_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + result.refresh_expires_in
};
RefreshCredentials.Save(origin, credential);
credential.refresh_token = null;
result.refresh_token = null;
}
private static RefreshCredential LoadRefresh()
{
string origin = NormalizedServerOrigin();
RefreshCredential credential = RefreshCredentials.Load(origin);
if (credential == null || credential.version < 1 || credential.version > 2 || credential.origin != origin ||
!ProviderEnabled(credential.provider) || string.IsNullOrEmpty(credential.refresh_token) ||
credential.expires_at <= DateTimeOffset.UtcNow.ToUnixTimeSeconds())
{
if (credential != null)
{
credential.refresh_token = null;
}
throw new InvalidDataException("saved automatic-login credential is invalid, expired, or belongs to another server");
}
return credential;
}
private static RefreshCredential PrepareRefreshAttempt()
{
string origin = NormalizedServerOrigin();
RefreshCredential credential = LoadRefresh();
bool missingAttempt = string.IsNullOrEmpty(credential.pending_attempt_id) ||
string.IsNullOrEmpty(credential.pending_refresh_token);
if (missingAttempt)
{
credential.version = 2;
credential.pending_attempt_id = System.Guid.NewGuid().ToString("N");
credential.pending_refresh_token = credential.refresh_token;
RefreshCredentials.Save(origin, credential);
}
return credential;
}
private static byte[] BuildRefreshRequest(string token, string attemptID)
{
if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(attemptID))
{
throw new InvalidDataException("refresh token or attempt ID is empty");
}
foreach (char item in token + attemptID)
{
bool safe = item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' ||
item >= '0' && item <= '9' || item == '-' || item == '_';
if (!safe)
{
throw new InvalidDataException("refresh token contains an unexpected character");
}
}
return Encoding.UTF8.GetBytes("{\"refresh_token\":\"" + token + "\",\"attempt_id\":\"" + attemptID + "\"}");
}
private static void ClearSavedLogin()
{
AccessTokens.Clear();
PlayerPrefs.SetInt("IsAutoLogin", 0);
PlayerPrefs.SetInt("StandaloneAutoLogin", 0);
PlayerPrefs.DeleteKey("AccessToken");
DeleteCurrentRefresh();
PlayerPrefs.Save();
}
private static void DeleteCurrentRefresh()
{
if (ServerRoot == null || RefreshCredentials == null || !RefreshCredentials.IsSupported)
{
return;
}
try
{
RefreshCredentials.Delete(NormalizedServerOrigin());
}
catch (Exception ex)
{
Log?.LogWarning("Could not delete the secure automatic-login credential: " + ex.Message);
}
}
private static bool ValidTokenResult(TokenResult result)
{
return result != null && !string.IsNullOrEmpty(result.provider) &&
!string.IsNullOrEmpty(result.access_token) && result.access_expires_in > 0 &&
!string.IsNullOrEmpty(result.refresh_token) && result.refresh_expires_in > 0;
}
private static bool ValidRefreshResult(TokenResult result)
{
return result != null && !string.IsNullOrEmpty(result.provider) &&
!string.IsNullOrEmpty(result.access_token) && result.access_expires_in >= 0 &&
!string.IsNullOrEmpty(result.refresh_token) && result.refresh_expires_in > 0;
}
internal static bool ProviderEnabled(string provider)
{
if (Authentication?.providers == null)
{
return false;
}
foreach (string enabled in Authentication.providers)
{
if (enabled == provider)
{
return true;
}
}
return false;
}
private static void ValidateAuthentication(ServerAuthentication policy)
{
if (policy == null || (policy.mode != "local" && policy.mode != "oauth") || policy.providers == null)
{
throw new InvalidDataException("missing or unknown authentication mode");
}
if (policy.mode == "local" && policy.providers.Length != 0)
{
throw new InvalidDataException("local mode enabled providers");
}
if (policy.mode == "oauth" && policy.providers.Length == 0)
{
throw new InvalidDataException("oauth mode omitted providers");
}
for (int i = 0; i < policy.providers.Length; i++)
{
string provider = policy.providers[i];
if (provider != "discord" && provider != "google")
{
throw new InvalidDataException("unsupported provider " + provider);
}
for (int j = 0; j < i; j++)
{
if (policy.providers[j] == provider)
{
throw new InvalidDataException("duplicate provider " + provider);
}
}
}
}
[Serializable]
private sealed class DeviceRequest
{
public string provider;
}
[Serializable]
private sealed class DeviceStart
{
public string transaction_id = null;
public string device_secret = null;
public string start_url = null;
public int expires_in = 0;
public int poll_interval = 0;
}
[Serializable]
private sealed class TokenResult
{
public string provider = null;
public string access_token = null;
public long access_expires_in = 0;
public string refresh_token = null;
public long refresh_expires_in = 0;
}
[Serializable]
internal sealed class ServerAuthentication
{
public string mode = null;
public string[] providers = null;
}
}
+301
View File
@@ -0,0 +1,301 @@
using System;
using System.Collections;
using System.IO;
using System.Reflection;
using BD2.GameNames;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
using static BD2.GameNames.Game;
using static Bd2LoginUI.LoginRuntime;
using static Bd2LoginUI.LoginController;
namespace Bd2LoginUI;
// Owns login branding, provider layout and button listeners.
internal static class LoginPanel
{
private const string SymbolResource = "Bd2LoginUI.Assets.Discord-Symbol.png";
private const string WordmarkResource = "Bd2LoginUI.Assets.Discord-Wordmark.png";
private static readonly Color DiscordBlurple = new Color32(88, 101, 242, 255);
private static Sprite DiscordSymbol;
private static Sprite DiscordWordmark;
internal static void ConfigureLoginPanel(object introUI)
{
if (Authentication == null || Authentication.mode != "oauth")
{
return;
}
try
{
Component component = introUI as Component;
Transform panel = component == null ? null : FindDescendant(component.transform, "SignInWithAccount");
if (panel == null)
{
throw new MissingMemberException("IntroUI/SignInWithAccount was not found");
}
Button google = FindButton(panel, "Button - Google");
Button discord = FindButton(panel, "Button - Facebook");
if (discord == null)
{
discord = FindButton(panel, "Button - Discord");
}
if (google == null || discord == null)
{
throw new MissingMemberException("Google or Facebook/Discord login button was not found");
}
Image box = FindImage(discord.transform, "Image - Box");
Image logo = FindImage(discord.transform, "Image - Logo");
Image title = FindImage(discord.transform, "Image - Title");
if (box == null || logo == null || title == null)
{
throw new MissingMemberException("Discord button images were not found");
}
SetActive(panel, "Button - Apple", false);
SetActive(panel, "Button - Email", false);
SetActive(panel, "Button - Mail", false);
SetActive(panel, "Button - Guest", false);
google.gameObject.SetActive(ProviderEnabled("google"));
discord.gameObject.SetActive(ProviderEnabled("discord"));
int providerCount = (google.gameObject.activeSelf ? 1 : 0) +
(discord.gameObject.activeSelf ? 1 : 0);
discord.transform.SetSiblingIndex(0);
google.transform.SetSiblingIndex(1);
discord.gameObject.name = "Button - Discord";
ReplaceClick(google, introUI, "google");
ReplaceClick(discord, introUI, "discord");
ApplyDiscordBrand(box, logo, title);
ConfigureProviderGrid(panel, providerCount);
Canvas.ForceUpdateCanvases();
if (panel is RectTransform panelRect)
{
LayoutRebuilder.ForceRebuildLayoutImmediate(panelRect);
}
}
catch (Exception ex)
{
Log?.LogError("Could not configure login panel: " + ex);
}
}
private static void ConfigureProviderGrid(Transform panel, int providerCount)
{
GridLayoutGroup grid = panel.GetComponentInChildren<GridLayoutGroup>(true);
if (grid == null)
{
throw new MissingMemberException("Login provider grid was not found");
}
int columns = Math.Max(1, providerCount);
grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
grid.constraintCount = columns;
if (grid.transform is RectTransform gridRect)
{
float width = grid.padding.horizontal + grid.cellSize.x * columns +
grid.spacing.x * Math.Max(0, columns - 1);
UpdateBetterGridProfiles(grid, columns);
UpdateBetterLocatorProfiles(grid, width);
gridRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width);
LayoutRebuilder.ForceRebuildLayoutImmediate(gridRect);
}
}
private static void UpdateBetterGridProfiles(GridLayoutGroup grid, int columns)
{
Type type = grid.GetType();
if (type.FullName != "TheraBytes.BetterUi.BetterGridLayoutGroup")
{
return;
}
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
UpdateBetterGridSettings(type.GetGameField("settingsFallback", flags)?.GetValue(grid), columns);
object collection = type.GetGameField("customSettings", flags)?.GetValue(grid);
IEnumerable items = collection?.GetType().GetGameProperty("Items", flags)?.GetValue(collection, null) as IEnumerable;
if (items == null)
{
return;
}
foreach (object settings in items)
{
UpdateBetterGridSettings(settings, columns);
}
}
private static void UpdateBetterGridSettings(object settings, int columns)
{
if (settings == null)
{
return;
}
Type type = settings.GetType();
FieldInfo constraint = type.GetGameField("Constraint", BindingFlags.Instance | BindingFlags.Public);
FieldInfo count = type.GetGameField("ConstraintCount", BindingFlags.Instance | BindingFlags.Public);
if (constraint != null)
{
constraint.SetValue(settings, Enum.ToObject(constraint.FieldType, (int)GridLayoutGroup.Constraint.FixedColumnCount));
}
count?.SetValue(settings, columns);
}
private static void UpdateBetterLocatorProfiles(GridLayoutGroup grid, float width)
{
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
foreach (Component component in grid.GetComponents<Component>())
{
Type type = component?.GetType();
if (type?.FullName != "TheraBytes.BetterUi.BetterLocator")
{
continue;
}
UpdateBetterRectTransformData(type.GetGameField("transformFallback", flags)?.GetValue(component), width);
object collection = type.GetGameField("transformConfigs", flags)?.GetValue(component);
IEnumerable items = collection?.GetType().GetGameProperty("Items", flags)?.GetValue(collection, null) as IEnumerable;
if (items == null)
{
continue;
}
foreach (object data in items)
{
UpdateBetterRectTransformData(data, width);
}
}
}
private static void UpdateBetterRectTransformData(object data, float width)
{
FieldInfo sizeField = data?.GetType().GetGameField("SizeDelta", BindingFlags.Instance | BindingFlags.Public);
if (sizeField == null || sizeField.FieldType != typeof(Vector2))
{
return;
}
Vector2 size = (Vector2)sizeField.GetValue(data);
size.x = width;
sizeField.SetValue(data, size);
}
private static void ReplaceClick(Button button, object introUI, string provider)
{
// Assigning a fresh event removes both serialized persistent calls and
// the listeners that IntroUI.Awake adds at runtime.
button.onClick = new Button.ButtonClickedEvent();
button.onClick.AddListener(new UnityAction(delegate { OpenLogin(introUI, provider); }));
button.interactable = true;
}
private static void ApplyDiscordBrand(Image box, Image logo, Image title)
{
box.color = DiscordBlurple;
logo.sprite = DiscordSymbol;
logo.color = Color.white;
logo.preserveAspect = true;
title.sprite = DiscordWordmark;
title.color = Color.white;
title.preserveAspect = true;
DisableSpriteLocalizer(logo.gameObject);
DisableSpriteLocalizer(title.gameObject);
}
private static void DisableSpriteLocalizer(GameObject target)
{
foreach (Behaviour behaviour in target.GetComponents<Behaviour>())
{
if (behaviour is SpriteLocalizer)
{
behaviour.enabled = false;
}
}
}
private static Sprite LoadSprite(string resourceName, string name)
{
using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName);
if (stream == null)
{
throw new FileNotFoundException("Embedded login asset is missing", resourceName);
}
byte[] bytes = new byte[stream.Length];
int offset = 0;
while (offset < bytes.Length)
{
int read = stream.Read(bytes, offset, bytes.Length - offset);
if (read == 0)
{
throw new EndOfStreamException("Unexpected end of embedded login asset " + resourceName);
}
offset += read;
}
Texture2D texture = new Texture2D(2, 2, TextureFormat.RGBA32, false, false)
{
name = name,
filterMode = FilterMode.Bilinear,
wrapMode = TextureWrapMode.Clamp
};
if (!ImageConversion.LoadImage(texture, bytes, true))
{
UnityEngine.Object.Destroy(texture);
throw new InvalidDataException("Could not decode embedded login asset " + resourceName);
}
Sprite sprite = Sprite.Create(
texture,
new Rect(0f, 0f, texture.width, texture.height),
new Vector2(0.5f, 0.5f),
100f);
sprite.name = name;
return sprite;
}
private static Button FindButton(Transform root, string name)
{
Transform match = FindDescendant(root, name);
return match == null ? null : match.GetComponent<Button>();
}
private static Image FindImage(Transform root, string name)
{
Transform match = FindDescendant(root, name);
return match == null ? null : match.GetComponent<Image>();
}
internal static void SetActive(Transform root, string name, bool active)
{
Transform match = FindDescendant(root, name);
if (match != null)
{
match.gameObject.SetActive(active);
}
}
private static Transform FindDescendant(Transform root, string name)
{
if (root == null)
{
return null;
}
if (root.name == name)
{
return root;
}
for (int i = 0; i < root.childCount; i++)
{
Transform match = FindDescendant(root.GetChild(i), name);
if (match != null)
{
return match;
}
}
return null;
}
internal static void InitializeBranding()
{
DiscordSymbol = LoadSprite(SymbolResource, "BD2 Discord Symbol");
DiscordWordmark = LoadSprite(WordmarkResource, "BD2 Discord Wordmark");
}
}
+111
View File
@@ -0,0 +1,111 @@
using System;
using System.IO;
using System.Reflection;
using BD2.GameNames;
using BepInEx.Logging;
using static BD2.GameNames.Game;
namespace Bd2LoginUI;
// Shared server origin and resolved client transition bindings; feature state stays in its owner.
internal static class LoginRuntime
{
internal static ManualLogSource Log;
internal static MethodInfo SetIntroState;
internal static MethodInfo SendMaintenance;
internal static Uri ServerRoot;
internal static MethodInfo OpenPCLoginPopup;
internal static MethodInfo EnterGame;
internal static string NormalizedServerOrigin()
{
return NormalizeOrigin(ServerRoot);
}
internal static bool SameOrigin(Uri left, Uri right)
{
return string.Equals(NormalizeOrigin(left), NormalizeOrigin(right), StringComparison.Ordinal);
}
private static string NormalizeOrigin(Uri uri)
{
if (uri == null || !uri.IsAbsoluteUri || string.IsNullOrEmpty(uri.Host))
{
throw new InvalidOperationException("authentication server origin is unavailable");
}
string host = uri.IdnHost.ToLowerInvariant();
int port = uri.IsDefaultPort ? -1 : uri.Port;
UriBuilder builder = new UriBuilder(uri.Scheme.ToLowerInvariant(), host, port);
return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
}
internal static void ValidateOAuthTransport(Uri uri)
{
if (uri == null || !uri.IsAbsoluteUri ||
(uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
{
throw new InvalidOperationException("OAuth requires HTTPS except when connecting to a loopback server");
}
}
internal static void ValidateBrowserURL(string raw)
{
if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri uri) ||
(uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
uri.UserInfo.Length != 0)
{
throw new InvalidDataException("server returned an unsafe browser login URL");
}
}
internal static T FindUnitySingleton<T>() where T : UnityEngine.Object
{
return UnityEngine.Object.FindObjectOfType<T>();
}
internal static Uri CurrentMaintenanceUri()
{
Type serverURLInfo = typeof(BDNetwork.ServerURLInfo);
FieldInfo maintenanceUri = serverURLInfo?.GetGameField(
nameof(BDNetwork.ServerURLInfo.MaintenanceUri),
BindingFlags.Static | BindingFlags.Public);
Uri result = maintenanceUri?.GetValue(null) as Uri;
if (result == null || (result.Scheme != Uri.UriSchemeHttp && result.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException("current server maintenance URL is unavailable");
}
return result;
}
internal static MethodInfo FindOpenPCLoginPopup()
{
Type uiManager = typeof(UIManager);
MethodInfo method = uiManager.GetGameMethod(
nameof(UIManager.OpenPCLoginPopupUI),
BindingFlags.Static | BindingFlags.Public,
null,
new[] { typeof(Action) },
null);
return method != null && method.ReturnType == typeof(void) ? method : null;
}
internal static MethodInfo FindAccessTokenGetter()
{
MethodInfo getter = Game.Getter(() => BDNetwork.CommonPacket.AccessToken);
return getter != null && getter.IsStatic && getter.ReturnType == typeof(string) &&
getter.GetParameters().Length == 0 ? getter : null;
}
internal static MethodInfo FindClearPCLocalData()
{
Type platformManager = typeof(gamfs.Platform.PlatformManager);
MethodInfo method = platformManager?.GetGameMethod(
nameof(gamfs.Platform.PlatformManager.ClearPCLocalData),
BindingFlags.Instance | BindingFlags.Public,
null,
Type.EmptyTypes,
null);
return method != null && method.ReturnType == typeof(void) ? method : null;
}
}
-6
View File
@@ -6,12 +6,7 @@
<AssemblyName>BD2LoginUI</AssemblyName>
<RootNamespace>Bd2LoginUI</RootNamespace>
<BD2PluginVersionKey>login_ui</BD2PluginVersionKey>
<BD2ManagedDir Condition="'$(BD2ManagedDir)' == ''">$(GameDir)\BrownDust II_Data\Managed</BD2ManagedDir>
<BD2BepInExDir Condition="'$(BD2BepInExDir)' == ''">$(GameDir)\BepInEx</BD2BepInExDir>
</PropertyGroup>
<Target Name="RequireGameDir" BeforeTargets="ResolveReferences" Condition="'$(GameDir)' == ''">
<Error Text="GameDir is required. Build with -p:GameDir=&lt;local-client-directory&gt;." />
</Target>
<ItemGroup>
<Reference Include="BepInEx">
<HintPath>$(BD2BepInExDir)\core\BepInEx.dll</HintPath>
@@ -58,5 +53,4 @@
<EmbeddedResource Include="Assets\Discord-Symbol.png" LogicalName="Bd2LoginUI.Assets.Discord-Symbol.png" />
<EmbeddedResource Include="Assets\Discord-Wordmark.png" LogicalName="Bd2LoginUI.Assets.Discord-Wordmark.png" />
</ItemGroup>
<Import Project="..\VersionSource.targets" />
</Project>
+398
View File
@@ -0,0 +1,398 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Bd2LoginUI;
// Own-origin control requests use OS TLS without Mono/Unity's TLS implementation.
internal static class PlatformControlHttp
{
public static bool IsSupported => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ||
RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
private static readonly object CurlInitializationLock = new object();
private static bool curlInitialized;
internal sealed class Response
{
public int StatusCode;
public string Body;
public string Error;
public byte[] Data;
public IReadOnlyDictionary<string, string> Headers;
public bool RefreshInvalid;
public bool Success => Error == null && StatusCode >= 200 && StatusCode < 300;
}
public static Task<Response> Send(Uri uri, string method, byte[] body, string authorization,
int timeoutSeconds, CancellationToken lifetime, CancellationToken generation, int maxResponseBytes = 16 * 1024,
IReadOnlyDictionary<string, string> requestHeaders = null)
{
return Task.Run(() =>
{
using (CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime, generation))
{
try
{
if (uri == null || !uri.IsAbsoluteUri ||
(uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
!string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Fragment) ||
(method != "GET" && method != "POST" && method != "PUT") || timeoutSeconds <= 0 || timeoutSeconds > 60 ||
maxResponseBytes <= 0 || maxResponseBytes > 64 * 1024 * 1024 ||
(body != null && body.Length > 64 * 1024 * 1024) ||
(authorization != null && (authorization.Length > 16384 || authorization.IndexOf('\r') >= 0 || authorization.IndexOf('\n') >= 0)))
return Failure("Invalid control request");
cancellation.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
cancellation.Token.ThrowIfCancellationRequested();
if (requestHeaders != null)
{
if (requestHeaders.Count > 64) return Failure("Invalid control request headers");
int headerSize = 0;
foreach (KeyValuePair<string, string> header in requestHeaders)
{
if (!AllowedRequestHeader(header.Key) || header.Key.Length > 256 || !ValidHeaderName(header.Key) || header.Value == null || header.Value.Length > 16384 ||
header.Value.IndexOf('\r') >= 0 || header.Value.IndexOf('\n') >= 0)
return Failure("Invalid control request headers");
headerSize += header.Key.Length + header.Value.Length;
if (headerSize > 65536) return Failure("Invalid control request headers");
}
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return SendWindows(uri, method, body, authorization, timeoutSeconds, cancellation.Token, maxResponseBytes, requestHeaders);
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
return SendMac(uri, method, body, authorization, timeoutSeconds, cancellation.Token, maxResponseBytes, requestHeaders);
return Failure("Native control transport is unsupported");
}
catch (OperationCanceledException) { return Failure("Control request canceled"); }
catch (Exception exception) { return Failure("Native control transport failed: " + exception.GetType().Name); }
}
});
}
private static Response Failure(string error) => new Response { Error = error };
private static Response SendWindows(Uri uri, string method, byte[] body, string authorization,
int timeoutSeconds, CancellationToken token, int maxResponseBytes, IReadOnlyDictionary<string, string> requestHeaders)
{
IntPtr session = IntPtr.Zero, connection = IntPtr.Zero, request = IntPtr.Zero;
try
{
// Owned endpoints connect directly; browser OAuth keeps its own proxy settings.
session = WinHttpOpen("BD2LoginUI", 1, null, null, 0); // WINHTTP_ACCESS_TYPE_NO_PROXY
if (session == IntPtr.Zero) return WindowsFailure();
int timeout = checked(timeoutSeconds * 1000);
if (!WinHttpSetTimeouts(session, timeout, timeout, timeout, timeout)) return WindowsFailure();
connection = WinHttpConnect(session, uri.IdnHost, (ushort)uri.Port, 0);
if (connection == IntPtr.Zero) return WindowsFailure();
request = WinHttpOpenRequest(connection, method, uri.PathAndQuery, null, null, IntPtr.Zero,
uri.Scheme == Uri.UriSchemeHttps ? 0x00800000u : 0u);
if (request == IntPtr.Zero) return WindowsFailure();
uint disabled = 0x2 | 0x4; // WINHTTP_DISABLE_COOKIES | WINHTTP_DISABLE_REDIRECTS
if (!WinHttpSetOption(request, 63, ref disabled, 4)) return WindowsFailure();
string headers = "Accept: application/json\r\n";
if (body != null && !HasContentType(requestHeaders)) headers += "Content-Type: application/json\r\n";
if (authorization != null) headers += "Authorization: " + authorization + "\r\n";
if (requestHeaders != null)
foreach (KeyValuePair<string, string> header in requestHeaders) headers += header.Key + ": " + header.Value + "\r\n";
token.ThrowIfCancellationRequested();
// Calls have native timeouts; never close a handle concurrently with a P/Invoke call.
if (!WinHttpSendRequest(request, headers, (uint)headers.Length, body,
(uint)(body?.Length ?? 0), (uint)(body?.Length ?? 0), UIntPtr.Zero)) return WindowsFailure();
token.ThrowIfCancellationRequested();
if (!WinHttpReceiveResponse(request, IntPtr.Zero)) return WindowsFailure();
uint status, size = 4;
if (!WinHttpQueryHeaders(request, 19 | 0x20000000, null, out status, ref size, IntPtr.Zero)) return WindowsFailure();
Dictionary<string, string> responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
size = 0;
WinHttpQueryHeadersText(request, 22, null, IntPtr.Zero, ref size, IntPtr.Zero);
if (size > 64 * 1024) return Failure("Control response headers exceeded limit");
if (size > 0 && size <= 64 * 1024)
{
int headerCapacity = (int)size;
IntPtr headerBuffer = Marshal.AllocHGlobal((int)size);
try
{
if (WinHttpQueryHeadersText(request, 22, null, headerBuffer, ref size, IntPtr.Zero))
foreach (string line in (Marshal.PtrToStringUni(headerBuffer) ?? string.Empty).Split(new[] { "\r\n" }, StringSplitOptions.None))
KeepResponseHeader(responseHeaders, line);
}
finally
{
for (int index = 0; index < headerCapacity; index++) Marshal.WriteByte(headerBuffer, index, 0);
Marshal.FreeHGlobal(headerBuffer);
}
}
using (MemoryStream response = new MemoryStream())
{
byte[] buffer = new byte[4096];
try
{
while (true)
{
token.ThrowIfCancellationRequested();
uint read;
if (!WinHttpReadData(request, buffer, (uint)buffer.Length, out read)) return WindowsFailure();
if (read == 0) break;
if (response.Length + read > maxResponseBytes) return Failure("Control response exceeded limit");
response.Write(buffer, 0, (int)read);
}
token.ThrowIfCancellationRequested();
byte[] data = response.ToArray();
Array.Clear(response.GetBuffer(), 0, (int)response.Length);
Array.Clear(buffer, 0, buffer.Length);
return new Response { StatusCode = (int)status, Data = data, Headers = responseHeaders, Body = Encoding.UTF8.GetString(data),
RefreshInvalid = IsRefreshInvalid(responseHeaders) };
}
finally
{
Array.Clear(buffer, 0, buffer.Length);
if (response.TryGetBuffer(out ArraySegment<byte> used)) Array.Clear(used.Array, used.Offset, used.Count);
}
}
}
finally
{
if (request != IntPtr.Zero) WinHttpCloseHandle(request);
if (connection != IntPtr.Zero) WinHttpCloseHandle(connection);
if (session != IntPtr.Zero) WinHttpCloseHandle(session);
}
}
private static Response WindowsFailure() => Failure("WinHTTP error " + Marshal.GetLastWin32Error());
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr WinHttpOpen(string agent, uint access, string proxy, string bypass, uint flags);
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr WinHttpConnect(IntPtr session, string server, ushort port, uint reserved);
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr WinHttpOpenRequest(IntPtr connection, string verb, string path, string version,
string referer, IntPtr acceptTypes, uint flags);
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WinHttpSetTimeouts(IntPtr handle, int resolve, int connect, int send, int receive);
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WinHttpSetOption(IntPtr handle, uint option, ref uint value, uint size);
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WinHttpSendRequest(IntPtr request, string headers, uint headersLength, byte[] body,
uint bodyLength, uint totalLength, UIntPtr context);
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WinHttpReceiveResponse(IntPtr request, IntPtr reserved);
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WinHttpQueryHeaders(IntPtr request, uint info, string name, out uint value, ref uint size, IntPtr index);
[DllImport("winhttp.dll", EntryPoint = "WinHttpQueryHeaders", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WinHttpQueryHeadersText(IntPtr request, uint info, string name, IntPtr value, ref uint size, IntPtr index);
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WinHttpReadData(IntPtr request, [Out] byte[] buffer, uint capacity, out uint read);
[DllImport("winhttp.dll")] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool WinHttpCloseHandle(IntPtr handle);
private static Response SendMac(Uri uri, string method, byte[] body, string authorization,
int timeoutSeconds, CancellationToken token, int maxResponseBytes, IReadOnlyDictionary<string, string> requestHeaders)
{
IntPtr curl = IntPtr.Zero, headers = IntPtr.Zero, pinnedBody = IntPtr.Zero;
using (MemoryStream response = new MemoryStream())
{
bool exceeded = false;
int headerBytes = 0, headerCount = 0;
Dictionary<string, string> responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
CurlWrite write = (data, size, count, context) =>
{
try
{
ulong length = checked(size.ToUInt64() * count.ToUInt64());
if (token.IsCancellationRequested || length > (ulong)maxResponseBytes || response.Length + (long)length > maxResponseBytes)
{ exceeded = !token.IsCancellationRequested; return UIntPtr.Zero; }
byte[] bytes = new byte[(int)length];
Marshal.Copy(data, bytes, 0, bytes.Length);
response.Write(bytes, 0, bytes.Length);
return new UIntPtr(length);
}
catch { return UIntPtr.Zero; }
};
CurlProgress progress = (context, downloadTotal, downloadNow, uploadTotal, uploadNow) => token.IsCancellationRequested ? 1 : 0;
CurlWrite header = (data, size, count, context) =>
{
try
{
ulong length = checked(size.ToUInt64() * count.ToUInt64());
if (length > 16384 || token.IsCancellationRequested || headerBytes + (long)length > 65536 || ++headerCount > 256)
return UIntPtr.Zero;
headerBytes += (int)length;
byte[] bytes = new byte[(int)length];
Marshal.Copy(data, bytes, 0, bytes.Length);
KeepResponseHeader(responseHeaders, Encoding.UTF8.GetString(bytes).TrimEnd('\r', '\n'));
return new UIntPtr(length);
}
catch { return UIntPtr.Zero; }
};
try
{
// System libcurl is built with the OS TLS backend and its default trust store.
if (!EnsureCurlInitialized()) return Failure("Could not initialize system curl");
curl = curl_easy_init();
if (curl == IntPtr.Zero) return Failure("Could not create system curl request");
SetCurl(curl, 10002, uri.AbsoluteUri);
SetCurl(curl, 10036, method);
SetCurl(curl, 52, 0L); // never follow redirects
SetCurl(curl, 64, 1L); // verify peer chain
SetCurl(curl, 81, 2L); // verify hostname
SetCurl(curl, 99, 1L); // no signals on worker threads
SetCurl(curl, 155, checked(timeoutSeconds * 1000L));
SetCurl(curl, 156, checked(timeoutSeconds * 1000L));
SetCurl(curl, 181, uri.Scheme == Uri.UriSchemeHttps ? 2L : 3L);
SetCurl(curl, 182, 2L); // redirects are disabled
SetCurl(curl, 10018, "BD2LoginUI");
headers = AddCurlHeader(headers, "Accept: application/json");
if (authorization != null) headers = AddCurlHeader(headers, "Authorization: " + authorization);
if (requestHeaders != null)
foreach (KeyValuePair<string, string> item in requestHeaders) headers = AddCurlHeader(headers, item.Key + ": " + item.Value);
if (body != null)
{
if (!HasContentType(requestHeaders)) headers = AddCurlHeader(headers, "Content-Type: application/json");
pinnedBody = Marshal.AllocHGlobal(Math.Max(1, body.Length));
if (body.Length > 0) Marshal.Copy(body, 0, pinnedBody, body.Length);
SetCurl(curl, 10015, pinnedBody);
SetCurl(curl, 60, (long)body.Length);
}
SetCurl(curl, 10023, headers);
SetCurl(curl, 20011, Marshal.GetFunctionPointerForDelegate(write));
SetCurl(curl, 20079, Marshal.GetFunctionPointerForDelegate(header));
SetCurl(curl, 20219, Marshal.GetFunctionPointerForDelegate(progress));
SetCurl(curl, 43, 0L);
token.ThrowIfCancellationRequested();
int result = curl_easy_perform(curl);
GC.KeepAlive(write);
GC.KeepAlive(progress);
GC.KeepAlive(header);
token.ThrowIfCancellationRequested();
if (result != 0) return Failure(exceeded ? "Control response exceeded limit" : "System curl error " + result);
long status;
int infoResult = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
? curl_easy_getinfo_arm64(curl, 0x200002, 0, 0, 0, 0, 0, 0, out status)
: curl_easy_getinfo(curl, 0x200002, out status);
if (infoResult != 0) return Failure("Could not read system curl status");
byte[] data = response.ToArray();
Array.Clear(response.GetBuffer(), 0, (int)response.Length);
return new Response { StatusCode = (int)status, Data = data, Headers = responseHeaders, Body = Encoding.UTF8.GetString(data),
RefreshInvalid = IsRefreshInvalid(responseHeaders) };
}
finally
{
if (curl != IntPtr.Zero) curl_easy_cleanup(curl);
if (headers != IntPtr.Zero) curl_slist_free_all(headers);
if (pinnedBody != IntPtr.Zero)
{
for (int index = 0; index < (body?.Length ?? 0); index++) Marshal.WriteByte(pinnedBody, index, 0);
Marshal.FreeHGlobal(pinnedBody);
}
if (response.TryGetBuffer(out ArraySegment<byte> used)) Array.Clear(used.Array, used.Offset, used.Count);
GC.KeepAlive(write);
GC.KeepAlive(progress);
GC.KeepAlive(header);
}
}
}
private static void KeepResponseHeader(Dictionary<string, string> headers, string line)
{
int separator = line.IndexOf(':');
if (separator <= 0) return;
string name = line.Substring(0, separator).Trim();
if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("X-BD2-", StringComparison.OrdinalIgnoreCase))
headers[name] = line.Substring(separator + 1).Trim();
}
private static bool HasContentType(IReadOnlyDictionary<string, string> headers)
{
if (headers == null) return false;
foreach (string name in headers.Keys)
if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
private static bool IsRefreshInvalid(Dictionary<string, string> headers) =>
headers.TryGetValue("X-BD2-Refresh-Invalid", out string value) && value == "1";
private static bool AllowedRequestHeader(string name) => name != null &&
(name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
name.Equals("b2_app_version", StringComparison.OrdinalIgnoreCase) || name.Equals("b2_bundle_version", StringComparison.OrdinalIgnoreCase) ||
name.Equals("market_type", StringComparison.OrdinalIgnoreCase) || name.StartsWith("X-BD2-", StringComparison.OrdinalIgnoreCase));
private static bool ValidHeaderName(string name)
{
foreach (char character in name)
if (!((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '-' || character == '_')) return false;
return true;
}
private static IntPtr AddCurlHeader(IntPtr list, string header)
{
IntPtr next = curl_slist_append(list, header);
if (next == IntPtr.Zero) throw new InvalidOperationException("Could not create native request headers");
return next;
}
private static bool EnsureCurlInitialized()
{
lock (CurlInitializationLock)
{
if (curlInitialized) return true;
if (curl_global_init(3) != 0) return false;
curlInitialized = true;
return true;
}
}
private static void SetCurl(IntPtr curl, int option, long value)
{
int result = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
? curl_easy_setopt_arm64_long(curl, option, 0, 0, 0, 0, 0, 0, value)
: curl_easy_setopt_long(curl, option, value);
if (result != 0) throw new InvalidOperationException("System curl option failed");
}
private static void SetCurl(IntPtr curl, int option, string value)
{
if (RuntimeInformation.ProcessArchitecture != Architecture.Arm64)
{
if (curl_easy_setopt_string(curl, option, value) != 0) throw new InvalidOperationException("System curl option failed");
return;
}
byte[] utf8 = Encoding.UTF8.GetBytes(value + "\0");
IntPtr pointer = Marshal.AllocHGlobal(utf8.Length);
try { Marshal.Copy(utf8, 0, pointer, utf8.Length); SetCurl(curl, option, pointer); }
finally { Array.Clear(utf8, 0, utf8.Length); Marshal.FreeHGlobal(pointer); }
}
private static void SetCurl(IntPtr curl, int option, IntPtr value)
{
int result = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
? curl_easy_setopt_arm64_pointer(curl, option, 0, 0, 0, 0, 0, 0, value)
: curl_easy_setopt_pointer(curl, option, value);
if (result != 0) throw new InvalidOperationException("System curl option failed");
}
private const string Curl = "/usr/lib/libcurl.dylib";
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate UIntPtr CurlWrite(IntPtr data, UIntPtr size, UIntPtr count, IntPtr context);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int CurlProgress(IntPtr context, long downloadTotal, long downloadNow, long uploadTotal, long uploadNow);
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern int curl_global_init(long flags);
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern IntPtr curl_easy_init();
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern void curl_easy_cleanup(IntPtr curl);
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_perform(IntPtr curl);
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_getinfo(IntPtr curl, int info, out long value);
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_setopt_long(IntPtr curl, int option, long value);
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_setopt_pointer(IntPtr curl, int option, IntPtr value);
// Apple's arm64 variadic ABI puts unnamed arguments on the stack. Occupying
// x2..x7 with unused arguments places the value at the first stack slot.
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)]
private static extern int curl_easy_setopt_arm64_long(IntPtr curl, int option, ulong x2, ulong x3, ulong x4, ulong x5, ulong x6, ulong x7, long value);
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)]
private static extern int curl_easy_setopt_arm64_pointer(IntPtr curl, int option, ulong x2, ulong x3, ulong x4, ulong x5, ulong x6, ulong x7, IntPtr value);
[DllImport(Curl, EntryPoint = "curl_easy_getinfo", CallingConvention = CallingConvention.Cdecl)]
private static extern int curl_easy_getinfo_arm64(IntPtr curl, int info, ulong x2, ulong x3, ulong x4, ulong x5, ulong x6, ulong x7, out long value);
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_setopt_string(IntPtr curl, int option, [MarshalAs(UnmanagedType.LPUTF8Str)] string value);
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_setopt_write(IntPtr curl, int option, CurlWrite value);
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_setopt_progress(IntPtr curl, int option, CurlProgress value);
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern IntPtr curl_slist_append(IntPtr list, [MarshalAs(UnmanagedType.LPUTF8Str)] string value);
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern void curl_slist_free_all(IntPtr list);
}
+37 -1681
View File
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BepInEx.Logging;
namespace Bd2LoginUI;
// Unity keeps its game packet parser; only the HTTPS transport runs through the OS.
// This listener binds loopback only and can forward game paths to one fixed origin.
internal sealed class SecureGameRelay : IDisposable
{
private readonly Uri server;
private readonly Uri local;
private readonly string prefix;
private readonly TcpListener listener;
private readonly CancellationTokenSource lifetime = new CancellationTokenSource();
private readonly SemaphoreSlim slots = new SemaphoreSlim(16, 16);
private readonly ManualLogSource log;
private int disposed;
private SecureGameRelay(Uri server, ManualLogSource log)
{
if (server == null || server.Scheme != Uri.UriSchemeHttps || server.AbsolutePath != "/" ||
server.UserInfo.Length != 0 || server.Query.Length != 0 || server.Fragment.Length != 0)
throw new ArgumentException("Game relay requires a fixed HTTPS origin");
this.server = server;
this.log = log;
byte[] nonce = new byte[32];
using (RandomNumberGenerator random = RandomNumberGenerator.Create()) random.GetBytes(nonce);
prefix = "/" + BitConverter.ToString(nonce).Replace("-", "").ToLowerInvariant() + "/";
Array.Clear(nonce, 0, nonce.Length);
listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
local = new Uri("http://127.0.0.1:" + ((IPEndPoint)listener.LocalEndpoint).Port.ToString(CultureInfo.InvariantCulture) + "/");
new Thread(AcceptLoop) { IsBackground = true, Name = "BD2 secure game transport" }.Start();
log?.LogInfo("Game HTTPS transport uses native OS networking through a private loopback relay");
}
internal static SecureGameRelay Start(Uri server, ManualLogSource log) => new SecureGameRelay(server, log);
internal Uri Rewrite(Uri uri)
{
return IsRelayedUri(uri) ? new Uri(local, prefix + uri.PathAndQuery.TrimStart('/')) : uri;
}
internal bool TryResolve(Uri uri, out Uri original)
{
original = null;
if (uri == null || uri.Scheme != local.Scheme || uri.Host != local.Host || uri.Port != local.Port ||
!uri.AbsolutePath.StartsWith(prefix, StringComparison.Ordinal)) return false;
Uri resolved = new Uri(server, "/" + uri.PathAndQuery.Substring(prefix.Length));
if (!IsRelayedUri(resolved)) return false;
original = resolved;
return true;
}
private bool IsRelayedUri(Uri uri) => uri != null && uri.Scheme == server.Scheme &&
uri.IdnHost == server.IdnHost && uri.Port == server.Port && uri.UserInfo.Length == 0 &&
uri.Fragment.Length == 0 && (uri.AbsolutePath.StartsWith("/game/", StringComparison.Ordinal) ||
uri.AbsolutePath.Equals("/logs", StringComparison.Ordinal));
private void AcceptLoop()
{
while (Volatile.Read(ref disposed) == 0)
{
TcpClient client;
try { client = listener.AcceptTcpClient(); }
catch (Exception) { if (Volatile.Read(ref disposed) != 0) return; continue; }
if (!slots.Wait(0)) { client.Dispose(); continue; }
Task.Run(async () =>
{
try { using (client) await Serve(client).ConfigureAwait(false); }
catch (Exception ex) when (ex is IOException || ex is SocketException || ex is OperationCanceledException || ex is InvalidDataException)
{ /* Close failed transport: the game's existing ConnectionError recovery handles it. */ }
catch (Exception ex) { log?.LogWarning("Native game transport failed: " + ex.GetType().Name); }
finally { slots.Release(); }
});
}
}
private async Task Serve(TcpClient client)
{
client.ReceiveTimeout = 5000;
client.SendTimeout = 5000;
NetworkStream stream = client.GetStream();
byte[] header = new byte[16 * 1024];
int used = 0;
while (used < header.Length)
{
int next = stream.ReadByte();
if (next < 0) throw new IOException("Incomplete relay headers");
header[used++] = (byte)next;
if (used >= 4 && header[used - 4] == 13 && header[used - 3] == 10 && header[used - 2] == 13 && header[used - 1] == 10) break;
}
if (used == header.Length) throw new InvalidDataException("Relay header limit");
string[] lines = Encoding.ASCII.GetString(header, 0, used).Split(new[] { "\r\n" }, StringSplitOptions.None);
Array.Clear(header, 0, header.Length);
string[] start = lines[0].Split(' ');
if (start.Length != 3 || (start[0] != "PUT" && start[0] != "POST" && start[0] != "GET") ||
!start[1].StartsWith(prefix, StringComparison.Ordinal) || !start[2].StartsWith("HTTP/1.", StringComparison.Ordinal))
throw new InvalidDataException("Invalid relay request");
if (!Uri.TryCreate(local, start[1], out Uri target) || !TryResolve(target, out Uri remote))
throw new InvalidDataException("Invalid relay target");
Dictionary<string, string> headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
int length = 0;
bool lengthSeen = false;
foreach (string line in lines)
{
int separator = line.IndexOf(':');
if (separator <= 0) continue;
string name = line.Substring(0, separator).Trim(), value = line.Substring(separator + 1).Trim();
if (name.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Chunked relay requests are not supported");
if (name.Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
{
if (lengthSeen || !int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out length) || length > 8 * 1024 * 1024)
throw new InvalidDataException("Invalid relay content length");
lengthSeen = true;
}
if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
name.Equals("b2_app_version", StringComparison.OrdinalIgnoreCase) || name.Equals("b2_bundle_version", StringComparison.OrdinalIgnoreCase) ||
name.Equals("market_type", StringComparison.OrdinalIgnoreCase)) headers[name] = value;
}
byte[] body = length == 0 ? null : new byte[length];
PlatformControlHttp.Response response = null;
try
{
for (int offset = 0; offset < length;)
{
int count = stream.Read(body, offset, length - offset);
if (count <= 0) throw new IOException("Incomplete relay body");
offset += count;
}
response = await PlatformControlHttp.Send(remote, start[0], body, null, 4, lifetime.Token,
lifetime.Token, 64 * 1024 * 1024, headers).ConfigureAwait(false);
if (response.StatusCode == 0 || response.Data == null) throw new IOException("Native game transport unavailable");
StringBuilder output = new StringBuilder("HTTP/1.1 ").Append(response.StatusCode.ToString(CultureInfo.InvariantCulture)).Append(" Response\r\nConnection: close\r\nCache-Control: no-store\r\n");
foreach (KeyValuePair<string, string> item in response.Headers)
{
if ((item.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || item.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
item.Key.StartsWith("X-BD2-", StringComparison.OrdinalIgnoreCase)) && item.Value.IndexOfAny(new[] { '\r', '\n' }) < 0)
output.Append(item.Key).Append(": ").Append(item.Value).Append("\r\n");
}
output.Append("Content-Length: ").Append(response.Data.Length.ToString(CultureInfo.InvariantCulture)).Append("\r\n\r\n");
byte[] replyHeader = Encoding.ASCII.GetBytes(output.ToString());
stream.Write(replyHeader, 0, replyHeader.Length);
stream.Write(response.Data, 0, response.Data.Length);
Array.Clear(replyHeader, 0, replyHeader.Length);
}
finally
{
if (body != null) Array.Clear(body, 0, body.Length);
if (response?.Data != null) Array.Clear(response.Data, 0, response.Data.Length);
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
lifetime.Cancel();
listener.Stop();
}
}
+595
View File
@@ -0,0 +1,595 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using System.Threading;
using BD2.GameNames;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
using static BD2.GameNames.Game;
using static Bd2LoginUI.LoginPanel;
using static Bd2LoginUI.LoginRuntime;
using static Bd2LoginUI.ControlRequests;
using static Bd2LoginUI.LoginController;
namespace Bd2LoginUI;
// Owns recovery generations, monitor coroutines, game relay and recovery overlay.
internal static class SessionRecovery
{
internal static int SessionRecoveryInProgress;
private static RecoveryHost Owner;
private static SecureGameRelay GameRelay;
private static readonly List<WeakReference<UnityWebRequest>> GameRequests = new List<WeakReference<UnityWebRequest>>();
internal static bool EstablishedGameSession;
private static GameObject RecoveryOverlay;
private static Text RecoveryText;
internal static string ServerInstanceID;
private static int RecoveryRestartScheduled;
private static int RecoveryEnterScheduled;
internal static int RecoveryGeneration;
internal static int RuntimeProbeFailures;
internal static CancellationTokenSource ControlProbeCancellation = new CancellationTokenSource();
internal static void DisposeGameRelay()
{
SecureGameRelay relay = GameRelay;
GameRelay = null;
relay?.Dispose();
}
internal static void EnsureGameRelay()
{
if (GameRelay == null && ServerRoot != null && ServerRoot.Scheme == Uri.UriSchemeHttps && PlatformControlHttp.IsSupported)
{
GameRelay = SecureGameRelay.Start(ServerRoot, Log);
}
}
private static bool TryGetOwnedRequestUri(string url, out Uri canonical)
{
canonical = null;
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri parsed)) return false;
if (GameRelay != null && GameRelay.TryResolve(parsed, out Uri original)) parsed = original;
if (ServerRoot == null || !SameOrigin(ServerRoot, parsed)) return false;
canonical = parsed;
return true;
}
private static void CancelControlProbes()
{
CancellationTokenSource previous = ControlProbeCancellation;
ControlProbeCancellation = new CancellationTokenSource();
previous.Cancel();
previous.Dispose();
}
internal static void IntroAwakePostfix(object __instance)
{
try
{
EnsureRecoveryHost();
}
catch (Exception ex)
{
Log?.LogError("Could not restore login recovery host: " + ex);
}
ConfigureLoginPanel(__instance);
}
// The BepInEx plugin component can disappear while its static Harmony hooks remain.
// Keep coroutines on a scene-independent host and recreate it if Unity destroys it.
internal static void EnsureRecoveryHost()
{
if (Owner != null && Owner.gameObject.activeInHierarchy)
{
return;
}
if (Owner != null)
{
UnityEngine.Object.Destroy(Owner.gameObject);
}
GameObject host = new GameObject("BD2 Login Recovery Host");
UnityEngine.Object.DontDestroyOnLoad(host);
Owner = host.AddComponent<RecoveryHost>();
Log?.LogInfo("Created persistent login recovery coroutine host");
CancelControlProbes();
Interlocked.Increment(ref RecoveryGeneration);
Interlocked.Exchange(ref RecoveryRestartScheduled, 0);
Interlocked.Exchange(ref RecoveryEnterScheduled, 0);
Owner.StartCoroutine(RuntimeMonitor());
}
public sealed class RecoveryHost : MonoBehaviour
{
internal readonly CancellationTokenSource Lifetime = new CancellationTokenSource();
private void OnDestroy()
{
Lifetime.Cancel();
Lifetime.Dispose();
Log?.LogWarning("Login recovery coroutine host was destroyed");
}
}
internal static void SendWebRequestPrefix(UnityWebRequest __instance)
{
if (__instance == null || !TryGetOwnedRequestUri(__instance.url, out Uri canonical)) return;
bool gameRequest = canonical.AbsolutePath.StartsWith("/game/", StringComparison.Ordinal);
bool telemetryRequest = canonical.AbsolutePath.Equals("/logs", StringComparison.Ordinal);
if (!gameRequest && !telemetryRequest) return;
EnsureGameRelay();
if (gameRequest)
{
GameRequests.RemoveAll(reference => !reference.TryGetTarget(out _));
if (GameRequests.Count >= 128) GameRequests.RemoveAt(0);
GameRequests.Add(new WeakReference<UnityWebRequest>(__instance));
}
if (GameRelay != null)
{
__instance.url = GameRelay.Rewrite(canonical).AbsoluteUri;
}
}
internal static void DisposeWebRequestPrefix(UnityWebRequest __instance)
{
if (__instance == null || !__instance.isDone)
{
return;
}
GameRequests.RemoveAll(reference => !reference.TryGetTarget(out UnityWebRequest request) || ReferenceEquals(request, __instance));
InspectCompletedGameRequest(__instance);
}
private static void InspectCompletedGameRequest(UnityWebRequest request)
{
try
{
if (!IsCurrentGameRequest(request, out Uri requestUri))
{
return;
}
if (requestUri.AbsolutePath.Equals("/game/LoginUser", StringComparison.Ordinal) &&
request.responseCode >= 200 && request.responseCode < 300)
{
EnsureRecoveryHost();
EstablishedGameSession = true;
SetRecoveryMessage("正在同步玩家数据……\nSynchronizing player data…");
return;
}
bool accessExpired = requestUri.AbsolutePath.Equals("/game/LoginUser", StringComparison.Ordinal) &&
request.responseCode == 401 &&
string.Equals(request.GetResponseHeader("X-BD2-Access-Expired"), "1", StringComparison.Ordinal);
if (accessExpired)
{
AccessTokens.Clear();
BeginSessionRecovery("expired game access credential");
return;
}
bool sessionExpired = request.responseCode == 401 &&
string.Equals(request.GetResponseHeader("X-BD2-Session-Expired"), "1", StringComparison.Ordinal);
bool serverRestarting = request.responseCode == 503 &&
string.Equals(request.GetResponseHeader("X-BD2-Reconnect"), "1", StringComparison.Ordinal);
if (!sessionExpired && !serverRestarting)
{
return;
}
BeginSessionRecovery(serverRestarting ? "server restart" : "expired game session");
}
catch (Exception ex)
{
Log?.LogError("Could not inspect the completed game request: " + ex);
}
}
private static bool IsCurrentGameRequest(UnityWebRequest request, out Uri requestUri)
{
requestUri = null;
if (request == null || !TryGetOwnedRequestUri(request.url, out Uri canonical) ||
!canonical.AbsolutePath.StartsWith("/game/", StringComparison.Ordinal)) return false;
requestUri = canonical;
return true;
}
internal static bool BeginSessionRecovery(string reason)
{
try
{
if (ServerRoot == null)
{
Log?.LogWarning("Cannot recover game session without a configured server");
return false;
}
EnsureRecoveryHost();
if (Interlocked.CompareExchange(ref SessionRecoveryInProgress, 1, 0) == 0)
{
Log?.LogWarning("Starting automatic game-session recovery: " + reason);
ShowRecoveryOverlay("正在重新连接服务器……\nReconnecting to server…");
}
else
{
Log?.LogWarning("Restarting automatic game-session recovery: " + reason);
}
ScheduleRecoveryRestart();
return true;
}
catch (Exception ex)
{
FinishRecovery(false, "could not start recovery: " + ex);
return false;
}
}
internal static bool ExponentialBackoffPrefix(object __0)
{
try
{
if (ServerRoot == null || !IsConfiguredServerFailure(__0) || !IsTransportFailure(__0))
{
return true;
}
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
{
return !BeginSessionRecovery("transport failure during recovery");
}
return !EstablishedGameSession || !BeginSessionRecovery("transport failure");
}
catch (Exception ex)
{
Log?.LogError("Could not classify game request failure: " + ex);
return true;
}
}
private static bool IsTransportFailure(object packetException)
{
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
object packet = packetException.GetType().GetGameProperty("PacketData", flags)?.GetValue(packetException, null);
string server = packet?.GetType().GetGameField("RequestServerURL", flags)?.GetValue(packet) as string;
string path = packet?.GetType().GetGameProperty("SendPath", flags)?.GetValue(packet, null) as string;
if (!Uri.TryCreate(server + path, UriKind.Absolute, out Uri packetUri))
{
return false;
}
UnityWebRequest match = null;
foreach (WeakReference<UnityWebRequest> reference in GameRequests)
{
if (!reference.TryGetTarget(out UnityWebRequest request) || !request.isDone ||
!TryGetOwnedRequestUri(request.url, out Uri requestUri) || requestUri != packetUri)
{
continue;
}
if (match != null)
{
return false; // Ambiguous concurrent requests must retain client error handling.
}
match = request;
}
// HTTP errors (including 501) are ProtocolError, even though the game calls
// ExponetialBackOff for every non-Success result. Do not infer status from error text.
return match != null && match.result == UnityWebRequest.Result.ConnectionError;
}
private static bool IsConfiguredServerFailure(object packetException)
{
if (packetException == null || ServerRoot == null)
{
return false;
}
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
Type exceptionType = packetException.GetType();
string url = exceptionType.GetGameProperty("Url", flags)?.GetValue(packetException, null) as string;
object packet = exceptionType.GetGameProperty("PacketData", flags)?.GetValue(packetException, null);
string requestServer = packet?.GetType().GetGameField("RequestServerURL", flags)?.GetValue(packet) as string;
foreach (string candidate in new[] { requestServer, url })
{
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}
if (!Uri.TryCreate(candidate, UriKind.Absolute, out Uri parsed))
{
parsed = new Uri(ServerRoot, candidate.TrimStart('/'));
}
if (SameOrigin(ServerRoot, parsed))
{
return true;
}
}
return false;
}
private static void ScheduleRecoveryRestart()
{
CancelControlProbes();
Interlocked.Increment(ref RecoveryGeneration);
AuthenticationLoadingOrigin = null;
LoginInProgress = false;
ContinueMaintenance = false;
EstablishedGameSession = false;
RuntimeProbeFailures = 0;
ServerInstanceID = null;
Interlocked.Exchange(ref RecoveryEnterScheduled, 0);
if (Interlocked.CompareExchange(ref RecoveryRestartScheduled, 1, 0) == 0)
{
Owner.StartCoroutine(StartLatestRecoveryGeneration());
}
}
private static IEnumerator StartLatestRecoveryGeneration()
{
int generation;
do
{
generation = Volatile.Read(ref RecoveryGeneration);
yield return new WaitForSecondsRealtime(0.25f);
}
while (generation != Volatile.Read(ref RecoveryGeneration));
Interlocked.Exchange(ref RecoveryRestartScheduled, 0);
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
{
Owner.StartCoroutine(WaitForServerAndRestart(generation));
}
}
private static IEnumerator WaitForServerAndRestart(int generation)
{
float delay = 0.5f;
string lastError = null;
CancellationToken lifetime = Owner.Lifetime.Token;
while (Volatile.Read(ref SessionRecoveryInProgress) != 0 && generation == Volatile.Read(ref RecoveryGeneration))
{
SetRecoveryMessage("等待服务器启动……\nWaiting for server…");
ControlProbeResult result = null;
yield return RequestControlEndpoint(new Uri(ServerRoot, "readyz"), UnityWebRequest.kHttpVerbGET, null, null, 6,
lifetime, ControlProbeCancellation.Token, response => result = response);
if (result == null || generation != Volatile.Read(ref RecoveryGeneration) || lifetime.IsCancellationRequested) yield break;
if (result.Success && result.StatusCode == 200) break;
if (result.Error != lastError)
{
Log?.LogWarning("Server readiness probe failed: " + result.Error);
lastError = result.Error;
}
yield return new WaitForSecondsRealtime(delay);
delay = Math.Min(delay * 2f, 5f);
}
if (Volatile.Read(ref SessionRecoveryInProgress) == 0 || generation != Volatile.Read(ref RecoveryGeneration))
{
yield break;
}
Log?.LogInfo("Server became ready; restarting client to restore session");
SetRecoveryMessage("正在恢复登录会话……\nRestoring session…");
if (!RestartClientForRecovery())
{
FinishRecovery(false, "client restart methods are unavailable", generation);
}
}
private static bool RestartClientForRecovery()
{
BDNetwork.NetworkManager network = FindUnitySingleton<BDNetwork.NetworkManager>();
AppManager app = FindUnitySingleton<AppManager>();
MethodInfo refresh = network?.GetType().GetGameMethod(nameof(BDNetwork.NetworkManager.Refresh), BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null);
MethodInfo restart = app?.GetType().GetGameMethod(nameof(AppManager.AppReStart), BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null);
if (refresh == null || restart == null)
{
return false;
}
AuthenticationLoadingOrigin = null;
LoginInProgress = false;
ContinueMaintenance = false;
Interlocked.Exchange(ref RecoveryEnterScheduled, 0);
ServerInstanceID = null;
refresh.Invoke(network, null);
restart.Invoke(app, null);
return true;
}
private static IEnumerator RuntimeMonitor()
{
WaitForSecondsRealtime interval = new WaitForSecondsRealtime(5f);
while (true)
{
if (ServerRoot == null || !EstablishedGameSession || Volatile.Read(ref SessionRecoveryInProgress) != 0)
{
yield return interval;
continue;
}
int generation = Volatile.Read(ref RecoveryGeneration);
CancellationToken lifetime = Owner.Lifetime.Token;
{
ControlProbeResult result = null;
yield return RequestControlEndpoint(new Uri(ServerRoot, "client/runtime"), UnityWebRequest.kHttpVerbGET, null, null, 6,
lifetime, ControlProbeCancellation.Token, response => result = response);
if (lifetime.IsCancellationRequested) yield break;
if (result == null || generation != Volatile.Read(ref RecoveryGeneration) || Volatile.Read(ref SessionRecoveryInProgress) != 0) continue;
if (!result.Success)
{
Log?.LogWarning("Server runtime probe failed: " + result.Error);
RuntimeProbeFailures++;
if (RuntimeProbeFailures >= 3)
{
BeginSessionRecovery("runtime probe failed three consecutive times");
}
}
else
{
RuntimeProbeFailures = 0;
RuntimeStatus status = null;
try
{
status = JsonUtility.FromJson<RuntimeStatus>(result.Body);
}
catch (Exception ex)
{
Log?.LogWarning("Server runtime response was invalid: " + ex.Message);
}
if (status != null && !string.IsNullOrEmpty(status.instance_id))
{
if (ServerInstanceID == null)
{
ServerInstanceID = status.instance_id;
}
else if (ServerInstanceID != status.instance_id || status.status == "draining")
{
ServerInstanceID = status.instance_id;
BeginSessionRecovery(status.status == "draining" ? "server draining" : "server instance changed");
}
}
}
}
yield return interval;
}
}
internal static bool SuppressNetworkErrorDuringRecovery()
{
return Volatile.Read(ref SessionRecoveryInProgress) == 0;
}
internal static void SetIntroStatePostfix(object __instance, object __0)
{
if (Volatile.Read(ref SessionRecoveryInProgress) == 0 || __0 == null || Convert.ToInt32(__0) != 9 || Owner == null)
{
return;
}
if (Interlocked.CompareExchange(ref RecoveryEnterScheduled, 1, 0) != 0)
{
return;
}
Owner.StartCoroutine(EnterAfterAuthoritativeLoad(__instance, Volatile.Read(ref RecoveryGeneration)));
}
private static IEnumerator EnterAfterAuthoritativeLoad(object introUI, int generation)
{
SetRecoveryMessage("正在返回安全场景……\nReturning to a safe scene…");
yield return null;
if (generation != Volatile.Read(ref RecoveryGeneration))
{
yield break;
}
try
{
EnterGame.Invoke(introUI, null);
}
catch (Exception ex)
{
BeginSessionRecovery("could not enter safe scene: " + ex.Message);
yield break;
}
float deadline = Time.realtimeSinceStartup + 30f;
bool fieldLoaded = false;
while (Time.realtimeSinceStartup < deadline && generation == Volatile.Read(ref RecoveryGeneration))
{
GameFieldManager field = FindUnitySingleton<GameFieldManager>();
PropertyInfo loaded = field?.GetType().GetGameProperty(nameof(GameFieldManager.IsLoadedField), BindingFlags.Instance | BindingFlags.Public);
if (loaded != null && loaded.GetValue(field) is bool ready && ready)
{
fieldLoaded = true;
break;
}
if (IsPackCollectionActive())
{
fieldLoaded = true;
break;
}
yield return new WaitForSecondsRealtime(0.25f);
}
if (!fieldLoaded)
{
if (generation == Volatile.Read(ref RecoveryGeneration))
{
BeginSessionRecovery("safe scene load timed out");
}
yield break;
}
FinishRecovery(true, null, generation);
}
private static bool IsPackCollectionActive()
{
Type uiManager = typeof(UIManager);
MethodInfo getUI = uiManager?.GetGameMethod(
"GetUI",
BindingFlags.Static | BindingFlags.Public,
null,
new[] { typeof(string) },
null);
Component collection = getUI?.Invoke(null, new object[] { "PackCollectionUI" }) as Component;
return collection != null && collection.gameObject != null && collection.gameObject.activeInHierarchy;
}
private static void ShowRecoveryOverlay(string message)
{
if (RecoveryOverlay == null)
{
RecoveryOverlay = new GameObject(
"BD2 Recovery Overlay",
typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster), typeof(Image));
UnityEngine.Object.DontDestroyOnLoad(RecoveryOverlay);
Canvas canvas = RecoveryOverlay.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = short.MaxValue;
Image background = RecoveryOverlay.GetComponent<Image>();
background.color = new Color(0.025f, 0.035f, 0.055f, 0.94f);
RectTransform root = RecoveryOverlay.GetComponent<RectTransform>();
root.anchorMin = Vector2.zero;
root.anchorMax = Vector2.one;
root.offsetMin = root.offsetMax = Vector2.zero;
GameObject label = new GameObject("Status", typeof(RectTransform), typeof(Text));
label.transform.SetParent(RecoveryOverlay.transform, false);
RecoveryText = label.GetComponent<Text>();
RecoveryText.font = Resources.GetBuiltinResource<Font>("Arial.ttf");
RecoveryText.fontSize = 28;
RecoveryText.alignment = TextAnchor.MiddleCenter;
RecoveryText.color = Color.white;
RectTransform rect = label.GetComponent<RectTransform>();
rect.anchorMin = new Vector2(0.15f, 0.35f);
rect.anchorMax = new Vector2(0.85f, 0.65f);
rect.offsetMin = rect.offsetMax = Vector2.zero;
}
RecoveryOverlay.SetActive(true);
SetRecoveryMessage(message);
}
private static void SetRecoveryMessage(string message)
{
if (RecoveryText != null)
{
RecoveryText.text = message;
}
}
internal static void FinishRecovery(bool success, string error, int expectedGeneration = 0)
{
if (expectedGeneration != 0 && expectedGeneration != Volatile.Read(ref RecoveryGeneration))
{
return;
}
if (!success)
{
Log?.LogError("Automatic session recovery failed: " + error);
}
if (RecoveryOverlay != null)
{
UnityEngine.Object.Destroy(RecoveryOverlay);
RecoveryOverlay = null;
RecoveryText = null;
}
CancelControlProbes();
Interlocked.Increment(ref RecoveryGeneration);
Interlocked.Exchange(ref SessionRecoveryInProgress, 0);
Interlocked.Exchange(ref RecoveryRestartScheduled, 0);
Interlocked.Exchange(ref RecoveryEnterScheduled, 0);
Log?.LogInfo(success ? "Automatic session recovery completed" : "Automatic session recovery stopped");
}
[Serializable]
private sealed class RuntimeStatus
{
public string status = null;
public string instance_id = null;
public int retry_after_ms = 0;
}
}
+18
View File
@@ -0,0 +1,18 @@
<Project>
<PropertyGroup>
<BD2PackageVersion>0.2.1</BD2PackageVersion>
<Version Condition="'$(Version)' == ''">$(BD2PackageVersion)</Version>
<Authors>Flechazo</Authors>
<RepositoryUrl>https://github.com/Flechazo098/bd2</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageProjectUrl>https://github.com/Flechazo098/bd2</PackageProjectUrl>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<None Include="$(MSBuildThisFileDirectory)..\LICENSE" Pack="true" PackagePath="" />
</ItemGroup>
</Project>
+13 -1
View File
@@ -1,5 +1,7 @@
# 客户端插件工程
对外开发库采用 NuGet `PackageReference` 导入;第三方插件无需本仓库源码或官方映射。导入、项目配置、Harmony/字符串 API 示例及部署步骤见 [BD2.GameSdk 使用说明](GameSdk/README.md)。维护者运行 `GameSdk/Pack.ps1` 生成 `BD2.GameSdk` 和 `BD2.GameNames` 两个版本配套的 `.nupkg`。
- `LocalIdentity/`:连接根目录 `versions.json` 所选客户端与本地服务器;构建产物为 `BD2LocalIdentity.dll`。
- `LoginUI/`:把客户端登录面板收敛为 Discord 与 Google 两个本服认证入口;构建产物为 `BD2LoginUI.dll`。
- `CaptureEnvironment/`:独立原版对照客户端抓包使用;构建产物为 `BD2CaptureEnvironment.dll`。
@@ -7,7 +9,11 @@
`LocalIdentity` 与 `LoginUI` 用于本地服客户端;`CaptureEnvironment` 用于独立原版抓包环境。原版抓包环境不得安装 `BD2LocalIdentity.dll` 或 `BD2LoginUI.dll`。
客户端、资源与插件版本都来自根目录 `versions.json`;MSBuild 在中间目录生成 C# 常量,源码不保存第二份版本值。
构建时必须显式传入对应客户端目录:
SDK 和共享运行时已内嵌当前游戏版本的同一份名字表。源码构建只需客户端目录,无需提供官方映射。`.NET 8 SDK` 构建工具;插件为 `netstandard2.1`,运行时为 `netstandard2.0`。构建自动生成可读引用、完整源码和内嵌源码 PDB,编译插件、执行 reobf 并验证真实 DLL 的引用。首次生成耗时数分钟,三个插件和 Debug/Release 共用本机缓存;IDE 转到定义可以查看方法体,`Game Sources` 文件夹提供全量浏览与搜索。IDE 设置见 [完整源码导航](GameSdk/README.md#完整源码导航)。
仓库公共路径和默认值集中在 `plugins/Directory.Build.props`;配置/框架相关的输出路径与构建步骤由 `Directory.Build.targets` 自动导入。三个插件 `.csproj` 只保留插件属性和需要的程序集引用。首次开发复制 `Directory.Build.local.props.example` 为 `Directory.Build.local.props`,填写 `BD2LocalGameDir` 和可选的 `BD2CaptureGameDir`;本机文件被 Git 忽略。配置后 IDE 与命令行共用这些路径,不需要在三个子目录分别放一份 `Directory.Build.props`。命令行 `-p:GameDir=...` 仍可覆盖本机设置。
`BD2GameSdkCache` 可放在本机 props 中指定共享源码缓存。特殊目录布局可以设置 `BD2ManagedDir`、`BD2BepInExDir`;`BD2SdkDir` 可覆盖中间产物目录。新增子目录 `Directory.Build.props` 会阻断 MSBuild 自动查找父文件,确有需要时应显式导入公共 `plugins/Directory.Build.props`。
```powershell
dotnet build .\plugins\LocalIdentity\LocalIdentity.csproj -c Release -p:GameDir="<本地服客户端目录>"
@@ -15,6 +21,12 @@ dotnet build .\plugins\LoginUI\LoginUI.csproj -c Release -p:GameDir="<本地服
dotnet build .\plugins\CaptureEnvironment\CaptureEnvironment.csproj -c Release -p:GameDir="<原版客户端目录>"
```
`bin` 中的插件是回映射后的运行产物,部署时同时复制相邻的 `BD2.GameNames.dll`。壳和明文表缓存在 `obj`,不部署。客户端开发入口 `go run .\cmd\bd2client --dev run` 自动使用内嵌表;发布和原版抓包启动脚本也携带共享库。
唯一名字数据源为 `GameNames/Mappings/names.json.gz`。只有维护者更新游戏版本的表时才需要官方 `.obfuscate`,执行 `GameSdk/UpdateNames.ps1`,验证后提交新表,再发布新版本 SDK。
开发方式、工具命令、字符串与编译期引用的边界见 [GameSdk/README.md](GameSdk/README.md)。现有 `tools/python/deobfuscate_client_source.py` 继续生成阅读镜像;它的全局标识符替换不作为程序集映射表使用。
认证策略由服务端同目录的 `authentication.json` 决定。`mode=local` 保持本地自动登录;公网或联机服可设为 `oauth`,并在 `providers` 中开启 `discord`、`google` 或两者。LoginUI 从当前连接的服务器读取这项策略,客户端不能自行启用服务端未开放的 provider。
OAuth access token 只保存在客户端进程内存中。自动登录 refresh credential 按规范化服务器 origin 隔离:Windows 使用当前用户作用域的 DPAPI 加密,再把密文按 origin 哈希键写入 PlayerPrefs 对应的用户注册表;macOS 使用系统 Keychain Services。没有受支持安全凭据存储的平台会禁用自动登录,不会回退到明文文件或 PlayerPrefs。除 Windows 的 DPAPI 密文外,PlayerPrefs 只保存原版的 `IsAutoLogin` 与 `StandaloneAutoLogin` 非敏感选择。
-4
View File
@@ -1,8 +1,4 @@
<Project>
<PropertyGroup>
<BD2VersionConfig>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\versions.json'))</BD2VersionConfig>
<BD2GeneratedVersionSource>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\obj\$(Configuration)\$(TargetFramework)\Bd2Versions.g.cs'))</BD2GeneratedVersionSource>
</PropertyGroup>
<Target Name="GenerateBD2VersionSource" BeforeTargets="CoreCompile">
<Error Condition="!Exists('$(BD2VersionConfig)')" Text="Missing repository version config: $(BD2VersionConfig)" />
<Exec Command="powershell -NoProfile -ExecutionPolicy Bypass -File &quot;$(MSBuildThisFileDirectory)Generate-VersionSource.ps1&quot; -Config &quot;$(BD2VersionConfig)&quot; -Output &quot;$(BD2GeneratedVersionSource)&quot; -Plugin &quot;$(BD2PluginVersionKey)&quot;" />
+8 -1
View File
@@ -43,11 +43,13 @@ $exe = Join-Path $GameDir 'BrownDust II.exe'
$pluginDir = Join-Path $GameDir 'BepInEx\plugins'
$localRes = Join-Path $pluginDir 'PluginLocalRes.dll'
$capturePlugin = Join-Path $pluginDir 'BD2CaptureEnvironment.dll'
$gameNamesLibrary = Join-Path $pluginDir 'BD2.GameNames.dll'
$identityPlugin = Join-Path $pluginDir 'BD2LocalIdentity.dll'
$loginUIPlugin = Join-Path $pluginDir 'BD2LoginUI.dll'
$doorstop = Join-Path $GameDir 'doorstop_config.ini'
$isolatedData = Join-Path $GameDir 'IsolatedUserData'
$stagedCapturePlugin = Join-Path $PSScriptRoot '..\..\plugins\CaptureEnvironment\bin\Release\netstandard2.1\BD2CaptureEnvironment.dll'
$stagedGameNamesLibrary = Join-Path (Split-Path -Parent $stagedCapturePlugin) 'BD2.GameNames.dll'
$stagedLocalResConfig = Join-Path $PSScriptRoot '..\..\plugins\CaptureEnvironment\bd2.localres.cfg'
$localResConfig = Join-Path $GameDir 'BepInEx\config\bd2.localres.cfg'
@@ -62,6 +64,11 @@ foreach ($requiredDirectory in @($GameDir, $ResourceRoot, $SharedDataDir)) {
}
if (Test-Path -LiteralPath $stagedCapturePlugin -PathType Leaf) {
if (-not (Test-Path -LiteralPath $stagedGameNamesLibrary -PathType Leaf)) {
throw "Capture build is missing its shared runtime: $stagedGameNamesLibrary"
}
New-Item -ItemType Directory -Force -Path $pluginDir | Out-Null
Copy-Item -LiteralPath $stagedGameNamesLibrary -Destination $gameNamesLibrary -Force
$installedHash = if (Test-Path -LiteralPath $capturePlugin -PathType Leaf) {
(Get-FileHash -LiteralPath $capturePlugin -Algorithm SHA256).Hash
}
@@ -73,7 +80,7 @@ if (Test-Path -LiteralPath $stagedCapturePlugin -PathType Leaf) {
}
}
foreach ($required in @($exe, $doorstop, $localRes, $capturePlugin)) {
foreach ($required in @($exe, $doorstop, $localRes, $capturePlugin, $gameNamesLibrary)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
throw "Required file is missing: $required"
}