diff --git a/.gitignore b/.gitignore index 4557ea7..31497dd 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Dockerfile.server b/Dockerfile.server index b6df7de..50acca8 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -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 diff --git a/GAME_CONFIGURATION.md b/GAME_CONFIGURATION.md new file mode 100644 index 0000000..9ee19dd --- /dev/null +++ b/GAME_CONFIGURATION.md @@ -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`,重启容器后生效。 diff --git a/README.md b/README.md index 149269d..ee5173e 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/RELEASE.md b/RELEASE.md index 92df680..8bb6cde 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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。 diff --git a/build-release.ps1 b/build-release.ps1 index 5ddd5fc..22931ec 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -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 diff --git a/game.json b/game.json new file mode 100644 index 0000000..971cd85 --- /dev/null +++ b/game.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "gacha": { + "include_collaboration_ur_weapons": false + } +} diff --git a/go/cmd/bd2client/dev_sdk_integration_test.go b/go/cmd/bd2client/dev_sdk_integration_test.go new file mode 100644 index 0000000..f98f9a3 --- /dev/null +++ b/go/cmd/bd2client/dev_sdk_integration_test.go @@ -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) + } + } +} diff --git a/go/cmd/bd2server/dev_command.go b/go/cmd/bd2server/dev_command.go index 3e3ec58..685f0e8 100644 --- a/go/cmd/bd2server/dev_command.go +++ b/go/cmd/bd2server/dev_command.go @@ -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) diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 068a08c..df233b6 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -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, diff --git a/go/cmd/bd2server/preflight.go b/go/cmd/bd2server/preflight.go index f2a161a..461a9b5 100644 --- a/go/cmd/bd2server/preflight.go +++ b/go/cmd/bd2server/preflight.go @@ -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 } diff --git a/go/internal/client/plugin/install.go b/go/internal/client/plugin/install.go index 07656a8..6644205 100644 --- a/go/internal/client/plugin/install.go +++ b/go/internal/client/plugin/install.go @@ -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)) { diff --git a/go/internal/client/plugin/install_test.go b/go/internal/client/plugin/install_test.go index 280e365..4c4e695 100644 --- a/go/internal/client/plugin/install_test.go +++ b/go/internal/client/plugin/install_test.go @@ -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) } diff --git a/go/internal/server/gacha/permanent_equipment_test.go b/go/internal/server/gacha/permanent_equipment_test.go new file mode 100644 index 0000000..9db4d21 --- /dev/null +++ b/go/internal/server/gacha/permanent_equipment_test.go @@ -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) + } + }) + } +} diff --git a/go/internal/server/gameconfig/config.go b/go/internal/server/gameconfig/config.go new file mode 100644 index 0000000..a416e7e --- /dev/null +++ b/go/internal/server/gameconfig/config.go @@ -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 +} diff --git a/go/internal/server/gameconfig/config_test.go b/go/internal/server/gameconfig/config_test.go new file mode 100644 index 0000000..afdd963 --- /dev/null +++ b/go/internal/server/gameconfig/config_test.go @@ -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) + } +} diff --git a/go/internal/server/gamedata/costume_burst.go b/go/internal/server/gamedata/costume_burst.go new file mode 100644 index 0000000..f4f4e1e --- /dev/null +++ b/go/internal/server/gamedata/costume_burst.go @@ -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 +} diff --git a/go/internal/server/gamedata/costume_burst_test.go b/go/internal/server/gamedata/costume_burst_test.go new file mode 100644 index 0000000..170e75f --- /dev/null +++ b/go/internal/server/gamedata/costume_burst_test.go @@ -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 +} diff --git a/go/internal/server/gamedata/equipment_collaboration.go b/go/internal/server/gamedata/equipment_collaboration.go new file mode 100644 index 0000000..770f2ca --- /dev/null +++ b/go/internal/server/gamedata/equipment_collaboration.go @@ -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 +} diff --git a/go/internal/server/gamedata/equipment_collaboration_test.go b/go/internal/server/gamedata/equipment_collaboration_test.go new file mode 100644 index 0000000..18415ac --- /dev/null +++ b/go/internal/server/gamedata/equipment_collaboration_test.go @@ -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") + } +} diff --git a/go/internal/server/gamedata/gacha.go b/go/internal/server/gamedata/gacha.go index 6e6ffb1..6249b46 100644 --- a/go/internal/server/gamedata/gacha.go +++ b/go/internal/server/gamedata/gacha.go @@ -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. diff --git a/go/internal/server/gamedata/gacha_active_test.go b/go/internal/server/gamedata/gacha_active_test.go index db0cf66..148ef34 100644 --- a/go/internal/server/gamedata/gacha_active_test.go +++ b/go/internal/server/gamedata/gacha_active_test.go @@ -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) { diff --git a/go/internal/server/player/collection.go b/go/internal/server/player/collection.go index b56e727..3ab3a52 100644 --- a/go/internal/server/player/collection.go +++ b/go/internal/server/player/collection.go @@ -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 diff --git a/go/internal/server/player/collection_entries.go b/go/internal/server/player/collection_entries.go index feca994..9ff57a5 100644 --- a/go/internal/server/player/collection_entries.go +++ b/go/internal/server/player/collection_entries.go @@ -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 } diff --git a/go/internal/server/player/costume_burst.go b/go/internal/server/player/costume_burst.go new file mode 100644 index 0000000..d3b38c3 --- /dev/null +++ b/go/internal/server/player/costume_burst.go @@ -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 +} diff --git a/go/internal/server/player/costume_burst_collection_test.go b/go/internal/server/player/costume_burst_collection_test.go new file mode 100644 index 0000000..d498889 --- /dev/null +++ b/go/internal/server/player/costume_burst_collection_test.go @@ -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") + } + }) + } +} diff --git a/go/internal/server/player/costume_burst_test.go b/go/internal/server/player/costume_burst_test.go new file mode 100644 index 0000000..2ec233c --- /dev/null +++ b/go/internal/server/player/costume_burst_test.go @@ -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) + } +} diff --git a/plugins/CaptureEnvironment/CaptureEnvironment.csproj b/plugins/CaptureEnvironment/CaptureEnvironment.csproj index 6b1388b..e2fcd7a 100644 --- a/plugins/CaptureEnvironment/CaptureEnvironment.csproj +++ b/plugins/CaptureEnvironment/CaptureEnvironment.csproj @@ -7,34 +7,30 @@ Bd2CaptureEnvironment capture_environment - - - - $(GameDir)\BepInEx\core\BepInEx.dll + $(BD2BepInExDir)/core/BepInEx.dll false - $(GameDir)\BepInEx\core\0Harmony.dll + $(BD2BepInExDir)/core/0Harmony.dll false - $(GameDir)\BrownDust II_Data\Managed\UnityEngine.CoreModule.dll + $(BD2ManagedDir)/UnityEngine.CoreModule.dll false - $(GameDir)\BrownDust II_Data\Managed\UnityEngine.dll + $(BD2ManagedDir)/UnityEngine.dll false - $(GameDir)\BrownDust II_Data\Managed\UnityEngine.UnityWebRequestModule.dll + $(BD2ManagedDir)/UnityEngine.UnityWebRequestModule.dll false - $(GameDir)\BrownDust II_Data\Managed\Google.Protobuf.dll + $(BD2ManagedDir)/Google.Protobuf.dll false - diff --git a/plugins/CaptureEnvironment/CaptureStorageIsolation.cs b/plugins/CaptureEnvironment/CaptureStorageIsolation.cs new file mode 100644 index 0000000..6e5d08f --- /dev/null +++ b/plugins/CaptureEnvironment/CaptureStorageIsolation.cs @@ -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(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(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; + } + +} diff --git a/plugins/CaptureEnvironment/CaptureWriter.cs b/plugins/CaptureEnvironment/CaptureWriter.cs new file mode 100644 index 0000000..401046a --- /dev/null +++ b/plugins/CaptureEnvironment/CaptureWriter.cs @@ -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 WriteQueue = + new BlockingCollection(new ConcurrentQueue(), 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(); + } + + +} diff --git a/plugins/CaptureEnvironment/PacketCapture.cs b/plugins/CaptureEnvironment/PacketCapture.cs new file mode 100644 index 0000000..26db24c --- /dev/null +++ b/plugins/CaptureEnvironment/PacketCapture.cs @@ -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> PendingRequests = + new Dictionary>(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 requests)) + { + requests = new Queue(); + PendingRequests.Add(path, requests); + } + requests.Enqueue(request); + } + } + + private static PendingRequest DequeueRequest(string path) + { + lock (CorrelationLock) + { + if (PendingRequests.TryGetValue(path, out Queue requests) && + requests.Count != 0) + { + PendingRequest request = requests.Dequeue(); + if (requests.Count == 0) PendingRequests.Remove(path); + return request; + } + } + return null; + } + +} diff --git a/plugins/CaptureEnvironment/Plugin.cs b/plugins/CaptureEnvironment/Plugin.cs index 2bc7874..878ecd2 100644 --- a/plugins/CaptureEnvironment/Plugin.cs +++ b/plugins/CaptureEnvironment/Plugin.cs @@ -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> PendingRequests = - new Dictionary>(StringComparer.Ordinal); - private static readonly BlockingCollection WriteQueue = - new BlockingCollection( - new ConcurrentQueue(), 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(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 requests)) - { - requests = new Queue(); - PendingRequests.Add(path, requests); - } - requests.Enqueue(request); - } - } - - private static PendingRequest DequeueRequest(string path) - { - lock (CorrelationLock) - { - if (PendingRequests.TryGetValue(path, out Queue 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)); } } diff --git a/plugins/CaptureEnvironment/README.md b/plugins/CaptureEnvironment/README.md index 732302b..98c8a58 100644 --- a/plugins/CaptureEnvironment/README.md +++ b/plugins/CaptureEnvironment/README.md @@ -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=' +``` + +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 `/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 diff --git a/plugins/Directory.Build.local.props.example b/plugins/Directory.Build.local.props.example new file mode 100644 index 0000000..1272ffe --- /dev/null +++ b/plugins/Directory.Build.local.props.example @@ -0,0 +1,9 @@ + + + E:\Games\BrownDustII + + E:\Games\BrownDustII-Capture + + + + diff --git a/plugins/Directory.Build.props b/plugins/Directory.Build.props new file mode 100644 index 0000000..f787693 --- /dev/null +++ b/plugins/Directory.Build.props @@ -0,0 +1,21 @@ + + + + $(MSBuildThisFileDirectory) + $([System.IO.Path]::GetFullPath('$(BD2PluginsDir)..'))/ + + + + $(BD2CaptureGameDir) + $(BD2LocalGameDir) + $(GameDir)/BrownDust II_Data/Managed + $(GameDir)/BepInEx + $(BD2RepositoryDir)versions.json + $(BD2PluginsDir)GameSdk/GameSdk.csproj + $(BD2PluginsDir)GameNames/GameNames.csproj + Release + $(BD2PluginsDir)GameSdk/bin/$(BD2SdkConfiguration)/net8.0/GameSdk.dll + game-sdk + $(BD2_GAME_SDK_CACHE) + + diff --git a/plugins/Directory.Build.targets b/plugins/Directory.Build.targets new file mode 100644 index 0000000..7d112bf --- /dev/null +++ b/plugins/Directory.Build.targets @@ -0,0 +1,9 @@ + + + + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)$(BD2SdkIntermediateFolder)')) + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)Bd2Versions.g.cs')) + + + + diff --git a/plugins/GameNames/Game.cs b/plugins/GameNames/Game.cs new file mode 100644 index 0000000..d83ab4e --- /dev/null +++ b/plugins/GameNames/Game.cs @@ -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; + +/// Readable names for string-based reflection. Unknown names retain their literal spelling. +public static class Game +{ + private sealed class Index + { + internal readonly NameTable Table; + internal readonly Dictionary Types; + internal readonly Dictionary 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 Names = new Lazy(() => new Index()); + private static readonly ConcurrentDictionary TypeCache = new ConcurrentDictionary(); + private static readonly ConcurrentDictionary MemberCache = new ConcurrentDictionary(); + private static readonly object ValidationLock = new object(); + private static string ValidatedStamp; + /// Gets the game version embedded in this package. + public static string GameVersion => Names.Value.Table.game_version; + + /// Translates a full readable type name. Unknown names retain their literal spelling. + 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; + } + + /// Finds a readable type in Assembly-CSharp or loaded assemblies; returns null when absent. + 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; + } + + /// Translate coroutine/member strings. Overloads with different runtime names require Method/MethodName(MethodInfo). + 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(), + GameMemberKind.Field => type.GetFields(all).Cast(), + GameMemberKind.Property => type.GetProperties(all).Cast(), + GameMemberKind.Event => type.GetEvents(all).Cast(), + _ => 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]; + } + /// Returns the actual CLR method name for APIs that accept strings. + public static string MethodName(MethodInfo method) => method?.Name ?? throw new ArgumentNullException(nameof(method)); + /// Tests a reflected method against its readable name, preserving overload identity. + public static bool IsGameMethod(this MethodInfo method, string readable) => Matches(method, "method", readable); + /// Translates a readable parameter name using the method declaration; unknown names remain unchanged. + 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; + } + + /// Extracts a MethodInfo from a call expression without executing the expression. + public static MethodInfo Method(Expression> expression) => Call(expression); + /// Extracts a MethodInfo from a call expression without executing the expression. + public static MethodInfo Method(Expression expression) => Call(expression); + /// Extracts a property getter from an expression without evaluating the property. + public static MethodInfo Getter(Expression> expression) => PropertyGetter(expression); + /// Extracts a property getter from an expression without evaluating the property. + public static MethodInfo Getter(Expression> 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(Type type, string kind, string readable, BindingFlags flags, Type[] parameters, Func 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; + } + /// Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw. + 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)); + /// Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw. + 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(IEnumerable 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(); + } + /// Finds a field by readable name and binding flags. Returns null when absent. + 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)); + /// Finds a property by readable name and binding flags. Returns null when absent; ambiguous matches throw. + 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)); + /// Finds an event by readable name and binding flags. Returns null when absent. + 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)); + + /// Validate plugin/table version, binary fingerprint and known metadata entries before installing patches. + public static void Validate(Assembly plugin, Action log = null) => Validate(plugin, GameVersion, log); + + /// Validates only the game binary against the embedded table for plugins that do not use the compiler SDK. + public static void ValidateGame(Action log = null) => ValidateCore(null, GameVersion, log); + + /// Validates a compiler SDK plugin, its expected game version, the table and the game binary. Throws on mismatch. + public static void Validate(Assembly plugin, string expectedVersion, Action 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 log) + { + try + { + var table = Names.Value.Table; + string stamp = table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256; + var metadata = plugin?.GetCustomAttributes().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; + } + } +} diff --git a/plugins/GameNames/GameMemberKind.cs b/plugins/GameNames/GameMemberKind.cs new file mode 100644 index 0000000..9830587 --- /dev/null +++ b/plugins/GameNames/GameMemberKind.cs @@ -0,0 +1,14 @@ +namespace BD2.GameNames; + +/// Specifies the kind of game member whose readable name should be resolved. +public enum GameMemberKind +{ + /// A method, including a coroutine entry point. + Method, + /// A field. + Field, + /// A property. + Property, + /// An event. + Event +} diff --git a/plugins/GameNames/GameNames.csproj b/plugins/GameNames/GameNames.csproj new file mode 100644 index 0000000..1a2a9a7 --- /dev/null +++ b/plugins/GameNames/GameNames.csproj @@ -0,0 +1,27 @@ + + + + netstandard2.0 + latest + BD2.GameNames + disable + false + true + none + $(BaseIntermediateOutputPath)=/_/BD2.GameNames/obj,$(MSBuildProjectDirectory)=/_/BD2.GameNames + BD2.GameNames + Readable game-name resolution, reflection helpers and compatibility validation for Brown Dust II plugins. Includes a version-locked names table. + BD2;BrownDust;Unity;BepInEx;Harmony;modding + true + 1.0.0.0 + $(MSBuildThisFileDirectory)Mappings/names.json + + + + + + + + + + diff --git a/plugins/GameNames/Mappings/names.json.gz b/plugins/GameNames/Mappings/names.json.gz new file mode 100644 index 0000000..b41384a Binary files /dev/null and b/plugins/GameNames/Mappings/names.json.gz differ diff --git a/plugins/GameNames/NameTable.cs b/plugins/GameNames/NameTable.cs new file mode 100644 index 0000000..6c564fa --- /dev/null +++ b/plugins/GameNames/NameTable.cs @@ -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 types = new List(); + [DataMember] public List members = new List(); +} + +[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 parameters = new List(); +} + +[DataContract] +internal sealed class ParameterName +{ + [DataMember] public int position; + [DataMember] public string readable; + [DataMember] public string original; +} diff --git a/plugins/GameNames/README.md b/plugins/GameNames/README.md new file mode 100644 index 0000000..6f2cf59 --- /dev/null +++ b/plugins/GameNames/README.md @@ -0,0 +1,29 @@ +# BD2.GameNames + +Brown Dust II 插件的共享名字解析运行时,目标框架为 `.NET Standard 2.0`。内嵌当前游戏版本的官方名字表,支持字符串反射、Harmony 目标表达式、缓存和插件启动自检。 + +开发插件建议引用配套的 `BD2.GameSdk` NuGet 包,它会自动引入本包并接入可读引用程序集和构建期 reobf。只有需要字符串反射、完全不引用游戏类型的插件才单独引用本包。 + +```xml + +``` + +```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` 中设置 `true`,让运行时 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 源或本地目录安装。 diff --git a/plugins/GameSdk.NavigationTests/GameSdk.NavigationTests.csproj b/plugins/GameSdk.NavigationTests/GameSdk.NavigationTests.csproj new file mode 100644 index 0000000..ccd6560 --- /dev/null +++ b/plugins/GameSdk.NavigationTests/GameSdk.NavigationTests.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + enable + enable + false + + + + + + diff --git a/plugins/GameSdk.NavigationTests/Program.cs b/plugins/GameSdk.NavigationTests/Program.cs new file mode 100644 index 0000000..c48ec77 --- /dev/null +++ b/plugins/GameSdk.NavigationTests/Program.cs @@ -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 [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().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 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 { type }; + if (fixture) + { + var ping = type.GetMembers("Ping").OfType().Single(m => m.Parameters[0].Type.SpecialType == SpecialType.System_Int32); + symbols.AddRange(new ISymbol[] { + ping, type.GetMembers("Ping").OfType().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().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); +} diff --git a/plugins/GameSdk.targets b/plugins/GameSdk.targets new file mode 100644 index 0000000..3c91a07 --- /dev/null +++ b/plugins/GameSdk.targets @@ -0,0 +1,21 @@ + + + + + + $(BD2SdkDir)\ref\Assembly-CSharp.Readable.dll + false + + + + + + + + + + + + + + diff --git a/plugins/GameSdk/GameSdk.csproj b/plugins/GameSdk/GameSdk.csproj new file mode 100644 index 0000000..52a856e --- /dev/null +++ b/plugins/GameSdk/GameSdk.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + enable + disable + false + + + + + + + + + diff --git a/plugins/GameSdk/Pack.ps1 b/plugins/GameSdk/Pack.ps1 new file mode 100644 index 0000000..3716f44 --- /dev/null +++ b/plugins/GameSdk/Pack.ps1 @@ -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, "") +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" diff --git a/plugins/GameSdk/Package/BD2.GameSdk.Package.csproj b/plugins/GameSdk/Package/BD2.GameSdk.Package.csproj new file mode 100644 index 0000000..90d0574 --- /dev/null +++ b/plugins/GameSdk/Package/BD2.GameSdk.Package.csproj @@ -0,0 +1,25 @@ + + + + netstandard2.0 + BD2.GameSdk + 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. + BD2;BrownDust;Unity;BepInEx;MSBuild;modding + false + false + + + + + + + + + + + + + + + + diff --git a/plugins/GameSdk/Package/_._ b/plugins/GameSdk/Package/_._ new file mode 100644 index 0000000..e69de29 diff --git a/plugins/GameSdk/Package/build/BD2.GameSdk.props b/plugins/GameSdk/Package/build/BD2.GameSdk.props new file mode 100644 index 0000000..9f24809 --- /dev/null +++ b/plugins/GameSdk/Package/build/BD2.GameSdk.props @@ -0,0 +1,7 @@ + + + true + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../')) + $(BD2_GAME_SDK_CACHE) + + diff --git a/plugins/GameSdk/Package/build/BD2.GameSdk.targets b/plugins/GameSdk/Package/build/BD2.GameSdk.targets new file mode 100644 index 0000000..ec2652d --- /dev/null +++ b/plugins/GameSdk/Package/build/BD2.GameSdk.targets @@ -0,0 +1,34 @@ + + + + true + $(GameDir)/BrownDust II_Data/Managed + $(GameDir)/BepInEx + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)bd2-game-sdk')) + $(BD2GameSdkPackageRoot)tools/net8.0/GameSdk.dll + + + + + $(BD2SdkDir)/ref/Assembly-CSharp.Readable.dll + false + + $(BD2BepInExDir)/core/BepInEx.dllfalse + $(BD2BepInExDir)/core/0Harmony.dllfalse + $(BD2ManagedDir)/UnityEngine.dllfalse + $(BD2ManagedDir)/UnityEngine.CoreModule.dllfalse + + + + + + + + + + + + + diff --git a/plugins/GameSdk/Program.cs b/plugins/GameSdk/Program.cs new file mode 100644 index 0000000..8441d4f --- /dev/null +++ b/plugins/GameSdk/Program.cs @@ -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 [versions.json] | export-names | names | shell | source-navigation | verify-navigation | reobf [Assembly-CSharp.dll] [dependency-directory] | verify [Assembly-CSharp.dll] | verify-runtime | 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(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(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 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(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(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().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(); + 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(); + 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 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 { 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"); + } +} diff --git a/plugins/GameSdk/README.md b/plugins/GameSdk/README.md new file mode 100644 index 0000000..39516f5 --- /dev/null +++ b/plugins/GameSdk/README.md @@ -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 + + + netstandard2.1 + latest + MyPlugin + + + + + +``` + +`PrivateAssets="all"` 让构建步骤只用于当前插件项目;它不会阻止运行时 DLL 复制到输出目录。SDK 自动引入 `BD2.GameNames`,以及游戏目录中的 `BepInEx`、`0Harmony`、`UnityEngine`、`UnityEngine.CoreModule` 引用,无需手写 ``。**不要再引用真实的 `Assembly-CSharp.dll`**,游戏 API 编译引用由 SDK 提供。 + +在项目旁创建仅保存在本机的 `Directory.Build.props`,并加入自己的 `.gitignore`: + +```xml + + + E:\Games\BrownDustII + + +``` + +或者构建时传入目录: + +```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(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 模块或第三方游戏依赖仍以普通 `` 添加,并设 `false`。强签名插件需要重新签名流程,当前 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 '' +``` + +脚本从官方映射和真实元数据生成表,完成全量壳转换验证后才替换 `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 '' '<输出目录>' +dotnet $tool export-names '<导出的 names.json>' +dotnet $tool reobf '' '<可读插件.dll>' '<运行插件.dll>' '' '' +dotnet $tool verify '' '<运行插件.dll>' '' +dotnet $tool verify-runtime '' '' +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 行为需在游戏启动后检查日志。 diff --git a/plugins/GameSdk/SelfTest.cs b/plugins/GameSdk/SelfTest.cs new file mode 100644 index 0000000..3d54d01 --- /dev/null +++ b/plugins/GameSdk/SelfTest.cs @@ -0,0 +1,273 @@ +using System.Diagnostics; +using System.Runtime.Loader; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace BD2.GameSdk; + +/// End-to-end tests with synthetic binaries. Never execute the production game or readable shell. +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 = "") => $""" + + netstandard2.1latest{name} + {references} + + """; + 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 value) { return value; } + public Agent[,] Grid(Agent[,] value) { return value; } + public void Ref(ref Agent value) {} + } + public class Box { public T Item; public T Echo(T value) { return value; } public class Nested { public U Item; } } + public class Derived : Box {} + } + """); + 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 { "#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(); + 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" ? "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 != ""; + 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+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 = $"{System.Security.SecurityElement.Escape(Path.Combine(sdkDir, "ref", Program.ShellName + ".dll"))}false{System.Security.SecurityElement.Escape(runtimePath)}"; + 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))] + 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(); + 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().Target == typeof(Box), "custom attribute type arguments"); + Check(a.Echo(a) == a && new Box().Echo(a) == a, "generic methods/types"); + var box = new Box(); 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.Nested(); 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), "generic base"); + Check(Game.Getter(x => x.IsPlatformLogin).Invoke(new AppManager(), null) is true, "property expression"); + Check(Game.Method(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+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>(); + 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(); } + } +} diff --git a/plugins/GameSdk/SourceNavigation.cs b/plugins/GameSdk/SourceNavigation.cs new file mode 100644 index 0000000..aeedc13 --- /dev/null +++ b/plugins/GameSdk/SourceNavigation.cs @@ -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; + +/// Build local, offline external-source navigation from the readable implementation. +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 Declarations, Dictionary> Methods); + internal sealed record Manifest(int SchemaVersion, string AssemblySha256, string PdbSha256, string DecompilerVersion, string SourceRoot, + int Documents, int Declarations, int MethodsWithBodies, int MethodsWithSymbols, List Diagnostics, List 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) != "").ToArray(); + var sources = new ConcurrentBag(); + var timer = Stopwatch.StartNew(); + int completed = 0; + var expected = module.GetTypes().ToDictionary(t => t.MetadataToken.ToInt32(), t => + t.Methods.Cast().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(); + var methodPoints = new Dictionary 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 { 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(), + 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(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) == "") 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 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 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 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()) + { + 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(); + 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>(); + 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 declarations, Dictionary Points)> methods, string dependencies) + { + var metadata = new MetadataBuilder(); + var documents = new Dictionary(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 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()) + }); + } + } + } +} diff --git a/plugins/GameSdk/THIRD-PARTY-NOTICES.txt b/plugins/GameSdk/THIRD-PARTY-NOTICES.txt new file mode 100644 index 0000000..8751677 --- /dev/null +++ b/plugins/GameSdk/THIRD-PARTY-NOTICES.txt @@ -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. diff --git a/plugins/GameSdk/UpdateNames.ps1 b/plugins/GameSdk/UpdateNames.ps1 new file mode 100644 index 0000000..48a0e3f --- /dev/null +++ b/plugins/GameSdk/UpdateNames.ps1 @@ -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." diff --git a/plugins/GameSdk/VerifyPackages.ps1 b/plugins/GameSdk/VerifyPackages.ps1 new file mode 100644 index 0000000..9712508 --- /dev/null +++ b/plugins/GameSdk/VerifyPackages.ps1 @@ -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'), "") +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" diff --git a/plugins/GameSdk/samples/ExamplePlugin/Directory.Build.props.example b/plugins/GameSdk/samples/ExamplePlugin/Directory.Build.props.example new file mode 100644 index 0000000..1ba458a --- /dev/null +++ b/plugins/GameSdk/samples/ExamplePlugin/Directory.Build.props.example @@ -0,0 +1,5 @@ + + + E:\Games\BrownDustII + + diff --git a/plugins/GameSdk/samples/ExamplePlugin/ExamplePlugin.csproj b/plugins/GameSdk/samples/ExamplePlugin/ExamplePlugin.csproj new file mode 100644 index 0000000..2479114 --- /dev/null +++ b/plugins/GameSdk/samples/ExamplePlugin/ExamplePlugin.csproj @@ -0,0 +1,11 @@ + + + netstandard2.1 + latest + ExamplePlugin + + + + + + diff --git a/plugins/GameSdk/samples/ExamplePlugin/NuGet.Config b/plugins/GameSdk/samples/ExamplePlugin/NuGet.Config new file mode 100644 index 0000000..08b3d47 --- /dev/null +++ b/plugins/GameSdk/samples/ExamplePlugin/NuGet.Config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/plugins/GameSdk/samples/ExamplePlugin/Plugin.cs b/plugins/GameSdk/samples/ExamplePlugin/Plugin.cs new file mode 100644 index 0000000..0f4db03 --- /dev/null +++ b/plugins/GameSdk/samples/ExamplePlugin/Plugin.cs @@ -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(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(); +} diff --git a/plugins/LocalIdentity/ClientDiagnostics.cs b/plugins/LocalIdentity/ClientDiagnostics.cs new file mode 100644 index 0000000..061f9b7 --- /dev/null +++ b/plugins/LocalIdentity/ClientDiagnostics.cs @@ -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 ?? ""; + 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; + } + +} diff --git a/plugins/LocalIdentity/ClientPresentation.cs b/plugins/LocalIdentity/ClientPresentation.cs new file mode 100644 index 0000000..ebb5337 --- /dev/null +++ b/plugins/LocalIdentity/ClientPresentation.cs @@ -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); + } + } + + +} diff --git a/plugins/LocalIdentity/ClientRouting.cs b/plugins/LocalIdentity/ClientRouting.cs index b6bb841..9400215 100644 --- a/plugins/LocalIdentity/ClientRouting.cs +++ b/plugins/LocalIdentity/ClientRouting.cs @@ -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(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; diff --git a/plugins/LocalIdentity/LocalAccountPolicy.cs b/plugins/LocalIdentity/LocalAccountPolicy.cs new file mode 100644 index 0000000..15c40aa --- /dev/null +++ b/plugins/LocalIdentity/LocalAccountPolicy.cs @@ -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) && + 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 ?? "")); + __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; + } + + +} diff --git a/plugins/LocalIdentity/LocalIdentity.csproj b/plugins/LocalIdentity/LocalIdentity.csproj index d8c3444..410367c 100644 --- a/plugins/LocalIdentity/LocalIdentity.csproj +++ b/plugins/LocalIdentity/LocalIdentity.csproj @@ -6,12 +6,7 @@ BD2LocalIdentity Bd2LocalIdentity local_identity - $(GameDir)\BrownDust II_Data\Managed - $(GameDir)\BepInEx - - - $(BD2BepInExDir)\core\BepInEx.dll @@ -34,5 +29,4 @@ false - diff --git a/plugins/LocalIdentity/Plugin.cs b/plugins/LocalIdentity/Plugin.cs index 59cb156..70182a4 100644 --- a/plugins/LocalIdentity/Plugin.cs +++ b/plugins/LocalIdentity/Plugin.cs @@ -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(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) && - 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 ?? "")); - __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) ?? ""; - 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; - } } diff --git a/plugins/LocalIdentity/SystemTimeZoneCountries.cs b/plugins/LocalIdentity/SystemTimeZoneCountries.cs new file mode 100644 index 0000000..4ecb183 --- /dev/null +++ b/plugins/LocalIdentity/SystemTimeZoneCountries.cs @@ -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 Countries = new Dictionary(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; + } +} diff --git a/plugins/LocalIdentity/SystemTimeZoneRegion.cs b/plugins/LocalIdentity/SystemTimeZoneRegion.cs new file mode 100644 index 0000000..bc06bb0 --- /dev/null +++ b/plugins/LocalIdentity/SystemTimeZoneRegion.cs @@ -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); +} diff --git a/plugins/LoginUI/ControlRequests.cs b/plugins/LoginUI/ControlRequests.cs new file mode 100644 index 0000000..3ed6342 --- /dev/null +++ b/plugins/LoginUI/ControlRequests.cs @@ -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 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 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 completed) + { + MonoBehaviour intro = introUI as MonoBehaviour; + if (intro == null) + { + if (body != null) Array.Clear(body, 0, body.Length); + yield break; + } + AuthenticationRequestLifetime owner = intro.GetComponent() ?? + intro.gameObject.AddComponent(); + 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); + } + +} diff --git a/plugins/LoginUI/LoginController.cs b/plugins/LoginUI/LoginController.cs new file mode 100644 index 0000000..bb1614a --- /dev/null +++ b/plugins/LoginUI/LoginController.cs @@ -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(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(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(), "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(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(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; + } + +} diff --git a/plugins/LoginUI/LoginPanel.cs b/plugins/LoginUI/LoginPanel.cs new file mode 100644 index 0000000..489880d --- /dev/null +++ b/plugins/LoginUI/LoginPanel.cs @@ -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(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()) + { + 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()) + { + 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