feat(all): add game SDK and harden client login and game rules
- Add costume burst upgrades with durable replay and fix permanent SR equipment gacha. - Add server game.json rules for optional collaboration UR weapons, with packaging and documentation. - Add GameNames/GameSdk NuGet tooling, readable types, reobfuscation, complete source/PDB navigation and shared-runtime installation. - Split plugin responsibilities, prefer typeof/nameof, centralize MSBuild settings and ignore local paths; remove the unused LoginUI test project. - Fix login recovery and TLS through native platform HTTP and a private game relay; read device region directly from the OS time zone. Validation: go test ./... -count=1, go vet ./..., plugin Release builds, SDK/runtime self-tests, Roslyn embedded-source navigation, gofmt check and git diff --check.
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
.env.*
|
||||
!.env.example
|
||||
go/config.json
|
||||
plugins/**/Directory.Build.props
|
||||
!plugins/Directory.Build.props
|
||||
plugins/Directory.Build.local.props
|
||||
|
||||
# Project notes and local/runtime data are intentionally outside source control
|
||||
docs/
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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`,重启容器后生效。
|
||||
@@ -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`。
|
||||
|
||||
@@ -32,4 +32,6 @@
|
||||
|
||||
`authentication.json` 和 `resources.json` 必须与 `bd2server.exe` 保持在同一目录。默认 `local` 认证模式继续自动本地登录;公网或联机服可以启用 OAuth,并把资源策略设为官方 CDN 或统一的服务器资源源。服主自建与反代使用相同的 `server` 模式;玩家本地资源目录仅由 `bd2client.exe` 配置。逐步配置方法见发布包内的 [服主第三方登录配置指南](AUTHENTICATION.md) 和 [服主资源与 CDN 配置指南](RESOURCES.md)。
|
||||
|
||||
游戏规则使用同目录的 `game.json`,修改后重启服务端生效。默认关闭联动 UR 专武扩展;需要将 14 种联动角色专武加入 UR 必得装备券池时,按 [服务端游戏规则配置](GAME_CONFIGURATION.md) 设置。升级时保留已修改的文件;旧安装缺少文件时使用默认规则。
|
||||
|
||||
OAuth access token 只在客户端内存中;Windows refresh credential 使用当前用户 DPAPI 加密后写入 PlayerPrefs 注册表,macOS 使用 Keychain Services。服务端 `auth.db` 只保存 token、device secret、OAuth state 及 provider subject 的 HMAC,临时可恢复材料使用 AES-256-GCM。
|
||||
|
||||
+13
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"gacha": {
|
||||
"include_collaboration_ur_weapons": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build !release
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Opt in on a developer machine with the game installed. Exercise the exact
|
||||
// --dev run preparation path without starting the interactive Wails window.
|
||||
func TestDevelopmentRunBuildsPluginsWithEmbeddedNames(t *testing.T) {
|
||||
if os.Getenv("BD2_TEST_DEVELOPMENT_BUILD") != "1" {
|
||||
t.Skip("set BD2_TEST_DEVELOPMENT_BUILD=1 to build against go/config.json")
|
||||
}
|
||||
args, options, err := developmentRunOptions([]string{"--dev", "run"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Fatalf("unexpected runtime arguments: %v", args)
|
||||
}
|
||||
for _, path := range []string{options.localIdentityPlugin, options.loginUIPlugin} {
|
||||
if !clientDevelopmentFile(path) {
|
||||
t.Fatalf("development plugin output missing: %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), spec.FileName())
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("plugin"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -54,6 +57,9 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), spec.FileName())
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -65,6 +71,18 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
|
||||
if err != nil || second.Changed {
|
||||
t.Fatalf("idempotent install=%+v err=%v", second, err)
|
||||
}
|
||||
runtimeSource := filepath.Join(filepath.Dir(source), GameNames.FileName())
|
||||
if err := os.WriteFile(runtimeSource, []byte("names-v2"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimeUpdate, err := Install(spec, gameDir, source)
|
||||
if err != nil || !runtimeUpdate.Changed {
|
||||
t.Fatalf("runtime-only update=%+v err=%v", runtimeUpdate, err)
|
||||
}
|
||||
installedRuntime, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", GameNames.FileName()))
|
||||
if err != nil || string(installedRuntime) != "names-v2" {
|
||||
t.Fatalf("installed runtime=%q err=%v", installedRuntime, err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("v2"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -80,6 +98,34 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallMissingSharedRuntimeDoesNotChangePlugin(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
|
||||
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
|
||||
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
|
||||
filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()): []byte("old-plugin"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), LocalIdentity.FileName())
|
||||
if err := os.WriteFile(source, []byte("new-plugin"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Install(LocalIdentity, gameDir, source); err == nil || !strings.Contains(err.Error(), GameNames.FileName()) {
|
||||
t.Fatalf("missing runtime error=%v", err)
|
||||
}
|
||||
installed, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()))
|
||||
if err != nil || string(installed) != "old-plugin" {
|
||||
t.Fatalf("installed=%q err=%v", installed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallKeepsPluginsSeparate(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
@@ -96,6 +142,9 @@ func TestInstallKeepsPluginsSeparate(t *testing.T) {
|
||||
}
|
||||
for _, spec := range []Spec{LocalIdentity, LoginUI} {
|
||||
source := filepath.Join(t.TempDir(), spec.FileName())
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte(spec.FileName()), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package gacha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestPermanentEquipmentBuyUsesActiveCatalogWithoutSchedule(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
regular, catalog, err := gamedata.LoadActiveGachaForSchedules(root, "20260923193640", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ticketCount := range []uint64{5, 10} {
|
||||
t.Run(fmt.Sprintf("tickets_%d", ticketCount), func(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
service := newMultiBuyTestService(t, storage)
|
||||
service.regular = regular
|
||||
service.schedule.Schedules = nil
|
||||
equipment, err := player.OpenEquipmentInventory(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.AttachEquipmentGacha(catalog, equipment)
|
||||
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := inventory.GrantOnce("tickets", []gamedata.BattleReward{{Type: 8, ID: 1000, Count: ticketCount + 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.AttachInventory(inventory)
|
||||
ticket := items[0]
|
||||
ticket.Count = ticketCount
|
||||
request := wire.AppendVarint(nil, 1, 90)
|
||||
request = wire.AppendVarint(request, 2, 201)
|
||||
request = wire.AppendVarint(request, 3, 1)
|
||||
request = wire.AppendBytes(request, 4, player.ItemWire(ticket))
|
||||
code, response, handled, err := service.Handle("/GachaBuy", request)
|
||||
if err != nil || !handled || code != 146 {
|
||||
t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
bundle, _, _ := wire.Bytes(response, 1)
|
||||
if countFields(bundle, 4) != 10 || len(equipment.All()) != 10 {
|
||||
t.Fatal("ten equipment instances missing")
|
||||
}
|
||||
if got := service.wallet.Snapshot().FreeJewelry; got != 1000-(10-ticketCount)*200 {
|
||||
t.Fatalf("free diamonds=%d", got)
|
||||
}
|
||||
user := service.collection.GachaUser(10002)
|
||||
if user.Point != 10 || user.TotalBuyCount != 10 {
|
||||
t.Fatalf("user=%+v", user)
|
||||
}
|
||||
if countFields(response, 3) != 4 {
|
||||
t.Fatal("shared pity snapshot missing")
|
||||
}
|
||||
for _, item := range inventory.All() {
|
||||
if item.InvenIndex == ticket.InvenIndex && item.Count != 1 {
|
||||
t.Fatalf("remaining tickets=%d", item.Count)
|
||||
}
|
||||
}
|
||||
_, replay, _, err := service.Handle("/GachaBuy", request)
|
||||
if err != nil || !bytes.Equal(response, replay) || len(equipment.All()) != 10 {
|
||||
t.Fatalf("retry changed draw: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package gameconfig loads server-authoritative gameplay rules.
|
||||
package gameconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const FileName = "game.json"
|
||||
|
||||
type Config struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Gacha GachaConfig `json:"gacha"`
|
||||
}
|
||||
|
||||
type GachaConfig struct {
|
||||
IncludeCollaborationURWeapons bool `json:"include_collaboration_ur_weapons"`
|
||||
}
|
||||
|
||||
func Default() Config { return Config{SchemaVersion: 1} }
|
||||
|
||||
// Load reads one explicit path. Older installations without the file retain
|
||||
// the default rules; an existing invalid file must never silently fall back.
|
||||
func Load(path string) (Config, error) {
|
||||
data, err := os.ReadFile(filepath.Clean(path))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Default(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: read %s: %w", path, err)
|
||||
}
|
||||
var cfg Config
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: decode %s: %w", path, err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return Config{}, fmt.Errorf("game configuration: trailing data in %s", path)
|
||||
}
|
||||
// encoding/json accepts null for scalar and struct fields. These rules have
|
||||
// explicit defaults for omissions, while null is a configuration error.
|
||||
var document any
|
||||
if err := json.Unmarshal(data, &document); err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: decode %s: %w", path, err)
|
||||
}
|
||||
if containsNull(document) {
|
||||
return Config{}, fmt.Errorf("game configuration: null is not allowed in %s", path)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: %s: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func containsNull(value any) bool {
|
||||
switch value := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case map[string]any:
|
||||
for _, child := range value {
|
||||
if containsNull(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range value {
|
||||
if containsNull(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.SchemaVersion != 1 {
|
||||
return fmt.Errorf("unsupported schema_version %d (expected 1)", c.SchemaVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BesideExecutable() (string, error) {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("game configuration: resolve server executable: %w", err)
|
||||
}
|
||||
return filepath.Join(filepath.Dir(executable), FileName), nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package gameconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, text string
|
||||
wantTrue, wantError bool
|
||||
}{
|
||||
{"enabled", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":true}}`, true, false},
|
||||
{"disabled", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":false}}`, false, false},
|
||||
{"omitted bool", `{"schema_version":1,"gacha":{}}`, false, false},
|
||||
{"omitted gacha", `{"schema_version":1}`, false, false},
|
||||
{"string bool", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":"true"}}`, false, true},
|
||||
{"unknown root", `{"schema_version":1,"unknown":true}`, false, true},
|
||||
{"unknown rule", `{"schema_version":1,"gacha":{"typo":true}}`, false, true},
|
||||
{"trailing object", `{"schema_version":1} {}`, false, true},
|
||||
{"trailing garbage", `{"schema_version":1} invalid`, false, true},
|
||||
{"null root", `null`, false, true},
|
||||
{"null section", `{"schema_version":1,"gacha":null}`, false, true},
|
||||
{"null bool", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":null}}`, false, true},
|
||||
{"null schema", `{"schema_version":null}`, false, true},
|
||||
{"wrong version", `{"schema_version":2}`, false, true},
|
||||
{"missing version", `{}`, false, true},
|
||||
{"malformed", `{`, false, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), FileName)
|
||||
if err := os.WriteFile(path, []byte(tc.text), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if (err != nil) != tc.wantError {
|
||||
t.Fatalf("Load error = %v, wantError %v", err, tc.wantError)
|
||||
}
|
||||
if err == nil && (cfg.SchemaVersion != 1 || cfg.Gacha.IncludeCollaborationURWeapons != tc.wantTrue) {
|
||||
t.Fatalf("unexpected config: %+v", cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingFileDefaultsWithoutCreating(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), FileName)
|
||||
cfg, err := Load(path)
|
||||
if err != nil || cfg != Default() {
|
||||
t.Fatalf("config = %+v, error = %v", cfg, err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("Load created missing config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadError(t *testing.T) {
|
||||
if _, err := Load(t.TempDir()); err == nil {
|
||||
t.Fatal("directory must not use defaults")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBesideExecutable(t *testing.T) {
|
||||
path, err := BesideExecutable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != filepath.Join(filepath.Dir(executable), FileName) {
|
||||
t.Fatalf("unexpected path %s", path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CostumeBurstLevel is one row of CostumeBurstTable. Level is the database
|
||||
// row id (and is the level unlocked by upgrading from Level-1).
|
||||
type CostumeBurstLevel struct {
|
||||
CostumeID uint64
|
||||
Level uint64
|
||||
Costs []PromotionCost
|
||||
}
|
||||
|
||||
// CostumeBurstUpgradeRule is the exact cost for the next burst level.
|
||||
type CostumeBurstUpgradeRule struct {
|
||||
CostumeID uint64
|
||||
CurrentLevel uint64
|
||||
NextLevel uint64
|
||||
MaxLevel uint64
|
||||
Costs []PromotionCost
|
||||
}
|
||||
|
||||
// CostumeBurstDesign contains only costumes which have a complete, contiguous
|
||||
// CostumeBurstTable group. GroupId is the CostumeTable id for burst-enabled
|
||||
// costumes (the table also carries CostumeType for newer costume families).
|
||||
type CostumeBurstDesign struct {
|
||||
Levels map[uint64]map[uint64]CostumeBurstLevel
|
||||
}
|
||||
|
||||
func LoadCostumeBurstDesign(root, version string) (*CostumeBurstDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadCostumeBurstDesign(db)
|
||||
}
|
||||
|
||||
func loadCostumeBurstDesign(db *sql.DB) (*CostumeBurstDesign, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("gamedata: nil costume burst database")
|
||||
}
|
||||
design := &CostumeBurstDesign{Levels: make(map[uint64]map[uint64]CostumeBurstLevel)}
|
||||
rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM CostumeBurstTable ORDER BY groupId,id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var groupID, level uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&groupID, &level, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if groupID == 0 || level == 0 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid costume burst identity %d/%d", groupID, level)
|
||||
}
|
||||
groups, groupErr := packedInts(raw, 8)
|
||||
ids, idErr := packedInts(raw, 9)
|
||||
counts, countErr := packedInts(raw, 10)
|
||||
itemIDs, itemIDErr := packedInts(raw, 11)
|
||||
types, typeErr := packedInts(raw, 12)
|
||||
if groupErr != nil || idErr != nil || countErr != nil || itemIDErr != nil || typeErr != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid fields", groupID, level)
|
||||
}
|
||||
if len(groups) != 1 || groups[0] != groupID || len(ids) != 1 || ids[0] != level {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched proto identity", groupID, level)
|
||||
}
|
||||
if len(counts) == 0 || len(counts) != len(itemIDs) || len(itemIDs) != len(types) {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched costs", groupID, level)
|
||||
}
|
||||
entry := CostumeBurstLevel{CostumeID: groupID, Level: level}
|
||||
for i := range counts {
|
||||
if counts[i] == 0 || (types[i] == 4 && itemIDs[i] != 0) ||
|
||||
(types[i] == 8 && itemIDs[i] == 0) || (types[i] != 4 && types[i] != 8) {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid cost", groupID, level)
|
||||
}
|
||||
entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: itemIDs[i], Count: counts[i]})
|
||||
}
|
||||
if _, exists := design.Levels[groupID][level]; exists {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: duplicate costume burst %d/%d", groupID, level)
|
||||
}
|
||||
if design.Levels[groupID] == nil {
|
||||
design.Levels[groupID] = make(map[uint64]CostumeBurstLevel)
|
||||
}
|
||||
design.Levels[groupID][level] = entry
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(design.Levels) == 0 {
|
||||
return nil, errors.New("gamedata: CostumeBurstTable is empty")
|
||||
}
|
||||
for costumeID, levels := range design.Levels {
|
||||
for level := uint64(1); level <= uint64(len(levels)); level++ {
|
||||
if _, ok := levels[level]; !ok {
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d missing level %d", costumeID, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
// UpgradeRule returns the costs for currentLevel -> currentLevel+1.
|
||||
func (d *CostumeBurstDesign) UpgradeRule(costumeID, currentLevel uint64) (CostumeBurstUpgradeRule, error) {
|
||||
if d == nil || costumeID == 0 {
|
||||
return CostumeBurstUpgradeRule{}, errors.New("gamedata: invalid costume burst lookup")
|
||||
}
|
||||
levels, ok := d.Levels[costumeID]
|
||||
if !ok {
|
||||
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d has no burst design", costumeID)
|
||||
}
|
||||
maxLevel := uint64(len(levels))
|
||||
if currentLevel >= maxLevel {
|
||||
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d burst is already at maximum level %d", costumeID, maxLevel)
|
||||
}
|
||||
next := currentLevel + 1
|
||||
entry, ok := levels[next]
|
||||
if !ok {
|
||||
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d missing burst level %d", costumeID, next)
|
||||
}
|
||||
return CostumeBurstUpgradeRule{
|
||||
CostumeID: costumeID, CurrentLevel: currentLevel, NextLevel: next,
|
||||
MaxLevel: maxLevel, Costs: append([]PromotionCost(nil), entry.Costs...),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCostumeBurstDesignAndUpgradeRule(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`CREATE TABLE CostumeBurstTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for level := 1; level <= 3; level++ {
|
||||
raw := burstProto(8, 42, 9, uint64(level), 10, 60, 11, 710, 12, 8)
|
||||
if _, err := db.Exec(`INSERT INTO CostumeBurstTable(groupId,id,ProtoBuf) VALUES(?,?,?)`, 42, level, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
design, err := loadCostumeBurstDesign(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule, err := design.UpgradeRule(42, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rule.NextLevel != 2 || rule.MaxLevel != 3 || len(rule.Costs) != 1 || rule.Costs[0] != (PromotionCost{Type: 8, ID: 710, Count: 60}) {
|
||||
t.Fatalf("unexpected rule: %+v", rule)
|
||||
}
|
||||
if _, err := design.UpgradeRule(42, 3); err == nil {
|
||||
t.Fatal("expected max-level error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumeBurstRejectsInvalidIdentityCostAndGap(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw []byte
|
||||
rows [][2]int
|
||||
}{
|
||||
{"identity", burstProto(8, 99, 9, 1, 10, 1, 11, 710, 12, 8), [][2]int{{42, 1}}},
|
||||
{"zero-count", burstProto(8, 42, 9, 1, 10, 0, 11, 710, 12, 8), [][2]int{{42, 1}}},
|
||||
{"gold-id", burstProto(8, 42, 9, 1, 10, 1, 11, 710, 12, 4), [][2]int{{42, 1}}},
|
||||
{"gap", burstProto(8, 42, 9, 2, 10, 1, 11, 710, 12, 8), [][2]int{{42, 2}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`CREATE TABLE CostumeBurstTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range tc.rows {
|
||||
if _, err := db.Exec(`INSERT INTO CostumeBurstTable(groupId,id,ProtoBuf) VALUES(?,?,?)`, row[0], row[1], tc.raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := loadCostumeBurstDesign(db); err == nil || !strings.Contains(err.Error(), "costume burst") {
|
||||
t.Fatalf("expected costume burst validation error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumeBurstAgainstInstalledCurrentVersion(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadCostumeBurstDesign(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(design.Levels) != 56 {
|
||||
t.Fatalf("installed burst groups=%d, want 56", len(design.Levels))
|
||||
}
|
||||
rows := 0
|
||||
for _, levels := range design.Levels {
|
||||
rows += len(levels)
|
||||
}
|
||||
if rows != 168 {
|
||||
t.Fatalf("installed burst rows=%d, want 168", rows)
|
||||
}
|
||||
rule, err := design.UpgradeRule(4202, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []PromotionCost{{Type: 4, Count: 1_500_000}, {Type: 8, ID: 710, Count: 60}}
|
||||
if rule.NextLevel != 1 || rule.MaxLevel != 3 || len(rule.Costs) != len(want) || rule.Costs[0] != want[0] || rule.Costs[1] != want[1] {
|
||||
t.Fatalf("installed rule=%+v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
// burstProto builds the small varint-only fields used by the loader tests.
|
||||
func burstProto(fields ...uint64) []byte {
|
||||
if len(fields)%2 != 0 {
|
||||
panic("burstProto requires field/value pairs")
|
||||
}
|
||||
var out []byte
|
||||
for i := 0; i < len(fields); i += 2 {
|
||||
field, value := fields[i], fields[i+1]
|
||||
out = append(out, byte(field<<3))
|
||||
for value >= 0x80 {
|
||||
out = append(out, byte(value)|0x80)
|
||||
value >>= 7
|
||||
}
|
||||
out = append(out, byte(value))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// IncludeCollaborationURWeapons extends only the UR equipment ticket pool.
|
||||
// Limited costumes identify eligible owners; equipment IDs remain version data.
|
||||
func (c *EquipmentGachaCatalog) IncludeCollaborationURWeapons(root, version string) error {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-collaboration-equipment-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return c.includeCollaborationURWeapons(db)
|
||||
}
|
||||
|
||||
func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error {
|
||||
if c == nil || c.equipment == nil {
|
||||
return fmt.Errorf("gamedata: missing equipment catalog")
|
||||
}
|
||||
g, ok := c.Gachas[71200001]
|
||||
if !ok || !g.TicketOnly || len(g.TicketIDs) != 1 || g.TicketIDs[0] != 1104 || len(g.Pool) != 3 {
|
||||
return fmt.Errorf("gamedata: missing or malformed UR equipment ticket pool")
|
||||
}
|
||||
readRows := func(query string) (map[uint64][]byte, error) {
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[uint64][]byte{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = raw
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
scalar := func(raw []byte, field int) (uint64, error) {
|
||||
v, err := packedInts(raw, field)
|
||||
if err != nil || len(v) != 1 || v[0] == 0 {
|
||||
return 0, fmt.Errorf("gamedata: invalid collaboration equipment field %d", field)
|
||||
}
|
||||
return v[0], nil
|
||||
}
|
||||
owners, err := readRows("SELECT DISTINCT c.useUniqueCharId,c.ProtoBuf FROM LimitedCostumeTable l JOIN CostumeTable c ON c.id=l.id ORDER BY c.useUniqueCharId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chars, err := readRows("SELECT id,ProtoBuf FROM CharTable WHERE uniqueCharId IN (SELECT privateUniqueCharId FROM EquipmentTable WHERE privateUniqueCharId>0) ORDER BY id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grades := map[uint64]uint64{}
|
||||
for _, raw := range chars {
|
||||
owner, err := scalar(raw, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grade, err := scalar(raw, 9)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if previous, exists := grades[owner]; exists && previous != grade {
|
||||
return fmt.Errorf("gamedata: conflicting character grade for %d", owner)
|
||||
}
|
||||
grades[owner] = grade
|
||||
}
|
||||
equipment, err := readRows("SELECT id,ProtoBuf FROM EquipmentTable ORDER BY id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pool := make([]WeightedEquipment, len(g.Pool))
|
||||
branchForGrade := map[uint64]int{}
|
||||
seen := map[uint64]bool{}
|
||||
for index, branch := range g.Pool {
|
||||
if branch.ID != 0 || branch.Weight == 0 || len(branch.Children) == 0 {
|
||||
return fmt.Errorf("gamedata: malformed UR ticket branch")
|
||||
}
|
||||
pool[index] = WeightedEquipment{Weight: branch.Weight, Children: append([]WeightedEquipment(nil), branch.Children...)}
|
||||
var branchGrade uint64
|
||||
for _, item := range branch.Children {
|
||||
if item.ID == 0 || item.Weight != 1 || len(item.Children) != 0 || seen[item.ID] {
|
||||
return fmt.Errorf("gamedata: malformed or duplicate UR ticket candidate %d", item.ID)
|
||||
}
|
||||
seen[item.ID] = true
|
||||
owner, err := scalar(equipment[item.ID], 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grade := grades[owner]
|
||||
if grade < 3 || grade > 5 || (branchGrade != 0 && branchGrade != grade) {
|
||||
return fmt.Errorf("gamedata: unknown or mixed UR ticket character tier")
|
||||
}
|
||||
branchGrade = grade
|
||||
}
|
||||
if _, exists := branchForGrade[branchGrade]; exists {
|
||||
return fmt.Errorf("gamedata: duplicate UR ticket tier")
|
||||
}
|
||||
branchForGrade[branchGrade] = index
|
||||
}
|
||||
// Collect designs before publishing the cloned pool, so a failed load leaves
|
||||
// the catalog unchanged. Order by ID to keep repeated loads deterministic.
|
||||
designs := map[uint64]EquipmentDesign{}
|
||||
ids := make([]uint64, 0, len(equipment))
|
||||
for id := range equipment {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
for _, id := range ids {
|
||||
raw := equipment[id]
|
||||
grade, _ := packedInts(raw, 3)
|
||||
quality, _ := packedInts(raw, 18)
|
||||
owner, _ := packedInts(raw, 16)
|
||||
if len(grade) != 1 || grade[0] != 4 || len(quality) != 1 || quality[0] != 3 || len(owner) != 1 {
|
||||
continue
|
||||
}
|
||||
if _, eligible := owners[owner[0]]; !eligible || seen[id] {
|
||||
continue
|
||||
}
|
||||
index, exists := branchForGrade[grades[owner[0]]]
|
||||
if !exists {
|
||||
return fmt.Errorf("gamedata: unknown collaboration equipment tier for %d", id)
|
||||
}
|
||||
design, err := loadEquipmentDesign(db, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
designs[id] = design
|
||||
pool[index].Children = append(pool[index].Children, WeightedEquipment{ID: id, Weight: 1})
|
||||
seen[id] = true
|
||||
}
|
||||
for id, design := range designs {
|
||||
c.equipment[id] = design
|
||||
}
|
||||
g.Pool = pool
|
||||
c.Gachas[g.ID] = g
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIncludeCollaborationURWeaponsInstalled(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
c, err := LoadEquipmentGacha(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := map[uint64]EquipmentGacha{}
|
||||
for id, g := range c.Gachas {
|
||||
before[id] = g
|
||||
}
|
||||
if err := c.IncludeCollaborationURWeapons(root, "20260923193640"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := c.Gachas[71200001]
|
||||
oldIDs := map[uint64]bool{}
|
||||
for _, branch := range before[g.ID].Pool {
|
||||
for _, item := range branch.Children {
|
||||
oldIDs[item.ID] = true
|
||||
}
|
||||
}
|
||||
added := 0
|
||||
for _, branch := range g.Pool {
|
||||
for _, item := range branch.Children {
|
||||
if !oldIDs[item.ID] {
|
||||
added++
|
||||
if item.ID < 943200 || item.ID > 943213 {
|
||||
t.Fatalf("unexpected added collaboration weapon %d", item.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(oldIDs) != 73 || added != 14 {
|
||||
t.Fatalf("original=%d added=%d", len(oldIDs), added)
|
||||
}
|
||||
for index, want := range []int{64, 9, 14} {
|
||||
if len(g.Pool[index].Children) != want || g.Pool[index].Weight != before[g.ID].Pool[index].Weight {
|
||||
t.Fatalf("branch %d: %+v", index, g.Pool[index])
|
||||
}
|
||||
}
|
||||
if len(before[g.ID].Pool[0].Children) != 50 {
|
||||
t.Fatal("original pool mutated")
|
||||
}
|
||||
for id, old := range before {
|
||||
if id != g.ID && !reflect.DeepEqual(old, c.Gachas[id]) {
|
||||
t.Fatalf("other product %d changed", id)
|
||||
}
|
||||
}
|
||||
for _, branch := range g.Pool {
|
||||
for _, item := range branch.Children {
|
||||
if _, _, _, err := c.RollOptions(item.ID); err != nil {
|
||||
t.Fatalf("options %d: %v", item.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := c.IncludeCollaborationURWeapons(root, "20260923193640"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(g, c.Gachas[g.ID]) {
|
||||
t.Fatal("second inclusion changed pool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncludeCollaborationURWeaponsSynthetic(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
for _, schema := range []string{"CREATE TABLE LimitedCostumeTable(id INTEGER)", "CREATE TABLE CostumeTable(id INTEGER,useUniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE CharTable(id INTEGER,uniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE EquipmentTable(id INTEGER,privateUniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE EquipmentOptionTable(id INTEGER,GroupId INTEGER,ProtoBuf BLOB)"} {
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
proto := func(values ...uint64) []byte {
|
||||
var b []byte
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
b = binary.AppendUvarint(b, values[i]<<3)
|
||||
b = binary.AppendUvarint(b, values[i+1])
|
||||
}
|
||||
return b
|
||||
}
|
||||
for i := uint64(1); i <= 4; i++ {
|
||||
grade := uint64(6 - i)
|
||||
if i == 4 {
|
||||
grade = 5
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO CharTable VALUES(?,?,?)", i, i, proto(20, i, 9, grade)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO EquipmentTable VALUES(?,?,?)", 100+i, i, proto(6, 100+i, 16, i, 3, 4, 18, 3)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO LimitedCostumeTable VALUES(40),(41)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO CostumeTable VALUES(40,4,?),(41,4,?)", proto(27, 4), proto(27, 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool := []WeightedEquipment{{Weight: 150, Children: []WeightedEquipment{{ID: 101, Weight: 1}}}, {Weight: 350, Children: []WeightedEquipment{{ID: 102, Weight: 1}}}, {Weight: 500, Children: []WeightedEquipment{{ID: 103, Weight: 1}}}}
|
||||
c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{71200001: {ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, Pool: pool}, 77: {ID: 77, Pool: pool}}, equipment: map[uint64]EquipmentDesign{}}
|
||||
if err := c.includeCollaborationURWeapons(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Gachas[71200001].Pool[0].Children) != 2 || len(c.Gachas[77].Pool[0].Children) != 1 {
|
||||
t.Fatal("extension or isolation failed")
|
||||
}
|
||||
if _, ok := c.equipment[104]; !ok {
|
||||
t.Fatal("new equipment design not loaded")
|
||||
}
|
||||
if err := c.includeCollaborationURWeapons(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Gachas[71200001].Pool[0].Children) != 2 {
|
||||
t.Fatal("duplicate owner or repeated inclusion duplicated equipment")
|
||||
}
|
||||
if _, err := db.Exec("UPDATE CharTable SET ProtoBuf=? WHERE id=4", proto(20, 4, 9, 2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(c.Gachas, 71200001)
|
||||
c.Gachas[71200001] = EquipmentGacha{ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, Pool: pool}
|
||||
if err := c.includeCollaborationURWeapons(db); err == nil {
|
||||
t.Fatal("unknown tier accepted")
|
||||
}
|
||||
if len(c.Gachas[71200001].Pool[0].Children) != 1 {
|
||||
t.Fatal("failed inclusion published partial pool")
|
||||
}
|
||||
invalid := c.Gachas[71200001]
|
||||
invalid.Pool = append([]WeightedEquipment(nil), pool...)
|
||||
invalid.Pool[0].Children = []WeightedEquipment{{ID: 101, Weight: 1}, {ID: 101, Weight: 1}}
|
||||
c.Gachas[71200001] = invalid
|
||||
if err := c.includeCollaborationURWeapons(db); err == nil {
|
||||
t.Fatal("duplicate existing candidate accepted")
|
||||
}
|
||||
}
|
||||
@@ -917,17 +917,17 @@ func LoadActiveGachaForSchedules(root, version string, scheduleGroupIDs, stepUpG
|
||||
return LoadActiveGacha(root, version, costume, equipment, stepUpGroupIDs)
|
||||
}
|
||||
|
||||
// permanentGachaGroupIDs returns static selection groups. ScheduleType=0
|
||||
// permanentGachaGroupIDs returns static selection and ordinary equipment groups. ScheduleType=0
|
||||
// groups are deliberately absent from the dynamic schedule response; the
|
||||
// selection metadata is what distinguishes permanent 12PICK-style banners
|
||||
// from standalone content-ticket banners handled by other flows.
|
||||
// selection or shared pity/point metadata distinguishes these banners from
|
||||
// content-ticket banners handled by other flows.
|
||||
func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable WHERE scheduleType=0 AND gachaType=1 ORDER BY id")
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable WHERE scheduleType=0 AND gachaType IN (1,2) ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: list permanent gacha groups: %w", err)
|
||||
}
|
||||
@@ -941,7 +941,10 @@ func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
|
||||
}
|
||||
subTypes, _ := packedInts(raw, 16)
|
||||
selectCounts, _ := packedInts(raw, 29)
|
||||
if len(subTypes) == 1 && subTypes[0] == 1 && len(selectCounts) == 1 && selectCounts[0] != 0 {
|
||||
types, _ := packedInts(raw, 17)
|
||||
if len(types) == 1 && types[0] == 1 && len(subTypes) == 1 && subTypes[0] == 1 && len(selectCounts) == 1 && selectCounts[0] != 0 {
|
||||
result = append(result, id)
|
||||
} else if isPermanentEquipmentGachaGroup(raw) {
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
@@ -951,6 +954,22 @@ func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func isPermanentEquipmentGachaGroup(raw []byte) bool {
|
||||
types, _ := packedInts(raw, 17)
|
||||
subTypes, _ := packedInts(raw, 16)
|
||||
selectCounts, _ := packedInts(raw, 29)
|
||||
fixed, _ := packedInts(raw, 10)
|
||||
points, _ := packedInts(raw, 27)
|
||||
one, _ := packedInts(raw, 24)
|
||||
ten, _ := packedInts(raw, 33)
|
||||
// Static content-ticket groups also have one/ten IDs, but have neither
|
||||
// shared pity nor draw points. They must not enter the ordinary catalog.
|
||||
// LoadEquipmentGachaGroups validates the referenced products and pools.
|
||||
return len(types) == 1 && types[0] == 2 && (len(subTypes) == 0 || len(subTypes) == 1 && subTypes[0] == 0) && len(selectCounts) == 0 &&
|
||||
len(fixed) == 1 && fixed[0] != 0 && len(points) == 1 && points[0] != 0 &&
|
||||
len(one) == 1 && one[0] != 0 && len(ten) == 1 && ten[0] != 0
|
||||
}
|
||||
|
||||
// ClassifyActiveGachaGroups reads static type metadata for groups already
|
||||
// selected by a captured schedule. Resemara is owned by LoadInfiniteGacha and
|
||||
// is intentionally omitted from both ordinary catalogs.
|
||||
|
||||
@@ -3,8 +3,29 @@ package gamedata
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestPermanentEquipmentGroupRequiresPityAndPoints(t *testing.T) {
|
||||
// Content-ticket groups have both products too; those alone do not make
|
||||
// a group an ordinary permanent equipment draw.
|
||||
content := wire.AppendVarint(nil, 17, 2)
|
||||
content = wire.AppendVarint(content, 24, 70100001)
|
||||
content = wire.AppendVarint(content, 33, 71000001)
|
||||
if isPermanentEquipmentGachaGroup(content) {
|
||||
t.Fatal("content-ticket group classified as ordinary equipment")
|
||||
}
|
||||
ordinary := wire.AppendVarint(content, 10, 1)
|
||||
ordinary = wire.AppendVarint(ordinary, 27, 1)
|
||||
if !isPermanentEquipmentGachaGroup(ordinary) {
|
||||
t.Fatal("permanent equipment group with shared pity and points omitted")
|
||||
}
|
||||
if isPermanentEquipmentGachaGroup(wire.AppendVarint(ordinary, 16, 1)) {
|
||||
t.Fatal("equipment selection group classified as ordinary equipment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleSidedCostumeFixedThresholdsDoNotTriggerZeroSide(t *testing.T) {
|
||||
pool := []WeightedCostume{
|
||||
{Weight: 150, ID: 5001}, {Weight: 150, ID: 5002},
|
||||
@@ -162,11 +183,26 @@ func TestActiveGachaAgainstInstalledVersion23510(t *testing.T) {
|
||||
t.Errorf("step-up %d=%+v ok=%v", groupID, stepUp, ok)
|
||||
}
|
||||
}
|
||||
for _, id := range []uint64{20100082, 21000082, 20100034, 21000034, 20100070, 21000070, 20100083, 21000083} {
|
||||
for _, id := range []uint64{200, 201, 20100082, 21000082, 20100034, 21000034, 20100070, 21000070, 20100083, 21000083} {
|
||||
if _, ok := equipment.Gacha(id); !ok {
|
||||
t.Errorf("equipment gacha %d missing", id)
|
||||
}
|
||||
}
|
||||
permanentEquipment, ok := equipment.Group(10002)
|
||||
if !ok || permanentEquipment.OneTimeGachaID != 200 || permanentEquipment.TenTimeGachaID != 201 || permanentEquipment.FixedID != 1 || permanentEquipment.PointCount != 1 {
|
||||
t.Fatalf("permanent equipment group=%+v ok=%v", permanentEquipment, ok)
|
||||
}
|
||||
for _, id := range []uint64{200, 201} {
|
||||
product, _ := equipment.Gacha(id)
|
||||
if product.TicketOnly || product.PriceType != 3 || product.Price != uint64(product.Count)*200 || len(product.TicketIDs) != 1 || product.TicketIDs[0] != 1000 || len(product.Pool) != 8 {
|
||||
t.Fatalf("permanent equipment product %d=%+v", id, product)
|
||||
}
|
||||
}
|
||||
for _, groupID := range []uint64{1003, 1004, 1005, 1006} {
|
||||
if _, ok := equipment.Group(groupID); ok {
|
||||
t.Errorf("content-ticket equipment group %d became ordinary permanent draw", groupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRegularGachaLoaderAgainstInstalledVersion23510(t *testing.T) {
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type CostumeUpgrade struct {
|
||||
@@ -106,32 +110,46 @@ type CharAwakeProgress struct {
|
||||
IsAwake bool `json:"is_awake"`
|
||||
}
|
||||
|
||||
// CostumeBurstUpgradeRecord is the durable result of one costume burst level
|
||||
// transition. Digest identifies the exact protobuf request whose successful
|
||||
// response is stored in Body, allowing a committed request to be replayed
|
||||
// after a process restart without charging its materials again.
|
||||
type CostumeBurstUpgradeRecord struct {
|
||||
CostumeID uint64 `json:"costume_id"`
|
||||
Level uint64 `json:"level"`
|
||||
Digest string `json:"digest"`
|
||||
Code int `json:"code"`
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
|
||||
// FirstGachaCompletedIdentity is the persisted account flag represented in
|
||||
// the existing collection grant ledger. It is written atomically with the
|
||||
// official GachaSubType=3 first-pick transaction.
|
||||
const FirstGachaCompletedIdentity = "account:first-gacha-completed"
|
||||
|
||||
type collectionSnapshot struct {
|
||||
Version string `json:"version"`
|
||||
NextCharacterIndex uint64 `json:"next_character_index"`
|
||||
NextCostumeIndex uint64 `json:"next_costume_index"`
|
||||
LatestPreview []uint64 `json:"latest_preview"`
|
||||
PreviewEventIndex uint64 `json:"preview_event_index"`
|
||||
PreviewLocked bool `json:"preview_locked"`
|
||||
Characters []Character `json:"characters,omitempty"`
|
||||
Costumes []Costume `json:"costumes,omitempty"`
|
||||
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"`
|
||||
CostumePotential map[string][]uint64 `json:"costume_potential,omitempty"`
|
||||
CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
|
||||
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
|
||||
GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
|
||||
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
|
||||
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
|
||||
GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
|
||||
GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
|
||||
GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
|
||||
GachaCountCorrected bool `json:"gacha_count_corrected"`
|
||||
Grants map[string]CollectionGrant `json:"grants,omitempty"`
|
||||
Version string `json:"version"`
|
||||
NextCharacterIndex uint64 `json:"next_character_index"`
|
||||
NextCostumeIndex uint64 `json:"next_costume_index"`
|
||||
LatestPreview []uint64 `json:"latest_preview"`
|
||||
PreviewEventIndex uint64 `json:"preview_event_index"`
|
||||
PreviewLocked bool `json:"preview_locked"`
|
||||
Characters []Character `json:"characters,omitempty"`
|
||||
Costumes []Costume `json:"costumes,omitempty"`
|
||||
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"`
|
||||
CostumePotential map[string][]uint64 `json:"costume_potential,omitempty"`
|
||||
CostumeBurstLevels map[string]uint64 `json:"-"`
|
||||
CostumeBurstUpgrades map[string]CostumeBurstUpgradeRecord `json:"-"`
|
||||
CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
|
||||
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
|
||||
GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
|
||||
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
|
||||
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
|
||||
GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
|
||||
GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
|
||||
GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
|
||||
GachaCountCorrected bool `json:"gacha_count_corrected"`
|
||||
Grants map[string]CollectionGrant `json:"grants,omitempty"`
|
||||
}
|
||||
|
||||
// CollectionStore owns non-stackable character/costume rewards as one atomic
|
||||
@@ -157,9 +175,10 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
|
||||
s := &CollectionStore{store: entries, base: append([]Costume(nil), base...), data: collectionSnapshot{
|
||||
Version: versionconfig.State(), NextCharacterIndex: 920000001, NextCostumeIndex: 930000001,
|
||||
BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{}, GachaSelectionChanges: map[string]uint64{},
|
||||
CostumePotential: map[string][]uint64{},
|
||||
CharAwake: map[string]CharAwakeProgress{},
|
||||
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
|
||||
CostumePotential: map[string][]uint64{},
|
||||
CostumeBurstLevels: map[string]uint64{}, CostumeBurstUpgrades: map[string]CostumeBurstUpgradeRecord{},
|
||||
CharAwake: map[string]CharAwakeProgress{},
|
||||
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
|
||||
GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{},
|
||||
}}
|
||||
b, err := store.Load("collection")
|
||||
@@ -193,6 +212,9 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
|
||||
if s.data.CharAwake == nil {
|
||||
return nil, errors.New("player: collection save requires char_awake; migrate the development save")
|
||||
}
|
||||
if err := s.validateCostumeBurstStorage(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, progress := range s.data.CharAwake {
|
||||
uniqueID, parseErr := strconv.ParseUint(key, 10, 64)
|
||||
if parseErr != nil || uniqueID == 0 || (progress.ImprintLevels == [3]uint64{} && !progress.IsAwake) {
|
||||
@@ -811,20 +833,191 @@ func (s *CollectionStore) UpdateCharacter(oldID uint64, character Character) err
|
||||
func (s *CollectionStore) Costumes() []Costume {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.costumesLocked()
|
||||
}
|
||||
|
||||
func (s *CollectionStore) costumesLocked() []Costume {
|
||||
result := append([]Costume(nil), s.base...)
|
||||
for i := range result {
|
||||
if level := s.data.BaseCostumeLevels[strconv.FormatUint(result[i].InvenIndex, 10)]; level > result[i].Level {
|
||||
key := strconv.FormatUint(result[i].InvenIndex, 10)
|
||||
if level := s.data.BaseCostumeLevels[key]; level > result[i].Level {
|
||||
result[i].Level = level
|
||||
}
|
||||
result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(result[i].InvenIndex, 10)]...)
|
||||
if level, found := s.data.CostumeBurstLevels[key]; found {
|
||||
result[i].BurstLevel = level
|
||||
}
|
||||
result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[key]...)
|
||||
}
|
||||
collection := append([]Costume(nil), s.data.Costumes...)
|
||||
for i := range collection {
|
||||
collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(collection[i].InvenIndex, 10)]...)
|
||||
key := strconv.FormatUint(collection[i].InvenIndex, 10)
|
||||
if level, found := s.data.CostumeBurstLevels[key]; found {
|
||||
collection[i].BurstLevel = level
|
||||
}
|
||||
collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[key]...)
|
||||
}
|
||||
return append(result, collection...)
|
||||
}
|
||||
|
||||
// CostumeByID finds the single owned costume with the logical CostumeTable ID.
|
||||
// A duplicate logical ID is invalid ownership state and is deliberately not
|
||||
// resolved by choosing an arbitrary inventory instance.
|
||||
func (s *CollectionStore) CostumeByID(id uint64) (Costume, bool) {
|
||||
if id == 0 {
|
||||
return Costume{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var result Costume
|
||||
found := false
|
||||
for _, costume := range s.costumesLocked() {
|
||||
if costume.ID != id {
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
return Costume{}, false
|
||||
}
|
||||
result, found = costume, true
|
||||
}
|
||||
return result, found
|
||||
}
|
||||
|
||||
// CostumeBurstReplay returns an owned copy of the durable response recorded
|
||||
// for one inventory instance and target burst level.
|
||||
func (s *CollectionStore) CostumeBurstReplay(invenIndex, target uint64) (CostumeBurstUpgradeRecord, bool) {
|
||||
if invenIndex == 0 || target == 0 {
|
||||
return CostumeBurstUpgradeRecord{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record, found := s.data.CostumeBurstUpgrades[costumeBurstUpgradeKey(invenIndex, target)]
|
||||
record.Body = append([]byte(nil), record.Body...)
|
||||
return record, found
|
||||
}
|
||||
|
||||
// ApplyCostumeBurst atomically advances one owned costume and stores the exact
|
||||
// successful reply. expectedCurrent is a compare-and-swap guard against a
|
||||
// stale request overwriting a newer level.
|
||||
func (s *CollectionStore) ApplyCostumeBurst(invenIndex, expectedCurrent, target uint64, record CostumeBurstUpgradeRecord) error {
|
||||
if invenIndex == 0 || expectedCurrent == math.MaxUint64 || target != expectedCurrent+1 {
|
||||
return errors.New("player: invalid costume burst transition")
|
||||
}
|
||||
if record.Level != target || record.CostumeID == 0 {
|
||||
return errors.New("player: inconsistent costume burst record")
|
||||
}
|
||||
if err := validateCostumeBurstRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
costume, found := s.costumeByIndexLocked(invenIndex)
|
||||
if !found {
|
||||
return fmt.Errorf("player: costume %d not found", invenIndex)
|
||||
}
|
||||
if costume.ID != record.CostumeID {
|
||||
return errors.New("player: costume burst record design mismatch")
|
||||
}
|
||||
if costume.BurstLevel != expectedCurrent {
|
||||
return errors.New("player: stale costume burst level")
|
||||
}
|
||||
ledgerKey := costumeBurstUpgradeKey(invenIndex, target)
|
||||
if _, exists := s.data.CostumeBurstUpgrades[ledgerKey]; exists {
|
||||
return errors.New("player: costume burst transition already recorded")
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
next.CostumeBurstLevels[strconv.FormatUint(invenIndex, 10)] = target
|
||||
record.Body = append([]byte(nil), record.Body...)
|
||||
next.CostumeBurstUpgrades[ledgerKey] = record
|
||||
return s.commit(next)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) costumeByIndexLocked(index uint64) (Costume, bool) {
|
||||
for _, costume := range s.costumesLocked() {
|
||||
if costume.InvenIndex == index {
|
||||
return costume, true
|
||||
}
|
||||
}
|
||||
return Costume{}, false
|
||||
}
|
||||
|
||||
func costumeBurstUpgradeKey(invenIndex, target uint64) string {
|
||||
return strconv.FormatUint(invenIndex, 10) + ":" + strconv.FormatUint(target, 10)
|
||||
}
|
||||
|
||||
func parseCostumeBurstUpgradeKey(key string) (uint64, uint64, bool) {
|
||||
left, right, found := strings.Cut(key, ":")
|
||||
if !found || strings.Contains(right, ":") {
|
||||
return 0, 0, false
|
||||
}
|
||||
index, indexErr := strconv.ParseUint(left, 10, 64)
|
||||
level, levelErr := strconv.ParseUint(right, 10, 64)
|
||||
return index, level, indexErr == nil && levelErr == nil && index != 0 && level != 0 && key == costumeBurstUpgradeKey(index, level)
|
||||
}
|
||||
|
||||
func validateCostumeBurstRecord(record CostumeBurstUpgradeRecord) error {
|
||||
if record.CostumeID == 0 || record.Level == 0 || record.Code != 578 || len(record.Digest) != costumeBurstDigestHexSize {
|
||||
return errors.New("player: invalid costume burst upgrade record")
|
||||
}
|
||||
if _, err := hex.DecodeString(record.Digest); err != nil {
|
||||
return errors.New("player: invalid costume burst upgrade digest")
|
||||
}
|
||||
level, found, err := wire.Varint(record.Body, 1)
|
||||
if err != nil || !found || level != record.Level {
|
||||
return errors.New("player: invalid costume burst upgrade response")
|
||||
}
|
||||
fieldCount := 0
|
||||
if err := wire.Walk(record.Body, func(field wire.Field) error {
|
||||
if field.Number != 1 || field.Type != 0 {
|
||||
return errors.New("unexpected costume burst response field")
|
||||
}
|
||||
fieldCount++
|
||||
return nil
|
||||
}); err != nil || fieldCount != 1 {
|
||||
return errors.New("player: invalid costume burst upgrade response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const costumeBurstDigestHexSize = 64
|
||||
|
||||
func (s *CollectionStore) validateCostumeBurstStorage() error {
|
||||
if s.data.CostumeBurstLevels == nil || s.data.CostumeBurstUpgrades == nil {
|
||||
return errors.New("player: collection save requires costume burst ledgers")
|
||||
}
|
||||
owned := make(map[uint64]Costume, len(s.base)+len(s.data.Costumes))
|
||||
for _, costume := range append(append([]Costume(nil), s.base...), s.data.Costumes...) {
|
||||
if costume.InvenIndex == 0 || costume.ID == 0 {
|
||||
return errors.New("player: invalid costume burst ownership")
|
||||
}
|
||||
if _, exists := owned[costume.InvenIndex]; exists {
|
||||
return errors.New("player: duplicate costume burst inventory index")
|
||||
}
|
||||
owned[costume.InvenIndex] = costume
|
||||
}
|
||||
for key, level := range s.data.CostumeBurstLevels {
|
||||
index, err := strconv.ParseUint(key, 10, 64)
|
||||
costume, found := owned[index]
|
||||
if err != nil || index == 0 || key != strconv.FormatUint(index, 10) || level == 0 || !found || level < costume.BurstLevel {
|
||||
return fmt.Errorf("player: invalid costume burst level entry %q", key)
|
||||
}
|
||||
}
|
||||
for key, record := range s.data.CostumeBurstUpgrades {
|
||||
index, level, valid := parseCostumeBurstUpgradeKey(key)
|
||||
costume, found := owned[index]
|
||||
current := costume.BurstLevel
|
||||
if overlay, exists := s.data.CostumeBurstLevels[strconv.FormatUint(index, 10)]; exists {
|
||||
current = overlay
|
||||
}
|
||||
if !valid || !found || record.CostumeID != costume.ID || record.Level != level || level > current {
|
||||
return fmt.Errorf("player: invalid costume burst upgrade entry %q", key)
|
||||
}
|
||||
if err := validateCostumeBurstRecord(record); err != nil {
|
||||
return fmt.Errorf("player: invalid costume burst upgrade entry %q: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CollectionStore) ValidateCostumePotentialActivation(costumeIndex uint64, nodes []uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -948,12 +1141,9 @@ func (s *CollectionStore) CharacterByIndex(index uint64) (Character, bool) {
|
||||
}
|
||||
|
||||
func (s *CollectionStore) CostumeByIndex(index uint64) (Costume, bool) {
|
||||
for _, costume := range s.Costumes() {
|
||||
if costume.InvenIndex == index {
|
||||
return costume, true
|
||||
}
|
||||
}
|
||||
return Costume{}, false
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.costumeByIndexLocked(index)
|
||||
}
|
||||
|
||||
func findCostume(costumes []Costume, id uint64) (int, bool) {
|
||||
@@ -1005,6 +1195,15 @@ func cloneCollection(in collectionSnapshot) collectionSnapshot {
|
||||
for k, v := range in.CostumePotential {
|
||||
out.CostumePotential[k] = append([]uint64(nil), v...)
|
||||
}
|
||||
out.CostumeBurstLevels = make(map[string]uint64, len(in.CostumeBurstLevels))
|
||||
for k, v := range in.CostumeBurstLevels {
|
||||
out.CostumeBurstLevels[k] = v
|
||||
}
|
||||
out.CostumeBurstUpgrades = make(map[string]CostumeBurstUpgradeRecord, len(in.CostumeBurstUpgrades))
|
||||
for k, v := range in.CostumeBurstUpgrades {
|
||||
v.Body = append([]byte(nil), v.Body...)
|
||||
out.CostumeBurstUpgrades[k] = v
|
||||
}
|
||||
out.CharAwake = make(map[string]CharAwakeProgress, len(in.CharAwake))
|
||||
for k, v := range in.CharAwake {
|
||||
out.CharAwake[k] = v
|
||||
|
||||
@@ -15,7 +15,8 @@ const collectionDomain = "collection"
|
||||
var collectionEntryBuckets = [...]string{
|
||||
"characters", "costumes", "grants", "gacha_applied", "gacha_users",
|
||||
"gacha_fixed", "step_up_progress", "gacha_point_exchanges",
|
||||
"gacha_selections", "gacha_selection_changes", "costume_potential", "char_awake",
|
||||
"gacha_selections", "gacha_selection_changes", "costume_potential",
|
||||
"costume_burst_levels", "costume_burst_upgrades", "char_awake",
|
||||
}
|
||||
|
||||
func rejectInlineCollectionEntries(raw []byte) error {
|
||||
@@ -88,6 +89,12 @@ func loadCollectionEntries(store stateio.EntryStore, data *collectionSnapshot) e
|
||||
if data.CostumePotential, err = loadEntryMap[[]uint64](store, "costume_potential"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.CostumeBurstLevels, err = loadEntryMap[uint64](store, "costume_burst_levels"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.CostumeBurstUpgrades, err = loadEntryMap[CostumeBurstUpgradeRecord](store, "costume_burst_upgrades"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.CharAwake, err = loadEntryMap[CharAwakeProgress](store, "char_awake"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,6 +134,8 @@ func collectionCore(in collectionSnapshot) collectionSnapshot {
|
||||
in.GachaSelections = nil
|
||||
in.GachaSelectionChanges = nil
|
||||
in.CostumePotential = nil
|
||||
in.CostumeBurstLevels = nil
|
||||
in.CostumeBurstUpgrades = nil
|
||||
in.CharAwake = nil
|
||||
return in
|
||||
}
|
||||
@@ -226,6 +235,12 @@ func diffCollectionEntries(before, after collectionSnapshot) ([]stateio.EntryMut
|
||||
if err := diffEntryMap("costume_potential", before.CostumePotential, after.CostumePotential, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("costume_burst_levels", before.CostumeBurstLevels, after.CostumeBurstLevels, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("costume_burst_upgrades", before.CostumeBurstUpgrades, after.CostumeBurstUpgrades, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("char_awake", before.CharAwake, after.CharAwake, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
const costumeBurstPacketCode = 578
|
||||
|
||||
type costumeBurstReply struct {
|
||||
Digest string
|
||||
Code int
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// CostumeBurstService applies one adjacent costume-burst level from the
|
||||
// authoritative GameData row. The account request transaction makes the
|
||||
// wallet, inventory, and collection writes one atomic SQLite operation.
|
||||
type CostumeBurstService struct {
|
||||
mu sync.Mutex
|
||||
design *gamedata.CostumeBurstDesign
|
||||
collection *CollectionStore
|
||||
inventory *Inventory
|
||||
wallet *Wallet
|
||||
sessionID string
|
||||
replies map[string]map[uint64]costumeBurstReply
|
||||
}
|
||||
|
||||
func NewCostumeBurstService(design *gamedata.CostumeBurstDesign, collection *CollectionStore, inventory *Inventory, wallet *Wallet) (*CostumeBurstService, error) {
|
||||
if design == nil || collection == nil || inventory == nil || wallet == nil {
|
||||
return nil, errors.New("player: incomplete costume burst service")
|
||||
}
|
||||
return &CostumeBurstService{
|
||||
design: design, collection: collection, inventory: inventory, wallet: wallet,
|
||||
replies: make(map[string]map[uint64]costumeBurstReply),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BeginSession scopes protobuf sequence replay without forgetting older live
|
||||
// sessions. Session dispatch calls it before each authenticated request.
|
||||
func (s *CostumeBurstService) BeginSession(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if id == "" || id == s.sessionID {
|
||||
return
|
||||
}
|
||||
s.sessionID = id
|
||||
if s.replies[id] != nil {
|
||||
return
|
||||
}
|
||||
if len(s.replies) >= 1024 {
|
||||
for oldID := range s.replies {
|
||||
if oldID != id {
|
||||
delete(s.replies, oldID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.replies[id] = make(map[uint64]costumeBurstReply)
|
||||
}
|
||||
|
||||
func (s *CostumeBurstService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path != "/CostumeBurst" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
|
||||
return 0, nil, true, errors.New("player: CostumeBurst missing sequence")
|
||||
}
|
||||
costumeID, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || costumeID == 0 || costumeID > math.MaxInt32 {
|
||||
return 0, nil, true, errors.New("player: CostumeBurst missing costume")
|
||||
}
|
||||
materials, err := equipmentRequestItems(request, 3, "CostumeBurst")
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
for _, material := range materials {
|
||||
if material.InvenIndex > math.MaxInt64 || material.ID > math.MaxInt32 || material.Type > math.MaxInt32 || material.Count > math.MaxInt32 ||
|
||||
material.KeepFlag > math.MaxInt32 || material.TimeValue > math.MaxInt64 || material.ExpiryTime > math.MaxInt64 ||
|
||||
material.SortID > math.MaxInt32 || material.UseCount > math.MaxInt32 {
|
||||
return 0, nil, true, errors.New("player: CostumeBurst material exceeds protocol range")
|
||||
}
|
||||
}
|
||||
|
||||
digestBytes := sha256.Sum256(request)
|
||||
digest := hex.EncodeToString(digestBytes[:])
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sessionID := s.sessionID
|
||||
if sessionID == "" {
|
||||
sessionID = "__direct__"
|
||||
}
|
||||
if s.replies[sessionID] == nil {
|
||||
s.replies[sessionID] = make(map[uint64]costumeBurstReply)
|
||||
}
|
||||
if reply, ok := s.replies[sessionID][seq]; ok {
|
||||
if reply.Digest != digest {
|
||||
return 0, nil, true, errors.New("player: CostumeBurst sequence reused with different request")
|
||||
}
|
||||
return reply.Code, append([]byte(nil), reply.Body...), true, nil
|
||||
}
|
||||
|
||||
costume, found := s.collection.CostumeByID(costumeID)
|
||||
if !found {
|
||||
return 0, nil, true, fmt.Errorf("player: CostumeBurst costume %d is not owned", costumeID)
|
||||
}
|
||||
if costume.BurstLevel != 0 {
|
||||
if applied, ok := s.collection.CostumeBurstReplay(costume.InvenIndex, costume.BurstLevel); ok && applied.Digest == digest {
|
||||
reply := costumeBurstReply{Digest: digest, Code: applied.Code, Body: append([]byte(nil), applied.Body...)}
|
||||
s.replies[sessionID][seq] = reply
|
||||
return reply.Code, append([]byte(nil), reply.Body...), true, nil
|
||||
}
|
||||
}
|
||||
rule, err := s.design.UpgradeRule(costume.ID, costume.BurstLevel)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: resolve CostumeBurst rule: %w", err)
|
||||
}
|
||||
items, gold, err := validateCostumeBurstMaterials(rule.Costs, materials)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if gold != 0 && !s.wallet.CanSpendGold(gold) {
|
||||
return 0, nil, true, errors.New("player: insufficient gold for CostumeBurst")
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.CanConsume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate CostumeBurst items: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
identity := "costume-burst:" + strconv.FormatUint(costume.InvenIndex, 10) + ":" + strconv.FormatUint(rule.NextLevel, 10)
|
||||
if gold != 0 {
|
||||
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume CostumeBurst gold: %w", err)
|
||||
}
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.Consume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume CostumeBurst items: %w", err)
|
||||
}
|
||||
}
|
||||
body := wire.AppendVarint(nil, 1, rule.NextLevel)
|
||||
record := CostumeBurstUpgradeRecord{CostumeID: costume.ID, Level: rule.NextLevel, Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)}
|
||||
if err := s.collection.ApplyCostumeBurst(costume.InvenIndex, costume.BurstLevel, rule.NextLevel, record); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist CostumeBurst: %w", err)
|
||||
}
|
||||
reply := costumeBurstReply{Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)}
|
||||
s.replies[sessionID][seq] = reply
|
||||
return reply.Code, append([]byte(nil), reply.Body...), true, nil
|
||||
}
|
||||
|
||||
func validateCostumeBurstMaterials(costs []gamedata.PromotionCost, materials []Item) ([]Item, uint64, error) {
|
||||
if len(costs) == 0 || len(materials) == 0 {
|
||||
return nil, 0, errors.New("player: CostumeBurst has no material")
|
||||
}
|
||||
want := make(map[[2]uint64]uint64, len(costs))
|
||||
for _, cost := range costs {
|
||||
if cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || (cost.Type == 8 && cost.ID == 0) || (cost.Type != 4 && cost.Type != 8) {
|
||||
return nil, 0, errors.New("player: invalid CostumeBurst GameData cost")
|
||||
}
|
||||
key := [2]uint64{cost.Type, cost.ID}
|
||||
if want[key] > math.MaxUint64-cost.Count {
|
||||
return nil, 0, errors.New("player: CostumeBurst GameData cost overflow")
|
||||
}
|
||||
want[key] += cost.Count
|
||||
}
|
||||
got := make(map[[2]uint64]uint64, len(materials))
|
||||
items := make([]Item, 0, len(materials))
|
||||
var gold uint64
|
||||
for _, material := range materials {
|
||||
if material.Type != 4 && material.Type != 8 {
|
||||
return nil, 0, fmt.Errorf("player: unsupported CostumeBurst material type %d", material.Type)
|
||||
}
|
||||
key := [2]uint64{material.Type, material.ID}
|
||||
if got[key] > math.MaxUint64-material.Count {
|
||||
return nil, 0, errors.New("player: CostumeBurst submitted material overflow")
|
||||
}
|
||||
got[key] += material.Count
|
||||
if material.Type == 4 {
|
||||
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
|
||||
return nil, 0, errors.New("player: invalid CostumeBurst currency")
|
||||
}
|
||||
gold = material.Count
|
||||
} else {
|
||||
items = append(items, material)
|
||||
}
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
return nil, 0, errors.New("player: CostumeBurst material kinds mismatch")
|
||||
}
|
||||
for key, count := range want {
|
||||
if got[key] != count {
|
||||
return nil, 0, fmt.Errorf("player: CostumeBurst material %d/%d=%d want %d", key[0], key[1], got[key], count)
|
||||
}
|
||||
}
|
||||
return items, gold, nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func testCostumeBurstRecord(costumeID, level uint64) CostumeBurstUpgradeRecord {
|
||||
return CostumeBurstUpgradeRecord{
|
||||
CostumeID: costumeID,
|
||||
Level: level,
|
||||
Digest: strings.Repeat("ab", 32),
|
||||
Code: 578,
|
||||
Body: wire.AppendVarint(nil, 1, level),
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionCostumeBurstPersistsOverlayAndReplayForEveryCostumeSource(t *testing.T) {
|
||||
storage := &collectionWriteSpy{Memory: stateio.NewMemory()}
|
||||
base := []Costume{{InvenIndex: 11, ID: 101, BurstLevel: 0}}
|
||||
collection, err := OpenCollectionStore(storage, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := collection.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := cloneCollection(collection.data)
|
||||
next.Costumes = []Costume{{InvenIndex: 22, ID: 202}}
|
||||
if err := collection.commit(next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
baseRecord := testCostumeBurstRecord(101, 1)
|
||||
before := len(storage.mutations)
|
||||
if err := collection.ApplyCostumeBurst(11, 0, 1, baseRecord); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(storage.mutations) != before+1 || len(storage.mutations[before]) != 2 {
|
||||
t.Fatalf("burst level and replay were not one collection write: %+v", storage.mutations[before:])
|
||||
}
|
||||
if storage.mutations[before][0].Bucket != "costume_burst_levels" || storage.mutations[before][1].Bucket != "costume_burst_upgrades" {
|
||||
t.Fatalf("unexpected burst mutations: %+v", storage.mutations[before])
|
||||
}
|
||||
if err := collection.ApplyCostumeBurst(22, 0, 1, testCostumeBurstRecord(202, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := collection.ApplyCostumeBurst(22, 1, 2, testCostumeBurstRecord(202, 2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got, found := collection.CostumeByID(101); !found || got.InvenIndex != 11 || got.BurstLevel != 1 {
|
||||
t.Fatalf("base costume overlay=%+v found=%v", got, found)
|
||||
}
|
||||
if got, found := collection.CostumeByIndex(22); !found || got.ID != 202 || got.BurstLevel != 2 {
|
||||
t.Fatalf("collection costume overlay=%+v found=%v", got, found)
|
||||
}
|
||||
replay, found := collection.CostumeBurstReplay(22, 2)
|
||||
if !found || replay.CostumeID != 202 || replay.Level != 2 || replay.Code != 578 {
|
||||
t.Fatalf("replay=%+v found=%v", replay, found)
|
||||
}
|
||||
replay.Body[0] = 0
|
||||
if again, _ := collection.CostumeBurstReplay(22, 2); len(again.Body) == 0 || again.Body[0] == 0 {
|
||||
t.Fatal("CostumeBurstReplay returned an aliased response body")
|
||||
}
|
||||
|
||||
core, err := storage.Load("collection")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(core), "costume_burst") {
|
||||
t.Fatalf("burst entries leaked into collection core: %s", core)
|
||||
}
|
||||
restarted, err := OpenCollectionStore(storage, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := restarted.CostumeByIndex(11); got.BurstLevel != 1 {
|
||||
t.Fatalf("restarted base burst level=%d", got.BurstLevel)
|
||||
}
|
||||
if got, _ := restarted.CostumeByID(202); got.BurstLevel != 2 {
|
||||
t.Fatalf("restarted collection burst level=%d", got.BurstLevel)
|
||||
}
|
||||
if got, ok := restarted.CostumeBurstReplay(22, 1); !ok || got.Level != 1 {
|
||||
t.Fatalf("restarted first replay=%+v found=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionCostumeBurstCASAndRecordValidation(t *testing.T) {
|
||||
collection, err := OpenCollectionStore(stateio.NewMemory(), []Costume{{InvenIndex: 11, ID: 101}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record := testCostumeBurstRecord(101, 1)
|
||||
if err := collection.ApplyCostumeBurst(11, 0, 1, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, call := range map[string]func() error{
|
||||
"stale current": func() error { return collection.ApplyCostumeBurst(11, 0, 1, record) },
|
||||
"skipped level": func() error { return collection.ApplyCostumeBurst(11, 1, 3, testCostumeBurstRecord(101, 3)) },
|
||||
"wrong costume": func() error { return collection.ApplyCostumeBurst(11, 1, 2, testCostumeBurstRecord(102, 2)) },
|
||||
"unknown instance": func() error { return collection.ApplyCostumeBurst(99, 0, 1, record) },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := call(); err == nil {
|
||||
t.Fatal("invalid costume burst transition was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if got, _ := collection.CostumeByIndex(11); got.BurstLevel != 1 {
|
||||
t.Fatalf("rejected transitions mutated level to %d", got.BurstLevel)
|
||||
}
|
||||
if _, found := collection.CostumeBurstReplay(11, 2); found {
|
||||
t.Fatal("rejected transition wrote a replay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionCostumeByIDRejectsAmbiguousOwnership(t *testing.T) {
|
||||
collection, err := OpenCollectionStore(stateio.NewMemory(), []Costume{{InvenIndex: 11, ID: 101}, {InvenIndex: 12, ID: 101}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if costume, found := collection.CostumeByID(101); found || costume.InvenIndex != 0 || costume.ID != 0 {
|
||||
t.Fatalf("ambiguous logical ID resolved to %+v", costume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionRejectsMalformedCostumeBurstEntries(t *testing.T) {
|
||||
validRecord, err := json.Marshal(testCostumeBurstRecord(101, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
bucket string
|
||||
key string
|
||||
payload []byte
|
||||
seed Costume
|
||||
prepare func(*stateio.Memory)
|
||||
}{
|
||||
{name: "noncanonical level key", bucket: "costume_burst_levels", key: "011", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101}},
|
||||
{name: "orphan level", bucket: "costume_burst_levels", key: "99", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101}},
|
||||
{name: "zero level", bucket: "costume_burst_levels", key: "11", payload: []byte("0"), seed: Costume{InvenIndex: 11, ID: 101}},
|
||||
{name: "level decreases seed", bucket: "costume_burst_levels", key: "11", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101, BurstLevel: 2}},
|
||||
{name: "noncanonical replay key", bucket: "costume_burst_upgrades", key: "011:1", payload: validRecord, seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
|
||||
{name: "replay above current", bucket: "costume_burst_upgrades", key: "11:1", payload: validRecord, seed: Costume{InvenIndex: 11, ID: 101}},
|
||||
{name: "invalid replay digest", bucket: "costume_burst_upgrades", key: "11:1", payload: func() []byte {
|
||||
r := testCostumeBurstRecord(101, 1)
|
||||
r.Digest = strings.Repeat("zz", 32)
|
||||
b, _ := json.Marshal(r)
|
||||
return b
|
||||
}(), seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
|
||||
{name: "mismatched response", bucket: "costume_burst_upgrades", key: "11:1", payload: func() []byte {
|
||||
r := testCostumeBurstRecord(101, 1)
|
||||
r.Body = wire.AppendVarint(nil, 1, 2)
|
||||
b, _ := json.Marshal(r)
|
||||
return b
|
||||
}(), seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
collection, err := OpenCollectionStore(storage, []Costume{test.seed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := collection.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.prepare != nil {
|
||||
test.prepare(storage)
|
||||
}
|
||||
if err := storage.PutEntry("collection", test.bucket, test.key, test.payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OpenCollectionStore(storage, []Costume{test.seed}); err == nil {
|
||||
t.Fatal("malformed costume burst storage was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func testCostumeBurstDesign() *gamedata.CostumeBurstDesign {
|
||||
return &gamedata.CostumeBurstDesign{Levels: map[uint64]map[uint64]gamedata.CostumeBurstLevel{
|
||||
4202: {
|
||||
1: {CostumeID: 4202, Level: 1, Costs: []gamedata.PromotionCost{{Type: 4, Count: 100}, {Type: 8, ID: 710, Count: 2}}},
|
||||
2: {CostumeID: 4202, Level: 2, Costs: []gamedata.PromotionCost{{Type: 4, Count: 200}, {Type: 8, ID: 710, Count: 2}}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func costumeBurstRequest(seq, costumeID uint64, materials ...Item) []byte {
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, costumeID)
|
||||
for _, material := range materials {
|
||||
request = wire.AppendBytes(request, 3, ItemWire(material))
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func newCostumeBurstFixture(t *testing.T, storePath string, stacks []gamedata.BattleReward) (*CostumeBurstService, *CollectionStore, *Inventory, *Wallet, []Item) {
|
||||
t.Helper()
|
||||
store := testStore(storePath)
|
||||
starter := &Starter{Version: "2.35.10"}
|
||||
inventory, err := OpenInventory(store, starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := inventory.GrantOnce("costume-burst-materials", stacks)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(store, Currency{Gold: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 88, ID: 4202, DesignID: 9911}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := NewCostumeBurstService(testCostumeBurstDesign(), collection, inventory, wallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service, collection, inventory, wallet, items
|
||||
}
|
||||
|
||||
func TestCostumeBurstConsumesExactSplitCostsPersistsAndReplays(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, path, []gamedata.BattleReward{
|
||||
{Type: 8, ID: 710, Count: 1},
|
||||
{Type: 8, ID: 710, Count: 1},
|
||||
})
|
||||
service.BeginSession("login-a")
|
||||
request := costumeBurstRequest(7, 4202, stacks[1], Item{Type: 4, Count: 100}, stacks[0])
|
||||
code, body, handled, err := service.Handle("/CostumeBurst", request)
|
||||
if err != nil || !handled || code != costumeBurstPacketCode {
|
||||
t.Fatalf("CostumeBurst code=%d handled=%v body=%x err=%v", code, handled, body, err)
|
||||
}
|
||||
level, found, err := wire.Varint(body, 1)
|
||||
if err != nil || !found || level != 1 {
|
||||
t.Fatalf("response level=%d found=%v err=%v body=%x", level, found, err, body)
|
||||
}
|
||||
got, found := collection.CostumeByID(4202)
|
||||
if !found || got.InvenIndex != 88 || got.DesignID != 9911 || got.BurstLevel != 1 {
|
||||
t.Fatalf("upgraded costume=%+v found=%v", got, found)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 900 {
|
||||
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
|
||||
}
|
||||
for _, stack := range stacks {
|
||||
if err := inventory.CanConsume([]Item{stack}); err == nil {
|
||||
t.Fatalf("material stack %d survived", stack.InvenIndex)
|
||||
}
|
||||
}
|
||||
|
||||
code2, body2, handled, err := service.Handle("/CostumeBurst", request)
|
||||
if err != nil || !handled || code2 != code || string(body2) != string(body) {
|
||||
t.Fatalf("same-session replay code=%d handled=%v body=%x err=%v", code2, handled, body2, err)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 900 || collection.Costumes()[0].BurstLevel != 1 {
|
||||
t.Fatal("same-session replay mutated state")
|
||||
}
|
||||
|
||||
store := testStore(path)
|
||||
reloadedInventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadedWallet, err := OpenWallet(store, Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadedCollection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 88, ID: 4202, DesignID: 9911}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted, err := NewCostumeBurstService(testCostumeBurstDesign(), reloadedCollection, reloadedInventory, reloadedWallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted.BeginSession("after-restart")
|
||||
code2, body2, handled, err = restarted.Handle("/CostumeBurst", request)
|
||||
if err != nil || !handled || code2 != code || string(body2) != string(body) {
|
||||
t.Fatalf("durable replay code=%d handled=%v body=%x err=%v", code2, handled, body2, err)
|
||||
}
|
||||
if reloadedWallet.Snapshot().Gold != 900 || reloadedCollection.Costumes()[0].BurstLevel != 1 {
|
||||
t.Fatal("durable replay charged or advanced again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumeBurstRequiresLogicalCostumeIDAndProtectsSessionSequence(t *testing.T) {
|
||||
service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, filepath.Join(t.TempDir(), "state.json"), []gamedata.BattleReward{{Type: 8, ID: 710, Count: 4}})
|
||||
material := stacks[0]
|
||||
material.Count = 2
|
||||
for _, wrong := range []uint64{88, 9911} {
|
||||
request := costumeBurstRequest(wrong, wrong, material, Item{Type: 4, Count: 100})
|
||||
if _, _, handled, err := service.Handle("/CostumeBurst", request); err == nil || !handled {
|
||||
t.Fatalf("non-logical costume ID %d accepted: handled=%v err=%v", wrong, handled, err)
|
||||
}
|
||||
}
|
||||
service.BeginSession("login-a")
|
||||
request := costumeBurstRequest(9, 4202, material, Item{Type: 4, Count: 100})
|
||||
if _, _, _, err := service.Handle("/CostumeBurst", request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
different := costumeBurstRequest(9, 4202, material, Item{Type: 4, Count: 200})
|
||||
if _, _, handled, err := service.Handle("/CostumeBurst", different); err == nil || !handled {
|
||||
t.Fatalf("sequence reuse accepted: handled=%v err=%v", handled, err)
|
||||
}
|
||||
if collection.Costumes()[0].BurstLevel != 1 || wallet.Snapshot().Gold != 900 {
|
||||
t.Fatal("rejected sequence reuse mutated state")
|
||||
}
|
||||
remaining := material
|
||||
remaining.Count = 2
|
||||
if err := inventory.CanConsume([]Item{remaining}); err != nil {
|
||||
t.Fatalf("rejected request consumed remaining items: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumeBurstRejectsInvalidMaterialsAndProtocolRanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
materials func(Item) []Item
|
||||
}{
|
||||
{"short", func(stack Item) []Item { stack.Count = 1; return []Item{stack, {Type: 4, Count: 100}} }},
|
||||
{"extra", func(stack Item) []Item {
|
||||
stack.Count = 2
|
||||
return []Item{stack, {Type: 4, Count: 100}, {InvenIndex: 900, Type: 8, ID: 711, Count: 1}}
|
||||
}},
|
||||
{"wrong-gold", func(stack Item) []Item { stack.Count = 2; return []Item{stack, {Type: 4, Count: 99}} }},
|
||||
{"duplicate-overdraw", func(stack Item) []Item { stack.Count = 1; return []Item{stack, stack, {Type: 4, Count: 100}} }},
|
||||
{"range", func(stack Item) []Item {
|
||||
stack.Count = 2
|
||||
stack.ExpiryTime = math.MaxInt64 + 1
|
||||
return []Item{stack, {Type: 4, Count: 100}}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stackCount := uint64(2)
|
||||
if test.name == "duplicate-overdraw" {
|
||||
stackCount = 1
|
||||
}
|
||||
service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, filepath.Join(t.TempDir(), "state.json"), []gamedata.BattleReward{{Type: 8, ID: 710, Count: stackCount}})
|
||||
request := costumeBurstRequest(1, 4202, test.materials(stacks[0])...)
|
||||
if _, _, handled, err := service.Handle("/CostumeBurst", request); err == nil || !handled {
|
||||
t.Fatalf("invalid request accepted: handled=%v err=%v", handled, err)
|
||||
}
|
||||
if collection.Costumes()[0].BurstLevel != 0 || wallet.Snapshot().Gold != 1000 {
|
||||
t.Fatal("invalid request mutated level or wallet")
|
||||
}
|
||||
if err := inventory.CanConsume([]Item{stacks[0]}); err != nil {
|
||||
t.Fatalf("invalid request consumed material: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumeBurstSQLiteRollbackIsAtomic(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repository, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
starter := &Starter{Version: "2.35.10"}
|
||||
inventory, err := OpenInventory(repository, starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stacks, err := inventory.GrantOnce("rollback-material", []gamedata.BattleReward{{Type: 8, ID: 710, Count: 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(repository, Currency{Gold: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection, err := OpenCollectionStore(repository, []Costume{{InvenIndex: 88, ID: 4202}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ensure := range []func() error{inventory.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted} {
|
||||
if err := ensure(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
service, _ := NewCostumeBurstService(testCostumeBurstDesign(), collection, inventory, wallet)
|
||||
request := costumeBurstRequest(1, 4202, stacks[0], Item{Type: 4, Count: 100})
|
||||
operation, err := repository.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _, handled, err := service.Handle("/CostumeBurst", request); err != nil || !handled || code != costumeBurstPacketCode {
|
||||
t.Fatalf("transactional request code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if err := operation.Rollback(); err == nil {
|
||||
t.Fatal("dirty request rollback did not fence published in-memory state")
|
||||
}
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reopened, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
reloadedInventory, err := OpenInventory(reopened, starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadedWallet, err := OpenWallet(reopened, Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadedCollection, err := OpenCollectionStore(reopened, []Costume{{InvenIndex: 88, ID: 4202}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloadedWallet.Snapshot().Gold != 1000 || reloadedCollection.Costumes()[0].BurstLevel != 0 {
|
||||
t.Fatalf("rolled back state gold=%d costume=%+v", reloadedWallet.Snapshot().Gold, reloadedCollection.Costumes()[0])
|
||||
}
|
||||
if err := reloadedInventory.CanConsume([]Item{stacks[0]}); err != nil {
|
||||
t.Fatalf("rolled back inventory did not restore materials: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -7,34 +7,30 @@
|
||||
<RootNamespace>Bd2CaptureEnvironment</RootNamespace>
|
||||
<BD2PluginVersionKey>capture_environment</BD2PluginVersionKey>
|
||||
</PropertyGroup>
|
||||
<Target Name="RequireGameDir" BeforeTargets="ResolveReferences" Condition="'$(GameDir)' == ''">
|
||||
<Error Text="GameDir is required. Build with -p:GameDir=<official-client-directory>." />
|
||||
</Target>
|
||||
<ItemGroup>
|
||||
<Reference Include="BepInEx">
|
||||
<HintPath>$(GameDir)\BepInEx\core\BepInEx.dll</HintPath>
|
||||
<HintPath>$(BD2BepInExDir)/core/BepInEx.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(GameDir)\BepInEx\core\0Harmony.dll</HintPath>
|
||||
<HintPath>$(BD2BepInExDir)/core/0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.CoreModule">
|
||||
<HintPath>$(GameDir)\BrownDust II_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
|
||||
<HintPath>$(BD2ManagedDir)/UnityEngine.CoreModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine">
|
||||
<HintPath>$(GameDir)\BrownDust II_Data\Managed\UnityEngine.dll</HintPath>
|
||||
<HintPath>$(BD2ManagedDir)/UnityEngine.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UnityWebRequestModule">
|
||||
<HintPath>$(GameDir)\BrownDust II_Data\Managed\UnityEngine.UnityWebRequestModule.dll</HintPath>
|
||||
<HintPath>$(BD2ManagedDir)/UnityEngine.UnityWebRequestModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Google.Protobuf">
|
||||
<HintPath>$(GameDir)\BrownDust II_Data\Managed\Google.Protobuf.dll</HintPath>
|
||||
<HintPath>$(BD2ManagedDir)/Google.Protobuf.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<Import Project="..\VersionSource.targets" />
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using BD2.GameNames;
|
||||
using BepInEx.Logging;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
using static BD2.GameNames.Game;
|
||||
|
||||
namespace Bd2CaptureEnvironment;
|
||||
|
||||
internal static class CaptureStorageIsolation
|
||||
{
|
||||
private const string PlayerPrefsPrefix = "BD2OfficialCapture:" + Bd2Build.Versions.Game + ":";
|
||||
private static ManualLogSource Log;
|
||||
internal static string DataDirectory { get; private set; }
|
||||
internal static string GameDataDirectory { get; private set; }
|
||||
internal static void Initialize(string root, ManualLogSource log)
|
||||
{
|
||||
Log = log;
|
||||
DataDirectory = Path.GetFullPath(Path.Combine(root, "IsolatedUserData"));
|
||||
GameDataDirectory = Path.Combine(DataDirectory, "Data", "t");
|
||||
EnsurePrivateGameDataDirectory();
|
||||
}
|
||||
private static void EnsurePrivateGameDataDirectory()
|
||||
{
|
||||
foreach (string path in new[]
|
||||
{
|
||||
DataDirectory,
|
||||
Path.Combine(DataDirectory, "Data"),
|
||||
GameDataDirectory
|
||||
})
|
||||
{
|
||||
if (Directory.Exists(path) &&
|
||||
(new DirectoryInfo(path).Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
throw new InvalidOperationException("Isolated GameData path is linked: " + path);
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void Install(Harmony harmony)
|
||||
{
|
||||
PropertyInfo persistent = typeof(Application).GetGameProperty(
|
||||
"persistentDataPath", BindingFlags.Static | BindingFlags.Public);
|
||||
MethodInfo getter = persistent?.GetGetMethod();
|
||||
if (getter == null)
|
||||
throw new MissingMethodException("Application.persistentDataPath getter not found");
|
||||
harmony.Patch(getter, prefix: new HarmonyMethod(
|
||||
typeof(CaptureStorageIsolation), nameof(PersistentDataPathPrefix)));
|
||||
|
||||
var names = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"GetString", "SetString", "GetInt", "SetInt", "GetFloat", "SetFloat",
|
||||
"HasKey", "DeleteKey"
|
||||
};
|
||||
foreach (MethodInfo method in typeof(PlayerPrefs).GetMethods(
|
||||
BindingFlags.Static | BindingFlags.Public))
|
||||
{
|
||||
ParameterInfo[] parameters = method.GetParameters();
|
||||
if (!names.Contains(method.Name) || parameters.Length == 0 ||
|
||||
parameters[0].ParameterType != typeof(string))
|
||||
continue;
|
||||
harmony.Patch(method, prefix: new HarmonyMethod(
|
||||
typeof(CaptureStorageIsolation), nameof(PlayerPrefsKeyPrefix)));
|
||||
}
|
||||
|
||||
MethodInfo deleteAll = typeof(PlayerPrefs).GetGameMethod(
|
||||
"DeleteAll", BindingFlags.Static | BindingFlags.Public,
|
||||
null, Type.EmptyTypes, null);
|
||||
if (deleteAll != null)
|
||||
harmony.Patch(deleteAll, prefix: new HarmonyMethod(
|
||||
typeof(CaptureStorageIsolation), nameof(BlockPlayerPrefsDeleteAll)));
|
||||
}
|
||||
|
||||
private static bool PersistentDataPathPrefix(ref string __result)
|
||||
{
|
||||
__result = DataDirectory;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void PlayerPrefsKeyPrefix(ref string key)
|
||||
{
|
||||
if (key != null && !key.StartsWith(PlayerPrefsPrefix, StringComparison.Ordinal))
|
||||
key = PlayerPrefsPrefix + key;
|
||||
}
|
||||
|
||||
private static bool BlockPlayerPrefsDeleteAll()
|
||||
{
|
||||
Log?.LogWarning("Blocked PlayerPrefs.DeleteAll to protect the primary client profile");
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static void InstallGameDataPath(Harmony harmony)
|
||||
{
|
||||
MethodInfo gameDataPath = Game.Method<BDNetwork.NetworkManager>(network => network.GetPachedGameDataPath());
|
||||
if (gameDataPath == null || gameDataPath.ReturnType != typeof(string))
|
||||
throw new MissingMethodException("NetworkManager.GetPachedGameDataPath not found");
|
||||
harmony.Patch(gameDataPath, prefix: new HarmonyMethod(
|
||||
typeof(CaptureStorageIsolation), nameof(GameDataPathPrefix)));
|
||||
Log?.LogInfo("Private installed GameData path hook installed");
|
||||
}
|
||||
|
||||
private static bool GameDataPathPrefix(ref string __result)
|
||||
{
|
||||
__result = GameDataDirectory;
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using BepInEx.Logging;
|
||||
|
||||
namespace Bd2CaptureEnvironment;
|
||||
|
||||
internal static class CaptureWriter
|
||||
{
|
||||
private const int MaxBodyBytes = 16 * 1024 * 1024;
|
||||
private const int MaxQueuedRecords = 256;
|
||||
private const int WriterShutdownSeconds = 30;
|
||||
private static readonly object FailureFileLock = new object();
|
||||
private static readonly BlockingCollection<CaptureRecord> WriteQueue =
|
||||
new BlockingCollection<CaptureRecord>(new ConcurrentQueue<CaptureRecord>(), MaxQueuedRecords);
|
||||
private static ManualLogSource Log;
|
||||
internal static string DirectoryPath { get; private set; }
|
||||
private static string JsonlPath;
|
||||
private static string ReadableLogPath;
|
||||
private static Thread WriterThread;
|
||||
private static int WriterFailed;
|
||||
private static int RejectionLogged;
|
||||
private static int StopRequested;
|
||||
internal static void Initialize(string root, ManualLogSource log)
|
||||
{
|
||||
Log = log;
|
||||
DirectoryPath = Path.GetFullPath(Path.Combine(root, "Capture", DateTime.Now.ToString("yyyyMMdd-HHmmss")));
|
||||
JsonlPath = Path.Combine(DirectoryPath, "capture.jsonl");
|
||||
ReadableLogPath = Path.Combine(DirectoryPath, "capture.log");
|
||||
Directory.CreateDirectory(Path.Combine(DirectoryPath, "bodies"));
|
||||
StartWriter();
|
||||
}
|
||||
private sealed class CaptureRecord
|
||||
{
|
||||
public DateTime Timestamp;
|
||||
public long Id;
|
||||
public string Direction;
|
||||
public string Path;
|
||||
public string Type;
|
||||
public byte[] Body;
|
||||
public int Length;
|
||||
public string Note;
|
||||
}
|
||||
|
||||
internal static void QueueCapture(long id, string direction, string path,
|
||||
string type, byte[] body)
|
||||
{
|
||||
if (Volatile.Read(ref WriterFailed) != 0)
|
||||
{
|
||||
LogRejectedPacket(id, direction, path,
|
||||
"capture writer is unavailable");
|
||||
return;
|
||||
}
|
||||
if (WriteQueue.IsAddingCompleted)
|
||||
{
|
||||
string closedReason = "capture queue was already closed when packet " +
|
||||
id + " " + direction + " " + path + " arrived";
|
||||
MarkIncomplete(closedReason, null);
|
||||
LogRejectedPacket(id, direction, path, closedReason);
|
||||
return;
|
||||
}
|
||||
int length = body?.Length ?? 0;
|
||||
string note = body == null ? "protobuf body is null" : null;
|
||||
byte[] owned = null;
|
||||
if (length <= MaxBodyBytes)
|
||||
{
|
||||
if (length != 0)
|
||||
{
|
||||
owned = new byte[length];
|
||||
Buffer.BlockCopy(body, 0, owned, 0, length);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
note = "body exceeds " + MaxBodyBytes + " byte capture limit";
|
||||
}
|
||||
var record = new CaptureRecord
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
Id = id,
|
||||
Direction = direction,
|
||||
Path = path,
|
||||
Type = type,
|
||||
Body = owned,
|
||||
Length = length,
|
||||
Note = note
|
||||
};
|
||||
try
|
||||
{
|
||||
if (WriteQueue.TryAdd(record)) return;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// CompleteAdding can race a producer during application shutdown.
|
||||
}
|
||||
string reason = WriteQueue.IsAddingCompleted
|
||||
? "capture queue closed before packet " + id + " " + direction +
|
||||
" " + path + " could be queued"
|
||||
: "capture queue capacity " + MaxQueuedRecords +
|
||||
" was exceeded; packet " + id + " " + direction + " " + path +
|
||||
" was not captured";
|
||||
MarkIncomplete(reason, null);
|
||||
LogRejectedPacket(id, direction, path, reason);
|
||||
}
|
||||
|
||||
private static void StartWriter()
|
||||
{
|
||||
WriterThread = new Thread(WriterLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "BD2 capture writer"
|
||||
};
|
||||
WriterThread.Start();
|
||||
}
|
||||
|
||||
internal static void Stop()
|
||||
{
|
||||
if (Interlocked.Exchange(ref StopRequested, 1) != 0) return;
|
||||
CompleteWriterQueue();
|
||||
if (WriterThread == null || !WriterThread.IsAlive) return;
|
||||
if (WriterThread.Join(TimeSpan.FromSeconds(WriterShutdownSeconds))) return;
|
||||
MarkIncomplete(
|
||||
"capture writer did not drain " + WriteQueue.Count +
|
||||
" queued records within " + WriterShutdownSeconds +
|
||||
" seconds during shutdown", null);
|
||||
}
|
||||
|
||||
private static void WriterLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var json = new StreamWriter(JsonlPath, false, new UTF8Encoding(false));
|
||||
using var readable = new StreamWriter(
|
||||
ReadableLogPath, false, new UTF8Encoding(false));
|
||||
readable.WriteLine("timestamp id dir path protobuf type bytes body");
|
||||
readable.WriteLine(new string('-', 160));
|
||||
foreach (CaptureRecord record in WriteQueue.GetConsumingEnumerable())
|
||||
{
|
||||
try
|
||||
{
|
||||
string bodyFile = WriteBody(record);
|
||||
json.WriteLine(RecordJson(record, bodyFile));
|
||||
readable.WriteLine(ReadableLine(record, bodyFile));
|
||||
json.Flush();
|
||||
readable.Flush();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MarkIncomplete(
|
||||
"capture writer failed while writing packet " + record.Id +
|
||||
" " + record.Direction + " " + record.Path, ex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MarkIncomplete("capture writer failed to initialize or finalize", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompleteWriterQueue()
|
||||
{
|
||||
if (WriteQueue.IsAddingCompleted) return;
|
||||
try { WriteQueue.CompleteAdding(); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
|
||||
internal static void MarkIncomplete(string reason, Exception error)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref WriterFailed, 1, 0) != 0) return;
|
||||
CompleteWriterQueue();
|
||||
string detail = DateTime.Now.ToString("O") + Environment.NewLine + reason;
|
||||
if (error != null) detail += Environment.NewLine + error;
|
||||
detail += Environment.NewLine;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(DirectoryPath))
|
||||
{
|
||||
Directory.CreateDirectory(DirectoryPath);
|
||||
lock (FailureFileLock)
|
||||
{
|
||||
File.WriteAllText(
|
||||
Path.Combine(DirectoryPath, "INCOMPLETE.txt"),
|
||||
detail, new UTF8Encoding(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception markerError)
|
||||
{
|
||||
Log?.LogError("could not write capture INCOMPLETE marker: " + markerError);
|
||||
}
|
||||
Log?.LogError("CAPTURE IS INCOMPLETE: " + reason +
|
||||
(error == null ? "" : Environment.NewLine + error));
|
||||
}
|
||||
|
||||
private static void LogRejectedPacket(long id, string direction, string path,
|
||||
string reason)
|
||||
{
|
||||
if (Interlocked.Exchange(ref RejectionLogged, 1) != 0) return;
|
||||
Log?.LogError("CAPTURE PACKETS ARE BEING REJECTED: packet " + id + " " +
|
||||
direction + " " + path + "; " + reason);
|
||||
}
|
||||
|
||||
private static string WriteBody(CaptureRecord record)
|
||||
{
|
||||
if (record.Body == null || record.Body.Length == 0) return null;
|
||||
string type = record.Type ?? record.Path ?? "protobuf";
|
||||
int dot = type.LastIndexOf('.');
|
||||
if (dot >= 0) type = type.Substring(dot + 1);
|
||||
string safe = new string(type.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_')
|
||||
.ToArray()).Trim('_');
|
||||
if (safe.Length == 0) safe = "protobuf";
|
||||
string name = record.Id.ToString("D6") + "_" + record.Direction +
|
||||
"_" + safe + ".pb";
|
||||
File.WriteAllBytes(Path.Combine(DirectoryPath, "bodies", name), record.Body);
|
||||
return "bodies/" + name;
|
||||
}
|
||||
|
||||
private static string RecordJson(CaptureRecord record, string bodyFile)
|
||||
{
|
||||
return "{" +
|
||||
"\"timestamp\":\"" + Escape(record.Timestamp.ToString("O")) + "\"," +
|
||||
"\"id\":" + record.Id + "," +
|
||||
"\"direction\":\"" + Escape(record.Direction) + "\"," +
|
||||
"\"path\":\"" + Escape(record.Path) + "\"," +
|
||||
"\"protobuf_type\":\"" + Escape(record.Type) + "\"," +
|
||||
"\"length\":" + record.Length + "," +
|
||||
"\"body\":" + JsonString(bodyFile) + "," +
|
||||
"\"note\":" + JsonString(record.Note) + "}";
|
||||
}
|
||||
|
||||
private static string ReadableLine(CaptureRecord record, string bodyFile)
|
||||
{
|
||||
string direction = record.Direction == "request" ? "REQ" : "RESP";
|
||||
return string.Format("{0,-33} {1,6:D6} {2,-4} {3,-40} {4,-50} {5,8} {6}",
|
||||
record.Timestamp.ToString("O"), record.Id, direction,
|
||||
Truncate(record.Path, 40), Truncate(record.Type, 50), record.Length,
|
||||
bodyFile ?? record.Note ?? "-");
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int width)
|
||||
{
|
||||
value ??= "";
|
||||
return value.Length <= width ? value : value.Substring(0, width - 1) + "…";
|
||||
}
|
||||
|
||||
private static string JsonString(string value)
|
||||
{
|
||||
return value == null ? "null" : "\"" + Escape(value) + "\"";
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
if (value == null) return "";
|
||||
var escaped = new StringBuilder(value.Length + 16);
|
||||
foreach (char character in value)
|
||||
{
|
||||
switch (character)
|
||||
{
|
||||
case '\"': escaped.Append("\\\""); break;
|
||||
case '\\': escaped.Append("\\\\"); break;
|
||||
case '\b': escaped.Append("\\b"); break;
|
||||
case '\f': escaped.Append("\\f"); break;
|
||||
case '\n': escaped.Append("\\n"); break;
|
||||
case '\r': escaped.Append("\\r"); break;
|
||||
case '\t': escaped.Append("\\t"); break;
|
||||
default:
|
||||
if (character <= '\u001f')
|
||||
escaped.Append("\\u").Append(((int)character).ToString("x4"));
|
||||
else
|
||||
escaped.Append(character);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return escaped.ToString();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using BD2.GameNames;
|
||||
using BepInEx.Logging;
|
||||
using Google.Protobuf;
|
||||
using HarmonyLib;
|
||||
using static BD2.GameNames.Game;
|
||||
|
||||
namespace Bd2CaptureEnvironment;
|
||||
|
||||
internal static class PacketCapture
|
||||
{
|
||||
private static readonly object CorrelationLock = new object();
|
||||
private static readonly Dictionary<string, Queue<PendingRequest>> PendingRequests =
|
||||
new Dictionary<string, Queue<PendingRequest>>(StringComparer.Ordinal);
|
||||
private static ManualLogSource Log;
|
||||
private static long Sequence;
|
||||
private sealed class PendingRequest
|
||||
{
|
||||
public long Id;
|
||||
public string Type;
|
||||
}
|
||||
|
||||
internal static void Install(Harmony harmony, ManualLogSource log)
|
||||
{
|
||||
Log = log;
|
||||
Type manager = typeof(BDNetwork.NetworkManager);
|
||||
MethodInfo send = manager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
|
||||
.SingleOrDefault(method => method.IsGameMethod("Send") &&
|
||||
method.ReturnType == typeof(void) &&
|
||||
method.GetParameters().Length == 6 &&
|
||||
typeof(IMessage).IsAssignableFrom(method.GetParameters()[0].ParameterType));
|
||||
if (send != null)
|
||||
{
|
||||
harmony.Patch(send, prefix: new HarmonyMethod(
|
||||
typeof(PacketCapture), nameof(GameSendPrefix)));
|
||||
Log?.LogInfo("Plaintext request capture installed");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log?.LogWarning("NetworkManager.Send plaintext hook not found");
|
||||
}
|
||||
|
||||
Type responseData = typeof(BDNetwork.ResponseData);
|
||||
MethodInfo responseCheck = manager?.GetMethods(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SingleOrDefault(method =>
|
||||
{
|
||||
ParameterInfo[] parameters = method.GetParameters();
|
||||
return method.IsGameMethod("ResponseCheck") && method.ReturnType == typeof(bool) && parameters.Length == 4 &&
|
||||
parameters[0].ParameterType == typeof(string) &&
|
||||
parameters[2].ParameterType == responseData &&
|
||||
parameters[3].ParameterType == typeof(byte[]);
|
||||
});
|
||||
if (responseCheck != null)
|
||||
{
|
||||
harmony.Patch(responseCheck, prefix: new HarmonyMethod(
|
||||
typeof(PacketCapture), nameof(ResponseCheckPrefix)));
|
||||
Log?.LogInfo("Path-correlated plaintext response capture installed");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log?.LogWarning("NetworkManager.ResponseCheck plaintext hook not found");
|
||||
}
|
||||
}
|
||||
|
||||
private static void GameSendPrefix(IMessage __0)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__0 == null) return;
|
||||
byte[] body = __0.ToByteArray();
|
||||
string type = __0.GetType().FullName ?? __0.GetType().Name;
|
||||
string path = RequestPath(__0.GetType().Name);
|
||||
long id = Interlocked.Increment(ref Sequence);
|
||||
EnqueueRequest(path, new PendingRequest { Id = id, Type = type });
|
||||
CaptureWriter.QueueCapture(id, "request", path, type, body);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("plaintext request capture failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResponseCheckPrefix(string __0, byte[] __3)
|
||||
{
|
||||
try
|
||||
{
|
||||
string path = NormalizePath(__0);
|
||||
PendingRequest request = DequeueRequest(path);
|
||||
long id = request?.Id ?? Interlocked.Increment(ref Sequence);
|
||||
string type = ResponseType(request?.Type, path);
|
||||
CaptureWriter.QueueCapture(id, "response", path, type, __3);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("plaintext response capture failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string RequestPath(string typeName)
|
||||
{
|
||||
const string suffix = "Request";
|
||||
if (typeName != null && typeName.EndsWith(suffix, StringComparison.Ordinal))
|
||||
typeName = typeName.Substring(0, typeName.Length - suffix.Length);
|
||||
return NormalizePath(typeName);
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return "/unknown";
|
||||
return path[0] == '/' ? path : "/" + path;
|
||||
}
|
||||
|
||||
private static string ResponseType(string requestType, string path)
|
||||
{
|
||||
const string suffix = "Request";
|
||||
if (!string.IsNullOrEmpty(requestType) &&
|
||||
requestType.EndsWith(suffix, StringComparison.Ordinal))
|
||||
return requestType.Substring(0, requestType.Length - suffix.Length) + "Response";
|
||||
return "Proto.Net." + path.TrimStart('/') + "Response";
|
||||
}
|
||||
|
||||
private static void EnqueueRequest(string path, PendingRequest request)
|
||||
{
|
||||
lock (CorrelationLock)
|
||||
{
|
||||
if (!PendingRequests.TryGetValue(path, out Queue<PendingRequest> requests))
|
||||
{
|
||||
requests = new Queue<PendingRequest>();
|
||||
PendingRequests.Add(path, requests);
|
||||
}
|
||||
requests.Enqueue(request);
|
||||
}
|
||||
}
|
||||
|
||||
private static PendingRequest DequeueRequest(string path)
|
||||
{
|
||||
lock (CorrelationLock)
|
||||
{
|
||||
if (PendingRequests.TryGetValue(path, out Queue<PendingRequest> requests) &&
|
||||
requests.Count != 0)
|
||||
{
|
||||
PendingRequest request = requests.Dequeue();
|
||||
if (requests.Count == 0) PendingRequests.Remove(path);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using BD2.GameNames;
|
||||
using BepInEx;
|
||||
using BepInEx.Logging;
|
||||
using Google.Protobuf;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -21,548 +15,46 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
public const string Name = "BD2 Capture Environment";
|
||||
public const string Version = Bd2Build.Versions.Plugin;
|
||||
|
||||
private const int MaxBodyBytes = 16 * 1024 * 1024;
|
||||
private const int MaxQueuedRecords = 256;
|
||||
private const int WriterShutdownSeconds = 30;
|
||||
private const string PlayerPrefsPrefix = "BD2OfficialCapture:" + Bd2Build.Versions.Game + ":";
|
||||
private static readonly object CorrelationLock = new object();
|
||||
private static readonly object FailureFileLock = new object();
|
||||
private static readonly Dictionary<string, Queue<PendingRequest>> PendingRequests =
|
||||
new Dictionary<string, Queue<PendingRequest>>(StringComparer.Ordinal);
|
||||
private static readonly BlockingCollection<CaptureRecord> WriteQueue =
|
||||
new BlockingCollection<CaptureRecord>(
|
||||
new ConcurrentQueue<CaptureRecord>(), MaxQueuedRecords);
|
||||
private static ManualLogSource Log;
|
||||
private static string CaptureDirectory;
|
||||
private static string JsonlPath;
|
||||
private static string ReadableLogPath;
|
||||
private static string IsolatedDataDirectory;
|
||||
private static string IsolatedGameDataDirectory;
|
||||
private static long Sequence;
|
||||
private static Thread WriterThread;
|
||||
private static int WriterFailed;
|
||||
private static int RejectionLogged;
|
||||
private static int StopRequested;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
try
|
||||
{
|
||||
Log = Logger;
|
||||
string root = Paths.GameRootPath;
|
||||
IsolatedDataDirectory = Path.GetFullPath(Path.Combine(root, "IsolatedUserData"));
|
||||
IsolatedGameDataDirectory = Path.Combine(IsolatedDataDirectory, "Data", "t");
|
||||
CaptureDirectory = Path.GetFullPath(Path.Combine(
|
||||
root, "Capture", DateTime.Now.ToString("yyyyMMdd-HHmmss")));
|
||||
JsonlPath = Path.Combine(CaptureDirectory, "capture.jsonl");
|
||||
ReadableLogPath = Path.Combine(CaptureDirectory, "capture.log");
|
||||
EnsurePrivateGameDataDirectory();
|
||||
Directory.CreateDirectory(Path.Combine(CaptureDirectory, "bodies"));
|
||||
StartWriter();
|
||||
Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message));
|
||||
CaptureStorageIsolation.Initialize(Paths.GameRootPath, Logger);
|
||||
CaptureWriter.Initialize(Paths.GameRootPath, Logger);
|
||||
|
||||
var harmony = new Harmony(Guid);
|
||||
InstallStorageIsolation(harmony);
|
||||
CaptureStorageIsolation.Install(harmony);
|
||||
string effectiveDataPath = Application.persistentDataPath;
|
||||
if (!string.Equals(
|
||||
Path.GetFullPath(effectiveDataPath).TrimEnd(Path.DirectorySeparatorChar),
|
||||
IsolatedDataDirectory.TrimEnd(Path.DirectorySeparatorChar),
|
||||
CaptureStorageIsolation.DataDirectory.TrimEnd(Path.DirectorySeparatorChar),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
"persistentDataPath isolation verification failed: " + effectiveDataPath);
|
||||
InstallPlaintextCapture(harmony);
|
||||
CaptureStorageIsolation.InstallGameDataPath(harmony);
|
||||
PacketCapture.Install(harmony, Logger);
|
||||
WriteMetadata();
|
||||
Logger.LogInfo("Official capture environment active");
|
||||
Logger.LogInfo("persistentDataPath => " + IsolatedDataDirectory);
|
||||
Logger.LogInfo("isolated GameData => " + IsolatedGameDataDirectory);
|
||||
Logger.LogInfo("capture => " + CaptureDirectory);
|
||||
Logger.LogInfo("persistentDataPath => " + CaptureStorageIsolation.DataDirectory);
|
||||
Logger.LogInfo("isolated GameData => " + CaptureStorageIsolation.GameDataDirectory);
|
||||
Logger.LogInfo("capture => " + CaptureWriter.DirectoryPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Capture environment setup failed: " + ex);
|
||||
MarkCaptureIncomplete("capture environment setup failed", ex);
|
||||
CaptureWriter.MarkIncomplete("capture environment setup failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApplicationQuit()
|
||||
{
|
||||
StopWriter();
|
||||
}
|
||||
|
||||
private sealed class PendingRequest
|
||||
{
|
||||
public long Id;
|
||||
public string Type;
|
||||
}
|
||||
|
||||
private sealed class CaptureRecord
|
||||
{
|
||||
public DateTime Timestamp;
|
||||
public long Id;
|
||||
public string Direction;
|
||||
public string Path;
|
||||
public string Type;
|
||||
public byte[] Body;
|
||||
public int Length;
|
||||
public string Note;
|
||||
}
|
||||
|
||||
private static void EnsurePrivateGameDataDirectory()
|
||||
{
|
||||
foreach (string path in new[]
|
||||
{
|
||||
IsolatedDataDirectory,
|
||||
Path.Combine(IsolatedDataDirectory, "Data"),
|
||||
IsolatedGameDataDirectory
|
||||
})
|
||||
{
|
||||
if (Directory.Exists(path) &&
|
||||
(new DirectoryInfo(path).Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
throw new InvalidOperationException("Isolated GameData path is linked: " + path);
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InstallStorageIsolation(Harmony harmony)
|
||||
{
|
||||
PropertyInfo persistent = typeof(Application).GetProperty(
|
||||
"persistentDataPath", BindingFlags.Static | BindingFlags.Public);
|
||||
MethodInfo getter = persistent?.GetGetMethod();
|
||||
if (getter == null)
|
||||
throw new MissingMethodException("Application.persistentDataPath getter not found");
|
||||
harmony.Patch(getter, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(PersistentDataPathPrefix)));
|
||||
|
||||
var names = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"GetString", "SetString", "GetInt", "SetInt", "GetFloat", "SetFloat",
|
||||
"HasKey", "DeleteKey"
|
||||
};
|
||||
foreach (MethodInfo method in typeof(PlayerPrefs).GetMethods(
|
||||
BindingFlags.Static | BindingFlags.Public))
|
||||
{
|
||||
ParameterInfo[] parameters = method.GetParameters();
|
||||
if (!names.Contains(method.Name) || parameters.Length == 0 ||
|
||||
parameters[0].ParameterType != typeof(string))
|
||||
continue;
|
||||
harmony.Patch(method, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(PlayerPrefsKeyPrefix)));
|
||||
}
|
||||
|
||||
MethodInfo deleteAll = typeof(PlayerPrefs).GetMethod(
|
||||
"DeleteAll", BindingFlags.Static | BindingFlags.Public,
|
||||
null, Type.EmptyTypes, null);
|
||||
if (deleteAll != null)
|
||||
harmony.Patch(deleteAll, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(BlockPlayerPrefsDeleteAll)));
|
||||
}
|
||||
|
||||
private static bool PersistentDataPathPrefix(ref string __result)
|
||||
{
|
||||
__result = IsolatedDataDirectory;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void PlayerPrefsKeyPrefix(ref string key)
|
||||
{
|
||||
if (key != null && !key.StartsWith(PlayerPrefsPrefix, StringComparison.Ordinal))
|
||||
key = PlayerPrefsPrefix + key;
|
||||
}
|
||||
|
||||
private static bool BlockPlayerPrefsDeleteAll()
|
||||
{
|
||||
Log?.LogWarning("Blocked PlayerPrefs.DeleteAll to protect the primary client profile");
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void InstallPlaintextCapture(Harmony harmony)
|
||||
{
|
||||
Type manager = FindType("BDNetwork.NetworkManager");
|
||||
MethodInfo gameDataPath = manager?.GetMethod("GetPachedGameDataPath",
|
||||
BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null);
|
||||
if (gameDataPath == null || gameDataPath.ReturnType != typeof(string))
|
||||
throw new MissingMethodException("NetworkManager.GetPachedGameDataPath not found");
|
||||
harmony.Patch(gameDataPath, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(GameDataPathPrefix)));
|
||||
Log?.LogInfo("Private installed GameData path hook installed");
|
||||
|
||||
MethodInfo send = manager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
|
||||
.FirstOrDefault(method => method.Name == "Send" &&
|
||||
method.ReturnType == typeof(void) &&
|
||||
method.GetParameters().Length == 6 &&
|
||||
typeof(IMessage).IsAssignableFrom(method.GetParameters()[0].ParameterType));
|
||||
if (send != null)
|
||||
{
|
||||
harmony.Patch(send, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(GameSendPrefix)));
|
||||
Log?.LogInfo("Plaintext request capture installed");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log?.LogWarning("NetworkManager.Send plaintext hook not found");
|
||||
}
|
||||
|
||||
Type responseData = FindType("BDNetwork.ResponseData");
|
||||
MethodInfo responseCheck = manager?.GetMethods(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SingleOrDefault(method =>
|
||||
{
|
||||
ParameterInfo[] parameters = method.GetParameters();
|
||||
return method.ReturnType == typeof(bool) && parameters.Length == 4 &&
|
||||
parameters[0].ParameterType == typeof(string) &&
|
||||
parameters[2].ParameterType == responseData &&
|
||||
parameters[3].ParameterType == typeof(byte[]);
|
||||
});
|
||||
if (responseCheck != null)
|
||||
{
|
||||
harmony.Patch(responseCheck, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(ResponseCheckPrefix)));
|
||||
Log?.LogInfo("Path-correlated plaintext response capture installed");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log?.LogWarning("NetworkManager.ResponseCheck plaintext hook not found");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool GameDataPathPrefix(ref string __result)
|
||||
{
|
||||
__result = IsolatedGameDataDirectory;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void GameSendPrefix(IMessage __0)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (__0 == null) return;
|
||||
byte[] body = __0.ToByteArray();
|
||||
string type = __0.GetType().FullName ?? __0.GetType().Name;
|
||||
string path = RequestPath(__0.GetType().Name);
|
||||
long id = Interlocked.Increment(ref Sequence);
|
||||
EnqueueRequest(path, new PendingRequest { Id = id, Type = type });
|
||||
QueueCapture(id, "request", path, type, body);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("plaintext request capture failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResponseCheckPrefix(string __0, byte[] __3)
|
||||
{
|
||||
try
|
||||
{
|
||||
string path = NormalizePath(__0);
|
||||
PendingRequest request = DequeueRequest(path);
|
||||
long id = request?.Id ?? Interlocked.Increment(ref Sequence);
|
||||
string type = ResponseType(request?.Type, path);
|
||||
QueueCapture(id, "response", path, type, __3);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("plaintext response capture failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string RequestPath(string typeName)
|
||||
{
|
||||
const string suffix = "Request";
|
||||
if (typeName != null && typeName.EndsWith(suffix, StringComparison.Ordinal))
|
||||
typeName = typeName.Substring(0, typeName.Length - suffix.Length);
|
||||
return NormalizePath(typeName);
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return "/unknown";
|
||||
return path[0] == '/' ? path : "/" + path;
|
||||
}
|
||||
|
||||
private static string ResponseType(string requestType, string path)
|
||||
{
|
||||
const string suffix = "Request";
|
||||
if (!string.IsNullOrEmpty(requestType) &&
|
||||
requestType.EndsWith(suffix, StringComparison.Ordinal))
|
||||
return requestType.Substring(0, requestType.Length - suffix.Length) + "Response";
|
||||
return "Proto.Net." + path.TrimStart('/') + "Response";
|
||||
}
|
||||
|
||||
private static void EnqueueRequest(string path, PendingRequest request)
|
||||
{
|
||||
lock (CorrelationLock)
|
||||
{
|
||||
if (!PendingRequests.TryGetValue(path, out Queue<PendingRequest> requests))
|
||||
{
|
||||
requests = new Queue<PendingRequest>();
|
||||
PendingRequests.Add(path, requests);
|
||||
}
|
||||
requests.Enqueue(request);
|
||||
}
|
||||
}
|
||||
|
||||
private static PendingRequest DequeueRequest(string path)
|
||||
{
|
||||
lock (CorrelationLock)
|
||||
{
|
||||
if (PendingRequests.TryGetValue(path, out Queue<PendingRequest> requests) &&
|
||||
requests.Count != 0)
|
||||
{
|
||||
PendingRequest request = requests.Dequeue();
|
||||
if (requests.Count == 0) PendingRequests.Remove(path);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void QueueCapture(long id, string direction, string path,
|
||||
string type, byte[] body)
|
||||
{
|
||||
if (Volatile.Read(ref WriterFailed) != 0)
|
||||
{
|
||||
LogRejectedPacket(id, direction, path,
|
||||
"capture writer is unavailable");
|
||||
return;
|
||||
}
|
||||
if (WriteQueue.IsAddingCompleted)
|
||||
{
|
||||
string closedReason = "capture queue was already closed when packet " +
|
||||
id + " " + direction + " " + path + " arrived";
|
||||
MarkCaptureIncomplete(closedReason, null);
|
||||
LogRejectedPacket(id, direction, path, closedReason);
|
||||
return;
|
||||
}
|
||||
int length = body?.Length ?? 0;
|
||||
string note = body == null ? "protobuf body is null" : null;
|
||||
byte[] owned = null;
|
||||
if (length <= MaxBodyBytes)
|
||||
{
|
||||
if (length != 0)
|
||||
{
|
||||
owned = new byte[length];
|
||||
Buffer.BlockCopy(body, 0, owned, 0, length);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
note = "body exceeds " + MaxBodyBytes + " byte capture limit";
|
||||
}
|
||||
var record = new CaptureRecord
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
Id = id,
|
||||
Direction = direction,
|
||||
Path = path,
|
||||
Type = type,
|
||||
Body = owned,
|
||||
Length = length,
|
||||
Note = note
|
||||
};
|
||||
try
|
||||
{
|
||||
if (WriteQueue.TryAdd(record)) return;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// CompleteAdding can race a producer during application shutdown.
|
||||
}
|
||||
string reason = WriteQueue.IsAddingCompleted
|
||||
? "capture queue closed before packet " + id + " " + direction +
|
||||
" " + path + " could be queued"
|
||||
: "capture queue capacity " + MaxQueuedRecords +
|
||||
" was exceeded; packet " + id + " " + direction + " " + path +
|
||||
" was not captured";
|
||||
MarkCaptureIncomplete(reason, null);
|
||||
LogRejectedPacket(id, direction, path, reason);
|
||||
}
|
||||
|
||||
private static void StartWriter()
|
||||
{
|
||||
WriterThread = new Thread(WriterLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "BD2 capture writer"
|
||||
};
|
||||
WriterThread.Start();
|
||||
}
|
||||
|
||||
private static void StopWriter()
|
||||
{
|
||||
if (Interlocked.Exchange(ref StopRequested, 1) != 0) return;
|
||||
CompleteWriterQueue();
|
||||
if (WriterThread == null || !WriterThread.IsAlive) return;
|
||||
if (WriterThread.Join(TimeSpan.FromSeconds(WriterShutdownSeconds))) return;
|
||||
MarkCaptureIncomplete(
|
||||
"capture writer did not drain " + WriteQueue.Count +
|
||||
" queued records within " + WriterShutdownSeconds +
|
||||
" seconds during shutdown", null);
|
||||
}
|
||||
|
||||
private static void WriterLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var json = new StreamWriter(JsonlPath, false, new UTF8Encoding(false));
|
||||
using var readable = new StreamWriter(
|
||||
ReadableLogPath, false, new UTF8Encoding(false));
|
||||
readable.WriteLine("timestamp id dir path protobuf type bytes body");
|
||||
readable.WriteLine(new string('-', 160));
|
||||
foreach (CaptureRecord record in WriteQueue.GetConsumingEnumerable())
|
||||
{
|
||||
try
|
||||
{
|
||||
string bodyFile = WriteBody(record);
|
||||
json.WriteLine(RecordJson(record, bodyFile));
|
||||
readable.WriteLine(ReadableLine(record, bodyFile));
|
||||
json.Flush();
|
||||
readable.Flush();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MarkCaptureIncomplete(
|
||||
"capture writer failed while writing packet " + record.Id +
|
||||
" " + record.Direction + " " + record.Path, ex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MarkCaptureIncomplete("capture writer failed to initialize or finalize", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompleteWriterQueue()
|
||||
{
|
||||
if (WriteQueue.IsAddingCompleted) return;
|
||||
try { WriteQueue.CompleteAdding(); }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
|
||||
private static void MarkCaptureIncomplete(string reason, Exception error)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref WriterFailed, 1, 0) != 0) return;
|
||||
CompleteWriterQueue();
|
||||
string detail = DateTime.Now.ToString("O") + Environment.NewLine + reason;
|
||||
if (error != null) detail += Environment.NewLine + error;
|
||||
detail += Environment.NewLine;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(CaptureDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(CaptureDirectory);
|
||||
lock (FailureFileLock)
|
||||
{
|
||||
File.WriteAllText(
|
||||
Path.Combine(CaptureDirectory, "INCOMPLETE.txt"),
|
||||
detail, new UTF8Encoding(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception markerError)
|
||||
{
|
||||
Log?.LogError("could not write capture INCOMPLETE marker: " + markerError);
|
||||
}
|
||||
Log?.LogError("CAPTURE IS INCOMPLETE: " + reason +
|
||||
(error == null ? "" : Environment.NewLine + error));
|
||||
}
|
||||
|
||||
private static void LogRejectedPacket(long id, string direction, string path,
|
||||
string reason)
|
||||
{
|
||||
if (Interlocked.Exchange(ref RejectionLogged, 1) != 0) return;
|
||||
Log?.LogError("CAPTURE PACKETS ARE BEING REJECTED: packet " + id + " " +
|
||||
direction + " " + path + "; " + reason);
|
||||
}
|
||||
|
||||
private static string WriteBody(CaptureRecord record)
|
||||
{
|
||||
if (record.Body == null || record.Body.Length == 0) return null;
|
||||
string type = record.Type ?? record.Path ?? "protobuf";
|
||||
int dot = type.LastIndexOf('.');
|
||||
if (dot >= 0) type = type.Substring(dot + 1);
|
||||
string safe = new string(type.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_')
|
||||
.ToArray()).Trim('_');
|
||||
if (safe.Length == 0) safe = "protobuf";
|
||||
string name = record.Id.ToString("D6") + "_" + record.Direction +
|
||||
"_" + safe + ".pb";
|
||||
File.WriteAllBytes(Path.Combine(CaptureDirectory, "bodies", name), record.Body);
|
||||
return "bodies/" + name;
|
||||
}
|
||||
|
||||
private static string RecordJson(CaptureRecord record, string bodyFile)
|
||||
{
|
||||
return "{" +
|
||||
"\"timestamp\":\"" + Escape(record.Timestamp.ToString("O")) + "\"," +
|
||||
"\"id\":" + record.Id + "," +
|
||||
"\"direction\":\"" + Escape(record.Direction) + "\"," +
|
||||
"\"path\":\"" + Escape(record.Path) + "\"," +
|
||||
"\"protobuf_type\":\"" + Escape(record.Type) + "\"," +
|
||||
"\"length\":" + record.Length + "," +
|
||||
"\"body\":" + JsonString(bodyFile) + "," +
|
||||
"\"note\":" + JsonString(record.Note) + "}";
|
||||
}
|
||||
|
||||
private static string ReadableLine(CaptureRecord record, string bodyFile)
|
||||
{
|
||||
string direction = record.Direction == "request" ? "REQ" : "RESP";
|
||||
return string.Format("{0,-33} {1,6:D6} {2,-4} {3,-40} {4,-50} {5,8} {6}",
|
||||
record.Timestamp.ToString("O"), record.Id, direction,
|
||||
Truncate(record.Path, 40), Truncate(record.Type, 50), record.Length,
|
||||
bodyFile ?? record.Note ?? "-");
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int width)
|
||||
{
|
||||
value ??= "";
|
||||
return value.Length <= width ? value : value.Substring(0, width - 1) + "…";
|
||||
}
|
||||
|
||||
private static string JsonString(string value)
|
||||
{
|
||||
return value == null ? "null" : "\"" + Escape(value) + "\"";
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
if (value == null) return "";
|
||||
var escaped = new StringBuilder(value.Length + 16);
|
||||
foreach (char character in value)
|
||||
{
|
||||
switch (character)
|
||||
{
|
||||
case '\"': escaped.Append("\\\""); break;
|
||||
case '\\': escaped.Append("\\\\"); break;
|
||||
case '\b': escaped.Append("\\b"); break;
|
||||
case '\f': escaped.Append("\\f"); break;
|
||||
case '\n': escaped.Append("\\n"); break;
|
||||
case '\r': escaped.Append("\\r"); break;
|
||||
case '\t': escaped.Append("\\t"); break;
|
||||
default:
|
||||
if (character <= '\u001f')
|
||||
escaped.Append("\\u").Append(((int)character).ToString("x4"));
|
||||
else
|
||||
escaped.Append(character);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return escaped.ToString();
|
||||
}
|
||||
|
||||
private static Type FindType(string fullName)
|
||||
{
|
||||
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
Type type = assembly.GetType(fullName, false);
|
||||
if (type != null) return type;
|
||||
}
|
||||
try { return Assembly.Load("Assembly-CSharp")?.GetType(fullName, false); }
|
||||
catch { return null; }
|
||||
CaptureWriter.Stop();
|
||||
}
|
||||
|
||||
private static void WriteMetadata()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(CaptureDirectory, "README.txt"),
|
||||
File.WriteAllText(Path.Combine(CaptureWriter.DirectoryPath, "README.txt"),
|
||||
"BD2 " + Bd2Build.Versions.Game + " official API capture.\r\n" +
|
||||
"All NetworkManager plaintext protobuf requests and responses are recorded.\r\n" +
|
||||
"capture.jsonl is machine-readable; capture.log is the aligned human-readable index.\r\n" +
|
||||
@@ -570,8 +62,8 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
"No Cookie/Authorization headers or URL query strings are recorded.\r\n" +
|
||||
"Raw protobuf/account bodies can still contain private account data. Do not share this directory.\r\n" +
|
||||
"clientVersion=" + Application.version + "\r\n" +
|
||||
"persistentDataPath=" + IsolatedDataDirectory + "\r\n" +
|
||||
"gameDataPath=" + IsolatedGameDataDirectory + "\r\n",
|
||||
"persistentDataPath=" + CaptureStorageIsolation.DataDirectory + "\r\n" +
|
||||
"gameDataPath=" + CaptureStorageIsolation.GameDataDirectory + "\r\n",
|
||||
new UTF8Encoding(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
This BepInEx plugin is only for the separately extracted official client selected by the repository-root `versions.json`.
|
||||
|
||||
The SDK embeds the version-locked names table. Build with the game directory; the build generates the readable reference assembly and reobfuscates the plugin automatically:
|
||||
|
||||
```powershell
|
||||
dotnet build plugins/CaptureEnvironment/CaptureEnvironment.csproj -c Release `
|
||||
'-p:GameDir=<official-client-directory>'
|
||||
```
|
||||
|
||||
Deploy the runtime `BD2CaptureEnvironment.dll` together with the adjacent
|
||||
`BD2.GameNames.dll`. The launcher copies both from the staged build and requires
|
||||
both to exist. The shared library validates the plugin/table version and game
|
||||
binary before game patches are installed. See [the GameSdk guide](../GameSdk/README.md)
|
||||
for the compiler/runtime name contract; the readable shell stays in `obj`.
|
||||
|
||||
- redirects `Application.persistentDataPath` to `<game>/IsolatedUserData`;
|
||||
- prefixes game `PlayerPrefs` keys with a namespace derived from the configured client version;
|
||||
- keeps account/profile/Neo/Intro and the installed GameData working set in
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<BD2LocalGameDir>E:\Games\BrownDustII</BD2LocalGameDir>
|
||||
<!-- Optional separate installation for CaptureEnvironment. -->
|
||||
<BD2CaptureGameDir>E:\Games\BrownDustII-Capture</BD2CaptureGameDir>
|
||||
<!-- Optional shared navigation cache; omit to use the SDK default. -->
|
||||
<!-- <BD2GameSdkCache>D:\Caches\BD2.GameSdk</BD2GameSdkCache> -->
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project>
|
||||
<!-- Shared repository settings. Machine paths belong in the local import. -->
|
||||
<PropertyGroup>
|
||||
<BD2PluginsDir>$(MSBuildThisFileDirectory)</BD2PluginsDir>
|
||||
<BD2RepositoryDir>$([System.IO.Path]::GetFullPath('$(BD2PluginsDir)..'))/</BD2RepositoryDir>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(BD2PluginsDir)Directory.Build.local.props" Condition="Exists('$(BD2PluginsDir)Directory.Build.local.props')" />
|
||||
<PropertyGroup>
|
||||
<GameDir Condition="'$(GameDir)' == '' and '$(MSBuildProjectName)' == 'CaptureEnvironment' and '$(BD2CaptureGameDir)' != ''">$(BD2CaptureGameDir)</GameDir>
|
||||
<GameDir Condition="'$(GameDir)' == ''">$(BD2LocalGameDir)</GameDir>
|
||||
<BD2ManagedDir Condition="'$(BD2ManagedDir)' == '' and '$(GameDir)' != ''">$(GameDir)/BrownDust II_Data/Managed</BD2ManagedDir>
|
||||
<BD2BepInExDir Condition="'$(BD2BepInExDir)' == '' and '$(GameDir)' != ''">$(GameDir)/BepInEx</BD2BepInExDir>
|
||||
<BD2VersionConfig Condition="'$(BD2VersionConfig)' == ''">$(BD2RepositoryDir)versions.json</BD2VersionConfig>
|
||||
<BD2SdkProject Condition="'$(BD2SdkProject)' == ''">$(BD2PluginsDir)GameSdk/GameSdk.csproj</BD2SdkProject>
|
||||
<BD2GameNamesProject Condition="'$(BD2GameNamesProject)' == ''">$(BD2PluginsDir)GameNames/GameNames.csproj</BD2GameNamesProject>
|
||||
<BD2SdkConfiguration Condition="'$(BD2SdkConfiguration)' == ''">Release</BD2SdkConfiguration>
|
||||
<BD2SdkTool Condition="'$(BD2SdkTool)' == ''">$(BD2PluginsDir)GameSdk/bin/$(BD2SdkConfiguration)/net8.0/GameSdk.dll</BD2SdkTool>
|
||||
<BD2SdkIntermediateFolder Condition="'$(BD2SdkIntermediateFolder)' == ''">game-sdk</BD2SdkIntermediateFolder>
|
||||
<BD2GameSdkCache Condition="'$(BD2GameSdkCache)' == ''">$(BD2_GAME_SDK_CACHE)</BD2GameSdkCache>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project>
|
||||
<!-- Framework/configuration/output paths are available only after the project. -->
|
||||
<PropertyGroup Condition="'$(BD2PluginVersionKey)' != ''">
|
||||
<BD2SdkDir Condition="'$(BD2SdkDir)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)$(BD2SdkIntermediateFolder)'))</BD2SdkDir>
|
||||
<BD2GeneratedVersionSource Condition="'$(BD2GeneratedVersionSource)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)Bd2Versions.g.cs'))</BD2GeneratedVersionSource>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(BD2PluginsDir)VersionSource.targets" Condition="'$(BD2PluginVersionKey)' != ''" />
|
||||
<Import Project="$(BD2PluginsDir)GameSdk.targets" Condition="'$(BD2PluginVersionKey)' != ''" />
|
||||
</Project>
|
||||
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization.Json;
|
||||
using System.Security.Cryptography;
|
||||
using BD2.GameNames.Internal;
|
||||
|
||||
namespace BD2.GameNames;
|
||||
|
||||
/// <summary>Readable names for string-based reflection. Unknown names retain their literal spelling.</summary>
|
||||
public static class Game
|
||||
{
|
||||
private sealed class Index
|
||||
{
|
||||
internal readonly NameTable Table;
|
||||
internal readonly Dictionary<string, TypeName> Types;
|
||||
internal readonly Dictionary<int, MemberName> Tokens;
|
||||
internal Index()
|
||||
{
|
||||
using (var stream = typeof(Game).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz"))
|
||||
using (var gzip = new GZipStream(stream ?? throw new InvalidDataException("BD2.GameNames: embedded table missing"), CompressionMode.Decompress))
|
||||
Table = (NameTable)new DataContractJsonSerializer(typeof(NameTable)).ReadObject(gzip);
|
||||
if (Table.schema_version != 1) throw new InvalidDataException("BD2.GameNames: unsupported table schema");
|
||||
Types = Table.types.ToDictionary(t => t.readable, StringComparer.Ordinal);
|
||||
Tokens = Table.members.ToDictionary(m => m.token);
|
||||
}
|
||||
}
|
||||
private static readonly Lazy<Index> Names = new Lazy<Index>(() => new Index());
|
||||
private static readonly ConcurrentDictionary<string, Type> TypeCache = new ConcurrentDictionary<string, Type>();
|
||||
private static readonly ConcurrentDictionary<string, MemberInfo> MemberCache = new ConcurrentDictionary<string, MemberInfo>();
|
||||
private static readonly object ValidationLock = new object();
|
||||
private static string ValidatedStamp;
|
||||
/// <summary>Gets the game version embedded in this package.</summary>
|
||||
public static string GameVersion => Names.Value.Table.game_version;
|
||||
|
||||
/// <summary>Translates a full readable type name. Unknown names retain their literal spelling.</summary>
|
||||
public static string TypeName(string readableFullName)
|
||||
{
|
||||
if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName));
|
||||
return Names.Value.Types.TryGetValue(readableFullName.Replace('/', '+'), out var entry) ? entry.original : readableFullName;
|
||||
}
|
||||
|
||||
/// <summary>Finds a readable type in Assembly-CSharp or loaded assemblies; returns null when absent.</summary>
|
||||
public static Type FindType(string readableFullName)
|
||||
{
|
||||
if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName));
|
||||
if (TypeCache.TryGetValue(readableFullName, out var cached)) return cached;
|
||||
string original = TypeName(readableFullName);
|
||||
Type found = GameAssembly().GetType(original, false);
|
||||
if (found == null)
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
found = assembly.GetType(original, false);
|
||||
if (found != null) break;
|
||||
}
|
||||
if (found != null) TypeCache.TryAdd(readableFullName, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
private static Assembly GameAssembly() => Assembly.Load("Assembly-CSharp");
|
||||
private static bool Matches(MemberInfo member, string kind, string readable)
|
||||
{
|
||||
if (member == null) throw new ArgumentNullException(nameof(member));
|
||||
if (readable == null) throw new ArgumentNullException(nameof(readable));
|
||||
// Metadata tokens retain declaration identity through inheritance and closed generics.
|
||||
// A hidden literal member must not be mistaken for a renamed member of a base class.
|
||||
if (member.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(member.MetadataToken, out var entry))
|
||||
return entry.kind == kind && entry.readable == readable;
|
||||
return member.Name == readable;
|
||||
}
|
||||
|
||||
/// <summary>Translate coroutine/member strings. Overloads with different runtime names require Method/MethodName(MethodInfo).</summary>
|
||||
public static string MemberName(Type type, string readable, GameMemberKind kind = GameMemberKind.Method)
|
||||
{
|
||||
if (type == null) throw new ArgumentNullException(nameof(type));
|
||||
if (readable == null) throw new ArgumentNullException(nameof(readable));
|
||||
string key = kind switch
|
||||
{
|
||||
GameMemberKind.Method => "method",
|
||||
GameMemberKind.Field => "field",
|
||||
GameMemberKind.Property => "property",
|
||||
GameMemberKind.Event => "event",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||
};
|
||||
const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
var reflected = kind switch
|
||||
{
|
||||
GameMemberKind.Method => type.GetMethods(all).Cast<MemberInfo>(),
|
||||
GameMemberKind.Field => type.GetFields(all).Cast<MemberInfo>(),
|
||||
GameMemberKind.Property => type.GetProperties(all).Cast<MemberInfo>(),
|
||||
GameMemberKind.Event => type.GetEvents(all).Cast<MemberInfo>(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||
};
|
||||
var names = reflected.Where(m => Matches(m, key, readable)).Select(m => m.Name).Distinct().ToArray();
|
||||
if (names.Length == 0) return readable;
|
||||
if (names.Length != 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature");
|
||||
return names[0];
|
||||
}
|
||||
/// <summary>Returns the actual CLR method name for APIs that accept strings.</summary>
|
||||
public static string MethodName(MethodInfo method) => method?.Name ?? throw new ArgumentNullException(nameof(method));
|
||||
/// <summary>Tests a reflected method against its readable name, preserving overload identity.</summary>
|
||||
public static bool IsGameMethod(this MethodInfo method, string readable) => Matches(method, "method", readable);
|
||||
/// <summary>Translates a readable parameter name using the method declaration; unknown names remain unchanged.</summary>
|
||||
public static string ParameterName(MethodBase method, string readable)
|
||||
{
|
||||
if (method == null) throw new ArgumentNullException(nameof(method));
|
||||
if (readable == null) throw new ArgumentNullException(nameof(readable));
|
||||
if (method.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(method.MetadataToken, out var entry))
|
||||
{
|
||||
var p = entry.parameters.SingleOrDefault(n => n.readable == readable);
|
||||
if (p != null) return p.original;
|
||||
}
|
||||
return readable;
|
||||
}
|
||||
|
||||
/// <summary>Extracts a MethodInfo from a call expression without executing the expression.</summary>
|
||||
public static MethodInfo Method<T>(Expression<Action<T>> expression) => Call(expression);
|
||||
/// <summary>Extracts a MethodInfo from a call expression without executing the expression.</summary>
|
||||
public static MethodInfo Method(Expression<Action> expression) => Call(expression);
|
||||
/// <summary>Extracts a property getter from an expression without evaluating the property.</summary>
|
||||
public static MethodInfo Getter<T, TResult>(Expression<Func<T, TResult>> expression) => PropertyGetter(expression);
|
||||
/// <summary>Extracts a property getter from an expression without evaluating the property.</summary>
|
||||
public static MethodInfo Getter<TResult>(Expression<Func<TResult>> expression) => PropertyGetter(expression);
|
||||
|
||||
private static MethodInfo Call(LambdaExpression expression)
|
||||
{
|
||||
if (expression == null) throw new ArgumentNullException(nameof(expression));
|
||||
return expression.Body is MethodCallExpression call ? call.Method : throw new ArgumentException("Expected a method call", nameof(expression));
|
||||
}
|
||||
private static MethodInfo PropertyGetter(LambdaExpression expression)
|
||||
{
|
||||
if (expression == null) throw new ArgumentNullException(nameof(expression));
|
||||
return expression.Body is MemberExpression member && member.Member is PropertyInfo property
|
||||
? property.GetGetMethod(true) : throw new ArgumentException("Expected a property access", nameof(expression));
|
||||
}
|
||||
|
||||
private static T Cached<T>(Type type, string kind, string readable, BindingFlags flags, Type[] parameters, Func<T> resolve) where T : MemberInfo
|
||||
{
|
||||
if (readable == null) throw new ArgumentNullException(nameof(readable));
|
||||
if (type == null) return null;
|
||||
string key = type.AssemblyQualifiedName + "|" + kind + "|" + readable + "|" + (int)flags + "|" + (parameters == null ? "*" : string.Join(";", parameters.Select(p => p.AssemblyQualifiedName)));
|
||||
if (MemberCache.TryGetValue(key, out var cached)) return (T)cached;
|
||||
T found = resolve();
|
||||
if (found != null) MemberCache.TryAdd(key, found);
|
||||
return found;
|
||||
}
|
||||
/// <summary>Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw.</summary>
|
||||
public static MethodInfo GetGameMethod(this Type type, string readable, BindingFlags flags) => Cached(type, "method", readable, flags, null,
|
||||
() => Single(type.GetMethods(flags).Where(m => Matches(m, "method", readable)), type, readable));
|
||||
/// <summary>Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw.</summary>
|
||||
public static MethodInfo GetGameMethod(this Type type, string readable, BindingFlags flags, Binder binder, Type[] parameters, ParameterModifier[] modifiers)
|
||||
{
|
||||
if (readable == null) throw new ArgumentNullException(nameof(readable));
|
||||
if (parameters == null) throw new ArgumentNullException(nameof(parameters));
|
||||
if (parameters.Any(p => p == null)) throw new ArgumentException("Parameter types cannot contain null", nameof(parameters));
|
||||
if (type == null) return null;
|
||||
MethodInfo Resolve()
|
||||
{
|
||||
var candidates = type.GetMethods(flags).Where(m => Matches(m, "method", readable)).ToArray();
|
||||
if (candidates.Length == 0) return null;
|
||||
return (MethodInfo)(binder ?? Type.DefaultBinder).SelectMethod(flags, candidates, parameters, modifiers);
|
||||
}
|
||||
// Custom binders/modifiers can affect resolution; do not cache those calls.
|
||||
if (binder != null || modifiers != null) return Resolve();
|
||||
return Cached(type, "method", readable, flags, parameters, Resolve);
|
||||
}
|
||||
private static T Single<T>(IEnumerable<T> members, Type type, string readable) where T : MemberInfo
|
||||
{
|
||||
var candidates = members.Take(2).ToArray();
|
||||
if (candidates.Length > 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature");
|
||||
return candidates.FirstOrDefault();
|
||||
}
|
||||
/// <summary>Finds a field by readable name and binding flags. Returns null when absent.</summary>
|
||||
public static FieldInfo GetGameField(this Type type, string readable, BindingFlags flags) => Cached(type, "field", readable, flags, null,
|
||||
() => Single(type.GetFields(flags).Where(m => Matches(m, "field", readable)), type, readable));
|
||||
/// <summary>Finds a property by readable name and binding flags. Returns null when absent; ambiguous matches throw.</summary>
|
||||
public static PropertyInfo GetGameProperty(this Type type, string readable, BindingFlags flags) => Cached(type, "property", readable, flags, null,
|
||||
() => Single(type.GetProperties(flags).Where(m => Matches(m, "property", readable)), type, readable));
|
||||
/// <summary>Finds an event by readable name and binding flags. Returns null when absent.</summary>
|
||||
public static EventInfo GetGameEvent(this Type type, string readable, BindingFlags flags) => Cached(type, "event", readable, flags, null,
|
||||
() => Single(type.GetEvents(flags).Where(m => Matches(m, "event", readable)), type, readable));
|
||||
|
||||
/// <summary>Validate plugin/table version, binary fingerprint and known metadata entries before installing patches.</summary>
|
||||
public static void Validate(Assembly plugin, Action<string> log = null) => Validate(plugin, GameVersion, log);
|
||||
|
||||
/// <summary>Validates only the game binary against the embedded table for plugins that do not use the compiler SDK.</summary>
|
||||
public static void ValidateGame(Action<string> log = null) => ValidateCore(null, GameVersion, log);
|
||||
|
||||
/// <summary>Validates a compiler SDK plugin, its expected game version, the table and the game binary. Throws on mismatch.</summary>
|
||||
public static void Validate(Assembly plugin, string expectedVersion, Action<string> log)
|
||||
{
|
||||
if (plugin == null) throw new ArgumentNullException(nameof(plugin));
|
||||
if (expectedVersion == null) throw new ArgumentNullException(nameof(expectedVersion));
|
||||
ValidateCore(plugin, expectedVersion, log);
|
||||
}
|
||||
|
||||
private static void ValidateCore(Assembly plugin, string expectedVersion, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var table = Names.Value.Table;
|
||||
string stamp = table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256;
|
||||
var metadata = plugin?.GetCustomAttributes<AssemblyMetadataAttribute>().SingleOrDefault(a => a.Key == "BD2.GameNames");
|
||||
if (expectedVersion != table.game_version || plugin != null && metadata?.Value != stamp)
|
||||
throw new InvalidDataException("plugin/table mismatch; rebuild the plugin and BD2.GameNames together");
|
||||
lock (ValidationLock)
|
||||
{
|
||||
if (ValidatedStamp != stamp)
|
||||
{
|
||||
var assembly = GameAssembly();
|
||||
if (assembly.ManifestModule.ModuleVersionId.ToString() != table.assembly_mvid)
|
||||
throw new InvalidDataException("Assembly-CSharp MVID mismatch");
|
||||
using (var sha = SHA256.Create())
|
||||
using (var stream = File.OpenRead(assembly.Location))
|
||||
{
|
||||
string actual = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant();
|
||||
if (actual != table.assembly_sha256) throw new InvalidDataException("Assembly-CSharp SHA-256 mismatch");
|
||||
}
|
||||
const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
var app = FindType("AppManager") ?? throw new TypeLoadException("AppManager");
|
||||
var intro = FindType("IntroUI") ?? throw new TypeLoadException("IntroUI");
|
||||
var network = FindType("BDNetwork.NetworkManager") ?? throw new TypeLoadException("BDNetwork.NetworkManager");
|
||||
if (app.GetGameProperty("IsPlatformLogin", all)?.PropertyType != typeof(bool) ||
|
||||
intro.GetGameMethod("SendMaintenanceInfo", all, null, new[] { typeof(bool) }, null) == null ||
|
||||
network.GetGameMethod("GetPachedGameDataPath", all, null, Type.EmptyTypes, null)?.ReturnType != typeof(string))
|
||||
throw new MissingMemberException("known game-name probes failed");
|
||||
ValidatedStamp = stamp;
|
||||
}
|
||||
}
|
||||
log?.Invoke("BD2.GameNames self-check passed: game=" + table.game_version + ", MVID=" + table.assembly_mvid);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke("BD2.GameNames SELF-CHECK FAILED: expected game=" + expectedVersion + "; " + ex.Message + "; game patches will not be installed");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace BD2.GameNames;
|
||||
|
||||
/// <summary>Specifies the kind of game member whose readable name should be resolved.</summary>
|
||||
public enum GameMemberKind
|
||||
{
|
||||
/// <summary>A method, including a coroutine entry point.</summary>
|
||||
Method,
|
||||
/// <summary>A field.</summary>
|
||||
Field,
|
||||
/// <summary>A property.</summary>
|
||||
Property,
|
||||
/// <summary>An event.</summary>
|
||||
Event
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../PackageMetadata.props" />
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>BD2.GameNames</AssemblyName>
|
||||
<Nullable>disable</Nullable>
|
||||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||
<Deterministic>true</Deterministic>
|
||||
<DebugType>none</DebugType>
|
||||
<PathMap>$(BaseIntermediateOutputPath)=/_/BD2.GameNames/obj,$(MSBuildProjectDirectory)=/_/BD2.GameNames</PathMap>
|
||||
<PackageId>BD2.GameNames</PackageId>
|
||||
<Description>Readable game-name resolution, reflection helpers and compatibility validation for Brown Dust II plugins. Includes a version-locked names table.</Description>
|
||||
<PackageTags>BD2;BrownDust;Unity;BepInEx;Harmony;modding</PackageTags>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<GameNamesTable Condition="'$(GameNamesTable)' == ''">$(MSBuildThisFileDirectory)Mappings/names.json</GameNamesTable>
|
||||
</PropertyGroup>
|
||||
<Target Name="RequireNameTable" BeforeTargets="PrepareForBuild">
|
||||
<Error Condition="!Exists('$(GameNamesTable).gz')" Text="Build a plugin with GameSdk.targets, or pass GameNamesTable pointing to generated names.json." />
|
||||
</Target>
|
||||
<ItemGroup>
|
||||
<Compile Include="Game.cs;GameMemberKind.cs;NameTable.cs" />
|
||||
<EmbeddedResource Include="$(GameNamesTable).gz" LogicalName="BD2.GameNames.names.json.gz" />
|
||||
<None Include="README.md" Pack="true" PackagePath="README.md" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Binary file not shown.
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
// DataContractJsonSerializer populates these internal fields from the embedded table.
|
||||
#pragma warning disable CS0649
|
||||
|
||||
namespace BD2.GameNames.Internal;
|
||||
|
||||
[DataContract]
|
||||
internal sealed class NameTable
|
||||
{
|
||||
[DataMember] public int schema_version = 1;
|
||||
[DataMember] public string game_version;
|
||||
[DataMember] public string assembly_name;
|
||||
[DataMember] public string assembly_mvid;
|
||||
[DataMember] public string assembly_sha256;
|
||||
[DataMember] public string mapping_sha256;
|
||||
[DataMember] public string generator_sha256;
|
||||
[DataMember] public List<TypeName> types = new List<TypeName>();
|
||||
[DataMember] public List<MemberName> members = new List<MemberName>();
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
internal sealed class TypeName
|
||||
{
|
||||
[DataMember] public int token;
|
||||
[DataMember] public string readable;
|
||||
[DataMember] public string original;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
internal sealed class MemberName
|
||||
{
|
||||
[DataMember] public int token;
|
||||
[DataMember] public string kind;
|
||||
[DataMember] public string declaring_type;
|
||||
[DataMember] public string readable;
|
||||
[DataMember] public string original;
|
||||
[DataMember] public string signature;
|
||||
[DataMember] public List<ParameterName> parameters = new List<ParameterName>();
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
internal sealed class ParameterName
|
||||
{
|
||||
[DataMember] public int position;
|
||||
[DataMember] public string readable;
|
||||
[DataMember] public string original;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# BD2.GameNames
|
||||
|
||||
Brown Dust II 插件的共享名字解析运行时,目标框架为 `.NET Standard 2.0`。内嵌当前游戏版本的官方名字表,支持字符串反射、Harmony 目标表达式、缓存和插件启动自检。
|
||||
|
||||
开发插件建议引用配套的 `BD2.GameSdk` NuGet 包,它会自动引入本包并接入可读引用程序集和构建期 reobf。只有需要字符串反射、完全不引用游戏类型的插件才单独引用本包。
|
||||
|
||||
```xml
|
||||
<PackageReference Include="BD2.GameSdk" Version="0.2.0-game.2.35.10" PrivateAssets="all" />
|
||||
```
|
||||
|
||||
```csharp
|
||||
using BD2.GameNames;
|
||||
using System.Reflection;
|
||||
|
||||
Game.Validate(typeof(MyPlugin).Assembly, message => Logger.LogInfo(message));
|
||||
var intro = Game.FindType("IntroUI");
|
||||
var enter = intro?.GetGameMethod("Enter", BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
null, Type.EmptyTypes, null);
|
||||
```
|
||||
|
||||
未知名字保留字面名;查不到成员返回 null,歧义抛出 `AmbiguousMatchException`。表中私有/编译器生成成员可通过字符串通道解析,公开调用可使用 `BD2.GameSdk` 生成的壳接受编译器检查。`nameof` 和协程字符串需显式经过名字表,普通字符串不会被 reobf 改写。
|
||||
|
||||
单独引用本包的插件使用 `Game.ValidateGame` 校验游戏和内嵌表;`Game.Validate` 还会校验 SDK 在插件中写入的编译指纹。运行环境只需要共享 DLL,无需 .NET SDK、Mono.Cecil 或构建工具。同一客户端只安装一份 `BD2.GameNames.dll`,不同表指纹的插件会在启动时明确报错。
|
||||
|
||||
单独引用本包的 SDK-style 类库在 `.csproj` 中设置 `<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>`,让运行时 DLL 复制到 `bin`;`BD2.GameSdk` 会自动设置此默认值。
|
||||
|
||||
包版本使用 `工具语义版本-game.游戏版本`,例如 `0.2.0-game.2.35.10`;游戏版本由 `Game.GameVersion` 获取。游戏版本变化后必须更新配套 SDK/运行时并重新构建插件。遵循包中附带的 LICENSE;本包并非 MIT 或其他开源许可。
|
||||
|
||||
完整导入和构建说明:[BD2.GameSdk 文档](https://github.com/Flechazo098/bd2/blob/main/plugins/GameSdk/README.md)。包目前由仓库打包脚本生成,通过维护者提供的 NuGet 源或本地目录安装。
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Exercise Visual Studio's actual metadata-as-source provider, not a mock. -->
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.11.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Host.Mef;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
if (args.Length == 0)
|
||||
throw new ArgumentException("Usage: GameSdk.NavigationTests <sdk-directory> [game-managed-directory] [--embedded-only]");
|
||||
|
||||
var hiddenSources = new List<(string Original, string Hidden)>();
|
||||
try
|
||||
{
|
||||
// Missing local source proves that navigation uses the embedded documents.
|
||||
if (args.Contains("--embedded-only"))
|
||||
{
|
||||
using var manifest = JsonDocument.Parse(File.ReadAllText(Path.Combine(args[0], "navigation.json")));
|
||||
string root = manifest.RootElement.GetProperty("SourceRoot").GetString()!;
|
||||
string fixtures = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, ".build", "game-sdk-tests")) + Path.DirectorySeparatorChar;
|
||||
if (!Path.GetFullPath(root).StartsWith(fixtures, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("Embedded-only tests may only move generated synthetic fixture sources.");
|
||||
foreach (string source in Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
string hidden = source + ".embedded-test";
|
||||
File.Move(source, hidden);
|
||||
hiddenSources.Add((source, hidden));
|
||||
}
|
||||
}
|
||||
|
||||
// Reflection accesses Visual Studio's internal Roslyn service. A mock would
|
||||
// miss the reference-assembly lookup and embedded-document loader failures.
|
||||
var features = Assembly.Load("Microsoft.CodeAnalysis.Features");
|
||||
var host = MefHostServices.Create(MefHostServices.DefaultAssemblies
|
||||
.Add(features).Add(Assembly.Load("Microsoft.CodeAnalysis.CSharp.Features")));
|
||||
var exportProvider = host.GetType().GetInterfaces().Single(t => t.Name == "IMefHostExportProvider");
|
||||
var getExports = exportProvider.GetMethods().Single(m => m.Name == "GetExports" && m.GetGenericArguments().Length == 1);
|
||||
var contract = features.GetType("Microsoft.CodeAnalysis.MetadataAsSource.IMetadataAsSourceFileService")!;
|
||||
var exports = (System.Collections.IEnumerable)getExports.MakeGenericMethod(contract).Invoke(host, null)!;
|
||||
object export = exports.Cast<object>().First();
|
||||
object service = export.GetType().GetProperty("Value")!.GetValue(export)!;
|
||||
|
||||
using var workspace = new AdhocWorkspace(host);
|
||||
string reference = Path.Combine(Path.GetFullPath(args[0]), "ref", "Assembly-CSharp.Readable.dll");
|
||||
IEnumerable<string> referencePaths = args.Length > 1 && !args[1].StartsWith("--")
|
||||
? Directory.GetFiles(Path.GetFullPath(args[1]), "*.dll").Where(p => Path.GetFileName(p) != "Assembly-CSharp.dll")
|
||||
: ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator);
|
||||
var references = referencePaths.Select(p => MetadataReference.CreateFromFile(p))
|
||||
.Append(MetadataReference.CreateFromFile(reference));
|
||||
var project = workspace.AddProject("Consumer", LanguageNames.CSharp)
|
||||
.WithMetadataReferences(references)
|
||||
.WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
if (!workspace.TryApplyChanges(project.Solution)) throw new InvalidOperationException("Cannot create navigation consumer project.");
|
||||
project = workspace.CurrentSolution.GetProject(project.Id)!;
|
||||
var compilation = (await project.GetCompilationAsync())!;
|
||||
bool fixture = compilation.GetTypeByMetadataName("Readable.Agent") is not null;
|
||||
var type = compilation.GetTypeByMetadataName(fixture ? "Readable.Agent" : "BDNetwork.NetworkManager")
|
||||
?? throw new InvalidOperationException("Navigation test type missing.");
|
||||
|
||||
var optionsType = features.GetType("Microsoft.CodeAnalysis.MetadataAsSource.MetadataAsSourceOptions")!;
|
||||
object options = optionsType.GetMethod("GetDefault", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!
|
||||
.Invoke(null, new object[] { project.Services })!;
|
||||
optionsType.GetProperty("NavigateToSourceLinkAndEmbeddedSources")!.SetValue(options, true);
|
||||
optionsType.GetProperty("NavigateToDecompiledSources")!.SetValue(options, false);
|
||||
|
||||
var symbols = new List<ISymbol> { type };
|
||||
if (fixture)
|
||||
{
|
||||
var ping = type.GetMembers("Ping").OfType<IMethodSymbol>().Single(m => m.Parameters[0].Type.SpecialType == SpecialType.System_Int32);
|
||||
symbols.AddRange(new ISymbol[] {
|
||||
ping, type.GetMembers("Ping").OfType<IMethodSymbol>().Single(m => m.Parameters[0].Type.SpecialType == SpecialType.System_String),
|
||||
ping.Parameters[0], type.GetMembers("Echo").Single(), type.GetMembers("Value").Single(), type.GetMembers("Changed").Single()
|
||||
});
|
||||
var box = compilation.GetTypeByMetadataName("Readable.Box`1")!;
|
||||
symbols.AddRange(new ISymbol[] { box, box.GetMembers("Echo").Single(), box.GetTypeMembers("Nested").Single() });
|
||||
}
|
||||
else
|
||||
{
|
||||
symbols.AddRange(new ISymbol[] {
|
||||
type.GetMembers("Send").First(), type.GetMembers("Refresh").Single(), type.GetMembers("BatchCount").Single(),
|
||||
type.GetMembers("UpdateNowFunc").Single(), type.GetMembers("OnRequestComplete").Single(), type.GetTypeMembers("BatchObject").Single()
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var symbol in symbols)
|
||||
{
|
||||
var task = (Task)contract.GetMethod("GetGeneratedFileAsync")!
|
||||
.Invoke(service, new object[] { workspace, project, symbol, false, options, CancellationToken.None })!;
|
||||
await task;
|
||||
object result = task.GetType().GetProperty("Result")!.GetValue(task)!;
|
||||
string path = (string)result.GetType().GetProperty("FilePath")!.GetValue(result)!;
|
||||
string source = File.ReadAllText(path);
|
||||
if (!path.Contains("PdbSourceDocumentMetadataAsSourceFileProvider", StringComparison.Ordinal))
|
||||
throw new InvalidDataException("Navigation fell back from PDB source: " + path);
|
||||
if (!source.Contains(fixture ? "return value;" : "Path.Combine", StringComparison.Ordinal))
|
||||
throw new InvalidDataException("Navigation did not load complete method bodies: " + path);
|
||||
var location = (Location)result.GetType().GetProperty("IdentifierLocation")!.GetValue(result)!;
|
||||
if (!location.IsInSource) throw new InvalidDataException("Navigation has no source declaration.");
|
||||
string identifier = source.Substring(location.SourceSpan.Start, location.SourceSpan.Length);
|
||||
if (identifier != symbol.Name)
|
||||
throw new InvalidDataException($"Navigation selected '{identifier}' instead of '{symbol.Name}'.");
|
||||
if (fixture && symbol is IMethodSymbol { Name: "Ping" } method)
|
||||
{
|
||||
var syntax = CSharpSyntaxTree.ParseText(source).GetRoot().FindToken(location.SourceSpan.Start)
|
||||
.Parent!.AncestorsAndSelf().OfType<MethodDeclarationSyntax>().First();
|
||||
string expected = method.Parameters[0].Type.SpecialType == SpecialType.System_Int32 ? "int" : "string";
|
||||
if (syntax.ParameterList.Parameters[0].Type!.ToString() != expected)
|
||||
throw new InvalidDataException("Navigation selected the wrong Ping overload.");
|
||||
}
|
||||
Console.WriteLine($"{symbol.ToDisplayString()} => {path}");
|
||||
}
|
||||
Console.WriteLine($"Visual Studio Roslyn PDB-source navigation: {symbols.Count} declarations resolved to exact identifiers and complete source; embedded-only={hiddenSources.Count > 0}.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var source in hiddenSources) File.Move(source.Hidden, source.Original);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project>
|
||||
<ItemGroup>
|
||||
<Compile Include="$(BD2SdkDir)\GameSdkIdentity.g.cs" />
|
||||
<ProjectReference Include="$(BD2GameNamesProject)" />
|
||||
<Reference Include="Assembly-CSharp.Readable">
|
||||
<HintPath>$(BD2SdkDir)\ref\Assembly-CSharp.Readable.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<Import Project="$(BD2SdkDir)/GameSourceNavigation.props" Condition="Exists('$(BD2SdkDir)/GameSourceNavigation.props')" />
|
||||
<Target Name="PrepareBD2GameSdk" BeforeTargets="ResolveProjectReferences;ResolveAssemblyReferences">
|
||||
<Error Condition="'$(GameDir)' == '' and '$(BD2ManagedDir)' == ''" Text="Set BD2LocalGameDir in plugins/Directory.Build.local.props, or pass -p:GameDir=<game-directory>." />
|
||||
<Error Condition="!Exists('$(BD2ManagedDir)/Assembly-CSharp.dll')" Text="Assembly-CSharp.dll missing in $(BD2ManagedDir). Set GameDir or BD2ManagedDir." />
|
||||
<Exec Condition="'$(DesignTimeBuild)' != 'true' or !Exists('$(BD2SdkTool)')" Command="dotnet build "$(BD2SdkProject)" -c $(BD2SdkConfiguration) --nologo" />
|
||||
<Exec EnvironmentVariables="BD2_GAME_SDK_CACHE=$(BD2GameSdkCache)" Command="dotnet "$(BD2SdkTool)" prepare-embedded "$(BD2ManagedDir)/Assembly-CSharp.dll" "$(BD2SdkDir)" "$(BD2VersionConfig)"" />
|
||||
</Target>
|
||||
<Target Name="ReobfuscateBD2Plugin" AfterTargets="Build" Condition="'$(DesignTimeBuild)' != 'true' and '$(SkipCompilerExecution)' != 'true'">
|
||||
<Exec Command="dotnet "$(BD2SdkTool)" reobf "$(BD2SdkDir)\names.json" "$(IntermediateOutputPath)$(TargetFileName)" "$(TargetPath)" "$(BD2ManagedDir)\Assembly-CSharp.dll" "$(BD2BepInExDir)\core"" />
|
||||
<Delete Files="$(TargetDir)$(TargetName).pdb" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../PackageMetadata.props" />
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>disable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ICSharpCode.Decompiler" Version="9.1.0.7988" />
|
||||
<PackageReference Include="Mono.Cecil" Version="0.11.6" />
|
||||
<Compile Include="../GameNames/NameTable.cs" Link="NameTable.cs" />
|
||||
<Compile Remove="samples/**/*.cs;Package/**/*.cs" />
|
||||
<EmbeddedResource Include="../GameNames/Mappings/names.json.gz" LogicalName="BD2.GameNames.names.json.gz" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,55 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$GameDir,
|
||||
[string]$VersionConfig,
|
||||
[string]$PackageVersion,
|
||||
[string]$OutputDirectory
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $VersionConfig) { $VersionConfig = Join-Path $PSScriptRoot '..\..\versions.json' }
|
||||
if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot '..\..\.build\nuget' }
|
||||
$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
|
||||
$versions = Get-Content -LiteralPath $VersionConfig -Raw | ConvertFrom-Json
|
||||
if (-not $PackageVersion) {
|
||||
[xml]$metadata = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\PackageMetadata.props') -Raw
|
||||
$PackageVersion = "$($metadata.Project.PropertyGroup.BD2PackageVersion)-game.$($versions.game_version)"
|
||||
}
|
||||
if ($PackageVersion -notmatch '^\d+\.\d+\.\d+-game\.\d+\.\d+\.\d+(?:\.[A-Za-z0-9-]+)*$') {
|
||||
throw 'PackageVersion must be SemVer with game version, for example 0.2.1-game.2.35.10.'
|
||||
}
|
||||
if ($PackageVersion -notmatch ('-game\.' + [regex]::Escape([string]$versions.game_version) + '(?:\.|$)')) {
|
||||
throw 'PackageVersion game suffix must match VersionConfig game_version.'
|
||||
}
|
||||
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
|
||||
$staging = Join-Path $repository ".build\nuget-staging\$PackageVersion"
|
||||
$sdk = Join-Path $staging 'sdk'
|
||||
$tool = Join-Path $staging 'tool'
|
||||
$runtimeObj = Join-Path $staging 'runtime-obj'
|
||||
$runtimeBin = Join-Path $staging 'runtime-bin'
|
||||
$packageObj = Join-Path $staging 'package-obj'
|
||||
$packageBin = Join-Path $staging 'package-bin'
|
||||
New-Item -ItemType Directory -Force -Path $OutputDirectory, $staging | Out-Null
|
||||
function Invoke-Dotnet([string[]]$Arguments) {
|
||||
& dotnet @Arguments
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
$toolVersion = $PackageVersion.Split('-')[0]
|
||||
Invoke-Dotnet @('publish', (Join-Path $PSScriptRoot 'GameSdk.csproj'), '-c', 'Release', '--nologo', '-o', $tool, "-p:Version=$toolVersion", '-p:UseAppHost=false')
|
||||
$assembly = Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll'
|
||||
$toolDll = Join-Path $tool 'GameSdk.dll'
|
||||
Invoke-Dotnet @($toolDll, 'prepare-embedded', $assembly, $sdk, $VersionConfig)
|
||||
$table = Join-Path $sdk 'names.json'
|
||||
$runtimeProject = Join-Path $PSScriptRoot '..\GameNames\GameNames.csproj'
|
||||
Invoke-Dotnet @('pack', $runtimeProject, '-c', 'Release', '--nologo', '-o', $OutputDirectory,
|
||||
"-p:Version=$PackageVersion", "-p:GameNamesTable=$table", "-p:BaseIntermediateOutputPath=$runtimeObj/", "-p:OutputPath=$runtimeBin/")
|
||||
Invoke-Dotnet @($toolDll, 'verify-runtime', $table, (Join-Path $runtimeBin 'BD2.GameNames.dll'))
|
||||
$packageProject = Join-Path $PSScriptRoot 'Package\BD2.GameSdk.Package.csproj'
|
||||
$nugetConfig = Join-Path $staging 'NuGet.Config'
|
||||
$escapedSource = [Security.SecurityElement]::Escape($OutputDirectory)
|
||||
[IO.File]::WriteAllText($nugetConfig, "<configuration><packageSources><clear/><add key=`"bd2-local`" value=`"$escapedSource`"/><add key=`"nuget.org`" value=`"https://api.nuget.org/v3/index.json`"/></packageSources></configuration>")
|
||||
Invoke-Dotnet @('restore', $packageProject, '--configfile', $nugetConfig, "-p:Version=$PackageVersion",
|
||||
'-p:BD2Packaging=true', "-p:BaseIntermediateOutputPath=$packageObj/", "-p:OutputPath=$packageBin/")
|
||||
Invoke-Dotnet @('pack', $packageProject, '-c', 'Release', '--no-restore', '--nologo', '-o', $OutputDirectory,
|
||||
"-p:Version=$PackageVersion", "-p:BD2ToolPublishDir=$tool", '-p:BD2Packaging=true',
|
||||
"-p:BaseIntermediateOutputPath=$packageObj/", "-p:OutputPath=$packageBin/")
|
||||
Write-Host "Created BD2.GameNames and BD2.GameSdk $PackageVersion in $OutputDirectory"
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../../PackageMetadata.props" />
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<PackageId>BD2.GameSdk</PackageId>
|
||||
<Description>Build integration for Brown Dust II plugins: readable game references, complete decompiled source navigation with embedded Portable PDB, and reobfuscation. Requires .NET 8 SDK and a matching local game installation.</Description>
|
||||
<PackageTags>BD2;BrownDust;Unity;BepInEx;MSBuild;modding</PackageTags>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- IDE restore must work before any local NuGet package has been produced. -->
|
||||
<ProjectReference Include="../../GameNames/GameNames.csproj" Condition="'$(BD2Packaging)' != 'true'" />
|
||||
<PackageReference Include="BD2.GameNames" Version="[$(Version)]" Condition="'$(BD2Packaging)' == 'true'" />
|
||||
<None Include="../README.md" Pack="true" PackagePath="README.md" />
|
||||
<None Include="../THIRD-PARTY-NOTICES.txt" Pack="true" PackagePath="" />
|
||||
<None Include="build/*" Pack="true" PackagePath="build/" />
|
||||
<None Include="_._" Pack="true" PackagePath="lib/netstandard2.0/" />
|
||||
<None Include="$(BD2ToolPublishDir)/**/*" Exclude="$(BD2ToolPublishDir)/**/*.exe;$(BD2ToolPublishDir)/**/*.pdb" Condition="'$(BD2ToolPublishDir)' != ''" Pack="true" PackagePath="tools/net8.0/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
<Target Name="RequirePackageInputs" BeforeTargets="GenerateNuspec">
|
||||
<Error Condition="'$(BD2Packaging)' != 'true'" Text="Pack with ../Pack.ps1; BD2Packaging=true is required for the version-locked runtime dependency." />
|
||||
<Error Condition="!Exists('$(BD2ToolPublishDir)/GameSdk.dll')" Text="Pack with ../Pack.ps1: published tool missing." />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<BD2GameSdkEnabled Condition="'$(BD2GameSdkEnabled)' == ''">true</BD2GameSdkEnabled>
|
||||
<BD2GameSdkPackageRoot>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../'))</BD2GameSdkPackageRoot>
|
||||
<BD2GameSdkCache Condition="'$(BD2GameSdkCache)' == ''">$(BD2_GAME_SDK_CACHE)</BD2GameSdkCache>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project>
|
||||
<PropertyGroup Condition="'$(BD2GameSdkEnabled)' == 'true'">
|
||||
<!-- SDK-style class libraries otherwise omit NuGet runtime assemblies from bin. -->
|
||||
<CopyLocalLockFileAssemblies Condition="'$(CopyLocalLockFileAssemblies)' == ''">true</CopyLocalLockFileAssemblies>
|
||||
<BD2ManagedDir Condition="'$(BD2ManagedDir)' == ''">$(GameDir)/BrownDust II_Data/Managed</BD2ManagedDir>
|
||||
<BD2BepInExDir Condition="'$(BD2BepInExDir)' == ''">$(GameDir)/BepInEx</BD2BepInExDir>
|
||||
<BD2SdkDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)bd2-game-sdk'))</BD2SdkDir>
|
||||
<BD2SdkTool>$(BD2GameSdkPackageRoot)tools/net8.0/GameSdk.dll</BD2SdkTool>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition="'$(BD2GameSdkEnabled)' == 'true'">
|
||||
<Compile Include="$(BD2SdkDir)/GameSdkIdentity.g.cs" />
|
||||
<Reference Include="Assembly-CSharp.Readable">
|
||||
<HintPath>$(BD2SdkDir)/ref/Assembly-CSharp.Readable.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="BepInEx" Condition="'$(BD2AddBepInExReferences)' != 'false'"><HintPath>$(BD2BepInExDir)/core/BepInEx.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="0Harmony" Condition="'$(BD2AddBepInExReferences)' != 'false'"><HintPath>$(BD2BepInExDir)/core/0Harmony.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="UnityEngine" Condition="'$(BD2AddUnityReferences)' != 'false'"><HintPath>$(BD2ManagedDir)/UnityEngine.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="UnityEngine.CoreModule" Condition="'$(BD2AddUnityReferences)' != 'false'"><HintPath>$(BD2ManagedDir)/UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
|
||||
</ItemGroup>
|
||||
<Import Project="$(BD2SdkDir)/GameSourceNavigation.props" Condition="Exists('$(BD2SdkDir)/GameSourceNavigation.props') and '$(BD2GameSdkEnabled)' == 'true'" />
|
||||
<Target Name="PrepareBD2GameSdk" BeforeTargets="ResolveAssemblyReferences;CoreCompile"
|
||||
Condition="'$(BD2GameSdkEnabled)' == 'true' and '$(IsCrossTargetingBuild)' != 'true'">
|
||||
<Error Condition="'$(GameDir)' == '' and '$(BD2ManagedDir)' == '/BrownDust II_Data/Managed'" Text="BD2.GameSdk: set GameDir in Directory.Build.props or pass -p:GameDir=<game-directory>." />
|
||||
<Error Condition="!Exists('$(BD2ManagedDir)/Assembly-CSharp.dll')" Text="BD2.GameSdk: Assembly-CSharp.dll missing in $(BD2ManagedDir). Set GameDir or BD2ManagedDir." />
|
||||
<Error Condition="'$(BD2AddBepInExReferences)' != 'false' and !Exists('$(BD2BepInExDir)/core/BepInEx.dll')" Text="BD2.GameSdk: install BepInEx or set BD2BepInExDir. Custom hosts can disable BD2AddBepInExReferences." />
|
||||
<Exec EnvironmentVariables="BD2_GAME_SDK_CACHE=$(BD2GameSdkCache)" Command="dotnet "$(BD2SdkTool)" prepare-embedded "$(BD2ManagedDir)/Assembly-CSharp.dll" "$(BD2SdkDir)"" />
|
||||
</Target>
|
||||
<Target Name="ReobfuscateBD2Plugin" AfterTargets="Build"
|
||||
Condition="'$(BD2GameSdkEnabled)' == 'true' and '$(DesignTimeBuild)' != 'true' and '$(IsCrossTargetingBuild)' != 'true'">
|
||||
<Exec Command="dotnet "$(BD2SdkTool)" reobf "$(BD2SdkDir)/names.json" "$(IntermediateOutputPath)$(TargetFileName)" "$(TargetPath)" "$(BD2ManagedDir)/Assembly-CSharp.dll" "$(BD2BepInExDir)/core"" />
|
||||
<Delete Files="$(TargetDir)$(TargetName).pdb" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,573 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using BD2.GameNames.Internal;
|
||||
using Mono.Cecil;
|
||||
using Mono.Cecil.Cil;
|
||||
|
||||
namespace BD2.GameSdk;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
internal const string ShellName = "Assembly-CSharp.Readable";
|
||||
internal static readonly JsonSerializerOptions Json = new() { IncludeFields = true };
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (args.FirstOrDefault())
|
||||
{
|
||||
case "prepare" when args.Length == 5: Prepare(args[1], args[2], args[3], args[4]); break;
|
||||
case "prepare-embedded" when args.Length is 3 or 4: PrepareEmbedded(args[1], args[2], args.Length == 4 ? args[3] : null); break;
|
||||
case "export-names" when args.Length == 2: ExportNames(args[1]); break;
|
||||
case "names" when args.Length == 5: GenerateNames(args[1], args[2], args[3], args[4]); break;
|
||||
case "shell" when args.Length == 4: GenerateShell(args[1], args[2], args[3]); break;
|
||||
case "source-navigation" when args.Length == 3: SourceNavigation.Generate(args[1], args[2]); break;
|
||||
case "verify-navigation" when args.Length == 2: SourceNavigation.Verify(args[1]); break;
|
||||
case "reobf" when args.Length is 4 or 5 or 6: Reobfuscate(args[1], args[2], args[3], args.Length >= 5 ? args[4] : null, args.Length == 6 ? args[5] : null); break;
|
||||
case "verify" when args.Length is 3 or 4: Verify(args[1], args[2], args.Length == 4 ? args[3] : null); break;
|
||||
case "verify-runtime" when args.Length == 3: SelfTest.VerifyRuntime(args[1], args[2]); break;
|
||||
case "self-test": SelfTest.Run(); break;
|
||||
default: throw new ArgumentException("Usage: prepare-embedded <Assembly-CSharp.dll> <output-dir> [versions.json] | export-names <names.json> | names <Assembly-CSharp.dll> <mapping.obfuscate> <versions.json> <names.json> | shell <names.json> <Assembly-CSharp.dll> <output.dll> | source-navigation <readable-implementation.dll> <dependency-directory> | verify-navigation <sdk-directory> | reobf <names.json> <input.dll> <output.dll> [Assembly-CSharp.dll] [dependency-directory] | verify <names.json> <plugin.dll> [Assembly-CSharp.dll] | verify-runtime <names.json> <BD2.GameNames.dll> | self-test");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex) { Console.Error.WriteLine("BD2 GameSdk: " + ex); return 1; }
|
||||
}
|
||||
|
||||
internal static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant();
|
||||
private static string GeneratorStamp() => Hash(typeof(Program).Assembly.Location) + "|" +
|
||||
Hash(typeof(ICSharpCode.Decompiler.CSharp.CSharpDecompiler).Assembly.Location) + "|" + Hash(typeof(ModuleDefinition).Assembly.Location);
|
||||
private static string DependencyStamp(string assembly) => string.Join("|",
|
||||
Directory.EnumerateFiles(Path.GetDirectoryName(Path.GetFullPath(assembly)), "*.dll").OrderBy(p => p, StringComparer.Ordinal)
|
||||
.Select(p => Path.GetFileName(p) + ":" + Hash(p)));
|
||||
internal static NameTable ReadTable(string path) => JsonSerializer.Deserialize<NameTable>(File.ReadAllText(path), Json);
|
||||
internal static string Stamp(NameTable table) => table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256;
|
||||
|
||||
internal static byte[] EmbeddedNames()
|
||||
{
|
||||
using var stream = typeof(Program).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz")
|
||||
?? throw new InvalidDataException("SDK embedded names table is missing; rebuild the SDK");
|
||||
using var output = new MemoryStream();
|
||||
stream.CopyTo(output);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
internal static void ExportNames(string tablePath)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(tablePath)));
|
||||
byte[] compressed = EmbeddedNames();
|
||||
File.WriteAllBytes(tablePath + ".gz", compressed);
|
||||
using var gzip = new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress);
|
||||
using var destination = File.Create(tablePath);
|
||||
gzip.CopyTo(destination);
|
||||
}
|
||||
|
||||
internal static void PrepareEmbedded(string assembly, string output, string versions = null)
|
||||
{
|
||||
byte[] compressed = EmbeddedNames();
|
||||
Directory.CreateDirectory(output);
|
||||
// IDE loads and command-line builds may prepare the same obj directory.
|
||||
// Serialize the whole transaction, including the exported table and props.
|
||||
using var outputLock = AcquireCacheLock(Path.Combine(output, "prepare.lock"), "Waiting for this project's game SDK preparation to finish...");
|
||||
string tablePath = Path.Combine(output, "names.json"), compressedPath = tablePath + ".gz";
|
||||
// Keep the cache copy exact; the only authoritative source is the embedded resource.
|
||||
if (!File.Exists(compressedPath) || !File.ReadAllBytes(compressedPath).SequenceEqual(compressed))
|
||||
File.WriteAllBytes(compressedPath, compressed);
|
||||
PreparePackage(compressedPath, assembly, output);
|
||||
if (versions != null)
|
||||
{
|
||||
using var config = JsonDocument.Parse(File.ReadAllText(versions));
|
||||
string expected = config.RootElement.GetProperty("game_version").GetString();
|
||||
string actual = ReadTable(tablePath).game_version;
|
||||
if (expected != actual) throw new InvalidDataException($"Repository game_version {expected} does not match SDK embedded names {actual}; update the SDK names table for this version");
|
||||
}
|
||||
}
|
||||
internal static void PreparePackage(string compressedTable, string assembly, string output)
|
||||
{
|
||||
Directory.CreateDirectory(output);
|
||||
string tablePath = Path.Combine(output, "names.json"), shellPath = Path.Combine(output, ShellName + ".dll");
|
||||
string identityPath = Path.Combine(output, "GameSdkIdentity.g.cs"), readyPath = Path.Combine(output, "package-ready.txt");
|
||||
string pdbPath = Path.Combine(output, "lib", ShellName + ".pdb"), navigationPath = Path.Combine(output, "navigation.json");
|
||||
string inputs = Hash(compressedTable) + "|" + GeneratorStamp() + "|" + DependencyStamp(assembly);
|
||||
string[] required = { tablePath, shellPath, identityPath, pdbPath, navigationPath,
|
||||
Path.Combine(output, "ref", ShellName + ".dll"), Path.Combine(output, "ref", ShellName + ".xml"),
|
||||
Path.Combine(output, "lib", ShellName + ".dll"), Path.Combine(output, "lib", ShellName + ".xml") };
|
||||
string Outputs() => string.Join("|", required.Select(Hash));
|
||||
if (File.Exists(readyPath) && required.All(File.Exists) &&
|
||||
File.ReadAllText(readyPath) == inputs + "|" + Outputs())
|
||||
{
|
||||
WriteNavigationItems(output);
|
||||
return;
|
||||
}
|
||||
File.Delete(readyPath);
|
||||
using (var gzip = new GZipStream(File.OpenRead(compressedTable), CompressionMode.Decompress))
|
||||
using (var destination = File.Create(tablePath)) gzip.CopyTo(destination);
|
||||
GenerateCachedShell(tablePath, assembly, shellPath);
|
||||
WriteIdentity(tablePath, identityPath);
|
||||
WriteNavigationItems(output);
|
||||
File.WriteAllText(readyPath, inputs + "|" + Outputs());
|
||||
}
|
||||
|
||||
private static void GenerateCachedShell(string tablePath, string assembly, string destination)
|
||||
{
|
||||
string cacheRoot = Environment.GetEnvironmentVariable("BD2_GAME_SDK_CACHE");
|
||||
if (string.IsNullOrEmpty(cacheRoot)) cacheRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BD2", "GameSdk", "navigation");
|
||||
// Changes to any managed dependency can affect decompilation and semantic references.
|
||||
string dependencyDirectory = Path.GetDirectoryName(Path.GetFullPath(assembly));
|
||||
string key = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Hash(tablePath) + "|" + GeneratorStamp() + "|" + DependencyStamp(assembly)))).ToLowerInvariant();
|
||||
string cache = Path.Combine(Path.GetFullPath(cacheRoot), key);
|
||||
Directory.CreateDirectory(cache);
|
||||
string cachedShell = Path.Combine(cache, ShellName + ".dll"), ready = Path.Combine(cache, "ready.txt");
|
||||
using (AcquireCacheLock(Path.Combine(cache, "generation.lock")))
|
||||
{
|
||||
string[] relativeFiles = { ShellName + ".dll", ShellName + ".xml", "navigation.json", "ref/" + ShellName + ".dll", "ref/" + ShellName + ".xml",
|
||||
"lib/" + ShellName + ".dll", "lib/" + ShellName + ".pdb", "lib/" + ShellName + ".xml", "lib/navigation.json" };
|
||||
string[] files = relativeFiles.Select(p => Path.Combine(cache, p)).ToArray();
|
||||
bool complete = File.Exists(ready) && files.All(File.Exists) && File.ReadAllText(ready) == string.Join("|", files.Select(Hash));
|
||||
if (!complete)
|
||||
{
|
||||
File.Delete(ready);
|
||||
GenerateShell(tablePath, assembly, cachedShell);
|
||||
File.WriteAllText(ready, string.Join("|", files.Select(Hash)));
|
||||
}
|
||||
string destinationDirectory = Path.GetDirectoryName(Path.GetFullPath(destination));
|
||||
foreach (string relative in relativeFiles)
|
||||
{
|
||||
if (relative == ShellName + ".xml" || relative == "lib/navigation.json") continue;
|
||||
string target = Path.Combine(destinationDirectory, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target));
|
||||
File.Copy(Path.Combine(cache, relative), target, overwrite: true);
|
||||
}
|
||||
File.Delete(Path.ChangeExtension(destination, ".xml"));
|
||||
File.Delete(Path.Combine(destinationDirectory, "lib", "navigation.json"));
|
||||
}
|
||||
}
|
||||
|
||||
private static FileStream AcquireCacheLock(string path, string message = "Waiting for the shared game source-navigation cache to finish generating...")
|
||||
{
|
||||
var elapsed = System.Diagnostics.Stopwatch.StartNew();
|
||||
bool announced = false;
|
||||
while (true)
|
||||
{
|
||||
try { return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); }
|
||||
catch (IOException) when (elapsed.Elapsed < TimeSpan.FromMinutes(30))
|
||||
{
|
||||
if (!announced) { Console.WriteLine(message); announced = true; }
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteNavigationItems(string output)
|
||||
{
|
||||
var manifest = JsonSerializer.Deserialize<SourceNavigation.Manifest>(File.ReadAllText(Path.Combine(output, "navigation.json")));
|
||||
if (!Directory.Exists(manifest.SourceRoot) || Directory.EnumerateFiles(manifest.SourceRoot, "*.cs", SearchOption.AllDirectories).Count() != manifest.Documents)
|
||||
SourceNavigation.RestoreSources(output);
|
||||
var items = new System.Xml.Linq.XElement("ItemGroup");
|
||||
foreach (var path in Directory.EnumerateFiles(manifest.SourceRoot, "*.cs", SearchOption.AllDirectories).OrderBy(p => p, StringComparer.Ordinal))
|
||||
items.Add(new System.Xml.Linq.XElement("None", new System.Xml.Linq.XAttribute("Include", path),
|
||||
new System.Xml.Linq.XElement("Link", "Game Sources/" + Path.GetRelativePath(manifest.SourceRoot, path)),
|
||||
new System.Xml.Linq.XElement("CopyToOutputDirectory", "Never")));
|
||||
using var stream = new MemoryStream();
|
||||
new System.Xml.Linq.XDocument(new System.Xml.Linq.XElement("Project", items)).Save(stream);
|
||||
WriteGeneratedFile(Path.Combine(output, "GameSourceNavigation.props"), stream.ToArray());
|
||||
}
|
||||
|
||||
private static void WriteGeneratedFile(string path, byte[] content)
|
||||
{
|
||||
// Rewriting an imported props file triggers another IDE project reload.
|
||||
// Keep its timestamp stable when the source list has not changed.
|
||||
if (File.Exists(path) && File.ReadAllBytes(path).SequenceEqual(content)) return;
|
||||
string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(temporary, content);
|
||||
var elapsed = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (true)
|
||||
{
|
||||
try { File.Move(temporary, path, overwrite: true); break; }
|
||||
catch (IOException ex) when (ex.HResult == unchecked((int)0x80070020) && elapsed.Elapsed < TimeSpan.FromSeconds(10))
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
|
||||
private static void WriteIdentity(string tablePath, string identityPath) =>
|
||||
File.WriteAllText(identityPath, "// Generated from the shared names table.\n[assembly: System.Reflection.AssemblyMetadataAttribute(\"BD2.GameNames\", " + JsonSerializer.Serialize(Stamp(ReadTable(tablePath))) + ")]\n");
|
||||
internal static IEnumerable<TypeDefinition> Types(ModuleDefinition module) => module.GetTypes();
|
||||
internal static string TypeKey(TypeReference type) => type switch
|
||||
{
|
||||
GenericParameter p => (p.Type == GenericParameterType.Method ? "!!" : "!") + p.Position,
|
||||
GenericInstanceType g => TypeKey(g.ElementType) + "<" + string.Join(",", g.GenericArguments.Select(TypeKey)) + ">",
|
||||
ArrayType a => TypeKey(a.ElementType) + "[" + new string(',', a.Rank - 1) + "]",
|
||||
ByReferenceType b => TypeKey(b.ElementType) + "&",
|
||||
PointerType p => TypeKey(p.ElementType) + "*",
|
||||
OptionalModifierType m => TypeKey(m.ElementType) + " modopt(" + TypeKey(m.ModifierType) + ")",
|
||||
RequiredModifierType m => TypeKey(m.ElementType) + " modreq(" + TypeKey(m.ModifierType) + ")",
|
||||
FunctionPointerType f => "fnptr(" + TypeKey(f.ReturnType) + ":" + string.Join(",", f.Parameters.Select(p => TypeKey(p.ParameterType))) + ")",
|
||||
_ => type.FullName.Replace('/', '+')
|
||||
};
|
||||
internal static string Signature(MemberReference member) => member switch
|
||||
{
|
||||
MethodReference m => $"{m.GenericParameters.Count}:{TypeKey(m.ReturnType)}({string.Join(",", m.Parameters.Select(p => TypeKey(p.ParameterType)))})",
|
||||
FieldReference f => TypeKey(f.FieldType),
|
||||
_ => throw new ArgumentException("Unsupported member " + member)
|
||||
};
|
||||
|
||||
internal static void Prepare(string assembly, string mapping, string versions, string output)
|
||||
{
|
||||
var version = JsonDocument.Parse(File.ReadAllText(versions)).RootElement.GetProperty("game_version").GetString();
|
||||
if (!Path.GetFileName(mapping).Contains(version, StringComparison.Ordinal))
|
||||
throw new InvalidDataException("Mapping filename must identify game_version " + version);
|
||||
Directory.CreateDirectory(output);
|
||||
string tablePath = Path.Combine(output, "names.json");
|
||||
string shellPath = Path.Combine(output, ShellName + ".dll");
|
||||
string assemblyHash = Hash(assembly), mappingHash = Hash(mapping), generatorHash = Hash(typeof(Program).Assembly.Location);
|
||||
string identityPath = Path.Combine(output, "GameSdkIdentity.g.cs"), readyPath = Path.Combine(output, "ready.txt");
|
||||
string inputStamp = version + "|" + assemblyHash + "|" + mappingHash + "|" + generatorHash;
|
||||
if (File.Exists(readyPath) && File.Exists(tablePath) && File.Exists(shellPath) && File.Exists(tablePath + ".gz") && File.Exists(identityPath))
|
||||
{
|
||||
string completeStamp = inputStamp + "|" + Hash(shellPath) + "|" + Hash(tablePath) + "|" + Hash(tablePath + ".gz") + "|" + Hash(identityPath);
|
||||
if (File.ReadAllText(readyPath) == completeStamp) return;
|
||||
}
|
||||
File.Delete(readyPath);
|
||||
GenerateNames(assembly, mapping, versions, tablePath);
|
||||
GenerateShell(tablePath, assembly, shellPath);
|
||||
WriteIdentity(tablePath, identityPath);
|
||||
File.WriteAllText(readyPath, inputStamp + "|" + Hash(shellPath) + "|" + Hash(tablePath) + "|" + Hash(tablePath + ".gz") + "|" + Hash(identityPath));
|
||||
}
|
||||
|
||||
internal static void GenerateNames(string assembly, string mapping, string versions, string tablePath)
|
||||
{
|
||||
using var config = JsonDocument.Parse(File.ReadAllText(versions));
|
||||
string version = config.RootElement.GetProperty("game_version").GetString();
|
||||
if (string.IsNullOrWhiteSpace(version) || !Path.GetFileName(mapping).Contains(version, StringComparison.Ordinal))
|
||||
throw new InvalidDataException("Mapping filename must identify game_version " + version);
|
||||
var map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var raw in File.ReadLines(mapping))
|
||||
{
|
||||
string line = raw.Trim().TrimStart('\ufeff');
|
||||
if (line.Length == 0 || line.StartsWith('#') || line.StartsWith("//")) continue;
|
||||
var parts = line.Split('⇨');
|
||||
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1]))
|
||||
throw new InvalidDataException("Invalid mapping row: " + line);
|
||||
string key = parts[0].Trim(), value = parts[1].Trim();
|
||||
if (map.TryGetValue(key, out var old) && old != value) throw new InvalidDataException("Ambiguous mapping: " + key);
|
||||
map[key] = value;
|
||||
}
|
||||
string Translate(string name)
|
||||
{
|
||||
// Dots in explicit interface members are part of the CLR name, not a scope prefix.
|
||||
if (map.TryGetValue(name, out var value)) return value.Split('/').Last();
|
||||
// Accessors can retain their CLR prefix while the property/event is renamed.
|
||||
foreach (string prefix in new[] { "get_", "set_", "add_", "remove_" })
|
||||
if (name.StartsWith(prefix) && map.TryGetValue(name[prefix.Length..], out value))
|
||||
{
|
||||
string property = value.Split('/').Last();
|
||||
int dot = property.LastIndexOf('.');
|
||||
return property[..(dot + 1)] + prefix + property[(dot + 1)..];
|
||||
}
|
||||
return name;
|
||||
}
|
||||
using var module = ReadModule(assembly);
|
||||
if (module.Assembly.Name.Name != "Assembly-CSharp") throw new InvalidDataException("Expected Assembly-CSharp");
|
||||
var table = new NameTable { game_version = version, assembly_name = module.Assembly.Name.Name,
|
||||
assembly_mvid = module.Mvid.ToString(), assembly_sha256 = Hash(assembly), mapping_sha256 = Hash(mapping), generator_sha256 = Hash(typeof(Program).Assembly.Location) };
|
||||
var definitions = Types(module).ToArray();
|
||||
var originalTypes = definitions.ToDictionary(t => t, TypeKey);
|
||||
string ReadableType(TypeDefinition t)
|
||||
{
|
||||
if (t.DeclaringType != null) return ReadableType(t.DeclaringType) + "+" + Translate(t.Name);
|
||||
if (map.TryGetValue(t.Name, out string full)) return full.Replace('/', '+');
|
||||
return string.IsNullOrEmpty(t.Namespace) ? Translate(t.Name) : t.Namespace + "." + Translate(t.Name);
|
||||
}
|
||||
foreach (var type in definitions)
|
||||
table.types.Add(new TypeName { token = type.MetadataToken.ToInt32(), original = originalTypes[type], readable = ReadableType(type) });
|
||||
if (table.types.GroupBy(t => t.readable).Any(g => g.Count() > 1)) throw new InvalidDataException("Readable type collision");
|
||||
var memberKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var type in definitions)
|
||||
{
|
||||
void Add(IMemberDefinition member, string kind, string signature = null, MethodDefinition method = null)
|
||||
{
|
||||
var entry = new MemberName { token = member.MetadataToken.ToInt32(), kind = kind,
|
||||
declaring_type = originalTypes[type], original = member.Name, readable = Translate(member.Name), signature = signature };
|
||||
if (!memberKeys.Add(originalTypes[type] + "|" + kind + "|" + entry.readable + "|" + signature))
|
||||
throw new InvalidDataException("Readable member collision: " + originalTypes[type] + "." + entry.readable + " " + signature);
|
||||
if (method != null)
|
||||
foreach (var p in method.Parameters)
|
||||
if (Translate(p.Name) != p.Name) entry.parameters.Add(new ParameterName { position = p.Index, original = p.Name, readable = Translate(p.Name) });
|
||||
table.members.Add(entry);
|
||||
}
|
||||
foreach (var m in type.Methods) Add(m, "method", Signature(m), m);
|
||||
foreach (var f in type.Fields) Add(f, "field", Signature(f));
|
||||
foreach (var p in type.Properties) Add(p, "property", TypeKey(p.PropertyType) + "(" + string.Join(",", p.Parameters.Select(a => TypeKey(a.ParameterType))) + ")");
|
||||
foreach (var e in type.Events) Add(e, "event", TypeKey(e.EventType));
|
||||
}
|
||||
// Keep unrenamed overloads that share a readable name with a renamed member.
|
||||
// Otherwise runtime lookup would accidentally omit the literal overload.
|
||||
var renamedGroups = table.members.Where(m => m.readable != m.original)
|
||||
.Select(m => m.declaring_type + "|" + m.kind + "|" + m.readable).ToHashSet(StringComparer.Ordinal);
|
||||
table.members = table.members.Where(m => m.readable != m.original || m.parameters.Count != 0 || renamedGroups.Contains(m.declaring_type + "|" + m.kind + "|" + m.readable)).ToList();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(tablePath)));
|
||||
File.WriteAllText(tablePath, JsonSerializer.Serialize(table, Json));
|
||||
using (var gz = new GZipStream(File.Create(tablePath + ".gz"), CompressionLevel.SmallestSize))
|
||||
gz.Write(File.ReadAllBytes(tablePath));
|
||||
Console.WriteLine($"Game names {version}: {table.types.Count} types, {table.members.Count} member deltas => {tablePath}");
|
||||
}
|
||||
|
||||
internal static void GenerateShell(string tablePath, string assembly, string shellPath)
|
||||
{
|
||||
var table = ReadTable(tablePath);
|
||||
if (Hash(assembly) != table.assembly_sha256) throw new InvalidDataException($"Assembly-CSharp does not match SDK names for game {table.game_version}; install the matching client or update the SDK names table");
|
||||
using var module = ReadModule(assembly);
|
||||
var definitions = Types(module).ToArray();
|
||||
// Rename references while their declaring types still have original names.
|
||||
Rewrite(module, table, toReadable: true);
|
||||
var membersByToken = table.members.ToDictionary(m => m.token);
|
||||
var typesByToken = table.types.ToDictionary(t => t.token);
|
||||
foreach (var type in definitions)
|
||||
{
|
||||
foreach (var member in type.Methods.Cast<IMemberDefinition>().Concat(type.Fields).Concat(type.Properties).Concat(type.Events))
|
||||
{
|
||||
membersByToken.TryGetValue(member.MetadataToken.ToInt32(), out var entry);
|
||||
if (entry != null) member.Name = entry.readable;
|
||||
if (member is MethodDefinition m)
|
||||
{
|
||||
if (entry != null) foreach (var p in entry.parameters) m.Parameters[p.position].Name = p.readable;
|
||||
// Retain IL only for offline source navigation/decompilation.
|
||||
// ReferenceAssemblyAttribute prevents this assembly from executing.
|
||||
}
|
||||
}
|
||||
SetTypeName(type, typesByToken[type.MetadataToken.ToInt32()].readable);
|
||||
}
|
||||
module.Assembly.Name.Name = ShellName;
|
||||
module.Name = ShellName + ".dll";
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(shellPath)));
|
||||
// Visual Studio resolves reference-source navigation via the standard ref -> lib
|
||||
// layout. The companion is never a compiler/runtime dependency; it is only for IDEs.
|
||||
string root = Path.GetDirectoryName(Path.GetFullPath(shellPath));
|
||||
string implementation = Path.Combine(root, "lib", ShellName + ".dll");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(implementation));
|
||||
module.Write(implementation);
|
||||
SourceNavigation.Generate(implementation, Path.GetDirectoryName(Path.GetFullPath(assembly)));
|
||||
// The CLR refuses to execute a reference assembly even if it is copied accidentally.
|
||||
var marker = new TypeReference("System.Runtime.CompilerServices", "ReferenceAssemblyAttribute", module, module.TypeSystem.CoreLibrary);
|
||||
module.Assembly.CustomAttributes.Add(new CustomAttribute(new MethodReference(".ctor", module.TypeSystem.Void, marker) { HasThis = true }));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(shellPath)));
|
||||
module.Write(shellPath);
|
||||
string reference = Path.Combine(root, "ref", ShellName + ".dll");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(reference));
|
||||
File.Copy(shellPath, reference, overwrite: true);
|
||||
File.Copy(Path.ChangeExtension(implementation, ".xml"), Path.ChangeExtension(reference, ".xml"), overwrite: true);
|
||||
File.Copy(Path.ChangeExtension(implementation, ".xml"), Path.ChangeExtension(shellPath, ".xml"), overwrite: true);
|
||||
File.Copy(Path.Combine(root, "lib", "navigation.json"), Path.Combine(root, "navigation.json"), overwrite: true);
|
||||
Console.WriteLine($"Readable reference assembly {table.game_version} => {shellPath}");
|
||||
}
|
||||
|
||||
internal static ModuleDefinition ReadModule(string path, params string[] searchDirectories)
|
||||
{
|
||||
var resolver = new DefaultAssemblyResolver();
|
||||
resolver.AddSearchDirectory(Path.GetDirectoryName(Path.GetFullPath(path)));
|
||||
foreach (var directory in searchDirectories) resolver.AddSearchDirectory(directory);
|
||||
return ModuleDefinition.ReadModule(path, new ReaderParameters { InMemory = true, AssemblyResolver = resolver });
|
||||
}
|
||||
|
||||
internal static void SetTypeName(TypeReference type, string full)
|
||||
{
|
||||
if (type.DeclaringType != null) { type.Name = full[(full.LastIndexOf('+') + 1)..]; return; }
|
||||
int dot = full.LastIndexOf('.');
|
||||
type.Namespace = dot < 0 ? "" : full[..dot];
|
||||
type.Name = full[(dot + 1)..];
|
||||
}
|
||||
|
||||
internal static void Rewrite(ModuleDefinition module, NameTable table, bool toReadable)
|
||||
{
|
||||
var types = table.types.ToDictionary(t => toReadable ? t.original : t.readable);
|
||||
var readableByOriginal = table.types.ToDictionary(t => t.original, t => t.readable);
|
||||
string TranslateSignature(string sig)
|
||||
{
|
||||
// Type references in signatures are tokenized, so substring collisions are avoided.
|
||||
return System.Text.RegularExpressions.Regex.Replace(sig, @"[^,:()\[\]&*<>\s]+", m => readableByOriginal.TryGetValue(m.Value, out var v) ? v : m.Value);
|
||||
}
|
||||
var members = table.members.Where(m => m.kind is "method" or "field").ToDictionary(
|
||||
m => (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) + "|" + m.kind + "|" + (toReadable ? m.original : m.readable) + "|" + (toReadable ? m.signature : TranslateSignature(m.signature)));
|
||||
var namedMembers = table.members.Where(m => m.original != m.readable && m.kind is "method" or "field")
|
||||
.Select(m => (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) + "|" + m.kind + "|" + (toReadable ? m.original : m.readable)).ToHashSet(StringComparer.Ordinal);
|
||||
bool IsGame(TypeReference type)
|
||||
{
|
||||
while (type is TypeSpecification s) type = s.ElementType;
|
||||
while (type.DeclaringType != null) type = type.DeclaringType;
|
||||
return type.Scope is AssemblyNameReference a ? a.Name == (toReadable ? table.assembly_name : ShellName) : toReadable && type.Scope == module;
|
||||
}
|
||||
var memberRefs = module.GetMemberReferences().ToList();
|
||||
var attributes = new List<CustomAttribute>();
|
||||
void Attributes(ICustomAttributeProvider provider)
|
||||
{
|
||||
if (provider.HasCustomAttributes) attributes.AddRange(provider.CustomAttributes);
|
||||
}
|
||||
Attributes(module); Attributes(module.Assembly);
|
||||
foreach (var t in Types(module))
|
||||
{
|
||||
Attributes(t);
|
||||
foreach (var gp in t.GenericParameters) Attributes(gp);
|
||||
foreach (var i in t.Interfaces) Attributes(i);
|
||||
foreach (var f in t.Fields) Attributes(f);
|
||||
foreach (var p in t.Properties) Attributes(p);
|
||||
foreach (var e in t.Events) Attributes(e);
|
||||
foreach (var m in t.Methods)
|
||||
{
|
||||
Attributes(m); Attributes(m.MethodReturnType);
|
||||
foreach (var p in m.Parameters) Attributes(p);
|
||||
foreach (var gp in m.GenericParameters) Attributes(gp);
|
||||
}
|
||||
}
|
||||
foreach (var attribute in attributes) memberRefs.Add(attribute.Constructor);
|
||||
foreach (var type in Types(module))
|
||||
foreach (var method in type.Methods)
|
||||
{
|
||||
foreach (var ov in method.Overrides) memberRefs.Add(ov);
|
||||
if (method.HasBody) foreach (var instruction in method.Body.Instructions)
|
||||
if (instruction.Operand is MemberReference mr) memberRefs.Add(mr);
|
||||
}
|
||||
foreach (var reference in memberRefs.Distinct())
|
||||
{
|
||||
MemberReference member = reference is GenericInstanceMethod gm ? gm.ElementMethod : reference;
|
||||
if (member is not MethodReference && member is not FieldReference || member is IMemberDefinition || !IsGame(member.DeclaringType)) continue;
|
||||
var declaring = member.DeclaringType is GenericInstanceType gi ? gi.ElementType : member.DeclaringType;
|
||||
// CLR array Get/Set/Address pseudo-methods have no metadata definitions.
|
||||
if (declaring is ArrayType) continue;
|
||||
string key = TypeKey(declaring) + "|" + (member is MethodReference ? "method" : "field") + "|" + member.Name + "|" + Signature(member);
|
||||
if (members.TryGetValue(key, out var entry)) member.Name = toReadable ? entry.readable : entry.original;
|
||||
else if (namedMembers.Contains(key[..key.LastIndexOf('|')])) throw new InvalidDataException("Game member signature does not match the names table: " + key);
|
||||
}
|
||||
// Snapshot names before renaming a parent of a nested type.
|
||||
var renames = new Dictionary<TypeReference, string>();
|
||||
void Visit(TypeReference type)
|
||||
{
|
||||
if (type == null || type is GenericParameter) return;
|
||||
if (type is FunctionPointerType fp) { Visit(fp.ReturnType); foreach (var p in fp.Parameters) Visit(p.ParameterType); return; }
|
||||
if (type is TypeSpecification spec)
|
||||
{
|
||||
Visit(spec.ElementType);
|
||||
if (type is GenericInstanceType gi) foreach (var a in gi.GenericArguments) Visit(a);
|
||||
if (type is IModifierType modifier) Visit(modifier.ModifierType);
|
||||
return;
|
||||
}
|
||||
if (!renames.ContainsKey(type) && IsGame(type))
|
||||
{
|
||||
if (!types.TryGetValue(TypeKey(type), out var entry)) throw new InvalidDataException("Unmapped game type: " + TypeKey(type));
|
||||
renames[type] = toReadable ? entry.readable : entry.original;
|
||||
}
|
||||
Visit(type.DeclaringType);
|
||||
}
|
||||
void VisitArgument(CustomAttributeArgument argument)
|
||||
{
|
||||
Visit(argument.Type);
|
||||
if (argument.Value is TypeReference type) Visit(type);
|
||||
if (argument.Value is CustomAttributeArgument boxed) VisitArgument(boxed);
|
||||
if (argument.Value is CustomAttributeArgument[] array) foreach (var item in array) VisitArgument(item);
|
||||
}
|
||||
foreach (var attribute in attributes)
|
||||
{
|
||||
// Decode blobs before changing assembly scopes. System.Type arguments are typed
|
||||
// metadata even though the ECMA-335 blob stores assembly-qualified text.
|
||||
foreach (var argument in attribute.ConstructorArguments) VisitArgument(argument);
|
||||
void NamedArguments(Mono.Collections.Generic.Collection<CustomAttributeNamedArgument> arguments, string kind)
|
||||
{
|
||||
for (int i = 0; i < arguments.Count; i++)
|
||||
{
|
||||
var argument = arguments[i]; VisitArgument(argument.Argument);
|
||||
if (!IsGame(attribute.AttributeType)) continue;
|
||||
string declaring = TypeKey(attribute.AttributeType);
|
||||
var entry = table.members.SingleOrDefault(m => m.kind == kind &&
|
||||
(toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) == declaring &&
|
||||
(toReadable ? m.original : m.readable) == argument.Name);
|
||||
if (entry != null) arguments[i] = new CustomAttributeNamedArgument(toReadable ? entry.readable : entry.original, argument.Argument);
|
||||
}
|
||||
}
|
||||
NamedArguments(attribute.Fields, "field"); NamedArguments(attribute.Properties, "property");
|
||||
}
|
||||
foreach (var t in module.GetTypeReferences()) Visit(t);
|
||||
foreach (var member in memberRefs)
|
||||
{
|
||||
Visit(member.DeclaringType);
|
||||
if (member is MethodReference m) { Visit(m.ReturnType); foreach (var p in m.Parameters) Visit(p.ParameterType); if (m is GenericInstanceMethod gm) foreach (var a in gm.GenericArguments) Visit(a); }
|
||||
if (member is FieldReference f) Visit(f.FieldType);
|
||||
}
|
||||
foreach (var type in Types(module))
|
||||
{
|
||||
Visit(type.BaseType);
|
||||
foreach (var i in type.Interfaces) Visit(i.InterfaceType);
|
||||
foreach (var f in type.Fields) Visit(f.FieldType);
|
||||
foreach (var p in type.Properties) { Visit(p.PropertyType); foreach (var a in p.Parameters) Visit(a.ParameterType); }
|
||||
foreach (var e in type.Events) Visit(e.EventType);
|
||||
foreach (var gp in type.GenericParameters) foreach (var c in gp.Constraints) Visit(c.ConstraintType);
|
||||
foreach (var m in type.Methods)
|
||||
{
|
||||
Visit(m.ReturnType);
|
||||
foreach (var p in m.Parameters) Visit(p.ParameterType);
|
||||
foreach (var gp in m.GenericParameters) foreach (var c in gp.Constraints) Visit(c.ConstraintType);
|
||||
if (m.HasBody) { foreach (var v in m.Body.Variables) Visit(v.VariableType); foreach (var h in m.Body.ExceptionHandlers) Visit(h.CatchType); foreach (var i in m.Body.Instructions) if (i.Operand is TypeReference t) Visit(t); }
|
||||
}
|
||||
}
|
||||
foreach (var pair in renames) SetTypeName(pair.Key, pair.Value);
|
||||
}
|
||||
|
||||
internal static void Reobfuscate(string tablePath, string input, string output, string originalAssembly = null, string dependencyDirectory = null)
|
||||
{
|
||||
var table = ReadTable(tablePath);
|
||||
var dependencies = new List<string> { Path.GetDirectoryName(Path.GetFullPath(tablePath)), Path.GetDirectoryName(Path.GetFullPath(output)) };
|
||||
if (originalAssembly != null) dependencies.Add(Path.GetDirectoryName(Path.GetFullPath(originalAssembly)));
|
||||
if (dependencyDirectory != null) dependencies.Add(Path.GetFullPath(dependencyDirectory));
|
||||
using var module = ReadModule(input, dependencies.ToArray());
|
||||
if (module.Assembly.Name.HasPublicKey) throw new InvalidDataException("Signed plugins require an explicit signing workflow");
|
||||
VerifyStamp(module, table);
|
||||
Rewrite(module, table, false);
|
||||
foreach (var a in module.AssemblyReferences.Where(a => a.Name == ShellName)) a.Name = table.assembly_name;
|
||||
// Preserve compiler PDBs in obj; rewritten runtime DLL deliberately has no stale symbols.
|
||||
string absoluteOutput = Path.GetFullPath(output);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(absoluteOutput));
|
||||
string temporary = absoluteOutput + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
module.Write(temporary);
|
||||
Verify(tablePath, temporary, originalAssembly);
|
||||
File.Move(temporary, absoluteOutput, overwrite: true);
|
||||
}
|
||||
finally { File.Delete(temporary); }
|
||||
Console.WriteLine("Reobfuscated runtime plugin: " + output);
|
||||
}
|
||||
|
||||
internal static void Verify(string tablePath, string plugin, string originalAssembly = null)
|
||||
{
|
||||
var table = ReadTable(tablePath);
|
||||
using var module = originalAssembly == null ? ReadModule(plugin) : ReadModule(plugin, Path.GetDirectoryName(Path.GetFullPath(originalAssembly)));
|
||||
if (module.AssemblyReferences.Any(a => a.Name == ShellName)) throw new InvalidDataException("Runtime DLL still references the readable shell");
|
||||
VerifyStamp(module, table);
|
||||
if (originalAssembly != null)
|
||||
{
|
||||
if (Hash(originalAssembly) != table.assembly_sha256) throw new InvalidDataException("Verification game binary does not match the names table");
|
||||
bool IsGame(TypeReference t) => t.GetElementType().Scope is AssemblyNameReference a && a.Name == table.assembly_name;
|
||||
foreach (var type in module.GetTypeReferences().Where(IsGame))
|
||||
if (type.Resolve() == null) throw new InvalidDataException("Unresolvable game type reference: " + type.FullName);
|
||||
foreach (var member in module.GetMemberReferences().Where(m => IsGame(m.DeclaringType)))
|
||||
{
|
||||
if (member.DeclaringType is ArrayType) continue;
|
||||
if (member is MethodReference method && method.Resolve() == null || member is FieldReference field && field.Resolve() == null)
|
||||
throw new InvalidDataException("Unresolvable game member reference: " + member.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void VerifyStamp(ModuleDefinition module, NameTable table)
|
||||
{
|
||||
var markers = module.Assembly.CustomAttributes.Where(a => a.AttributeType.FullName == "System.Reflection.AssemblyMetadataAttribute" && a.ConstructorArguments.Count == 2 && a.ConstructorArguments[0].Value as string == "BD2.GameNames").ToArray();
|
||||
if (markers.Length != 1 || markers[0].ConstructorArguments[1].Value as string != Stamp(table))
|
||||
throw new InvalidDataException("Missing/mismatched SDK stamp: compile with GameSdkIdentity.g.cs from this names table");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
# BD2.GameSdk
|
||||
|
||||
给 Brown Dust II 插件作者使用的 C# 开发包。通过标准 NuGet `PackageReference` 导入,自动完成 **生成可读引用程序集和全量源码导航 → 编译插件 → reobf → 验证运行 DLL**。源码可以使用可读类型和成员,字符串反射由配套的 `BD2.GameNames` 处理。转到定义可查看完整反编译方法体。
|
||||
|
||||
## 包与版本
|
||||
|
||||
| NuGet 包 | 内容 | 使用方式 |
|
||||
| --- | --- | --- |
|
||||
| `BD2.GameSdk` | .NET 8 构建工具、`build/*.props/targets`、压缩名字表 | 插件项目直接引用,`PrivateAssets="all"` |
|
||||
| `BD2.GameNames` | `lib/netstandard2.0/BD2.GameNames.dll`、XML API 文档、同一份内嵌表 | SDK 固定依赖对应版本,自动引入;DLL 随插件部署 |
|
||||
|
||||
当前包版本为 `0.2.1-game.2.35.10`:`0.2.1` 是开发工具/API 的语义版本,`game.2.35.10` 指定游戏版本。采用 SemVer 的 prerelease 段,安装时明确指定版本。升级游戏后更新包并重新构建插件;相同游戏版本下的不同官方 DLL 也会因指纹不同而被拒绝。
|
||||
|
||||
包不包含游戏 DLL 或可读壳。维护者用官方映射生成名字表,提交为 `plugins/GameNames/Mappings/names.json.gz`,SDK 和运行时从同一文件嵌入,插件作者只需要对应游戏客户端和 BepInEx,无需官方映射、Python、仓库 `versions.json` 或本仓库源码。
|
||||
|
||||
包当前由维护者提供 `.nupkg` 或 NuGet 源,**尚未发布到 nuget.org**。包内包含仓库 LICENSE,许可条款沿用项目现有授权,不声明为开源许可。
|
||||
|
||||
## 导入到自己的插件项目
|
||||
|
||||
前提:安装 **.NET 8 SDK**,准备与包对应的游戏目录,并在该目录安装 BepInEx。推荐 SDK-style `.csproj` 与 `netstandard2.1`;运行时库本身兼容 `.NET Standard 2.0`。旧式 `packages.config` 不受支持。
|
||||
|
||||
将维护者提供的两个 `.nupkg` 放在一个目录,例如 `D:\NuGet\BD2`,添加为本地 NuGet 源:
|
||||
|
||||
```powershell
|
||||
dotnet nuget add source 'D:\NuGet\BD2' --name BD2
|
||||
dotnet add MyPlugin.csproj package BD2.GameSdk --version 0.2.1-game.2.35.10
|
||||
```
|
||||
|
||||
Visual Studio / Rider 也可在 NuGet 包管理界面添加该源,打开“包含预发布版本”,安装指定版本。若维护者提供远程 NuGet 源,把上面的目录换成该源 URL。
|
||||
|
||||
最小项目文件:
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>MyPlugin</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BD2.GameSdk"
|
||||
Version="0.2.1-game.2.35.10"
|
||||
PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
`PrivateAssets="all"` 让构建步骤只用于当前插件项目;它不会阻止运行时 DLL 复制到输出目录。SDK 自动引入 `BD2.GameNames`,以及游戏目录中的 `BepInEx`、`0Harmony`、`UnityEngine`、`UnityEngine.CoreModule` 引用,无需手写 `<Import>`。**不要再引用真实的 `Assembly-CSharp.dll`**,游戏 API 编译引用由 SDK 提供。
|
||||
|
||||
在项目旁创建仅保存在本机的 `Directory.Build.props`,并加入自己的 `.gitignore`:
|
||||
|
||||
```xml
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<GameDir>E:\Games\BrownDustII</GameDir>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
或者构建时传入目录:
|
||||
|
||||
```powershell
|
||||
dotnet build MyPlugin.csproj -c Release '-p:GameDir=E:\Games\BrownDustII'
|
||||
```
|
||||
|
||||
IDE 项目加载/设计时构建会准备可读引用;CLI 首次构建同样会自动生成。使用 SDK 不需要运行额外的生成命令。完整可复制示例在 [samples/ExamplePlugin](samples/ExamplePlugin),包含 `.csproj`、插件源码和本机配置示例。
|
||||
|
||||
## 完整源码导航
|
||||
|
||||
导入包、设置 `GameDir` 后,先完成一次构建。SDK 使用 ILSpy 从匹配版本的真实游戏 DLL 生成**全部类型的可读 C# 源码**,保留方法体、私有成员、嵌套类型和编译器生成实现;同步生成 Portable PDB,将源码内嵌到 PDB。名字仍来自同一份内嵌表,没有另一份源码映射。
|
||||
|
||||
在支持外部源码导航的 IDE 中,对 `NetworkManager.Send` 等可读 API 使用“转到定义”(Visual Studio 的 F12),即可打开完整方法体。重载、泛型、参数、属性、字段、事件和嵌套类型使用真实元数据及 PDB 定位,不靠搜索同名字符串。
|
||||
|
||||
- **Visual Studio 2022**:在“工具 → 选项 → 文本编辑器 → C# → 高级”中启用“导航到 Source Link 和嵌入源”(不同语言/版本的名称可能略有差异)。如果仍打开旧的签名视图,关闭原外部源码页并重新加载项目后再 F12。只阅读源码不需要关闭“仅我的代码”调试设置。
|
||||
- **Rider**:启用外部代码的源文件/PDB 或反编译导航。可读引用保留完整 IL,IDE 也可直接反编译出方法体;本项目的自动测试验证了 Visual Studio 的 Roslyn PDB 导航引擎,尚未手动验证 Rider 各版本界面。
|
||||
- 项目中还会链接一个 **`Game Sources`** 文件夹,支持直接浏览和文本搜索。首次生成后若文件夹未出现,重新加载项目使生成的 MSBuild 导入生效。这些 `.cs` 是 `None` 项,**不参与插件编译,也不复制到部署目录**。
|
||||
|
||||
普通视图恢复 `async/await`、迭代器和 lambda,便于阅读;`generated` 视图补全被这些语法隐藏的状态机、访问器等,`metadata` 视图补全其他隐藏声明。无法作为 C# 标识符的 CLR 生成名在源码中显示为 `__generated_...`,`navigation.json` 和名字表仍保留真实元数据身份。
|
||||
|
||||
首次全量生成会花费数分钟,日志持续报告已处理的类型数。本机 2.35.10 的一次验证生成了 **24,856 个源码文件、654,265 个声明、319,350 个带方法体的符号**。生成结果按名字表、生成器及其依赖、全部游戏 Managed DLL 的指纹缓存,同一台机器上的插件项目共用;并行构建会等待同一个缓存生成完成。后续构建不再执行全量反编译。
|
||||
|
||||
默认共享缓存路径为 `%LOCALAPPDATA%\BD2\GameSdk\navigation`。可用环境变量改变位置:
|
||||
|
||||
```powershell
|
||||
$env:BD2_GAME_SDK_CACHE = 'D:\Caches\BD2.GameSdk'
|
||||
dotnet build MyPlugin.csproj -c Release '-p:GameDir=E:\Games\BrownDustII'
|
||||
```
|
||||
|
||||
保持这个路径稳定;更改路径、工具或游戏 DLL 后首次构建会重新准备缓存。删除 `obj` 不影响共享源码缓存;缺失的缓存源码可在下次准备时从 PDB 恢复。可删除共享缓存来强制重新生成,它不存放手写源码。
|
||||
|
||||
插件 `obj/<配置>/<框架>/bd2-game-sdk` 中的布局遵循 .NET 的引用程序集查找约定(仓库插件使用 `game-sdk` 子目录):
|
||||
|
||||
| 路径 | 用途 |
|
||||
| --- | --- |
|
||||
| `ref/Assembly-CSharp.Readable.dll` | 编译器引用,带引用程序集标记,禁止执行 |
|
||||
| `lib/Assembly-CSharp.Readable.dll` + `.pdb` | IDE 对应实现和导航符号;源码内嵌,不依赖联网下载 |
|
||||
| `GameSourceNavigation.props` | 把共享源码链接到项目浏览器 |
|
||||
| `navigation.json` | 元数据 token 到文件/行列的全量索引、覆盖统计与 DLL/PDB 指纹;schema 2 的文件路径相对于 `SourceRoot` |
|
||||
|
||||
`lib` 中的程序集仅供开发导航,**不要部署或执行**。这里展示的是从当前 DLL 重建的源码,局部变量名和语法可能与开发商原始工程不同。导航 PDB 对应可读程序集;它不能用来在真实混淆游戏 DLL 上逐行调试。
|
||||
|
||||
## 插件代码
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using BD2.GameNames;
|
||||
using BepInEx;
|
||||
using HarmonyLib;
|
||||
|
||||
[BepInPlugin("example.my-plugin", "My Plugin", "1.0.0")]
|
||||
public sealed class MyPlugin : BaseUnityPlugin
|
||||
{
|
||||
private void Awake()
|
||||
{
|
||||
try
|
||||
{
|
||||
Game.Validate(typeof(MyPlugin).Assembly,
|
||||
message => Logger.LogInfo(message));
|
||||
|
||||
var target = Game.Method<IntroUI>(ui => ui.SendMaintenanceInfo(false));
|
||||
new Harmony("example.my-plugin").Patch(target,
|
||||
prefix: new HarmonyMethod(typeof(MyPlugin), nameof(BeforeMaintenance)));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Logger.LogError("My Plugin initialization failed: " + exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void BeforeMaintenance() { }
|
||||
}
|
||||
```
|
||||
|
||||
表达式只取得 `MethodInfo`,不会执行游戏调用;签名由 C# 编译器检查。已知游戏类型优先使用 `typeof`,公开成员优先使用强类型表达式或 `nameof`,可以获得 IDE 补全和编译检查。实际公开调用和字段访问也能直接使用可读名字。私有成员保持原访问性,通过已知类型和映射反射 API 查询:
|
||||
|
||||
```csharp
|
||||
using System.Reflection;
|
||||
|
||||
var intro = typeof(IntroUI);
|
||||
var enter = intro.GetGameMethod("Enter",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
null, Type.EmptyTypes, null);
|
||||
|
||||
var field = typeof(IntroUI).GetGameField("_maintenanceTimeoutCts",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
// nameof 的结果仍是可读字符串,必须传给 GetGameMethod 等运行时 API。
|
||||
var maintenance = typeof(IntroUI).GetGameMethod(nameof(IntroUI.SendMaintenanceInfo),
|
||||
BindingFlags.Instance | BindingFlags.Public, null, new[] { typeof(bool) }, null);
|
||||
|
||||
// 对实际协程可读名调用 MemberName,再交给 Unity 的字符串 API。
|
||||
owner.StartCoroutine(Game.MemberName(owner.GetType(), "ReadableCoroutineName"));
|
||||
```
|
||||
|
||||
`GetGameMethod/GetGameField/GetGameProperty/GetGameEvent` 采用 .NET 反射约定:查不到返回 null,重载歧义抛出 `AmbiguousMatchException`,null 名字参数抛出 `ArgumentNullException`。`MemberName` 的成员种类使用 `GameMemberKind` 枚举。未知映射保留字面名;普通 UI 文案、`nameof` 字符串和协程常量都不会被 reobf 自动替换。
|
||||
|
||||
`Game.FindType` 保留给运行时才知道类型名称的查询。只做字符串反射的项目可单独安装 `BD2.GameNames`,启动时用 `Game.ValidateGame(...)`。这种方式没有可读游戏类型引用,也没有编译指纹;使用 SDK 的项目必须用 `Game.Validate(typeof(MyPlugin).Assembly, ...)`。
|
||||
|
||||
## 构建与部署
|
||||
|
||||
构建成功后,将 `bin/Release/netstandard2.1/MyPlugin.dll` 和相邻的 **`BD2.GameNames.dll`** 安装到游戏 `BepInEx/plugins`。客户端只安装一份共享库;多个插件应使用同一游戏表。不要部署 `obj`、可读壳、源码、导航 PDB、`GameSdk.dll`、`Mono.Cecil.dll`、`ICSharpCode.Decompiler.dll` 或其他构建工具。
|
||||
|
||||
最终插件 DLL 已回映射,`obj` 保留可读编译产物和 PDB,最终目录删除改写前的旧 PDB。SDK 包是构建依赖;玩家电脑不需要 .NET 8 SDK 或 NuGet。这里支持 Unity 的 Mono/Managed 客户端,要求存在真实 `Assembly-CSharp.dll`;IL2CPP/AOT 客户端不受支持。
|
||||
|
||||
| MSBuild 属性 | 用途 |
|
||||
| --- | --- |
|
||||
| `GameDir` | 游戏根目录,常规项目唯一必填配置 |
|
||||
| `BD2ManagedDir` | 自定义 Managed 路径,默认 `GameDir/BrownDust II_Data/Managed` |
|
||||
| `BD2BepInExDir` | 自定义 BepInEx 路径,默认 `GameDir/BepInEx` |
|
||||
| `BD2GameSdkCache` | 共享导航缓存目录,可在 `Directory.Build.props` 配置;默认取 `BD2_GAME_SDK_CACHE` 环境变量或用户缓存目录 |
|
||||
| `BD2AddBepInExReferences=false` | 使用其他宿主或自己提供 BepInEx/Harmony 引用时关闭自动引用 |
|
||||
| `BD2AddUnityReferences=false` | 自己提供 Unity 引用时关闭自动引用 |
|
||||
| `BD2GameSdkEnabled=false` | 暂时关闭壳生成与 reobf;只适用于不依赖可读游戏 API 的项目 |
|
||||
|
||||
额外 Unity 模块或第三方游戏依赖仍以普通 `<Reference>` 添加,并设 `<Private>false</Private>`。强签名插件需要重新签名流程,当前 reobf 明确拒绝。CI 用相同的 PackageReference 和 `GameDir` 参数构建,直接分发 Build 的 DLL 产物;不要把原始 `obj` DLL 放进发布包。
|
||||
|
||||
## 维护者打包
|
||||
|
||||
在本仓库运行:
|
||||
|
||||
```powershell
|
||||
.\plugins\GameSdk\Pack.ps1 `
|
||||
-GameDir '<当前客户端目录>'
|
||||
```
|
||||
|
||||
默认生成 `.build/nuget/BD2.GameSdk.<版本>.nupkg` 和 `BD2.GameNames.<版本>.nupkg`。工具版本来自 `plugins/PackageMetadata.props`,游戏版本来自根目录 `versions.json`,组合成 `工具版本-game.游戏版本`。可传 `-PackageVersion` 和 `-OutputDirectory`;发布后的同一包版本必须保持内容不可变,有任何变更都递增工具版本。
|
||||
|
||||
SDK 精确依赖同版本运行时包;两个包中的压缩表都直接来自同一生成结果。工具通过 `dotnet publish` 打包自带 Mono.Cecil,不把 Cecil 作为插件的 NuGet 依赖。包内携带 README、LICENSE、仓库地址和作者信息。本脚本只生成本地包,不上传到任何 NuGet 服务。
|
||||
|
||||
工具包也包含 Mono.Cecil、ILSpy 和相关 .NET 组件的 MIT 授权原文 `THIRD-PARTY-NOTICES.txt`;第三方组件保持其自身许可。打包后运行 `VerifyPackages.ps1 -GameDir '<客户端目录>'`,它在仓库外的全新目录和 NuGet 缓存中构建示例插件,检查运行时复制、禁止部署的文件、回映射引用、完整内嵌表、源码/PDB 覆盖和重复构建。
|
||||
|
||||
打包工程在普通 IDE 加载/源码构建时直接引用 `GameNames.csproj`,无需先生成包,也不会到 nuget.org 查找未发布的 `BD2.GameNames`。`Pack.ps1` 用 `BD2Packaging=true` 切换到同版本的精确 NuGet 依赖,并把打包用 `obj/bin` 放在独立 staging 目录,避免 IDE restore 与 pack 互相覆盖依赖资产。不要直接对打包 `.csproj` 执行 `dotnet pack`,使用脚本才能保证两个包版本配套。
|
||||
|
||||
仓库中的 `samples/ExamplePlugin/NuGet.Config` 指向 `.build/nuget`,示例保持真正的 NuGet 消费方式;先运行 `Pack.ps1` 再构建示例。复制示例到其他目录时按前文配置自己的包源,不要照搬仓库相对路径。
|
||||
|
||||
## 仓库源码构建与内嵌表
|
||||
|
||||
仓库的三个插件与 NuGet 消费项目采用同样的内嵌表流程。开发启动命令保持不变:
|
||||
|
||||
公共配置见 `plugins/Directory.Build.props`,本机游戏安装位置见不提交的 `plugins/Directory.Build.local.props`。可从 `Directory.Build.local.props.example` 复制并设置 `BD2LocalGameDir`、`BD2CaptureGameDir`、可选的 `BD2GameSdkCache`。配置和目标框架确定后,由 `Directory.Build.targets` 计算中间目录并导入 SDK/版本构建步骤。
|
||||
|
||||
仓库插件通过普通 `ProjectReference` 使用共享运行时;IDE 设计时加载不会嵌套构建三份运行时。SDK 准备过程按输出目录加锁,导航 `.props` 只在内容改变时原子替换,重复设计时构建不会因重写该文件触发连续项目加载。设计时构建不执行 reobf。
|
||||
|
||||
```powershell
|
||||
# 从 go 目录运行;游戏目录来自 go/config.json
|
||||
go run .\cmd\bd2client --dev run
|
||||
```
|
||||
|
||||
直接构建源码插件也只需要游戏目录:
|
||||
|
||||
```powershell
|
||||
dotnet build plugins/LocalIdentity/LocalIdentity.csproj -c Release '-p:GameDir=<客户端目录>'
|
||||
```
|
||||
|
||||
构建工具从自身资源导出表到 `obj` 缓存,用本机真实 DLL 的元数据生成壳,再做回映射。`GameSdk` 和 `BD2.GameNames` 的唯一名字数据源是仓库内的 `GameNames/Mappings/names.json.gz`。两份程序集内嵌的是同一份压缩字节,不维护第二份映射;`obj` 里的表只是可以删除重建的缓存。NuGet 包同样不再包含单独的 `tools/data` 表文件。
|
||||
|
||||
表包含 `game_version`、完整类型名、成员声明类型/签名/metadata token、参数映射、真实 DLL 的 MVID/SHA-256 和官方映射 SHA-256。构建时先验证 DLL 指纹,仓库构建还核对 `versions.json`;不匹配会要求更新 SDK,不会尝试使用其他版本或猜名字。插件启动时核对表指纹、游戏 DLL 和少量已知条目。
|
||||
|
||||
## 更新游戏版本的名字表
|
||||
|
||||
只有维护者更新 SDK 名字数据时需要官方 `.obfuscate`。先更新仓库 `versions.json`,然后执行:
|
||||
|
||||
```powershell
|
||||
.\plugins\GameSdk\UpdateNames.ps1 `
|
||||
-GameDir '<新版本客户端目录>' `
|
||||
-GameMapping '<ObfuscationTranslation_新版本.obfuscate>'
|
||||
```
|
||||
|
||||
脚本从官方映射和真实元数据生成表,完成全量壳转换验证后才替换 `plugins/GameNames/Mappings/names.json.gz`。提交这份表与对应版本变更;重新构建插件并发布新版本配套包。更新过程中产生的明文表和壳留在 `.build/names-update`,不作为源码提交。
|
||||
|
||||
`tools/python/deobfuscate_client_source.py` 继续生成阅读镜像;程序集映射使用这里的元数据名字表,保留作用域、泛型位置和重载签名。官方映射冲突或可读签名冲突会报错。
|
||||
|
||||
工具也提供独立命令:
|
||||
|
||||
```powershell
|
||||
dotnet build plugins/GameSdk/GameSdk.csproj -c Release
|
||||
$tool = 'plugins/GameSdk/bin/Release/net8.0/GameSdk.dll'
|
||||
dotnet $tool prepare-embedded '<Assembly-CSharp.dll>' '<输出目录>'
|
||||
dotnet $tool export-names '<导出的 names.json>'
|
||||
dotnet $tool reobf '<names.json>' '<可读插件.dll>' '<运行插件.dll>' '<Assembly-CSharp.dll>' '<BepInEx/core>'
|
||||
dotnet $tool verify '<names.json>' '<运行插件.dll>' '<Assembly-CSharp.dll>'
|
||||
dotnet $tool verify-runtime '<names.json>' '<BD2.GameNames.dll>'
|
||||
dotnet $tool verify-navigation '<生成的 SDK 目录>'
|
||||
dotnet $tool self-test
|
||||
```
|
||||
|
||||
手动编译须引用 `ref/Assembly-CSharp.Readable.dll` 并编译同目录 `GameSdkIdentity.g.cs`,保留相邻 `lib` 以供 IDE 查找。`verify-navigation` 全量检查 PE/PDB 身份、内嵌/本地源码校验和、类型文档和全部方法体的符号。
|
||||
|
||||
`self-test` 生成合成游戏 DLL,验证重载、泛型、继承、嵌套/编译器生成类型、私有成员、事件、参数、表达式、字符串不变和版本拒绝;它会打印合成 SDK 目录。使用实际 Roslyn 引擎验证该目录的导航:
|
||||
|
||||
```powershell
|
||||
dotnet run --project plugins/GameSdk.NavigationTests -- '<合成 SDK 目录>' --embedded-only
|
||||
# 或验证完整游戏导航:
|
||||
dotnet run --project plugins/GameSdk.NavigationTests -- '<插件 obj 中的 SDK 目录>' '<游戏 Managed 目录>'
|
||||
```
|
||||
|
||||
嵌入源码测试临时移走合成 fixture 的 `.cs`,结束时恢复;它限制在 `.build/game-sdk-tests` 下运行。测试断言类型、重载、泛型、参数、字段、事件和嵌套类型跳到准确的源码标识符,并拒绝签名/反编译回退。`VerifyPackages.ps1` 验证仓库外 NuGet 项目。实际 Unity/Harmony 行为需在游戏启动后检查日志。
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.Loader;
|
||||
using Mono.Cecil;
|
||||
using Mono.Cecil.Cil;
|
||||
|
||||
namespace BD2.GameSdk;
|
||||
|
||||
/// <summary>End-to-end tests with synthetic binaries. Never execute the production game or readable shell.</summary>
|
||||
internal static class SelfTest
|
||||
{
|
||||
private static void Assert(bool condition, string message)
|
||||
{
|
||||
if (!condition) throw new InvalidOperationException("Self-test: " + message);
|
||||
}
|
||||
private static void Build(string project, params string[] properties)
|
||||
{
|
||||
var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
foreach (string arg in new[] { "build", project, "--nologo", "-c", "Release" }.Concat(properties)) start.ArgumentList.Add(arg);
|
||||
using var process = Process.Start(start);
|
||||
var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync();
|
||||
process.WaitForExit();
|
||||
string output = stdout.GetAwaiter().GetResult() + stderr.GetAwaiter().GetResult();
|
||||
if (process.ExitCode != 0) throw new InvalidOperationException(output);
|
||||
}
|
||||
private static string Project(string name, string references = "") => $"""
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>netstandard2.1</TargetFramework><LangVersion>latest</LangVersion><AssemblyName>{name}</AssemblyName></PropertyGroup>
|
||||
<ItemGroup>{references}</ItemGroup>
|
||||
</Project>
|
||||
""";
|
||||
internal static void Run()
|
||||
{
|
||||
string repository = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../"));
|
||||
Assert(File.Exists(Path.Combine(repository, "versions.json")), "run from a built GameSdk project");
|
||||
string root = Path.Combine(repository, ".build", "game-sdk-tests", Guid.NewGuid().ToString("N"));
|
||||
string gameDir = Path.Combine(root, "game"), sdkDir = Path.Combine(root, "sdk"), probeDir = Path.Combine(root, "probe");
|
||||
Directory.CreateDirectory(gameDir); Directory.CreateDirectory(probeDir);
|
||||
string exported = Path.Combine(root, "embedded", "names.json");
|
||||
Program.ExportNames(exported);
|
||||
Assert(File.ReadAllBytes(exported + ".gz").SequenceEqual(Program.EmbeddedNames()), "SDK exports the exact embedded table");
|
||||
Assert(Program.ReadTable(exported).schema_version == 1, "embedded names have supported schema");
|
||||
string gameProject = Path.Combine(gameDir, "Game.csproj");
|
||||
File.WriteAllText(gameProject, Project("Assembly-CSharp"));
|
||||
File.WriteAllText(Path.Combine(gameDir, "Game.cs"), """
|
||||
using System;
|
||||
public class AppManager { public bool IsPlatformLogin { get { return true; } } }
|
||||
public class IntroUI { public void SendMaintenanceInfo(bool force) {} }
|
||||
namespace BDNetwork { public class NetworkManager { public string GetPachedGameDataPath() { return "path"; } } }
|
||||
namespace Readable {
|
||||
public interface IThing { string Name { get; } }
|
||||
public class Agent : IThing {
|
||||
string IThing.Name { get { return "agent"; } }
|
||||
public event EventHandler Changed;
|
||||
public int Value;
|
||||
public int Ping(int value) { return value; }
|
||||
public string Ping(string value) { return value; }
|
||||
public void Act() {}
|
||||
public void Awake() {}
|
||||
private void PauseRoutine() {}
|
||||
private int secret = 3;
|
||||
public class State {}
|
||||
public T Echo<T>(T value) { return value; }
|
||||
public Agent[,] Grid(Agent[,] value) { return value; }
|
||||
public void Ref(ref Agent value) {}
|
||||
}
|
||||
public class Box<T> { public T Item; public T Echo(T value) { return value; } public class Nested<U> { public U Item; } }
|
||||
public class Derived : Box<Agent> {}
|
||||
}
|
||||
""");
|
||||
Build(gameProject);
|
||||
string gamePath = Path.Combine(gameDir, "bin/Release/netstandard2.1/Assembly-CSharp.dll");
|
||||
string mappingPath = Path.Combine(root, "ObfuscationTranslation_9.8.7.obfuscate"), versionPath = Path.Combine(root, "versions.json");
|
||||
File.WriteAllText(versionPath, "{\"game_version\":\"9.8.7\"}");
|
||||
var rows = new List<string> { "#ReverseOrder", "#Classes", "#Methods", "#Fields", "#Properties", "#Events", "#Parameters" };
|
||||
using (var game = Program.ReadModule(gamePath))
|
||||
{
|
||||
var table = new BD2.GameNames.Internal.NameTable { assembly_name = "Assembly-CSharp" };
|
||||
var defs = game.GetTypes().ToArray(); int counter = 0;
|
||||
string Obfuscated() => "ὠ" + string.Concat((counter++).ToString().Select(c => (char)('ὠ' + c - '0')));
|
||||
var newNames = new Dictionary<TypeDefinition, string>();
|
||||
string Full(TypeDefinition t) => t.DeclaringType != null ? Full(t.DeclaringType) + "+" + newNames[t] : newNames.TryGetValue(t, out var n) ? n : Program.TypeKey(t);
|
||||
foreach (var t in defs)
|
||||
if (t.Namespace == "Readable" || t.DeclaringType?.Namespace == "Readable")
|
||||
{
|
||||
newNames[t] = Obfuscated();
|
||||
string meaning = t.DeclaringType == null ? Program.TypeKey(t) : newNames[t.DeclaringType] + "/" + (t.Name == "State" ? "<Run>d__0" : t.Name);
|
||||
rows.Add(newNames[t] + "⇨" + meaning);
|
||||
}
|
||||
foreach (var t in defs) table.types.Add(new BD2.GameNames.Internal.TypeName { token = t.MetadataToken.ToInt32(), original = Program.TypeKey(t), readable = Full(t) });
|
||||
foreach (var t in defs)
|
||||
{
|
||||
void Rename(IMemberDefinition member, string kind, string signature = null, MethodDefinition method = null)
|
||||
{
|
||||
bool renamed = member.Name != ".ctor" && member.Name != "Awake" && t.Name != "<Module>";
|
||||
string original = member.Name, target = renamed ? Obfuscated() : original;
|
||||
if (renamed) rows.Add(target + "⇨" + original);
|
||||
var entry = new BD2.GameNames.Internal.MemberName { token = member.MetadataToken.ToInt32(), declaring_type = Program.TypeKey(t), original = original, readable = target, kind = kind, signature = signature };
|
||||
if (method != null) foreach (var p in method.Parameters)
|
||||
{
|
||||
if (string.IsNullOrEmpty(p.Name)) continue;
|
||||
string name = Obfuscated(); rows.Add(name + "⇨" + p.Name);
|
||||
entry.parameters.Add(new BD2.GameNames.Internal.ParameterName { position = p.Index, original = p.Name, readable = name });
|
||||
}
|
||||
table.members.Add(entry);
|
||||
}
|
||||
foreach (var m in t.Methods) Rename(m, "method", Program.Signature(m), m);
|
||||
foreach (var f in t.Fields) Rename(f, "field", Program.Signature(f));
|
||||
foreach (var p in t.Properties) Rename(p, "property");
|
||||
foreach (var e in t.Events) Rename(e, "event", Program.TypeKey(e.EventType));
|
||||
}
|
||||
Program.Rewrite(game, table, true);
|
||||
var entries = table.members.ToDictionary(m => m.token);
|
||||
foreach (var t in defs)
|
||||
{
|
||||
foreach (var m in t.Methods)
|
||||
{
|
||||
var entry = entries[m.MetadataToken.ToInt32()]; m.Name = entry.readable;
|
||||
foreach (var p in entry.parameters) m.Parameters[p.position].Name = p.readable;
|
||||
}
|
||||
foreach (var f in t.Fields) f.Name = entries[f.MetadataToken.ToInt32()].readable;
|
||||
foreach (var p in t.Properties) p.Name = entries[p.MetadataToken.ToInt32()].readable;
|
||||
foreach (var e in t.Events) e.Name = entries[e.MetadataToken.ToInt32()].readable;
|
||||
Program.SetTypeName(t, table.types.Single(e => e.token == t.MetadataToken.ToInt32()).readable);
|
||||
}
|
||||
game.Write(gamePath);
|
||||
}
|
||||
File.WriteAllLines(mappingPath, rows);
|
||||
bool embeddedMismatchRejected = false;
|
||||
try { Program.PrepareEmbedded(gamePath, Path.Combine(root, "mismatched-embedded")); }
|
||||
catch (InvalidDataException ex) { embeddedMismatchRejected = ex.Message.Contains("does not match SDK names for game"); }
|
||||
Assert(embeddedMismatchRejected, "embedded table rejects another game binary without seeking an external mapping");
|
||||
Program.Prepare(gamePath, mappingPath, versionPath, sdkDir);
|
||||
SourceNavigation.Verify(sdkDir);
|
||||
string tablePath = Path.Combine(sdkDir, "names.json");
|
||||
var generated = Program.ReadTable(tablePath);
|
||||
Assert(generated.types.Any(t => t.readable == "Readable.Agent+<Run>d__0"), "compiler-generated scoped type");
|
||||
using (var shell = Program.ReadModule(Path.Combine(sdkDir, Program.ShellName + ".dll")))
|
||||
{
|
||||
Assert(shell.Assembly.CustomAttributes.Any(a => a.AttributeType.Name == "ReferenceAssemblyAttribute"), "reference assembly marker");
|
||||
Assert(shell.GetType("Readable.Agent").Methods.First(m => m.Name == "Ping").Body.Instructions.Any(i => i.OpCode == OpCodes.Ret), "readable IL retained for offline decompiler fallback");
|
||||
Assert(shell.GetType("Readable.Agent").Methods.Count(m => m.Name == "Ping") == 2, "overload names restored");
|
||||
}
|
||||
string runtimeProject = Path.Combine(repository, "plugins/GameNames/GameNames.csproj"), runtimeBin = Path.Combine(root, "runtime-bin") + Path.DirectorySeparatorChar;
|
||||
Build(runtimeProject, "-p:GameNamesTable=" + tablePath, "-p:BaseIntermediateOutputPath=" + Path.Combine(root, "runtime-obj") + Path.DirectorySeparatorChar, "-p:OutputPath=" + runtimeBin);
|
||||
string runtimePath = Path.Combine(runtimeBin, "BD2.GameNames.dll");
|
||||
string references = $"<Compile Include=\"{System.Security.SecurityElement.Escape(Path.Combine(sdkDir, "GameSdkIdentity.g.cs"))}\" /><Reference Include=\"{Program.ShellName}\"><HintPath>{System.Security.SecurityElement.Escape(Path.Combine(sdkDir, "ref", Program.ShellName + ".dll"))}</HintPath><Private>false</Private></Reference><Reference Include=\"BD2.GameNames\"><HintPath>{System.Security.SecurityElement.Escape(runtimePath)}</HintPath></Reference>";
|
||||
string probeProject = Path.Combine(probeDir, "Probe.csproj");
|
||||
File.WriteAllText(probeProject, Project("Probe", references));
|
||||
File.WriteAllText(Path.Combine(probeDir, "Probe.cs"), """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using BD2.GameNames;
|
||||
using Readable;
|
||||
public sealed class MarkerAttribute : Attribute { public Type Target; public MarkerAttribute(Type target) { Target = target; } }
|
||||
[Marker(typeof(Box<Agent>))]
|
||||
public class Probe {
|
||||
private static void Check(bool condition, string name) { if (!condition) throw new Exception(name); }
|
||||
public static string Run() {
|
||||
var log = new List<string>();
|
||||
Game.Validate(typeof(Probe).Assembly, "9.8.7", log.Add);
|
||||
Game.Validate(typeof(Probe).Assembly, log.Add);
|
||||
Game.ValidateGame(log.Add);
|
||||
var a = new Agent(); a.Value = 42;
|
||||
Check(a.Value == 42 && a.Ping(7) == 7 && a.Ping("ok") == "ok", "fields and overloads");
|
||||
Check(((IThing)a).Name == "agent", "explicit interface property");
|
||||
a.Changed += (s, e) => {};
|
||||
Check(typeof(Probe).GetCustomAttribute<MarkerAttribute>().Target == typeof(Box<Agent>), "custom attribute type arguments");
|
||||
Check(a.Echo(a) == a && new Box<Agent>().Echo(a) == a, "generic methods/types");
|
||||
var box = new Box<Agent>(); box.Item = a; Check(box.Item == a, "generic field");
|
||||
var derived = new Derived(); derived.Item = a; Check(derived.Echo(a) == a && derived.Item == a, "inherited generic members");
|
||||
var nested = new Box<Agent>.Nested<string>(); nested.Item = "nested"; Check(nested.Item == "nested", "nested generic field");
|
||||
var grid = new Agent[1,1]; grid[0,0] = a; Check(a.Grid(grid)[0,0] == a, "array pseudo methods");
|
||||
a.Ref(ref a);
|
||||
Check(typeof(Derived).BaseType == typeof(Box<Agent>), "generic base");
|
||||
Check(Game.Getter<AppManager, bool>(x => x.IsPlatformLogin).Invoke(new AppManager(), null) is true, "property expression");
|
||||
Check(Game.Method<Agent>(x => x.Act()).Name != "Act", "Harmony method expression");
|
||||
Check(nameof(Agent.Ping) == "Ping", "nameof is unchanged");
|
||||
const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||
var ping = typeof(Agent).GetGameMethod(nameof(Agent.Ping), all, null, new[] { typeof(int) }, null);
|
||||
Check((int)ping.Invoke(a, new object[] { 12 }) == 12, "overloaded runtime lookup");
|
||||
Check(Game.ParameterName(ping, "value") == ping.GetParameters()[0].Name, "parameter mapping");
|
||||
Check(typeof(Derived).GetGameMethod("Echo", all, null, new[] { typeof(Agent) }, null).Invoke(derived, new object[] { a }) == a, "inherited generic runtime method");
|
||||
Check(typeof(Derived).GetGameField("Item", all).GetValue(derived) == a, "inherited generic runtime field");
|
||||
bool ambiguous = false;
|
||||
try { typeof(Agent).GetGameMethod("Ping", all); } catch (AmbiguousMatchException) { ambiguous = true; }
|
||||
Check(ambiguous, "ambiguous overloads require a signature");
|
||||
Check(Game.MemberName(typeof(Agent), "PauseRoutine") != "PauseRoutine", "coroutine string mapping");
|
||||
Check(typeof(Agent).GetGameField("secret", all).GetValue(a) is 3, "private field");
|
||||
Check(Game.MemberName(typeof(Agent), "secret", GameMemberKind.Field) != "secret", "typed member kind");
|
||||
bool nullRejected = false;
|
||||
try { Game.TypeName(null); } catch (ArgumentNullException) { nullRejected = true; }
|
||||
Check(nullRejected, "null input uses .NET exception contract");
|
||||
Check(typeof(Agent).GetGameEvent("Changed", all).Name != "Changed", "event string mapping");
|
||||
Check(Game.FindType("Readable.Agent+<Run>d__0").DeclaringType == typeof(Agent), "generated nested type");
|
||||
Check(typeof(Agent).GetGameMethod("Awake", all).Name == "Awake", "Unity literal fallback");
|
||||
Check(Game.TypeName("Unmapped.Type") == "Unmapped.Type" && Game.MemberName(typeof(Agent), "Missing") == "Missing", "unknown literal fallback");
|
||||
Check(typeof(string).GetGameMethod("Contains", all, null, new[] { typeof(string) }, null) != null, "external member unaffected");
|
||||
bool rejected = false;
|
||||
try { Game.Validate(typeof(Probe).Assembly, "wrong", log.Add); } catch { rejected = true; }
|
||||
Check(rejected && log.Exists(s => s.Contains("SELF-CHECK FAILED")), "version mismatch diagnosed");
|
||||
return "All SDK/runtime integration probes passed; UI Ping remains Ping";
|
||||
}
|
||||
public static Agent[] Signature(Agent[] value) { return value; }
|
||||
}
|
||||
""");
|
||||
Build(probeProject);
|
||||
string readablePlugin = Path.Combine(probeDir, "bin/Release/netstandard2.1/Probe.dll"), pluginPath = Path.Combine(root, "Probe.dll");
|
||||
using (var plugin = Program.ReadModule(readablePlugin))
|
||||
{
|
||||
var foreign = new TypeReference("Readable", "Agent", plugin, new AssemblyNameReference("Foreign", new Version(1, 0)));
|
||||
var holder = new TypeDefinition("", "ExternalHolder", TypeAttributes.Public, plugin.TypeSystem.Object);
|
||||
holder.Fields.Add(new FieldDefinition("Other", FieldAttributes.Public, foreign)); plugin.Types.Add(holder);
|
||||
plugin.Write(readablePlugin);
|
||||
}
|
||||
Program.Reobfuscate(tablePath, readablePlugin, pluginPath, gamePath);
|
||||
using (var rewritten = Program.ReadModule(pluginPath))
|
||||
{
|
||||
Assert(rewritten.GetType("ExternalHolder").Fields[0].FieldType.FullName == "Readable.Agent", "external assembly scope isolation");
|
||||
Assert(!rewritten.AssemblyReferences.Any(a => a.Name == "System.Private.CoreLib"), "target framework preserved");
|
||||
Assert(Strings(readablePlugin).SequenceEqual(Strings(pluginPath)), "all ldstr operands preserved byte-for-byte");
|
||||
}
|
||||
var context = new AssemblyLoadContext("BD2 GameSdk self-test", isCollectible: true);
|
||||
context.Resolving += (_, name) => name.Name switch
|
||||
{
|
||||
"Assembly-CSharp" => context.LoadFromAssemblyPath(gamePath),
|
||||
"BD2.GameNames" => context.LoadFromAssemblyPath(runtimePath),
|
||||
_ => null
|
||||
};
|
||||
try { Console.WriteLine(context.LoadFromAssemblyPath(pluginPath).GetType("Probe").GetMethod("Run").Invoke(null, null)); }
|
||||
finally { context.Unload(); }
|
||||
string wrongTable = Path.Combine(root, "wrong-names.json");
|
||||
generated.game_version = "wrong";
|
||||
File.WriteAllText(wrongTable, System.Text.Json.JsonSerializer.Serialize(generated, Program.Json));
|
||||
bool mismatchRejected = false;
|
||||
string protectedOutput = Path.Combine(root, "protected-output.dll");
|
||||
File.WriteAllText(protectedOutput, "untouched");
|
||||
try { Program.Reobfuscate(wrongTable, readablePlugin, protectedOutput); } catch (InvalidDataException) { mismatchRejected = true; }
|
||||
Assert(mismatchRejected && File.ReadAllText(protectedOutput) == "untouched", "mismatched table rejected before publishing");
|
||||
File.AppendAllText(mappingPath, "\nὠὡ⇨Conflicting.Name\n");
|
||||
bool conflictRejected = false;
|
||||
try { Program.Prepare(gamePath, mappingPath, versionPath, sdkDir); } catch (InvalidDataException) { conflictRejected = true; }
|
||||
Assert(conflictRejected, "ambiguous mapping rejected");
|
||||
Console.WriteLine("Self-test artifacts: " + root);
|
||||
}
|
||||
private static string[] Strings(string path)
|
||||
{
|
||||
using var module = Program.ReadModule(path);
|
||||
return module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody).SelectMany(m => m.Body.Instructions).Where(i => i.OpCode == OpCodes.Ldstr).Select(i => (string)i.Operand).ToArray();
|
||||
}
|
||||
internal static void VerifyRuntime(string tablePath, string runtimePath)
|
||||
{
|
||||
var table = Program.ReadTable(tablePath);
|
||||
using (var runtime = Program.ReadModule(runtimePath))
|
||||
{
|
||||
var resource = (EmbeddedResource)runtime.Resources.Single(r => r.Name == "BD2.GameNames.names.json.gz");
|
||||
Assert(resource.GetResourceData().SequenceEqual(File.ReadAllBytes(tablePath + ".gz")), "embedded runtime table equals the shared table");
|
||||
}
|
||||
var context = new AssemblyLoadContext("BD2 full runtime-table check", isCollectible: true);
|
||||
try
|
||||
{
|
||||
var runtime = context.LoadFromAssemblyPath(Path.GetFullPath(runtimePath));
|
||||
Assert(runtime.GetExportedTypes().Select(t => t.FullName).OrderBy(n => n).SequenceEqual(new[] { "BD2.GameNames.Game", "BD2.GameNames.GameMemberKind" }), "serialization models are not public API");
|
||||
var game = runtime.GetType("BD2.GameNames.Game");
|
||||
Assert((string)game.GetProperty("GameVersion").GetValue(null) == table.game_version, "runtime deserializes the full table");
|
||||
var translate = game.GetMethod("TypeName").CreateDelegate<Func<string, string>>();
|
||||
foreach (var type in table.types) Assert(translate(type.readable) == type.original, "runtime type translation: " + type.readable);
|
||||
Assert(translate("Unknown.Type") == "Unknown.Type", "runtime literal fallback");
|
||||
Console.WriteLine($"Verified embedded runtime table: game={table.game_version}, {table.types.Count} type lookups; {runtimePath}");
|
||||
}
|
||||
finally { context.Unload(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Reflection.Metadata.Ecma335;
|
||||
using System.Reflection.PortableExecutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ICSharpCode.Decompiler;
|
||||
using ICSharpCode.Decompiler.CSharp;
|
||||
using ICSharpCode.Decompiler.CSharp.OutputVisitor;
|
||||
using ICSharpCode.Decompiler.CSharp.Syntax;
|
||||
using ICSharpCode.Decompiler.Metadata;
|
||||
using ICSharpCode.Decompiler.TypeSystem;
|
||||
using Mono.Cecil;
|
||||
using Mono.Cecil.Cil;
|
||||
using EntityHandle = System.Reflection.Metadata.EntityHandle;
|
||||
using ModuleDefinition = Mono.Cecil.ModuleDefinition;
|
||||
using MethodDefinition = Mono.Cecil.MethodDefinition;
|
||||
using MethodDebugInformation = Mono.Cecil.Cil.MethodDebugInformation;
|
||||
|
||||
namespace BD2.GameSdk;
|
||||
|
||||
/// <summary>Build local, offline external-source navigation from the readable implementation.</summary>
|
||||
internal static class SourceNavigation
|
||||
{
|
||||
internal static readonly Guid EmbeddedSource = new("0E8A571B-6926-466E-B4AD-8AB04611F5FE");
|
||||
internal static readonly Guid TypeDocuments = new("932E74BC-DBA9-4478-8D46-0F32A7BAB3D3");
|
||||
private static readonly Guid CompilationOptions = new("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8");
|
||||
private static readonly Guid CompilationReferences = new("7E4D4708-096E-4C5C-AEDA-CB10BA6A740D");
|
||||
private static readonly Guid CSharpLanguage = new("3F5162F8-07C6-11D3-9053-00C04FA302A1");
|
||||
|
||||
internal sealed record Declaration(int Token, string Name, string Kind, string File, int Line, int Column, int EndLine, int EndColumn, bool GeneratedView);
|
||||
internal sealed record Point(int Offset, int Line, int Column, int EndLine, int EndColumn);
|
||||
private sealed record Source(string Path, byte[] Content, Dictionary<int, Declaration> Declarations, Dictionary<int, List<Point>> Methods);
|
||||
internal sealed record Manifest(int SchemaVersion, string AssemblySha256, string PdbSha256, string DecompilerVersion, string SourceRoot,
|
||||
int Documents, int Declarations, int MethodsWithBodies, int MethodsWithSymbols, List<string> Diagnostics, List<Declaration> Symbols);
|
||||
|
||||
internal static void Generate(string shellPath, string dependencyDirectory)
|
||||
{
|
||||
string directory = Path.GetDirectoryName(Path.GetFullPath(shellPath));
|
||||
string sourceRoot = Path.Combine(directory, "sources");
|
||||
Directory.CreateDirectory(sourceRoot);
|
||||
using var module = Program.ReadModule(shellPath, dependencyDirectory);
|
||||
using var pe = new PEFile(shellPath, new MemoryStream(File.ReadAllBytes(shellPath)), PEStreamOptions.PrefetchEntireImage);
|
||||
var reader = pe.Metadata;
|
||||
var types = reader.TypeDefinitions.Where(h => reader.GetTypeDefinition(h).GetDeclaringType().IsNil && reader.GetString(reader.GetTypeDefinition(h).Name) != "<Module>").ToArray();
|
||||
var sources = new ConcurrentBag<Source>();
|
||||
var timer = Stopwatch.StartNew();
|
||||
int completed = 0;
|
||||
var expected = module.GetTypes().ToDictionary(t => t.MetadataToken.ToInt32(), t =>
|
||||
t.Methods.Cast<IMemberDefinition>().Concat(t.Fields).Concat(t.Properties).Concat(t.Events).Select(m => m.MetadataToken.ToInt32()).Append(t.MetadataToken.ToInt32()).ToHashSet());
|
||||
var definitionsByToken = module.GetTypes().ToDictionary(t => t.MetadataToken.ToInt32());
|
||||
// Each worker owns its decompiler and resolver. PE metadata is immutable;
|
||||
// mutable decompiler state is never shared between workers.
|
||||
int workers = Math.Clamp(Environment.ProcessorCount / 2, 1, 4);
|
||||
Parallel.ForEach(types, new ParallelOptions { MaxDegreeOfParallelism = workers },
|
||||
() => new Worker(shellPath, dependencyDirectory), (handle, _, worker) =>
|
||||
{
|
||||
int token = MetadataTokens.GetToken(handle);
|
||||
string relative = PathFor(reader, handle);
|
||||
var primary = worker.Decompile(handle, Path.Combine(sourceRoot, relative), raw: false);
|
||||
ApplyImplicitDeclarations(primary.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]);
|
||||
sources.Add(primary);
|
||||
var typeTokens = Descendants(reader, handle).Select(h => MetadataTokens.GetToken(h)).ToArray();
|
||||
var wanted = typeTokens.SelectMany(t => expected[t]).ToHashSet();
|
||||
var covered = primary.Declarations.Keys.ToHashSet();
|
||||
if (wanted.Except(covered).Any())
|
||||
{
|
||||
// Keep idiomatic async/iterator/lambda code in the primary view, and
|
||||
// expose the compiler-generated implementation in a companion view.
|
||||
// Roslyn materializes embedded documents by basename, so primary
|
||||
// and generated views must have distinct filenames as well as paths.
|
||||
string generatedPath = Path.Combine(Path.GetDirectoryName(relative), Path.GetFileNameWithoutExtension(relative) + ".generated.cs");
|
||||
var raw = worker.Decompile(handle, Path.Combine(sourceRoot, "generated", generatedPath), raw: true);
|
||||
ApplyImplicitDeclarations(raw.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]);
|
||||
sources.Add(raw);
|
||||
covered.UnionWith(raw.Declarations.Keys);
|
||||
}
|
||||
foreach (int missing in wanted.Except(covered))
|
||||
{
|
||||
// ILSpy hides some runtime-only definitions even with transformations
|
||||
// disabled. Decompile them explicitly, retaining exact token identity.
|
||||
var supplemental = worker.DecompileMember(MetadataTokens.EntityHandle(missing),
|
||||
Path.Combine(sourceRoot, "metadata", missing.ToString("X8") + ".cs"));
|
||||
sources.Add(supplemental);
|
||||
if (!supplemental.Declarations.ContainsKey(missing))
|
||||
throw new InvalidDataException($"Decompiler omitted declaration 0x{missing:X8}; complete navigation cannot be published");
|
||||
}
|
||||
int done = Interlocked.Increment(ref completed);
|
||||
if (done % 500 == 0 || done == types.Length)
|
||||
Console.WriteLine($"Game source navigation: {done}/{types.Length} top-level types ({timer.Elapsed.TotalSeconds:F0}s)");
|
||||
return worker;
|
||||
}, worker => worker.Dispose());
|
||||
|
||||
var ordered = sources.OrderBy(s => s.Path, StringComparer.Ordinal).ToArray();
|
||||
var sourceByPath = ordered.ToDictionary(s => s.Path, StringComparer.Ordinal);
|
||||
var declarations = new Dictionary<int, Declaration>();
|
||||
var methodPoints = new Dictionary<int, (Source Source, List<Point> Points)>();
|
||||
foreach (var source in ordered.OrderBy(s => s.Declarations.Values.FirstOrDefault()?.GeneratedView == true))
|
||||
{
|
||||
foreach (var declaration in source.Declarations) declarations.TryAdd(declaration.Key, declaration.Value);
|
||||
foreach (var method in source.Methods) methodPoints.TryAdd(method.Key, (source, method.Value));
|
||||
}
|
||||
// Accessors may be folded into properties/events. Their declaration navigation
|
||||
// maps to the exact accessor when present, otherwise its owning declaration.
|
||||
foreach (var type in module.GetTypes())
|
||||
{
|
||||
foreach (var property in type.Properties)
|
||||
Alias(property, property.GetMethod, property.SetMethod);
|
||||
foreach (var @event in type.Events)
|
||||
Alias(@event, @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod);
|
||||
}
|
||||
void Alias(IMemberDefinition owner, params MethodDefinition[] accessors)
|
||||
{
|
||||
if (!declarations.TryGetValue(owner.MetadataToken.ToInt32(), out var location)) return;
|
||||
foreach (var method in accessors.Where(m => m != null))
|
||||
declarations.TryAdd(method.MetadataToken.ToInt32(), location with { Token = method.MetadataToken.ToInt32(), Name = method.FullName, Kind = "Method" });
|
||||
}
|
||||
foreach (var method in module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody))
|
||||
{
|
||||
int token = method.MetadataToken.ToInt32();
|
||||
if (!methodPoints.ContainsKey(token) && declarations.TryGetValue(token, out var declaration))
|
||||
{
|
||||
var source = sourceByPath[declaration.File];
|
||||
methodPoints[token] = (source, new List<Point> { new(0, declaration.Line, declaration.Column, declaration.EndLine, declaration.EndColumn) });
|
||||
}
|
||||
}
|
||||
int bodies = module.GetTypes().Sum(t => t.Methods.Count(m => m.HasBody));
|
||||
if (methodPoints.Count < bodies) throw new InvalidDataException($"Navigation coverage incomplete: {methodPoints.Count} symbols for {bodies} method bodies");
|
||||
string pdbPath = Path.ChangeExtension(shellPath, ".pdb");
|
||||
var pdb = BuildPdb(pe, module, ordered, declarations, methodPoints, dependencyDirectory);
|
||||
File.WriteAllBytes(pdbPath, pdb.Bytes);
|
||||
// Attach exactly the PDB we generated to this PE. No implementation is executed.
|
||||
module.Write(shellPath, new WriterParameters { WriteSymbols = true, SymbolWriterProvider = new NavigationSymbolWriterProvider(pdb.Id, pdbPath, pdb.Bytes) });
|
||||
// Store the source root once instead of repeating a long machine-specific
|
||||
// cache path for hundreds of thousands of declarations.
|
||||
var manifest = new Manifest(2, Program.Hash(shellPath), Program.Hash(pdbPath), typeof(CSharpDecompiler).Assembly.GetName().Version.ToString(), sourceRoot,
|
||||
ordered.Length, declarations.Count, bodies, methodPoints.Count, new List<string>(),
|
||||
declarations.Values.OrderBy(d => d.Token).Select(d => d with { File = Path.GetRelativePath(sourceRoot, d.File) }).ToList());
|
||||
File.WriteAllText(Path.Combine(directory, "navigation.json"), JsonSerializer.Serialize(manifest));
|
||||
WriteXmlDocumentation(shellPath, pe, declarations);
|
||||
Console.WriteLine($"Game source navigation ready: {ordered.Length} source files, {declarations.Count} declarations, {methodPoints.Count} method symbols");
|
||||
}
|
||||
|
||||
internal static void Verify(string sdkDirectory)
|
||||
{
|
||||
string library = Path.Combine(sdkDirectory, "lib", Program.ShellName + ".dll"), pdbPath = Path.ChangeExtension(library, ".pdb");
|
||||
var manifest = JsonSerializer.Deserialize<Manifest>(File.ReadAllText(Path.Combine(sdkDirectory, "navigation.json")));
|
||||
if (Program.Hash(library) != manifest.AssemblySha256 || Program.Hash(pdbPath) != manifest.PdbSha256)
|
||||
throw new InvalidDataException("Source navigation manifest, DLL and PDB do not match");
|
||||
using var peStream = File.OpenRead(library); using var pe = new PEReader(peStream);
|
||||
using var pdbStream = File.OpenRead(pdbPath); using var provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream);
|
||||
var pdb = provider.GetMetadataReader(); var dll = pe.GetMetadataReader();
|
||||
var id = new BlobContentId(pdb.DebugMetadataHeader.Id);
|
||||
var entry = pe.ReadDebugDirectory().Single(d => d.Type == DebugDirectoryEntryType.CodeView);
|
||||
var codeview = pe.ReadCodeViewDebugDirectoryData(entry);
|
||||
if (codeview.Guid != id.Guid || entry.Stamp != id.Stamp || codeview.Age != 1) throw new InvalidDataException("PDB identity does not match navigation assembly");
|
||||
var checksum = pe.ReadDebugDirectory().Single(d => d.Type == DebugDirectoryEntryType.PdbChecksum);
|
||||
if (!pe.ReadPdbChecksumDebugDirectoryData(checksum).Checksum.SequenceEqual(SHA256.HashData(File.ReadAllBytes(pdbPath))))
|
||||
throw new InvalidDataException("PE/PDB checksum mismatch");
|
||||
int documents = 0, methods = 0;
|
||||
foreach (var documentHandle in pdb.Documents)
|
||||
{
|
||||
var document = pdb.GetDocument(documentHandle);
|
||||
if (pdb.GetGuid(document.Language) != CSharpLanguage) throw new InvalidDataException("Navigation document language is not C#");
|
||||
var embedded = pdb.GetCustomDebugInformation(documentHandle).Select(h => pdb.GetCustomDebugInformation(h)).Single(c => pdb.GetGuid(c.Kind) == EmbeddedSource);
|
||||
byte[] content = DecodeEmbedded(pdb.GetBlobBytes(embedded.Value));
|
||||
if (!SHA256.HashData(content).SequenceEqual(pdb.GetBlobBytes(document.Hash))) throw new InvalidDataException("Embedded-source checksum mismatch");
|
||||
string path = pdb.GetString(document.Name);
|
||||
if (!File.Exists(path) || !File.ReadAllBytes(path).SequenceEqual(content)) throw new InvalidDataException("Local source does not match embedded source: " + path);
|
||||
documents++;
|
||||
}
|
||||
foreach (var method in dll.MethodDefinitions)
|
||||
{
|
||||
var definition = dll.GetMethodDefinition(method);
|
||||
if (definition.RelativeVirtualAddress == 0) continue;
|
||||
var debug = pdb.GetMethodDebugInformation(method);
|
||||
var points = debug.GetSequencePoints().ToArray();
|
||||
if (debug.Document.IsNil || points.Length == 0) throw new InvalidDataException("Method lacks navigation symbols: 0x" + MetadataTokens.GetToken(method).ToString("X8"));
|
||||
int size = pe.GetMethodBody(definition.RelativeVirtualAddress).GetILContent().Length;
|
||||
if (points.Any(p => !p.IsHidden && (p.Offset >= size || p.StartLine <= 0 || p.EndLine < p.StartLine))) throw new InvalidDataException("Invalid navigation sequence point");
|
||||
methods++;
|
||||
}
|
||||
foreach (var type in dll.TypeDefinitions)
|
||||
{
|
||||
if (dll.GetString(dll.GetTypeDefinition(type).Name) == "<Module>") continue;
|
||||
var custom = pdb.GetCustomDebugInformation(type).Select(h => pdb.GetCustomDebugInformation(h)).SingleOrDefault(c => pdb.GetGuid(c.Kind) == TypeDocuments);
|
||||
if (custom.Value.IsNil) throw new InvalidDataException("Type lacks source documents: 0x" + MetadataTokens.GetToken(type).ToString("X8"));
|
||||
var blob = pdb.GetBlobReader(custom.Value);
|
||||
while (blob.RemainingBytes > 0) _ = pdb.GetDocument(MetadataTokens.DocumentHandle(blob.ReadCompressedInteger()));
|
||||
}
|
||||
if (documents != manifest.Documents || methods != manifest.MethodsWithBodies || manifest.Symbols.Select(s => s.Token).Distinct().Count() != manifest.Declarations)
|
||||
throw new InvalidDataException("Navigation coverage counts do not match manifest");
|
||||
Console.WriteLine($"Verified source navigation: {documents} embedded documents, {manifest.Declarations} declarations, {methods} method bodies; PE/PDB identity, source checksums and type documents match");
|
||||
}
|
||||
|
||||
internal static void RestoreSources(string sdkDirectory)
|
||||
{
|
||||
using var stream = File.OpenRead(Path.Combine(sdkDirectory, "lib", Program.ShellName + ".pdb"));
|
||||
using var provider = MetadataReaderProvider.FromPortablePdbStream(stream);
|
||||
var reader = provider.GetMetadataReader();
|
||||
foreach (var handle in reader.Documents)
|
||||
{
|
||||
string path = reader.GetString(reader.GetDocument(handle).Name);
|
||||
if (File.Exists(path)) continue;
|
||||
var info = reader.GetCustomDebugInformation(handle).Select(h => reader.GetCustomDebugInformation(h)).Single(c => reader.GetGuid(c.Kind) == EmbeddedSource);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
File.WriteAllBytes(path, DecodeEmbedded(reader.GetBlobBytes(info.Value)));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] DecodeEmbedded(byte[] blob)
|
||||
{
|
||||
int length = BitConverter.ToInt32(blob, 0);
|
||||
if (length == 0) return blob[4..];
|
||||
using var input = new MemoryStream(blob, 4, blob.Length - 4);
|
||||
using var gzip = new System.IO.Compression.DeflateStream(input, System.IO.Compression.CompressionMode.Decompress);
|
||||
using var output = new MemoryStream(); gzip.CopyTo(output);
|
||||
if (output.Length != length) throw new InvalidDataException("Embedded-source length mismatch");
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static void ApplyImplicitDeclarations(Dictionary<int, Declaration> declarations, Mono.Cecil.TypeDefinition type)
|
||||
{
|
||||
foreach (var property in type.Properties)
|
||||
if (declarations.TryGetValue(property.MetadataToken.ToInt32(), out var location))
|
||||
foreach (var accessor in new[] { property.GetMethod, property.SetMethod }.Where(m => m != null))
|
||||
declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" });
|
||||
foreach (var @event in type.Events)
|
||||
if (declarations.TryGetValue(@event.MetadataToken.ToInt32(), out var location))
|
||||
foreach (var accessor in new[] { @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod }.Where(m => m != null))
|
||||
declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" });
|
||||
if (declarations.TryGetValue(type.MetadataToken.ToInt32(), out var declaration))
|
||||
{
|
||||
foreach (var constructor in type.Methods.Where(m => m.IsConstructor && !declarations.ContainsKey(m.MetadataToken.ToInt32())))
|
||||
declarations.TryAdd(constructor.MetadataToken.ToInt32(), declaration with { Token = constructor.MetadataToken.ToInt32(), Name = constructor.FullName, Kind = "Method" });
|
||||
foreach (var field in type.Fields.Where(f => f.IsRuntimeSpecialName))
|
||||
declarations.TryAdd(field.MetadataToken.ToInt32(), declaration with { Token = field.MetadataToken.ToInt32(), Name = field.FullName, Kind = "Field" });
|
||||
}
|
||||
foreach (var nested in type.NestedTypes) ApplyImplicitDeclarations(declarations, nested);
|
||||
}
|
||||
|
||||
private static IEnumerable<TypeDefinitionHandle> Descendants(MetadataReader reader, TypeDefinitionHandle handle)
|
||||
{
|
||||
yield return handle;
|
||||
foreach (var child in reader.GetTypeDefinition(handle).GetNestedTypes())
|
||||
foreach (var nested in Descendants(reader, child)) yield return nested;
|
||||
}
|
||||
private static string PathFor(MetadataReader reader, TypeDefinitionHandle handle)
|
||||
{
|
||||
var type = reader.GetTypeDefinition(handle);
|
||||
static string Safe(string name) => string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' ? c : '_'));
|
||||
string ns = reader.GetString(type.Namespace), name = reader.GetString(type.Name);
|
||||
// Token suffix prevents collisions from generic arity, case-insensitive filesystems,
|
||||
// and names whose invalid filename characters normalize to the same spelling.
|
||||
return Path.Combine(Safe(ns), Safe(name) + "." + MetadataTokens.GetToken(handle).ToString("X8") + ".cs");
|
||||
}
|
||||
|
||||
private sealed class Worker : IDisposable
|
||||
{
|
||||
private readonly PEFile file;
|
||||
private readonly UniversalAssemblyResolver resolver;
|
||||
private readonly CSharpDecompiler primary;
|
||||
private readonly CSharpDecompiler raw;
|
||||
private readonly DecompilerSettings primarySettings;
|
||||
private readonly DecompilerSettings rawSettings;
|
||||
internal Worker(string path, string dependencies)
|
||||
{
|
||||
file = new PEFile(path, new MemoryStream(File.ReadAllBytes(path)), PEStreamOptions.PrefetchEntireImage);
|
||||
resolver = new UniversalAssemblyResolver(path, true, file.DetectTargetFrameworkId());
|
||||
resolver.AddSearchDirectory(dependencies);
|
||||
primarySettings = Settings(false); rawSettings = Settings(true);
|
||||
primary = new CSharpDecompiler(file, resolver, primarySettings);
|
||||
raw = new CSharpDecompiler((DecompilerTypeSystem)primary.TypeSystem, rawSettings);
|
||||
}
|
||||
private static DecompilerSettings Settings(bool raw) => new()
|
||||
{
|
||||
ThrowOnAssemblyResolveErrors = true, UseDebugSymbols = false, ShowXmlDocumentation = false,
|
||||
UseNestedDirectoriesForNamespaces = true, AnonymousMethods = !raw, AnonymousTypes = !raw,
|
||||
AsyncAwait = !raw, YieldReturn = !raw, AutomaticProperties = !raw, AutomaticEvents = !raw,
|
||||
GetterOnlyAutomaticProperties = !raw, UseExpressionBodyForCalculatedGetterOnlyProperties = !raw
|
||||
};
|
||||
internal Source Decompile(TypeDefinitionHandle type, string path, bool raw)
|
||||
{
|
||||
var decompiler = raw ? this.raw : primary;
|
||||
return Render(decompiler, decompiler.DecompileTypes(new[] { type }), path, raw);
|
||||
}
|
||||
internal Source DecompileMember(EntityHandle member, string path)
|
||||
{
|
||||
var source = Render(raw, raw.Decompile(member), path, true);
|
||||
int token = MetadataTokens.GetToken(member);
|
||||
if (!source.Declarations.ContainsKey(token))
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(source.Content);
|
||||
source.Declarations[token] = new Declaration(token, "Metadata declaration 0x" + token.ToString("X8"), member.Kind.ToString(), path, 2, 1, text.Count(c => c == '\n') + 1, 1, true);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
private Source Render(CSharpDecompiler decompiler, SyntaxTree tree, string path, bool generated)
|
||||
{
|
||||
// CLR compiler-generated names such as <Run>d__0 are not legal C#.
|
||||
// Keep token identity in the index/PDB while spelling those identifiers
|
||||
// legally in the generated view, so one state machine cannot break the
|
||||
// semantic parser and navigation for all ordinary members in its file.
|
||||
foreach (var identifier in tree.DescendantsAndSelf.OfType<Identifier>())
|
||||
{
|
||||
string name = identifier.Name;
|
||||
if (name.Any(c => !(char.IsLetterOrDigit(c) || c == '_')))
|
||||
identifier.Name = "__generated_" + string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c.ToString() : "u" + ((int)c).ToString("X4")));
|
||||
}
|
||||
tree.InsertChildAfter(null, new Comment(" Decompiled from the matching game DLL using the shared BD2 names table. For navigation; not compiled or executed."), Roles.Comment);
|
||||
using var text = new StringWriter(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var writer = TokenWriter.WrapInWriterThatSetsLocationsInAST(new TextWriterTokenWriter(text));
|
||||
tree.AcceptVisitor(new CSharpOutputVisitor(writer, (generated ? rawSettings : primarySettings).CSharpFormattingOptions));
|
||||
byte[] content = new UTF8Encoding(false).GetBytes(text.ToString());
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
File.WriteAllBytes(path, content);
|
||||
var declarations = new Dictionary<int, Declaration>();
|
||||
foreach (var node in tree.DescendantsAndSelf)
|
||||
{
|
||||
if (node is not EntityDeclaration && node is not VariableInitializer && node is not Accessor) continue;
|
||||
if (node.GetSymbol() is not IEntity entity || entity.MetadataToken.IsNil) continue;
|
||||
int token = MetadataTokens.GetToken(entity.MetadataToken);
|
||||
var start = node is EntityDeclaration declaration && !declaration.NameToken.IsNull ? declaration.NameToken.StartLocation : node.StartLocation;
|
||||
var end = node.EndLocation;
|
||||
if (start.Line <= 0 || end.Line < start.Line) continue;
|
||||
declarations.TryAdd(token, new Declaration(token, entity.ReflectionName, entity.SymbolKind.ToString(), path, start.Line, start.Column, end.Line, end.Column, generated));
|
||||
}
|
||||
var points = new Dictionary<int, List<Point>>();
|
||||
foreach (var function in decompiler.CreateSequencePoints(tree))
|
||||
{
|
||||
var method = function.Key.MoveNextMethod ?? function.Key.Method;
|
||||
if (method == null || method.MetadataToken.IsNil) continue;
|
||||
int token = MetadataTokens.GetToken(method.MetadataToken);
|
||||
var sequence = function.Value.Where(p => !p.IsHidden).Select(p => new Point(p.Offset, p.StartLine, p.StartColumn, p.EndLine, p.EndColumn)).ToList();
|
||||
if (sequence.Count > 0) points.TryAdd(token, sequence);
|
||||
}
|
||||
return new Source(path, content, declarations, points);
|
||||
}
|
||||
public void Dispose() => file.Dispose();
|
||||
}
|
||||
|
||||
private static (byte[] Bytes, BlobContentId Id) BuildPdb(PEFile pe, ModuleDefinition module, Source[] sources,
|
||||
Dictionary<int, Declaration> declarations, Dictionary<int, (Source Source, List<Point> Points)> methods, string dependencies)
|
||||
{
|
||||
var metadata = new MetadataBuilder();
|
||||
var documents = new Dictionary<string, DocumentHandle>(StringComparer.Ordinal);
|
||||
var custom = new List<(EntityHandle Parent, Guid Kind, byte[] Bytes)>();
|
||||
var csharp = metadata.GetOrAddGuid(CSharpLanguage);
|
||||
var sha256 = metadata.GetOrAddGuid(new Guid("8829D00F-11B8-4213-878B-770E8597AC16"));
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var document = metadata.AddDocument(metadata.GetOrAddDocumentName(source.Path), sha256, metadata.GetOrAddBlob(SHA256.HashData(source.Content)), csharp);
|
||||
documents.Add(source.Path, document);
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) writer.Write(source.Content.Length);
|
||||
using (var deflate = new System.IO.Compression.DeflateStream(stream, System.IO.Compression.CompressionLevel.Fastest, leaveOpen: true)) deflate.Write(source.Content);
|
||||
custom.Add((document, EmbeddedSource, stream.ToArray()));
|
||||
}
|
||||
var methodDefs = module.GetTypes().SelectMany(t => t.Methods).ToDictionary(m => m.MetadataToken.ToInt32());
|
||||
foreach (var handle in pe.Metadata.MethodDefinitions)
|
||||
{
|
||||
int token = MetadataTokens.GetToken(handle);
|
||||
if (!methods.TryGetValue(token, out var mapped)) { metadata.AddMethodDebugInformation(default, default); continue; }
|
||||
var points = mapped.Points.Where(p => p.Offset >= 0 && p.Offset < methodDefs[token].Body.CodeSize && p.Line > 0 && p.EndLine >= p.Line)
|
||||
.GroupBy(p => p.Offset).Select(g => g.First()).OrderBy(p => p.Offset).ToArray();
|
||||
var blob = new BlobBuilder(); blob.WriteCompressedInteger(0); // no local signature is needed for navigation
|
||||
int previousOffset = 0, previousLine = 0, previousColumn = 0;
|
||||
for (int i = 0; i < points.Length; i++)
|
||||
{
|
||||
var p = points[i]; blob.WriteCompressedInteger(i == 0 ? p.Offset : p.Offset - previousOffset);
|
||||
int lines = p.EndLine - p.Line; blob.WriteCompressedInteger(lines);
|
||||
int columns = p.EndColumn - p.Column;
|
||||
if (lines == 0) blob.WriteCompressedInteger(Math.Max(1, columns)); else blob.WriteCompressedSignedInteger(columns);
|
||||
if (i == 0) { blob.WriteCompressedInteger(p.Line); blob.WriteCompressedInteger(p.Column); }
|
||||
else { blob.WriteCompressedSignedInteger(p.Line - previousLine); blob.WriteCompressedSignedInteger(p.Column - previousColumn); }
|
||||
previousOffset = p.Offset; previousLine = p.Line; previousColumn = p.Column;
|
||||
}
|
||||
metadata.AddMethodDebugInformation(documents[mapped.Source.Path], metadata.GetOrAddBlob(blob));
|
||||
}
|
||||
foreach (var type in module.GetTypes())
|
||||
{
|
||||
if (!declarations.TryGetValue(type.MetadataToken.ToInt32(), out var d)) continue;
|
||||
var docs = new BlobBuilder(); docs.WriteCompressedInteger(MetadataTokens.GetRowNumber(documents[d.File]));
|
||||
custom.Add((MetadataTokens.EntityHandle(type.MetadataToken.ToInt32()), TypeDocuments, docs.ToArray()));
|
||||
}
|
||||
var options = Encoding.UTF8.GetBytes("language\0C#\0language-version\0" + "12.0\0compiler-version\0BD2.GameSdk\0output-kind\0DynamicallyLinkedLibrary\0optimization\0debug\0");
|
||||
custom.Add((MetadataTokens.EntityHandle(1), CompilationOptions, options));
|
||||
var references = new BlobBuilder();
|
||||
foreach (var reference in module.AssemblyReferences)
|
||||
{
|
||||
string path = Path.Combine(dependencies, reference.Name + ".dll");
|
||||
if (!File.Exists(path)) continue;
|
||||
using var stream = File.OpenRead(path); using var dependency = new PEReader(stream);
|
||||
var reader = dependency.GetMetadataReader();
|
||||
references.WriteBytes(Encoding.UTF8.GetBytes(Path.GetFileName(path))); references.WriteByte(0);
|
||||
references.WriteByte(0); // global alias
|
||||
references.WriteByte(1); // combined embedInteropTypes=false, image kind=assembly
|
||||
references.WriteUInt32((uint)dependency.PEHeaders.CoffHeader.TimeDateStamp);
|
||||
references.WriteUInt32((uint)dependency.PEHeaders.PEHeader.SizeOfImage);
|
||||
references.WriteBytes(reader.GetGuid(reader.GetModuleDefinition().Mvid).ToByteArray());
|
||||
}
|
||||
custom.Add((MetadataTokens.EntityHandle(1), CompilationReferences, references.ToArray()));
|
||||
foreach (var info in custom.OrderBy(c => CodedIndex.HasCustomDebugInformation(c.Parent)))
|
||||
metadata.AddCustomDebugInformation(info.Parent, metadata.GetOrAddGuid(info.Kind), metadata.GetOrAddBlob(info.Bytes));
|
||||
var counts = Enumerable.Range(0, 64).Select(i => pe.Metadata.GetTableRowCount((TableIndex)i)).ToImmutableArray();
|
||||
var builder = new PortablePdbBuilder(metadata, counts, default, blobs =>
|
||||
{
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
foreach (var blob in blobs) hash.AppendData(blob.GetBytes());
|
||||
return BlobContentId.FromHash(hash.GetHashAndReset());
|
||||
});
|
||||
var output = new BlobBuilder(); var id = builder.Serialize(output);
|
||||
return (output.ToArray(), id);
|
||||
}
|
||||
|
||||
private static void WriteXmlDocumentation(string assembly, PEFile pe, Dictionary<int, Declaration> declarations)
|
||||
{
|
||||
var settings = new DecompilerSettings();
|
||||
var resolver = new UniversalAssemblyResolver(assembly, false, pe.DetectTargetFrameworkId());
|
||||
var decompiler = new CSharpDecompiler(pe, resolver, settings);
|
||||
using var writer = System.Xml.XmlWriter.Create(Path.ChangeExtension(assembly, ".xml"), new System.Xml.XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false) });
|
||||
writer.WriteStartElement("doc"); writer.WriteStartElement("assembly"); writer.WriteElementString("name", Program.ShellName); writer.WriteEndElement(); writer.WriteStartElement("members");
|
||||
// Method signatures already provide IntelliSense, and PDB supplies exact
|
||||
// navigation. Repeating the same boilerplate for every member inflated
|
||||
// each XML file to hundreds of MB without adding API documentation.
|
||||
foreach (IEntity entity in decompiler.TypeSystem.MainModule.TypeDefinitions)
|
||||
{
|
||||
if (entity.MetadataToken.IsNil || !declarations.TryGetValue(MetadataTokens.GetToken(entity.MetadataToken), out var location)) continue;
|
||||
string id = ICSharpCode.Decompiler.Documentation.IdStringProvider.GetIdString(entity);
|
||||
writer.WriteStartElement("member"); writer.WriteAttributeString("name", id);
|
||||
writer.WriteElementString("summary", "Readable game type. Decompiled source: " + Path.GetRelativePath(Path.Combine(Path.GetDirectoryName(assembly), "sources"), location.File) + ":" + location.Line);
|
||||
writer.WriteElementString("remarks", "Navigation-only reference; the runtime implementation is in the matching Assembly-CSharp.");
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
writer.WriteEndElement(); writer.WriteEndElement();
|
||||
}
|
||||
|
||||
private sealed class NavigationSymbolWriterProvider(BlobContentId id, string pdbPath, byte[] pdb) : ISymbolWriterProvider
|
||||
{
|
||||
public ISymbolWriter GetSymbolWriter(ModuleDefinition module, string fileName) => new Writer(id, pdbPath, pdb);
|
||||
public ISymbolWriter GetSymbolWriter(ModuleDefinition module, Stream symbolStream) => throw new NotSupportedException();
|
||||
private sealed class Writer(BlobContentId id, string path, byte[] pdb) : ISymbolWriter
|
||||
{
|
||||
public ISymbolReaderProvider GetReaderProvider() => new PortablePdbReaderProvider();
|
||||
public void Write(MethodDebugInformation info) { }
|
||||
public void Write(ICustomDebugInformationProvider provider) { }
|
||||
public void Write() { }
|
||||
public void Dispose() { }
|
||||
public ImageDebugHeader GetDebugHeader()
|
||||
{
|
||||
using var stream = new MemoryStream(); using var writer = new BinaryWriter(stream);
|
||||
writer.Write(0x53445352); writer.Write(id.Guid.ToByteArray()); writer.Write(1); writer.Write(Encoding.UTF8.GetBytes(path)); writer.Write((byte)0);
|
||||
byte[] codeview = stream.ToArray();
|
||||
return new ImageDebugHeader(new[] {
|
||||
new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.CodeView, MajorVersion = 0x100, MinorVersion = 0x504d, TimeDateStamp = (int)id.Stamp, SizeOfData = codeview.Length }, codeview),
|
||||
new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.PdbChecksum, SizeOfData = 39 }, Encoding.UTF8.GetBytes("SHA256\0").Concat(SHA256.HashData(pdb)).ToArray())
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
@@ -0,0 +1,53 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$GameDir,
|
||||
[string]$PackageVersion,
|
||||
[string]$PackageDirectory
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $PackageDirectory) { $PackageDirectory = Join-Path $PSScriptRoot '..\..\.build\nuget' }
|
||||
if (-not $PackageVersion) {
|
||||
[xml]$metadata = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\PackageMetadata.props') -Raw
|
||||
$versions = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\versions.json') -Raw | ConvertFrom-Json
|
||||
$PackageVersion = "$($metadata.Project.PropertyGroup.BD2PackageVersion)-game.$($versions.game_version)"
|
||||
}
|
||||
$PackageDirectory = [IO.Path]::GetFullPath($PackageDirectory)
|
||||
# A fresh external directory and package cache prevent repository imports and stale
|
||||
# same-version development packages from making an invalid package appear to work.
|
||||
$workspace = Join-Path ([IO.Path]::GetTempPath()) ('BD2 NuGet Test ' + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $workspace | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'samples\ExamplePlugin\ExamplePlugin.csproj'), (Join-Path $PSScriptRoot 'samples\ExamplePlugin\Plugin.cs') -Destination $workspace
|
||||
$project = Join-Path $workspace 'ExamplePlugin.csproj'
|
||||
$projectText = [IO.File]::ReadAllText($project).Replace('Version="0.2.1-game.2.35.10"', "Version=`"$PackageVersion`"")
|
||||
[IO.File]::WriteAllText($project, $projectText)
|
||||
$feed = [Security.SecurityElement]::Escape($PackageDirectory)
|
||||
$cache = [Security.SecurityElement]::Escape((Join-Path $workspace 'packages'))
|
||||
[IO.File]::WriteAllText((Join-Path $workspace 'NuGet.Config'), "<configuration><packageSources><clear/><add key=`"bd2`" value=`"$feed`"/><add key=`"nuget.org`" value=`"https://api.nuget.org/v3/index.json`"/></packageSources><config><add key=`"globalPackagesFolder`" value=`"$cache`"/></config></configuration>")
|
||||
function Invoke-Dotnet([string[]]$Arguments) {
|
||||
& dotnet @Arguments
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
Invoke-Dotnet @('build', $project, '-c', 'Release', '--nologo', "-p:GameDir=$GameDir")
|
||||
$output = Join-Path $workspace 'bin\Release\netstandard2.1'
|
||||
$runtime = Join-Path $output 'BD2.GameNames.dll'
|
||||
foreach ($required in @('BD2.GameNames.dll', 'ExamplePlugin.dll')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $output $required))) { throw "Missing output: $required" }
|
||||
}
|
||||
foreach ($forbidden in @('Assembly-CSharp.Readable.dll', 'Assembly-CSharp.Readable.pdb', 'Assembly-CSharp.dll', 'GameSdk.dll', 'Mono.Cecil.dll', 'ICSharpCode.Decompiler.dll', 'BepInEx.dll', '0Harmony.dll', 'UnityEngine.dll', 'ExamplePlugin.pdb', 'navigation.json', 'sources', 'ref', 'lib')) {
|
||||
if (Test-Path -LiteralPath (Join-Path $output $forbidden)) { throw "Unexpected deployment artifact: $forbidden" }
|
||||
}
|
||||
$tool = Join-Path $workspace "packages\bd2.gamesdk\$PackageVersion\tools\net8.0\GameSdk.dll"
|
||||
$sdk = Join-Path $workspace 'obj\Release\netstandard2.1\bd2-game-sdk'
|
||||
$table = Join-Path $sdk 'names.json'
|
||||
Invoke-Dotnet @($tool, 'verify-runtime', $table, $runtime)
|
||||
Invoke-Dotnet @($tool, 'verify-navigation', $sdk)
|
||||
Invoke-Dotnet @($tool, 'verify', $table, (Join-Path $output 'ExamplePlugin.dll'), (Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll'))
|
||||
# Repeated builds must always transform the readable obj DLL, never reprocess bin.
|
||||
$navigationProps = Join-Path $sdk 'GameSourceNavigation.props'
|
||||
$navigationTimestamp = (Get-Item -LiteralPath $navigationProps).LastWriteTimeUtc
|
||||
Invoke-Dotnet @('build', $project, '-c', 'Release', '--no-restore', '--nologo', "-p:GameDir=$GameDir")
|
||||
if ((Get-Item -LiteralPath $navigationProps).LastWriteTimeUtc -ne $navigationTimestamp) {
|
||||
throw 'Unchanged navigation props were rewritten; this triggers repeated IDE reloads.'
|
||||
}
|
||||
Invoke-Dotnet @($tool, 'verify', $table, (Join-Path $output 'ExamplePlugin.dll'), (Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll'))
|
||||
Write-Host "Verified external PackageReference consumer, complete source navigation, shared runtime and incremental rebuild: $workspace"
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<GameDir>E:\Games\BrownDustII</GameDir>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<AssemblyName>ExamplePlugin</AssemblyName>
|
||||
<!-- GameDir belongs in local Directory.Build.props or on the command line. -->
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BD2.GameSdk" Version="0.2.1-game.2.35.10" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<!-- Repository sample consumes the packages produced by GameSdk/Pack.ps1. -->
|
||||
<add key="bd2-local" value="../../../../.build/nuget" />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using BD2.GameNames;
|
||||
using BepInEx;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace ExamplePlugin;
|
||||
|
||||
[BepInPlugin("example.readable-names", "Readable Names Example", "1.0.0")]
|
||||
public sealed class Plugin : BaseUnityPlugin
|
||||
{
|
||||
private Harmony harmony;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fail before installing any game patches when binary/table/plugin versions differ.
|
||||
Game.Validate(typeof(Plugin).Assembly, message => Logger.LogInfo(message));
|
||||
MethodInfo target = Game.Method<IntroUI>(ui => ui.SendMaintenanceInfo(false));
|
||||
harmony = new Harmony("example.readable-names");
|
||||
harmony.Patch(target, prefix: new HarmonyMethod(typeof(Plugin), nameof(BeforeMaintenance)));
|
||||
|
||||
// Private members require the runtime string channel. The prefix expression above
|
||||
// and the Unity callback name below belong to separate channels.
|
||||
var awake = typeof(IntroUI).GetGameMethod("Awake",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null);
|
||||
Logger.LogInfo("Resolved IntroUI.Awake: " + (awake != null));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Logger.LogError("Readable Names Example initialization failed: " + exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void BeforeMaintenance() { }
|
||||
private void OnDestroy() => harmony?.UnpatchSelf();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using BD2.GameNames;
|
||||
using static BD2.GameNames.Game;
|
||||
using System.Reflection;
|
||||
using BepInEx.Logging;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
|
||||
namespace Bd2LocalIdentity;
|
||||
|
||||
internal static class ClientDiagnostics
|
||||
{
|
||||
private static ManualLogSource Log;
|
||||
internal static void Initialize(ManualLogSource logger) => Log = logger;
|
||||
|
||||
internal static void InstallMaintenanceTimeoutGuard(Harmony harmony, Type introUI)
|
||||
{
|
||||
MethodInfo finishMaintenance = introUI?.GetGameMethod(
|
||||
nameof(IntroUI.OnFinishMaintenanceRequest),
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
MethodInfo falseTimeoutTelemetry = introUI?.GetGameMethod(
|
||||
"CheckMaintenanceTimeout",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
if (finishMaintenance == null || falseTimeoutTelemetry == null)
|
||||
{
|
||||
throw new MissingMethodException("IntroUI maintenance timeout methods were not found");
|
||||
}
|
||||
harmony.Patch(
|
||||
finishMaintenance,
|
||||
postfix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(OnFinishMaintenancePostfix)));
|
||||
harmony.Patch(
|
||||
falseTimeoutTelemetry,
|
||||
prefix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(SkipLocalTimeoutTelemetry)));
|
||||
}
|
||||
|
||||
private static void OnFinishMaintenancePostfix(object __instance)
|
||||
{
|
||||
// CancelMaintenanceTimeout cancels the active CTS and then
|
||||
// immediately stores a fresh CTS. Depending on async scheduling, the
|
||||
// timeout task can capture that fresh token after the successful
|
||||
// response and emit a false 10-second timeout. Cancel the replacement
|
||||
// token only after OnFinishMaintenanceRequest has completed.
|
||||
FieldInfo timeout = __instance.GetType().GetGameField(
|
||||
"_maintenanceTimeoutCts",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
CancellationTokenSource source = timeout?.GetValue(__instance) as CancellationTokenSource;
|
||||
source?.Cancel();
|
||||
Log?.LogInfo("Maintenance timeout guard cancelled after successful response");
|
||||
}
|
||||
|
||||
private static bool SkipLocalTimeoutTelemetry()
|
||||
{
|
||||
// This method only emits intro_server_info_timeout telemetry after ten
|
||||
// seconds. Network request failures have their own callbacks. In
|
||||
// In the configured client it can outlive a successful local maintenance response.
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static void InstallDatabaseDiagnostics(Harmony harmony)
|
||||
{
|
||||
Type rawDataManager = typeof(RawDataManager);
|
||||
MethodInfo dbLoad = rawDataManager?.GetGameMethod(
|
||||
nameof(RawDataManager.DBLoad),
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
if (dbLoad == null)
|
||||
{
|
||||
throw new MissingMethodException("RawDataManager.DBLoad was not found");
|
||||
}
|
||||
|
||||
harmony.Patch(
|
||||
dbLoad,
|
||||
prefix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(DBLoadPrefix)));
|
||||
|
||||
Type clientLocalInfo = typeof(Proto.Local.ClientLocalInfo);
|
||||
MethodInfo loadDB = clientLocalInfo?.GetGameMethod(
|
||||
nameof(Proto.Local.ClientLocalInfo.LoadDB),
|
||||
BindingFlags.Static | BindingFlags.Public);
|
||||
if (loadDB == null)
|
||||
{
|
||||
throw new MissingMethodException("ClientLocalInfo.LoadDB was not found");
|
||||
}
|
||||
|
||||
harmony.Patch(
|
||||
loadDB,
|
||||
prefix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(ClientLocalLoadPrefix)),
|
||||
postfix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(ClientLocalLoadPostfix)),
|
||||
finalizer: new HarmonyMethod(typeof(ClientDiagnostics), nameof(ClientLocalLoadFinalizer)));
|
||||
|
||||
Log?.LogInfo("Database diagnostics active");
|
||||
}
|
||||
|
||||
private static void DBLoadPrefix(RawDataManager.DBOption __0, ref Action __1)
|
||||
{
|
||||
string dbName = __0.dbName ?? "<unknown>";
|
||||
Action original = __1;
|
||||
Stopwatch elapsed = Stopwatch.StartNew();
|
||||
Log?.LogInfo("DBLoad start: " + dbName);
|
||||
__1 = delegate
|
||||
{
|
||||
Log?.LogInfo("DBLoad callback enter: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)");
|
||||
try
|
||||
{
|
||||
original?.Invoke();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log?.LogInfo("DBLoad callback exit: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void ClientLocalLoadPrefix(out Stopwatch __state)
|
||||
{
|
||||
__state = Stopwatch.StartNew();
|
||||
Log?.LogInfo("ClientLocalInfo.LoadDB enter");
|
||||
}
|
||||
|
||||
private static void ClientLocalLoadPostfix(Stopwatch __state)
|
||||
{
|
||||
Log?.LogInfo("ClientLocalInfo.LoadDB exit (" + __state.ElapsedMilliseconds + " ms)");
|
||||
}
|
||||
|
||||
private static Exception ClientLocalLoadFinalizer(Exception __exception, Stopwatch __state)
|
||||
{
|
||||
if (__exception != null)
|
||||
{
|
||||
Log?.LogError(
|
||||
"ClientLocalInfo.LoadDB threw after " + __state.ElapsedMilliseconds + " ms: " + __exception);
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using BD2.GameNames;
|
||||
using static BD2.GameNames.Game;
|
||||
using HarmonyLib;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
@@ -11,6 +15,49 @@ namespace Bd2LocalIdentity;
|
||||
|
||||
internal sealed class ClientRouting : IDisposable
|
||||
{
|
||||
private static ClientRouting installedRouting;
|
||||
private static ManualLogSource routingLog;
|
||||
|
||||
internal void Install(Harmony harmony)
|
||||
{
|
||||
installedRouting = this;
|
||||
routingLog = log;
|
||||
MethodInfo load = Game.Method(() => BDNetwork.ServerURLInfo.Load());
|
||||
MethodInfo sendMaintenance = Game.Method<IntroUI>(ui => ui.SendMaintenanceInfo(false));
|
||||
MethodInfo makeCDNInfo = Game.Method(() => BDNetwork.CommonPacket.MakeCDNInfo(null));
|
||||
if (load == null || sendMaintenance == null || makeCDNInfo == null)
|
||||
{
|
||||
throw new MissingMethodException("2.35.10 server/resource routing methods were not found");
|
||||
}
|
||||
harmony.Patch(load, postfix: new HarmonyMethod(typeof(ClientRouting), nameof(ApplyServerOriginHook)));
|
||||
harmony.Patch(sendMaintenance, prefix: new HarmonyMethod(typeof(ClientRouting), nameof(ApplyServerOriginHook)));
|
||||
harmony.Patch(makeCDNInfo, postfix: new HarmonyMethod(typeof(ClientRouting), nameof(ApplyResourcesHook)));
|
||||
routingLog?.LogInfo("Server origin and native CdnInfo routing patches installed");
|
||||
}
|
||||
|
||||
private static void ApplyServerOriginHook()
|
||||
{
|
||||
ClientRouting routing = installedRouting;
|
||||
if (routing == null)
|
||||
{
|
||||
routingLog?.LogError("Client routing is unavailable while applying the server origin");
|
||||
return;
|
||||
}
|
||||
routing.ApplyServerOrigin();
|
||||
}
|
||||
|
||||
private static void ApplyResourcesHook()
|
||||
{
|
||||
ClientRouting routing = installedRouting;
|
||||
if (routing == null)
|
||||
{
|
||||
routingLog?.LogError("Client routing is unavailable while applying resources");
|
||||
return;
|
||||
}
|
||||
routing.ApplyResources();
|
||||
}
|
||||
|
||||
|
||||
private const string ConfigFileName = "bd2.client.json";
|
||||
private const string OfficialServerData = "https://bd2-cdn.akamaized.net/ServerData";
|
||||
private const string OfficialGameData = "https://bd2-cdn.akamaized.net/GameData";
|
||||
@@ -90,44 +137,24 @@ internal sealed class ClientRouting : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (ReferenceEquals(installedRouting, this)) installedRouting = null;
|
||||
localResourceServer?.Dispose();
|
||||
}
|
||||
|
||||
internal void ApplyServerOrigin()
|
||||
{
|
||||
Type serverURLInfo = Plugin.FindType("ὫὡὩὤὣὨὯὭὥὠὩ") ?? Plugin.FindType("BDNetwork.ServerURLInfo");
|
||||
var setUriDirectly = serverURLInfo?.GetMethod(
|
||||
"ὬὠὢὮὪὮὭὫὮὡὥ",
|
||||
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public,
|
||||
null,
|
||||
new[] { typeof(Uri) },
|
||||
null) ?? serverURLInfo?.GetMethod(
|
||||
"SetUriDirectly",
|
||||
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public,
|
||||
null,
|
||||
new[] { typeof(Uri) },
|
||||
null);
|
||||
if (setUriDirectly == null)
|
||||
{
|
||||
throw new MissingMethodException("BDNetwork.ServerURLInfo.SetUriDirectly(Uri) was not found");
|
||||
}
|
||||
Uri gameEndpoint = new Uri(new Uri(config.server_origin + "/"), "game/");
|
||||
setUriDirectly.Invoke(null, new object[] { gameEndpoint });
|
||||
BDNetwork.ServerURLInfo.SetUriDirectly(gameEndpoint);
|
||||
log.LogInfo("Client server origin applied: " + config.server_origin);
|
||||
}
|
||||
|
||||
internal void ApplyResources()
|
||||
{
|
||||
Type cdnInfo = Plugin.FindType("BDNetwork.CdnInfo");
|
||||
if (cdnInfo == null)
|
||||
{
|
||||
throw new TypeLoadException("BDNetwork.CdnInfo was not found");
|
||||
}
|
||||
SetStaticString(cdnInfo, "Info", resources.server_data_url);
|
||||
SetStaticString(cdnInfo, "Version", resources.bundle_version);
|
||||
SetStaticString(cdnInfo, "SoundVersion", resources.bundle_version);
|
||||
SetStaticString(cdnInfo, "GameDataInfo", resources.game_data_url);
|
||||
SetStaticString(cdnInfo, "GameDataVersion", resources.game_data_version);
|
||||
BDNetwork.CdnInfo.Info = resources.server_data_url;
|
||||
BDNetwork.CdnInfo.Version = resources.bundle_version;
|
||||
BDNetwork.CdnInfo.SoundVersion = resources.bundle_version;
|
||||
BDNetwork.CdnInfo.GameDataInfo = resources.game_data_url;
|
||||
BDNetwork.CdnInfo.GameDataVersion = resources.game_data_version;
|
||||
log.LogInfo("Client resources applied: mode=" + resources.mode +
|
||||
" server_data=" + resources.server_data_url +
|
||||
" game_data=" + resources.game_data_url +
|
||||
@@ -370,16 +397,6 @@ internal sealed class ClientRouting : IDisposable
|
||||
return (string)token;
|
||||
}
|
||||
|
||||
private static void SetStaticString(Type type, string propertyName, string value)
|
||||
{
|
||||
var property = type.GetProperty(propertyName, System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public);
|
||||
if (property?.PropertyType != typeof(string) || property.GetSetMethod() == null)
|
||||
{
|
||||
throw new MissingMemberException(type.FullName + "." + propertyName + " was not found");
|
||||
}
|
||||
property.SetValue(null, value, null);
|
||||
}
|
||||
|
||||
private sealed class ClientConfig
|
||||
{
|
||||
public int schema_version;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using System;
|
||||
using BD2.GameNames;
|
||||
using static BD2.GameNames.Game;
|
||||
using System.Reflection;
|
||||
using BepInEx.Logging;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
using System.Linq;
|
||||
|
||||
namespace Bd2LocalIdentity;
|
||||
|
||||
internal static class LocalAccountPolicy
|
||||
{
|
||||
private static ManualLogSource Log;
|
||||
internal static void Initialize(ManualLogSource logger) => Log = logger;
|
||||
|
||||
internal static void InstallAgeGatePersistence(Harmony harmony)
|
||||
{
|
||||
// LoginUserResponse field 13 is the client's sole gate for opening
|
||||
// AgeGatePopupUI. Retain the original first-run UI and request; only
|
||||
// change a later LoginUser parse after its successful local state has
|
||||
// been read from disk.
|
||||
Type commonPacket = typeof(BDNetwork.CommonPacket);
|
||||
MethodInfo updateAgeGate = commonPacket?.GetGameMethod(
|
||||
nameof(BDNetwork.CommonPacket.SendUpdateAgeGateRequest),
|
||||
BindingFlags.Static | BindingFlags.Public,
|
||||
null,
|
||||
new[] { typeof(bool), typeof(int), typeof(int), typeof(int), typeof(Action) },
|
||||
null);
|
||||
if (updateAgeGate == null)
|
||||
{
|
||||
throw new MissingMethodException("CommonPacket.SendUpdateAgeGateRequest(bool, int, int, int, Action) was not found");
|
||||
}
|
||||
Type loginUserResponse = typeof(Proto.Net.LoginUserResponse);
|
||||
MethodInfo needsAgeVerificationSetter = loginUserResponse?.GetGameProperty(
|
||||
nameof(Proto.Net.LoginUserResponse.NeedsAgeVerification),
|
||||
BindingFlags.Instance | BindingFlags.Public)?.GetSetMethod();
|
||||
if (needsAgeVerificationSetter == null)
|
||||
{
|
||||
throw new MissingMethodException("LoginUserResponse.NeedsAgeVerification setter was not found");
|
||||
}
|
||||
harmony.Patch(
|
||||
updateAgeGate,
|
||||
prefix: new HarmonyMethod(typeof(LocalAccountPolicy), nameof(UpdateAgeGateRequestPrefix)));
|
||||
harmony.Patch(
|
||||
needsAgeVerificationSetter,
|
||||
prefix: new HarmonyMethod(typeof(LocalAccountPolicy), nameof(NeedsAgeVerificationSetterPrefix)));
|
||||
|
||||
Log?.LogInfo("Local age-gate confirmation persistence active (confirmed=" + AgeGateState.IsConfirmed() + ")");
|
||||
}
|
||||
|
||||
private static void NeedsAgeVerificationSetterPrefix(ref bool value)
|
||||
{
|
||||
if (value && AgeGateState.IsConfirmed())
|
||||
{
|
||||
value = false;
|
||||
Log?.LogInfo("Used persisted local age-gate confirmation for LoginUser");
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateAgeGateRequestPrefix(ref Action __4)
|
||||
{
|
||||
// CommonPacket invokes this callback only after it has parsed the
|
||||
// empty UpdateAgeGateResponse and accepted errorType == 0. Wrapping
|
||||
// it therefore never records failed/cancelled submissions.
|
||||
Action continuation = __4;
|
||||
__4 = delegate
|
||||
{
|
||||
try
|
||||
{
|
||||
AgeGateState.MarkConfirmed();
|
||||
Log?.LogInfo("Stored successful local age-gate confirmation");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Preserve the original continuation: inability to persist
|
||||
// should not break a successfully completed first login.
|
||||
Log?.LogWarning("Could not persist local age-gate confirmation: " + ex.Message);
|
||||
}
|
||||
continuation?.Invoke();
|
||||
};
|
||||
}
|
||||
|
||||
internal static void InstallLocalPurchaseBypass(Harmony harmony)
|
||||
{
|
||||
Type platformRuler = typeof(gamfs.Platform.PlatformRuler);
|
||||
MethodInfo getProducts = platformRuler?
|
||||
.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
|
||||
.SingleOrDefault(method =>
|
||||
method.IsGameMethod(nameof(gamfs.Platform.PlatformRuler.GetProductAsync)) &&
|
||||
method.GetParameters().Length == 6 &&
|
||||
method.GetParameters()[0].ParameterType == typeof(string[]) &&
|
||||
method.GetParameters()[3].ParameterType == typeof(Action) &&
|
||||
method.GetParameters()[4].ParameterType == typeof(Action<int, int, string>) &&
|
||||
method.GetParameters()[5].ParameterType == typeof(bool));
|
||||
if (getProducts == null)
|
||||
{
|
||||
throw new MissingMethodException("PlatformRuler.GetProductAsync was not found");
|
||||
}
|
||||
harmony.Patch(
|
||||
getProducts,
|
||||
prefix: new HarmonyMethod(typeof(LocalAccountPolicy), nameof(GetProductsPrefix)));
|
||||
|
||||
Type platformManager = typeof(gamfs.Platform.PlatformManager);
|
||||
MethodInfo purchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
|
||||
.SingleOrDefault(method => method.IsGameMethod(nameof(gamfs.Platform.PlatformManager.Purchase)) &&
|
||||
method.GetParameters().Length == 3 &&
|
||||
method.GetParameters()[0].ParameterType == typeof(string) &&
|
||||
method.GetParameters()[2].ParameterType == typeof(Action));
|
||||
MethodInfo finishPurchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
|
||||
.SingleOrDefault(method => method.IsGameMethod(nameof(gamfs.Platform.PlatformManager.FinishPurchase)) &&
|
||||
method.GetParameters().Length == 2 &&
|
||||
method.GetParameters()[0].ParameterType == typeof(string) &&
|
||||
method.GetParameters()[1].ParameterType == typeof(long));
|
||||
if (purchase == null || finishPurchase == null)
|
||||
throw new MissingMethodException("PlatformManager purchase methods were not found");
|
||||
harmony.Patch(purchase, prefix: new HarmonyMethod(
|
||||
typeof(LocalAccountPolicy), nameof(LocalPurchasePrefix)));
|
||||
harmony.Patch(finishPurchase, prefix: new HarmonyMethod(
|
||||
typeof(LocalAccountPolicy), nameof(FinishLocalPurchasePrefix)));
|
||||
Log?.LogInfo("Local purchase price lookup disabled");
|
||||
Log?.LogInfo("Infinite reroll confirmation is local/free; all other paid purchases are blocked");
|
||||
}
|
||||
|
||||
private static bool GetProductsPrefix(Action __3)
|
||||
{
|
||||
// A private server has no Neon/GPG commerce identity. Treat price
|
||||
// prefetch as complete so startup can continue without contacting the
|
||||
// production payment API. No purchase result or currency is forged.
|
||||
__3?.Invoke();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LocalPurchasePrefix(string __0, object __1, Action __2)
|
||||
{
|
||||
// Product 9100033 is the infinite-reroll confirmation. The
|
||||
// local server grants the last preview through CashShopBuy without
|
||||
// contacting Neon/GPG. No other real-money product is authorized.
|
||||
if (__0 == "brd2_limited_pack_660" || __0 == "brd2_limited_pack_660_ios")
|
||||
{
|
||||
object result = new gamfs.Platform.PurchaseData(__0, 1L,
|
||||
"bd2-local-free-infinite", "bd2-local-free-receipt");
|
||||
Log?.LogInfo("Approved local/free infinite-reroll confirmation");
|
||||
(__1 as Delegate)?.DynamicInvoke(result);
|
||||
return false;
|
||||
}
|
||||
Log?.LogWarning("Blocked unsupported paid product: " + (__0 ?? "<null>"));
|
||||
__2?.Invoke();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool FinishLocalPurchasePrefix(string __0)
|
||||
{
|
||||
if (__0 == "bd2-local-free-infinite")
|
||||
{
|
||||
Log?.LogInfo("Finished local/free infinite-reroll confirmation");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -6,12 +6,7 @@
|
||||
<AssemblyName>BD2LocalIdentity</AssemblyName>
|
||||
<RootNamespace>Bd2LocalIdentity</RootNamespace>
|
||||
<BD2PluginVersionKey>local_identity</BD2PluginVersionKey>
|
||||
<BD2ManagedDir Condition="'$(BD2ManagedDir)' == ''">$(GameDir)\BrownDust II_Data\Managed</BD2ManagedDir>
|
||||
<BD2BepInExDir Condition="'$(BD2BepInExDir)' == ''">$(GameDir)\BepInEx</BD2BepInExDir>
|
||||
</PropertyGroup>
|
||||
<Target Name="RequireGameDir" BeforeTargets="ResolveReferences" Condition="'$(GameDir)' == ''">
|
||||
<Error Text="GameDir is required. Build with -p:GameDir=<local-client-directory>." />
|
||||
</Target>
|
||||
<ItemGroup>
|
||||
<Reference Include="BepInEx">
|
||||
<HintPath>$(BD2BepInExDir)\core\BepInEx.dll</HintPath>
|
||||
@@ -34,5 +29,4 @@
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<Import Project="..\VersionSource.targets" />
|
||||
</Project>
|
||||
|
||||
+20
-510
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using BD2.GameNames;
|
||||
using static BD2.GameNames.Game;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using BepInEx;
|
||||
@@ -18,7 +18,6 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
public const string Version = Bd2Build.Versions.Plugin;
|
||||
private static ManualLogSource Log;
|
||||
private static ClientRouting Routing;
|
||||
private static MethodInfo SwitchToFullScreenMethod;
|
||||
private static int ShutdownHooksInstalled;
|
||||
|
||||
internal static void LogWarning(string message)
|
||||
@@ -31,6 +30,7 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
try
|
||||
{
|
||||
Log = Logger;
|
||||
Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message));
|
||||
// The non-SDK branch still needs a local bootstrap identity for
|
||||
// MaintenanceInfo. OAuth LoginUI replaces this value after its
|
||||
// browser/device transaction completes.
|
||||
@@ -42,31 +42,32 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
AppDomain.CurrentDomain.ProcessExit += delegate { DisposeRouting(); };
|
||||
AppDomain.CurrentDomain.DomainUnload += delegate { DisposeRouting(); };
|
||||
}
|
||||
Type appManager = FindType("AppManager");
|
||||
PropertyInfo useSdk = appManager?.GetProperty(
|
||||
"ὬὦὠὫὡὥὥὦὠὠὠ",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
MethodInfo getter = useSdk?.GetGetMethod();
|
||||
Type appManager = typeof(AppManager);
|
||||
MethodInfo getter = Game.Getter<AppManager, bool>(app => app.IsPlatformLogin);
|
||||
if (getter == null || getter.ReturnType != typeof(bool) || getter.GetParameters().Length != 0)
|
||||
{
|
||||
throw new MissingMethodException("AppManager.UseSdk getter was not found (client version mismatch)");
|
||||
throw new MissingMethodException("AppManager.IsPlatformLogin getter was not found (client version mismatch)");
|
||||
}
|
||||
|
||||
MethodInfo prefix = typeof(Plugin).GetMethod(
|
||||
MethodInfo prefix = typeof(Plugin).GetGameMethod(
|
||||
nameof(UseSdkPrefix),
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
Harmony harmony = new Harmony(Guid);
|
||||
harmony.Patch(getter, prefix: new HarmonyMethod(prefix));
|
||||
TryInstall("OS time zone device country", () => SystemTimeZoneRegion.Install(harmony, Logger));
|
||||
|
||||
Type introUI = FindType("IntroUI");
|
||||
InstallClientRouting(harmony, introUI);
|
||||
TryInstall("startup fullscreen", () => InstallStartupFullscreen(harmony, appManager));
|
||||
TryInstall("performance overlay suppression", () => InstallPerformanceOverlaySuppression(harmony));
|
||||
TryInstall("maintenance timeout guard", () => InstallMaintenanceTimeoutGuard(harmony, introUI));
|
||||
TryInstall("age-gate persistence", () => InstallAgeGatePersistence(harmony));
|
||||
TryInstall("local purchase bypass", () => InstallLocalPurchaseBypass(harmony));
|
||||
TryInstall("database diagnostics", () => InstallDatabaseDiagnostics(harmony));
|
||||
Logger.LogInfo("Local identity active: AppManager.UseSdk => false");
|
||||
ClientPresentation.Initialize(Logger);
|
||||
LocalAccountPolicy.Initialize(Logger);
|
||||
ClientDiagnostics.Initialize(Logger);
|
||||
Type introUI = typeof(IntroUI);
|
||||
Routing.Install(harmony);
|
||||
TryInstall("startup fullscreen", () => ClientPresentation.InstallStartupFullscreen(harmony, appManager));
|
||||
TryInstall("performance overlay suppression", () => ClientPresentation.InstallPerformanceOverlaySuppression(harmony));
|
||||
TryInstall("maintenance timeout guard", () => ClientDiagnostics.InstallMaintenanceTimeoutGuard(harmony, introUI));
|
||||
TryInstall("age-gate persistence", () => LocalAccountPolicy.InstallAgeGatePersistence(harmony));
|
||||
TryInstall("local purchase bypass", () => LocalAccountPolicy.InstallLocalPurchaseBypass(harmony));
|
||||
TryInstall("database diagnostics", () => ClientDiagnostics.InstallDatabaseDiagnostics(harmony));
|
||||
Logger.LogInfo("Local identity active: AppManager.IsPlatformLogin => false");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -103,496 +104,5 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void InstallStartupFullscreen(Harmony harmony, Type appManager)
|
||||
{
|
||||
MethodInfo initializeResolution = appManager?.GetMethod(
|
||||
"ὪὣὬὤὪὥὣὨὤὦὧ",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
SwitchToFullScreenMethod = appManager?.GetMethod(
|
||||
"SwitchToFullScreen",
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
null,
|
||||
new[] { typeof(bool) },
|
||||
null);
|
||||
if (initializeResolution == null || SwitchToFullScreenMethod == null)
|
||||
{
|
||||
throw new MissingMethodException("AppManager fullscreen methods were not found (client version mismatch)");
|
||||
}
|
||||
harmony.Patch(
|
||||
initializeResolution,
|
||||
postfix: new HarmonyMethod(typeof(Plugin), nameof(InitializeResolutionPostfix)));
|
||||
Log?.LogInfo("Native startup fullscreen patch installed");
|
||||
}
|
||||
|
||||
private static void InitializeResolutionPostfix(object __instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use the game's own FullScreenWindow path so the behaviour is
|
||||
// identical on Windows and macOS. This runs once during startup;
|
||||
// later user-initiated switches to windowed mode remain intact.
|
||||
SwitchToFullScreenMethod?.Invoke(__instance, new object[] { false });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogError("Could not apply native startup fullscreen: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InstallPerformanceOverlaySuppression(Harmony harmony)
|
||||
{
|
||||
Type fpsCheck = FindType("FPS_Check");
|
||||
MethodInfo start = fpsCheck?.GetMethod(
|
||||
"StartFPS",
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
MethodInfo render = fpsCheck?.GetMethod(
|
||||
"ὬὡὡὬὢὭὦὣὥὬὯ",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
MethodInfo onEnable = fpsCheck?.GetMethod(
|
||||
"OnEnable",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
MethodInfo onInitialize = fpsCheck?.GetMethod(
|
||||
"OnInitialize",
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
if (start == null || render == null || onEnable == null || onInitialize == null)
|
||||
{
|
||||
throw new MissingMethodException("FPS_Check overlay methods were not found (client version mismatch)");
|
||||
}
|
||||
|
||||
HarmonyMethod skip = new HarmonyMethod(typeof(Plugin), nameof(SkipPerformanceOverlay));
|
||||
HarmonyMethod hide = new HarmonyMethod(typeof(Plugin), nameof(HidePerformanceOverlayPostfix));
|
||||
harmony.Patch(start, prefix: skip);
|
||||
harmony.Patch(render, prefix: skip);
|
||||
harmony.Patch(onEnable, postfix: hide);
|
||||
harmony.Patch(onInitialize, postfix: hide);
|
||||
Log?.LogInfo("FPS_Check performance overlay disabled");
|
||||
}
|
||||
|
||||
private static bool SkipPerformanceOverlay(object __instance)
|
||||
{
|
||||
HidePerformanceOverlay(__instance);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void HidePerformanceOverlayPostfix(object __instance)
|
||||
{
|
||||
HidePerformanceOverlay(__instance);
|
||||
}
|
||||
|
||||
private static void HidePerformanceOverlay(object instance)
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
MethodInfo disposeRecorders = instance.GetType().GetMethod(
|
||||
"DisposeRecorders",
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
disposeRecorders?.Invoke(instance, null);
|
||||
|
||||
FieldInfo textField = instance.GetType().GetField(
|
||||
"_text",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
object text = textField?.GetValue(instance);
|
||||
PropertyInfo textProperty = text?.GetType().GetProperty(
|
||||
"text",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
if (textProperty?.CanWrite == true)
|
||||
{
|
||||
textProperty.SetValue(text, string.Empty);
|
||||
}
|
||||
if (text is Component component && component != null)
|
||||
{
|
||||
component.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogError("Could not suppress FPS_Check overlay: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InstallClientRouting(Harmony harmony, Type introUI)
|
||||
{
|
||||
Type serverURLInfo = FindType("ὫὡὩὤὣὨὯὭὥὠὩ") ?? FindType("BDNetwork.ServerURLInfo");
|
||||
MethodInfo load = serverURLInfo?.GetMethod(
|
||||
"ὣὪὦὮὦὠὪὯὧὯὭ",
|
||||
BindingFlags.Static | BindingFlags.Public,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null) ?? serverURLInfo?.GetMethod(
|
||||
"Load",
|
||||
BindingFlags.Static | BindingFlags.Public,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
MethodInfo sendMaintenance = introUI?.GetMethod(
|
||||
"SendMaintenanceInfo",
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
null,
|
||||
new[] { typeof(bool) },
|
||||
null);
|
||||
Type commonPacket = FindType("ὣὡὧὡὦὣὣὬὨὪὫ");
|
||||
MethodInfo makeCDNInfo = commonPacket?.GetMethod(
|
||||
"ὥὢὨὡὪὨὥὥὪὩὠ",
|
||||
BindingFlags.Static | BindingFlags.Public,
|
||||
null,
|
||||
new[] { typeof(string) },
|
||||
null);
|
||||
if (load == null || sendMaintenance == null || makeCDNInfo == null)
|
||||
{
|
||||
throw new MissingMethodException("2.35.10 server/resource routing methods were not found");
|
||||
}
|
||||
harmony.Patch(load, postfix: new HarmonyMethod(typeof(Plugin), nameof(ApplyServerOrigin)));
|
||||
harmony.Patch(sendMaintenance, prefix: new HarmonyMethod(typeof(Plugin), nameof(ApplyServerOrigin)));
|
||||
harmony.Patch(makeCDNInfo, postfix: new HarmonyMethod(typeof(Plugin), nameof(ApplyResources)));
|
||||
Log?.LogInfo("Server origin and native CdnInfo routing patches installed");
|
||||
}
|
||||
|
||||
private static void ApplyServerOrigin()
|
||||
{
|
||||
ClientRouting routing = Routing;
|
||||
if (routing == null)
|
||||
{
|
||||
Log?.LogError("Client routing is unavailable while applying the server origin");
|
||||
return;
|
||||
}
|
||||
routing.ApplyServerOrigin();
|
||||
}
|
||||
|
||||
private static void ApplyResources()
|
||||
{
|
||||
ClientRouting routing = Routing;
|
||||
if (routing == null)
|
||||
{
|
||||
Log?.LogError("Client routing is unavailable while applying resources");
|
||||
return;
|
||||
}
|
||||
routing.ApplyResources();
|
||||
}
|
||||
|
||||
private static void InstallMaintenanceTimeoutGuard(Harmony harmony, Type introUI)
|
||||
{
|
||||
MethodInfo finishMaintenance = introUI?.GetMethod(
|
||||
"OnFinishMaintenanceRequest",
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
MethodInfo falseTimeoutTelemetry = introUI?.GetMethod(
|
||||
"ὡὡὤὧὫὥὬὯὣὠὬ",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
if (finishMaintenance == null || falseTimeoutTelemetry == null)
|
||||
{
|
||||
throw new MissingMethodException("IntroUI maintenance timeout methods were not found");
|
||||
}
|
||||
harmony.Patch(
|
||||
finishMaintenance,
|
||||
postfix: new HarmonyMethod(typeof(Plugin), nameof(OnFinishMaintenancePostfix)));
|
||||
harmony.Patch(
|
||||
falseTimeoutTelemetry,
|
||||
prefix: new HarmonyMethod(typeof(Plugin), nameof(SkipLocalTimeoutTelemetry)));
|
||||
}
|
||||
|
||||
private static void OnFinishMaintenancePostfix(object __instance)
|
||||
{
|
||||
// CancelMaintenanceTimeout cancels the active CTS and then
|
||||
// immediately stores a fresh CTS. Depending on async scheduling, the
|
||||
// timeout task can capture that fresh token after the successful
|
||||
// response and emit a false 10-second timeout. Cancel the replacement
|
||||
// token only after OnFinishMaintenanceRequest has completed.
|
||||
FieldInfo timeout = __instance.GetType().GetField(
|
||||
"ὫὥὨὨὠὯὥὭὨὨὪ",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
CancellationTokenSource source = timeout?.GetValue(__instance) as CancellationTokenSource;
|
||||
source?.Cancel();
|
||||
Log?.LogInfo("Maintenance timeout guard cancelled after successful response");
|
||||
}
|
||||
|
||||
private static bool SkipLocalTimeoutTelemetry()
|
||||
{
|
||||
// This method only emits intro_server_info_timeout telemetry after ten
|
||||
// seconds. Network request failures have their own callbacks. In
|
||||
// In the configured client it can outlive a successful local maintenance response.
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void InstallAgeGatePersistence(Harmony harmony)
|
||||
{
|
||||
// LoginUserResponse field 13 is the client's sole gate for opening
|
||||
// AgeGatePopupUI. Retain the original first-run UI and request; only
|
||||
// change a later LoginUser parse after its successful local state has
|
||||
// been read from disk.
|
||||
Type commonPacket = FindType("ὣὡὧὡὦὣὣὬὨὪὫ");
|
||||
MethodInfo updateAgeGate = commonPacket?.GetMethod(
|
||||
"ὪὯὭὣὨὡὬὪὭὨὡ",
|
||||
BindingFlags.Static | BindingFlags.Public,
|
||||
null,
|
||||
new[] { typeof(bool), typeof(int), typeof(int), typeof(int), typeof(Action) },
|
||||
null);
|
||||
if (updateAgeGate == null)
|
||||
{
|
||||
throw new MissingMethodException("CommonPacket.SendUpdateAgeGateRequest(bool, int, int, int, Action) was not found");
|
||||
}
|
||||
Type loginUserResponse = FindType("Proto.Net.LoginUserResponse");
|
||||
MethodInfo needsAgeVerificationSetter = loginUserResponse?.GetProperty(
|
||||
"NeedsAgeVerification",
|
||||
BindingFlags.Instance | BindingFlags.Public)?.GetSetMethod();
|
||||
if (needsAgeVerificationSetter == null)
|
||||
{
|
||||
throw new MissingMethodException("LoginUserResponse.NeedsAgeVerification setter was not found");
|
||||
}
|
||||
harmony.Patch(
|
||||
updateAgeGate,
|
||||
prefix: new HarmonyMethod(typeof(Plugin), nameof(UpdateAgeGateRequestPrefix)));
|
||||
harmony.Patch(
|
||||
needsAgeVerificationSetter,
|
||||
prefix: new HarmonyMethod(typeof(Plugin), nameof(NeedsAgeVerificationSetterPrefix)));
|
||||
|
||||
Log?.LogInfo("Local age-gate confirmation persistence active (confirmed=" + AgeGateState.IsConfirmed() + ")");
|
||||
}
|
||||
|
||||
private static void NeedsAgeVerificationSetterPrefix(ref bool value)
|
||||
{
|
||||
if (value && AgeGateState.IsConfirmed())
|
||||
{
|
||||
value = false;
|
||||
Log?.LogInfo("Used persisted local age-gate confirmation for LoginUser");
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateAgeGateRequestPrefix(ref Action __4)
|
||||
{
|
||||
// CommonPacket invokes this callback only after it has parsed the
|
||||
// empty UpdateAgeGateResponse and accepted errorType == 0. Wrapping
|
||||
// it therefore never records failed/cancelled submissions.
|
||||
Action continuation = __4;
|
||||
__4 = delegate
|
||||
{
|
||||
try
|
||||
{
|
||||
AgeGateState.MarkConfirmed();
|
||||
Log?.LogInfo("Stored successful local age-gate confirmation");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Preserve the original continuation: inability to persist
|
||||
// should not break a successfully completed first login.
|
||||
Log?.LogWarning("Could not persist local age-gate confirmation: " + ex.Message);
|
||||
}
|
||||
continuation?.Invoke();
|
||||
};
|
||||
}
|
||||
|
||||
private static void InstallLocalPurchaseBypass(Harmony harmony)
|
||||
{
|
||||
Type platformRuler = FindType("ὧὩὬὦὤὥὦὢὤὠὡ");
|
||||
MethodInfo getProducts = platformRuler?
|
||||
.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
|
||||
.FirstOrDefault(method =>
|
||||
method.Name == "ὮὨὡὩὠὭὧὦὭὥὮ" &&
|
||||
method.GetParameters().Length == 6 &&
|
||||
method.GetParameters()[0].ParameterType == typeof(string[]) &&
|
||||
method.GetParameters()[3].ParameterType == typeof(Action) &&
|
||||
method.GetParameters()[4].ParameterType == typeof(Action<int, int, string>) &&
|
||||
method.GetParameters()[5].ParameterType == typeof(bool));
|
||||
if (getProducts == null)
|
||||
{
|
||||
throw new MissingMethodException("PlatformRuler.GetProductAsync was not found");
|
||||
}
|
||||
harmony.Patch(
|
||||
getProducts,
|
||||
prefix: new HarmonyMethod(typeof(Plugin), nameof(GetProductsPrefix)));
|
||||
|
||||
Type platformManager = FindType("gamfs.Platform.PlatformManager");
|
||||
MethodInfo purchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
|
||||
.FirstOrDefault(method => method.Name == "Purchase" &&
|
||||
method.GetParameters().Length == 3 &&
|
||||
method.GetParameters()[0].ParameterType == typeof(string) &&
|
||||
method.GetParameters()[2].ParameterType == typeof(Action));
|
||||
MethodInfo finishPurchase = platformManager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
|
||||
.FirstOrDefault(method => method.Name == "FinishPurchase" &&
|
||||
method.GetParameters().Length == 2 &&
|
||||
method.GetParameters()[0].ParameterType == typeof(string) &&
|
||||
method.GetParameters()[1].ParameterType == typeof(long));
|
||||
if (purchase == null || finishPurchase == null)
|
||||
throw new MissingMethodException("PlatformManager purchase methods were not found");
|
||||
harmony.Patch(purchase, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(LocalPurchasePrefix)));
|
||||
harmony.Patch(finishPurchase, prefix: new HarmonyMethod(
|
||||
typeof(Plugin), nameof(FinishLocalPurchasePrefix)));
|
||||
Log?.LogInfo("Local purchase price lookup disabled");
|
||||
Log?.LogInfo("Infinite reroll confirmation is local/free; all other paid purchases are blocked");
|
||||
}
|
||||
|
||||
private static bool GetProductsPrefix(Action __3)
|
||||
{
|
||||
// A private server has no Neon/GPG commerce identity. Treat price
|
||||
// prefetch as complete so startup can continue without contacting the
|
||||
// production payment API. No purchase result or currency is forged.
|
||||
__3?.Invoke();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LocalPurchasePrefix(string __0, object __1, Action __2)
|
||||
{
|
||||
// Product 9100033 is the infinite-reroll confirmation. The
|
||||
// local server grants the last preview through CashShopBuy without
|
||||
// contacting Neon/GPG. No other real-money product is authorized.
|
||||
if (__0 == "brd2_limited_pack_660" || __0 == "brd2_limited_pack_660_ios")
|
||||
{
|
||||
Type purchaseData = FindType("ὦὯὢὡὥὨὦὯὤὯὩ");
|
||||
object result = Activator.CreateInstance(purchaseData, new object[]
|
||||
{
|
||||
__0,
|
||||
1L,
|
||||
"bd2-local-free-infinite",
|
||||
"bd2-local-free-receipt"
|
||||
});
|
||||
Log?.LogInfo("Approved local/free infinite-reroll confirmation");
|
||||
(__1 as Delegate)?.DynamicInvoke(result);
|
||||
return false;
|
||||
}
|
||||
Log?.LogWarning("Blocked unsupported paid product: " + (__0 ?? "<null>"));
|
||||
__2?.Invoke();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool FinishLocalPurchasePrefix(string __0)
|
||||
{
|
||||
if (__0 == "bd2-local-free-infinite")
|
||||
{
|
||||
Log?.LogInfo("Finished local/free infinite-reroll confirmation");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void InstallDatabaseDiagnostics(Harmony harmony)
|
||||
{
|
||||
Type rawDataManager = FindType("RawDataManager");
|
||||
MethodInfo dbLoad = rawDataManager?.GetMethod(
|
||||
"DBLoad",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
if (dbLoad == null)
|
||||
{
|
||||
throw new MissingMethodException("RawDataManager.DBLoad was not found");
|
||||
}
|
||||
|
||||
harmony.Patch(
|
||||
dbLoad,
|
||||
prefix: new HarmonyMethod(typeof(Plugin), nameof(DBLoadPrefix)));
|
||||
|
||||
Type clientLocalInfo = FindType("Proto.Local.ClientLocalInfo");
|
||||
MethodInfo loadDB = clientLocalInfo?.GetMethod(
|
||||
"LoadDB",
|
||||
BindingFlags.Static | BindingFlags.Public);
|
||||
if (loadDB == null)
|
||||
{
|
||||
throw new MissingMethodException("ClientLocalInfo.LoadDB was not found");
|
||||
}
|
||||
|
||||
harmony.Patch(
|
||||
loadDB,
|
||||
prefix: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadPrefix)),
|
||||
postfix: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadPostfix)),
|
||||
finalizer: new HarmonyMethod(typeof(Plugin), nameof(ClientLocalLoadFinalizer)));
|
||||
|
||||
Log?.LogInfo("Database diagnostics active");
|
||||
}
|
||||
|
||||
private static void DBLoadPrefix(object __0, ref Action __1)
|
||||
{
|
||||
string dbName = ReadFirstStringField(__0) ?? "<unknown>";
|
||||
Action original = __1;
|
||||
Stopwatch elapsed = Stopwatch.StartNew();
|
||||
Log?.LogInfo("DBLoad start: " + dbName);
|
||||
__1 = delegate
|
||||
{
|
||||
Log?.LogInfo("DBLoad callback enter: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)");
|
||||
try
|
||||
{
|
||||
original?.Invoke();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log?.LogInfo("DBLoad callback exit: " + dbName + " (" + elapsed.ElapsedMilliseconds + " ms)");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void ClientLocalLoadPrefix(out Stopwatch __state)
|
||||
{
|
||||
__state = Stopwatch.StartNew();
|
||||
Log?.LogInfo("ClientLocalInfo.LoadDB enter");
|
||||
}
|
||||
|
||||
private static void ClientLocalLoadPostfix(Stopwatch __state)
|
||||
{
|
||||
Log?.LogInfo("ClientLocalInfo.LoadDB exit (" + __state.ElapsedMilliseconds + " ms)");
|
||||
}
|
||||
|
||||
private static Exception ClientLocalLoadFinalizer(Exception __exception, Stopwatch __state)
|
||||
{
|
||||
if (__exception != null)
|
||||
{
|
||||
Log?.LogError(
|
||||
"ClientLocalInfo.LoadDB threw after " + __state.ElapsedMilliseconds + " ms: " + __exception);
|
||||
}
|
||||
return __exception;
|
||||
}
|
||||
|
||||
private static string ReadFirstStringField(object value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
FieldInfo field = value.GetType()
|
||||
.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
|
||||
.FirstOrDefault(candidate => candidate.FieldType == typeof(string));
|
||||
return field?.GetValue(value) as string;
|
||||
}
|
||||
|
||||
internal static Type FindType(string name)
|
||||
{
|
||||
Assembly assembly = Assembly.Load("Assembly-CSharp");
|
||||
Type type = assembly?.GetType(name);
|
||||
if (type != null)
|
||||
{
|
||||
return type;
|
||||
}
|
||||
foreach (Assembly loaded in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
type = loaded.GetType(name);
|
||||
if (type != null)
|
||||
{
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Bd2LocalIdentity;
|
||||
|
||||
// Time zones describe a representative territory, not a player's physical location.
|
||||
// Windows mappings: Unicode CLDR release-48 windowsZones.xml (territory 001).
|
||||
// https://github.com/unicode-org/cldr/blob/release-48/common/supplemental/windowsZones.xml
|
||||
// CLDR data copyright Unicode, Inc.; Unicode License v3: https://www.unicode.org/license.txt
|
||||
// IANA mappings and aliases: tzdata 2025b zone.tab / backward (public-domain data).
|
||||
// https://github.com/eggert/tz/tree/2025b
|
||||
// UTC, offset-only zones and unknown identifiers intentionally have no territory.
|
||||
internal static class SystemTimeZoneCountries
|
||||
{
|
||||
private static readonly Dictionary<string, string> Countries = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
{ "AUS Central Standard Time", "AU" },
|
||||
{ "AUS Eastern Standard Time", "AU" },
|
||||
{ "Afghanistan Standard Time", "AF" },
|
||||
{ "Africa/Abidjan", "CI" },
|
||||
{ "Africa/Accra", "GH" },
|
||||
{ "Africa/Addis_Ababa", "ET" },
|
||||
{ "Africa/Algiers", "DZ" },
|
||||
{ "Africa/Asmara", "ER" },
|
||||
{ "Africa/Asmera", "KE" },
|
||||
{ "Africa/Bamako", "ML" },
|
||||
{ "Africa/Bangui", "CF" },
|
||||
{ "Africa/Banjul", "GM" },
|
||||
{ "Africa/Bissau", "GW" },
|
||||
{ "Africa/Blantyre", "MW" },
|
||||
{ "Africa/Brazzaville", "CG" },
|
||||
{ "Africa/Bujumbura", "BI" },
|
||||
{ "Africa/Cairo", "EG" },
|
||||
{ "Africa/Casablanca", "MA" },
|
||||
{ "Africa/Ceuta", "ES" },
|
||||
{ "Africa/Conakry", "GN" },
|
||||
{ "Africa/Dakar", "SN" },
|
||||
{ "Africa/Dar_es_Salaam", "TZ" },
|
||||
{ "Africa/Djibouti", "DJ" },
|
||||
{ "Africa/Douala", "CM" },
|
||||
{ "Africa/El_Aaiun", "EH" },
|
||||
{ "Africa/Freetown", "SL" },
|
||||
{ "Africa/Gaborone", "BW" },
|
||||
{ "Africa/Harare", "ZW" },
|
||||
{ "Africa/Johannesburg", "ZA" },
|
||||
{ "Africa/Juba", "SS" },
|
||||
{ "Africa/Kampala", "UG" },
|
||||
{ "Africa/Khartoum", "SD" },
|
||||
{ "Africa/Kigali", "RW" },
|
||||
{ "Africa/Kinshasa", "CD" },
|
||||
{ "Africa/Lagos", "NG" },
|
||||
{ "Africa/Libreville", "GA" },
|
||||
{ "Africa/Lome", "TG" },
|
||||
{ "Africa/Luanda", "AO" },
|
||||
{ "Africa/Lubumbashi", "CD" },
|
||||
{ "Africa/Lusaka", "ZM" },
|
||||
{ "Africa/Malabo", "GQ" },
|
||||
{ "Africa/Maputo", "MZ" },
|
||||
{ "Africa/Maseru", "LS" },
|
||||
{ "Africa/Mbabane", "SZ" },
|
||||
{ "Africa/Mogadishu", "SO" },
|
||||
{ "Africa/Monrovia", "LR" },
|
||||
{ "Africa/Nairobi", "KE" },
|
||||
{ "Africa/Ndjamena", "TD" },
|
||||
{ "Africa/Niamey", "NE" },
|
||||
{ "Africa/Nouakchott", "MR" },
|
||||
{ "Africa/Ouagadougou", "BF" },
|
||||
{ "Africa/Porto-Novo", "BJ" },
|
||||
{ "Africa/Sao_Tome", "ST" },
|
||||
{ "Africa/Timbuktu", "CI" },
|
||||
{ "Africa/Tripoli", "LY" },
|
||||
{ "Africa/Tunis", "TN" },
|
||||
{ "Africa/Windhoek", "NA" },
|
||||
{ "Alaskan Standard Time", "US" },
|
||||
{ "Aleutian Standard Time", "US" },
|
||||
{ "Altai Standard Time", "RU" },
|
||||
{ "America/Adak", "US" },
|
||||
{ "America/Anchorage", "US" },
|
||||
{ "America/Anguilla", "AI" },
|
||||
{ "America/Antigua", "AG" },
|
||||
{ "America/Araguaina", "BR" },
|
||||
{ "America/Argentina/Buenos_Aires", "AR" },
|
||||
{ "America/Argentina/Catamarca", "AR" },
|
||||
{ "America/Argentina/ComodRivadavia", "AR" },
|
||||
{ "America/Argentina/Cordoba", "AR" },
|
||||
{ "America/Argentina/Jujuy", "AR" },
|
||||
{ "America/Argentina/La_Rioja", "AR" },
|
||||
{ "America/Argentina/Mendoza", "AR" },
|
||||
{ "America/Argentina/Rio_Gallegos", "AR" },
|
||||
{ "America/Argentina/Salta", "AR" },
|
||||
{ "America/Argentina/San_Juan", "AR" },
|
||||
{ "America/Argentina/San_Luis", "AR" },
|
||||
{ "America/Argentina/Tucuman", "AR" },
|
||||
{ "America/Argentina/Ushuaia", "AR" },
|
||||
{ "America/Aruba", "AW" },
|
||||
{ "America/Asuncion", "PY" },
|
||||
{ "America/Atikokan", "CA" },
|
||||
{ "America/Atka", "US" },
|
||||
{ "America/Bahia", "BR" },
|
||||
{ "America/Bahia_Banderas", "MX" },
|
||||
{ "America/Barbados", "BB" },
|
||||
{ "America/Belem", "BR" },
|
||||
{ "America/Belize", "BZ" },
|
||||
{ "America/Blanc-Sablon", "CA" },
|
||||
{ "America/Boa_Vista", "BR" },
|
||||
{ "America/Bogota", "CO" },
|
||||
{ "America/Boise", "US" },
|
||||
{ "America/Buenos_Aires", "AR" },
|
||||
{ "America/Cambridge_Bay", "CA" },
|
||||
{ "America/Campo_Grande", "BR" },
|
||||
{ "America/Cancun", "MX" },
|
||||
{ "America/Caracas", "VE" },
|
||||
{ "America/Catamarca", "AR" },
|
||||
{ "America/Cayenne", "GF" },
|
||||
{ "America/Cayman", "KY" },
|
||||
{ "America/Chicago", "US" },
|
||||
{ "America/Chihuahua", "MX" },
|
||||
{ "America/Ciudad_Juarez", "MX" },
|
||||
{ "America/Coral_Harbour", "PA" },
|
||||
{ "America/Cordoba", "AR" },
|
||||
{ "America/Costa_Rica", "CR" },
|
||||
{ "America/Coyhaique", "CL" },
|
||||
{ "America/Creston", "CA" },
|
||||
{ "America/Cuiaba", "BR" },
|
||||
{ "America/Curacao", "CW" },
|
||||
{ "America/Danmarkshavn", "GL" },
|
||||
{ "America/Dawson", "CA" },
|
||||
{ "America/Dawson_Creek", "CA" },
|
||||
{ "America/Denver", "US" },
|
||||
{ "America/Detroit", "US" },
|
||||
{ "America/Dominica", "DM" },
|
||||
{ "America/Edmonton", "CA" },
|
||||
{ "America/Eirunepe", "BR" },
|
||||
{ "America/El_Salvador", "SV" },
|
||||
{ "America/Ensenada", "MX" },
|
||||
{ "America/Fort_Nelson", "CA" },
|
||||
{ "America/Fort_Wayne", "US" },
|
||||
{ "America/Fortaleza", "BR" },
|
||||
{ "America/Glace_Bay", "CA" },
|
||||
{ "America/Godthab", "GL" },
|
||||
{ "America/Goose_Bay", "CA" },
|
||||
{ "America/Grand_Turk", "TC" },
|
||||
{ "America/Grenada", "GD" },
|
||||
{ "America/Guadeloupe", "GP" },
|
||||
{ "America/Guatemala", "GT" },
|
||||
{ "America/Guayaquil", "EC" },
|
||||
{ "America/Guyana", "GY" },
|
||||
{ "America/Halifax", "CA" },
|
||||
{ "America/Havana", "CU" },
|
||||
{ "America/Hermosillo", "MX" },
|
||||
{ "America/Indiana/Indianapolis", "US" },
|
||||
{ "America/Indiana/Knox", "US" },
|
||||
{ "America/Indiana/Marengo", "US" },
|
||||
{ "America/Indiana/Petersburg", "US" },
|
||||
{ "America/Indiana/Tell_City", "US" },
|
||||
{ "America/Indiana/Vevay", "US" },
|
||||
{ "America/Indiana/Vincennes", "US" },
|
||||
{ "America/Indiana/Winamac", "US" },
|
||||
{ "America/Indianapolis", "US" },
|
||||
{ "America/Inuvik", "CA" },
|
||||
{ "America/Iqaluit", "CA" },
|
||||
{ "America/Jamaica", "JM" },
|
||||
{ "America/Jujuy", "AR" },
|
||||
{ "America/Juneau", "US" },
|
||||
{ "America/Kentucky/Louisville", "US" },
|
||||
{ "America/Kentucky/Monticello", "US" },
|
||||
{ "America/Knox_IN", "US" },
|
||||
{ "America/Kralendijk", "BQ" },
|
||||
{ "America/La_Paz", "BO" },
|
||||
{ "America/Lima", "PE" },
|
||||
{ "America/Los_Angeles", "US" },
|
||||
{ "America/Louisville", "US" },
|
||||
{ "America/Lower_Princes", "SX" },
|
||||
{ "America/Maceio", "BR" },
|
||||
{ "America/Managua", "NI" },
|
||||
{ "America/Manaus", "BR" },
|
||||
{ "America/Marigot", "MF" },
|
||||
{ "America/Martinique", "MQ" },
|
||||
{ "America/Matamoros", "MX" },
|
||||
{ "America/Mazatlan", "MX" },
|
||||
{ "America/Mendoza", "AR" },
|
||||
{ "America/Menominee", "US" },
|
||||
{ "America/Merida", "MX" },
|
||||
{ "America/Metlakatla", "US" },
|
||||
{ "America/Mexico_City", "MX" },
|
||||
{ "America/Miquelon", "PM" },
|
||||
{ "America/Moncton", "CA" },
|
||||
{ "America/Monterrey", "MX" },
|
||||
{ "America/Montevideo", "UY" },
|
||||
{ "America/Montreal", "CA" },
|
||||
{ "America/Montserrat", "MS" },
|
||||
{ "America/Nassau", "BS" },
|
||||
{ "America/New_York", "US" },
|
||||
{ "America/Nipigon", "CA" },
|
||||
{ "America/Nome", "US" },
|
||||
{ "America/Noronha", "BR" },
|
||||
{ "America/North_Dakota/Beulah", "US" },
|
||||
{ "America/North_Dakota/Center", "US" },
|
||||
{ "America/North_Dakota/New_Salem", "US" },
|
||||
{ "America/Nuuk", "GL" },
|
||||
{ "America/Ojinaga", "MX" },
|
||||
{ "America/Panama", "PA" },
|
||||
{ "America/Pangnirtung", "CA" },
|
||||
{ "America/Paramaribo", "SR" },
|
||||
{ "America/Phoenix", "US" },
|
||||
{ "America/Port-au-Prince", "HT" },
|
||||
{ "America/Port_of_Spain", "TT" },
|
||||
{ "America/Porto_Acre", "BR" },
|
||||
{ "America/Porto_Velho", "BR" },
|
||||
{ "America/Puerto_Rico", "PR" },
|
||||
{ "America/Punta_Arenas", "CL" },
|
||||
{ "America/Rainy_River", "CA" },
|
||||
{ "America/Rankin_Inlet", "CA" },
|
||||
{ "America/Recife", "BR" },
|
||||
{ "America/Regina", "CA" },
|
||||
{ "America/Resolute", "CA" },
|
||||
{ "America/Rio_Branco", "BR" },
|
||||
{ "America/Rosario", "AR" },
|
||||
{ "America/Santa_Isabel", "MX" },
|
||||
{ "America/Santarem", "BR" },
|
||||
{ "America/Santiago", "CL" },
|
||||
{ "America/Santo_Domingo", "DO" },
|
||||
{ "America/Sao_Paulo", "BR" },
|
||||
{ "America/Scoresbysund", "GL" },
|
||||
{ "America/Shiprock", "US" },
|
||||
{ "America/Sitka", "US" },
|
||||
{ "America/St_Barthelemy", "BL" },
|
||||
{ "America/St_Johns", "CA" },
|
||||
{ "America/St_Kitts", "KN" },
|
||||
{ "America/St_Lucia", "LC" },
|
||||
{ "America/St_Thomas", "VI" },
|
||||
{ "America/St_Vincent", "VC" },
|
||||
{ "America/Swift_Current", "CA" },
|
||||
{ "America/Tegucigalpa", "HN" },
|
||||
{ "America/Thule", "GL" },
|
||||
{ "America/Thunder_Bay", "CA" },
|
||||
{ "America/Tijuana", "MX" },
|
||||
{ "America/Toronto", "CA" },
|
||||
{ "America/Tortola", "VG" },
|
||||
{ "America/Vancouver", "CA" },
|
||||
{ "America/Virgin", "PR" },
|
||||
{ "America/Whitehorse", "CA" },
|
||||
{ "America/Winnipeg", "CA" },
|
||||
{ "America/Yakutat", "US" },
|
||||
{ "America/Yellowknife", "CA" },
|
||||
{ "Antarctica/Casey", "AQ" },
|
||||
{ "Antarctica/Davis", "AQ" },
|
||||
{ "Antarctica/DumontDUrville", "AQ" },
|
||||
{ "Antarctica/Macquarie", "AU" },
|
||||
{ "Antarctica/Mawson", "AQ" },
|
||||
{ "Antarctica/McMurdo", "AQ" },
|
||||
{ "Antarctica/Palmer", "AQ" },
|
||||
{ "Antarctica/Rothera", "AQ" },
|
||||
{ "Antarctica/South_Pole", "NZ" },
|
||||
{ "Antarctica/Syowa", "AQ" },
|
||||
{ "Antarctica/Troll", "AQ" },
|
||||
{ "Antarctica/Vostok", "AQ" },
|
||||
{ "Arab Standard Time", "SA" },
|
||||
{ "Arabian Standard Time", "AE" },
|
||||
{ "Arabic Standard Time", "IQ" },
|
||||
{ "Arctic/Longyearbyen", "SJ" },
|
||||
{ "Argentina Standard Time", "AR" },
|
||||
{ "Asia/Aden", "YE" },
|
||||
{ "Asia/Almaty", "KZ" },
|
||||
{ "Asia/Amman", "JO" },
|
||||
{ "Asia/Anadyr", "RU" },
|
||||
{ "Asia/Aqtau", "KZ" },
|
||||
{ "Asia/Aqtobe", "KZ" },
|
||||
{ "Asia/Ashgabat", "TM" },
|
||||
{ "Asia/Ashkhabad", "TM" },
|
||||
{ "Asia/Atyrau", "KZ" },
|
||||
{ "Asia/Baghdad", "IQ" },
|
||||
{ "Asia/Bahrain", "BH" },
|
||||
{ "Asia/Baku", "AZ" },
|
||||
{ "Asia/Bangkok", "TH" },
|
||||
{ "Asia/Barnaul", "RU" },
|
||||
{ "Asia/Beirut", "LB" },
|
||||
{ "Asia/Bishkek", "KG" },
|
||||
{ "Asia/Brunei", "BN" },
|
||||
{ "Asia/Calcutta", "IN" },
|
||||
{ "Asia/Chita", "RU" },
|
||||
{ "Asia/Choibalsan", "MN" },
|
||||
{ "Asia/Chongqing", "CN" },
|
||||
{ "Asia/Chungking", "CN" },
|
||||
{ "Asia/Colombo", "LK" },
|
||||
{ "Asia/Dacca", "BD" },
|
||||
{ "Asia/Damascus", "SY" },
|
||||
{ "Asia/Dhaka", "BD" },
|
||||
{ "Asia/Dili", "TL" },
|
||||
{ "Asia/Dubai", "AE" },
|
||||
{ "Asia/Dushanbe", "TJ" },
|
||||
{ "Asia/Famagusta", "CY" },
|
||||
{ "Asia/Gaza", "PS" },
|
||||
{ "Asia/Harbin", "CN" },
|
||||
{ "Asia/Hebron", "PS" },
|
||||
{ "Asia/Ho_Chi_Minh", "VN" },
|
||||
{ "Asia/Hong_Kong", "HK" },
|
||||
{ "Asia/Hovd", "MN" },
|
||||
{ "Asia/Irkutsk", "RU" },
|
||||
{ "Asia/Istanbul", "TR" },
|
||||
{ "Asia/Jakarta", "ID" },
|
||||
{ "Asia/Jayapura", "ID" },
|
||||
{ "Asia/Jerusalem", "IL" },
|
||||
{ "Asia/Kabul", "AF" },
|
||||
{ "Asia/Kamchatka", "RU" },
|
||||
{ "Asia/Karachi", "PK" },
|
||||
{ "Asia/Kashgar", "CN" },
|
||||
{ "Asia/Kathmandu", "NP" },
|
||||
{ "Asia/Katmandu", "NP" },
|
||||
{ "Asia/Khandyga", "RU" },
|
||||
{ "Asia/Kolkata", "IN" },
|
||||
{ "Asia/Krasnoyarsk", "RU" },
|
||||
{ "Asia/Kuala_Lumpur", "MY" },
|
||||
{ "Asia/Kuching", "MY" },
|
||||
{ "Asia/Kuwait", "KW" },
|
||||
{ "Asia/Macao", "MO" },
|
||||
{ "Asia/Macau", "MO" },
|
||||
{ "Asia/Magadan", "RU" },
|
||||
{ "Asia/Makassar", "ID" },
|
||||
{ "Asia/Manila", "PH" },
|
||||
{ "Asia/Muscat", "OM" },
|
||||
{ "Asia/Nicosia", "CY" },
|
||||
{ "Asia/Novokuznetsk", "RU" },
|
||||
{ "Asia/Novosibirsk", "RU" },
|
||||
{ "Asia/Omsk", "RU" },
|
||||
{ "Asia/Oral", "KZ" },
|
||||
{ "Asia/Phnom_Penh", "KH" },
|
||||
{ "Asia/Pontianak", "ID" },
|
||||
{ "Asia/Pyongyang", "KP" },
|
||||
{ "Asia/Qatar", "QA" },
|
||||
{ "Asia/Qostanay", "KZ" },
|
||||
{ "Asia/Qyzylorda", "KZ" },
|
||||
{ "Asia/Rangoon", "MM" },
|
||||
{ "Asia/Riyadh", "SA" },
|
||||
{ "Asia/Saigon", "VN" },
|
||||
{ "Asia/Sakhalin", "RU" },
|
||||
{ "Asia/Samarkand", "UZ" },
|
||||
{ "Asia/Seoul", "KR" },
|
||||
{ "Asia/Shanghai", "CN" },
|
||||
{ "Asia/Singapore", "SG" },
|
||||
{ "Asia/Srednekolymsk", "RU" },
|
||||
{ "Asia/Taipei", "TW" },
|
||||
{ "Asia/Tashkent", "UZ" },
|
||||
{ "Asia/Tbilisi", "GE" },
|
||||
{ "Asia/Tehran", "IR" },
|
||||
{ "Asia/Tel_Aviv", "IL" },
|
||||
{ "Asia/Thimbu", "BT" },
|
||||
{ "Asia/Thimphu", "BT" },
|
||||
{ "Asia/Tokyo", "JP" },
|
||||
{ "Asia/Tomsk", "RU" },
|
||||
{ "Asia/Ujung_Pandang", "ID" },
|
||||
{ "Asia/Ulaanbaatar", "MN" },
|
||||
{ "Asia/Ulan_Bator", "MN" },
|
||||
{ "Asia/Urumqi", "CN" },
|
||||
{ "Asia/Ust-Nera", "RU" },
|
||||
{ "Asia/Vientiane", "LA" },
|
||||
{ "Asia/Vladivostok", "RU" },
|
||||
{ "Asia/Yakutsk", "RU" },
|
||||
{ "Asia/Yangon", "MM" },
|
||||
{ "Asia/Yekaterinburg", "RU" },
|
||||
{ "Asia/Yerevan", "AM" },
|
||||
{ "Astrakhan Standard Time", "RU" },
|
||||
{ "Atlantic Standard Time", "CA" },
|
||||
{ "Atlantic/Azores", "PT" },
|
||||
{ "Atlantic/Bermuda", "BM" },
|
||||
{ "Atlantic/Canary", "ES" },
|
||||
{ "Atlantic/Cape_Verde", "CV" },
|
||||
{ "Atlantic/Faeroe", "FO" },
|
||||
{ "Atlantic/Faroe", "FO" },
|
||||
{ "Atlantic/Jan_Mayen", "DE" },
|
||||
{ "Atlantic/Madeira", "PT" },
|
||||
{ "Atlantic/Reykjavik", "IS" },
|
||||
{ "Atlantic/South_Georgia", "GS" },
|
||||
{ "Atlantic/St_Helena", "SH" },
|
||||
{ "Atlantic/Stanley", "FK" },
|
||||
{ "Aus Central W. Standard Time", "AU" },
|
||||
{ "Australia/ACT", "AU" },
|
||||
{ "Australia/Adelaide", "AU" },
|
||||
{ "Australia/Brisbane", "AU" },
|
||||
{ "Australia/Broken_Hill", "AU" },
|
||||
{ "Australia/Canberra", "AU" },
|
||||
{ "Australia/Currie", "AU" },
|
||||
{ "Australia/Darwin", "AU" },
|
||||
{ "Australia/Eucla", "AU" },
|
||||
{ "Australia/Hobart", "AU" },
|
||||
{ "Australia/LHI", "AU" },
|
||||
{ "Australia/Lindeman", "AU" },
|
||||
{ "Australia/Lord_Howe", "AU" },
|
||||
{ "Australia/Melbourne", "AU" },
|
||||
{ "Australia/NSW", "AU" },
|
||||
{ "Australia/North", "AU" },
|
||||
{ "Australia/Perth", "AU" },
|
||||
{ "Australia/Queensland", "AU" },
|
||||
{ "Australia/South", "AU" },
|
||||
{ "Australia/Sydney", "AU" },
|
||||
{ "Australia/Tasmania", "AU" },
|
||||
{ "Australia/Victoria", "AU" },
|
||||
{ "Australia/West", "AU" },
|
||||
{ "Australia/Yancowinna", "AU" },
|
||||
{ "Azerbaijan Standard Time", "AZ" },
|
||||
{ "Azores Standard Time", "PT" },
|
||||
{ "Bahia Standard Time", "BR" },
|
||||
{ "Bangladesh Standard Time", "BD" },
|
||||
{ "Belarus Standard Time", "BY" },
|
||||
{ "Bougainville Standard Time", "PG" },
|
||||
{ "Brazil/Acre", "BR" },
|
||||
{ "Brazil/DeNoronha", "BR" },
|
||||
{ "Brazil/East", "BR" },
|
||||
{ "Brazil/West", "BR" },
|
||||
{ "CET", "BE" },
|
||||
{ "CST6CDT", "US" },
|
||||
{ "Canada Central Standard Time", "CA" },
|
||||
{ "Canada/Atlantic", "CA" },
|
||||
{ "Canada/Central", "CA" },
|
||||
{ "Canada/Eastern", "CA" },
|
||||
{ "Canada/Mountain", "CA" },
|
||||
{ "Canada/Newfoundland", "CA" },
|
||||
{ "Canada/Pacific", "CA" },
|
||||
{ "Canada/Saskatchewan", "CA" },
|
||||
{ "Canada/Yukon", "CA" },
|
||||
{ "Cape Verde Standard Time", "CV" },
|
||||
{ "Caucasus Standard Time", "AM" },
|
||||
{ "Cen. Australia Standard Time", "AU" },
|
||||
{ "Central America Standard Time", "GT" },
|
||||
{ "Central Asia Standard Time", "KG" },
|
||||
{ "Central Brazilian Standard Time", "BR" },
|
||||
{ "Central Europe Standard Time", "HU" },
|
||||
{ "Central European Standard Time", "PL" },
|
||||
{ "Central Pacific Standard Time", "SB" },
|
||||
{ "Central Standard Time", "US" },
|
||||
{ "Central Standard Time (Mexico)", "MX" },
|
||||
{ "Chatham Islands Standard Time", "NZ" },
|
||||
{ "Chile/Continental", "CL" },
|
||||
{ "Chile/EasterIsland", "CL" },
|
||||
{ "China Standard Time", "CN" },
|
||||
{ "Cuba", "CU" },
|
||||
{ "Cuba Standard Time", "CU" },
|
||||
{ "E. Africa Standard Time", "KE" },
|
||||
{ "E. Australia Standard Time", "AU" },
|
||||
{ "E. Europe Standard Time", "MD" },
|
||||
{ "E. South America Standard Time", "BR" },
|
||||
{ "EET", "GR" },
|
||||
{ "EST", "PA" },
|
||||
{ "EST5EDT", "US" },
|
||||
{ "Easter Island Standard Time", "CL" },
|
||||
{ "Eastern Standard Time", "US" },
|
||||
{ "Eastern Standard Time (Mexico)", "MX" },
|
||||
{ "Egypt", "EG" },
|
||||
{ "Egypt Standard Time", "EG" },
|
||||
{ "Eire", "IE" },
|
||||
{ "Ekaterinburg Standard Time", "RU" },
|
||||
{ "Europe/Amsterdam", "NL" },
|
||||
{ "Europe/Andorra", "AD" },
|
||||
{ "Europe/Astrakhan", "RU" },
|
||||
{ "Europe/Athens", "GR" },
|
||||
{ "Europe/Belfast", "GB" },
|
||||
{ "Europe/Belgrade", "RS" },
|
||||
{ "Europe/Berlin", "DE" },
|
||||
{ "Europe/Bratislava", "SK" },
|
||||
{ "Europe/Brussels", "BE" },
|
||||
{ "Europe/Bucharest", "RO" },
|
||||
{ "Europe/Budapest", "HU" },
|
||||
{ "Europe/Busingen", "DE" },
|
||||
{ "Europe/Chisinau", "MD" },
|
||||
{ "Europe/Copenhagen", "DK" },
|
||||
{ "Europe/Dublin", "IE" },
|
||||
{ "Europe/Gibraltar", "GI" },
|
||||
{ "Europe/Guernsey", "GG" },
|
||||
{ "Europe/Helsinki", "FI" },
|
||||
{ "Europe/Isle_of_Man", "IM" },
|
||||
{ "Europe/Istanbul", "TR" },
|
||||
{ "Europe/Jersey", "JE" },
|
||||
{ "Europe/Kaliningrad", "RU" },
|
||||
{ "Europe/Kiev", "UA" },
|
||||
{ "Europe/Kirov", "RU" },
|
||||
{ "Europe/Kyiv", "UA" },
|
||||
{ "Europe/Lisbon", "PT" },
|
||||
{ "Europe/Ljubljana", "SI" },
|
||||
{ "Europe/London", "GB" },
|
||||
{ "Europe/Luxembourg", "LU" },
|
||||
{ "Europe/Madrid", "ES" },
|
||||
{ "Europe/Malta", "MT" },
|
||||
{ "Europe/Mariehamn", "AX" },
|
||||
{ "Europe/Minsk", "BY" },
|
||||
{ "Europe/Monaco", "MC" },
|
||||
{ "Europe/Moscow", "RU" },
|
||||
{ "Europe/Nicosia", "CY" },
|
||||
{ "Europe/Oslo", "NO" },
|
||||
{ "Europe/Paris", "FR" },
|
||||
{ "Europe/Podgorica", "ME" },
|
||||
{ "Europe/Prague", "CZ" },
|
||||
{ "Europe/Riga", "LV" },
|
||||
{ "Europe/Rome", "IT" },
|
||||
{ "Europe/Samara", "RU" },
|
||||
{ "Europe/San_Marino", "SM" },
|
||||
{ "Europe/Sarajevo", "BA" },
|
||||
{ "Europe/Saratov", "RU" },
|
||||
{ "Europe/Simferopol", "UA" },
|
||||
{ "Europe/Skopje", "MK" },
|
||||
{ "Europe/Sofia", "BG" },
|
||||
{ "Europe/Stockholm", "SE" },
|
||||
{ "Europe/Tallinn", "EE" },
|
||||
{ "Europe/Tirane", "AL" },
|
||||
{ "Europe/Tiraspol", "MD" },
|
||||
{ "Europe/Ulyanovsk", "RU" },
|
||||
{ "Europe/Uzhgorod", "UA" },
|
||||
{ "Europe/Vaduz", "LI" },
|
||||
{ "Europe/Vatican", "VA" },
|
||||
{ "Europe/Vienna", "AT" },
|
||||
{ "Europe/Vilnius", "LT" },
|
||||
{ "Europe/Volgograd", "RU" },
|
||||
{ "Europe/Warsaw", "PL" },
|
||||
{ "Europe/Zagreb", "HR" },
|
||||
{ "Europe/Zaporozhye", "UA" },
|
||||
{ "Europe/Zurich", "CH" },
|
||||
{ "FLE Standard Time", "UA" },
|
||||
{ "Fiji Standard Time", "FJ" },
|
||||
{ "GB", "GB" },
|
||||
{ "GB-Eire", "GB" },
|
||||
{ "GMT Standard Time", "GB" },
|
||||
{ "GTB Standard Time", "RO" },
|
||||
{ "Georgian Standard Time", "GE" },
|
||||
{ "Greenland Standard Time", "GL" },
|
||||
{ "Greenwich Standard Time", "IS" },
|
||||
{ "HST", "US" },
|
||||
{ "Haiti Standard Time", "HT" },
|
||||
{ "Hawaiian Standard Time", "US" },
|
||||
{ "Hongkong", "HK" },
|
||||
{ "Iceland", "CI" },
|
||||
{ "India Standard Time", "IN" },
|
||||
{ "Indian/Antananarivo", "MG" },
|
||||
{ "Indian/Chagos", "IO" },
|
||||
{ "Indian/Christmas", "CX" },
|
||||
{ "Indian/Cocos", "CC" },
|
||||
{ "Indian/Comoro", "KM" },
|
||||
{ "Indian/Kerguelen", "TF" },
|
||||
{ "Indian/Mahe", "SC" },
|
||||
{ "Indian/Maldives", "MV" },
|
||||
{ "Indian/Mauritius", "MU" },
|
||||
{ "Indian/Mayotte", "YT" },
|
||||
{ "Indian/Reunion", "RE" },
|
||||
{ "Iran", "IR" },
|
||||
{ "Iran Standard Time", "IR" },
|
||||
{ "Israel", "IL" },
|
||||
{ "Israel Standard Time", "IL" },
|
||||
{ "Jamaica", "JM" },
|
||||
{ "Japan", "JP" },
|
||||
{ "Jordan Standard Time", "JO" },
|
||||
{ "Kaliningrad Standard Time", "RU" },
|
||||
{ "Korea Standard Time", "KR" },
|
||||
{ "Kwajalein", "MH" },
|
||||
{ "Libya", "LY" },
|
||||
{ "Libya Standard Time", "LY" },
|
||||
{ "Line Islands Standard Time", "KI" },
|
||||
{ "Lord Howe Standard Time", "AU" },
|
||||
{ "MET", "BE" },
|
||||
{ "MST", "US" },
|
||||
{ "MST7MDT", "US" },
|
||||
{ "Magadan Standard Time", "RU" },
|
||||
{ "Magallanes Standard Time", "CL" },
|
||||
{ "Marquesas Standard Time", "PF" },
|
||||
{ "Mauritius Standard Time", "MU" },
|
||||
{ "Mexico/BajaNorte", "MX" },
|
||||
{ "Mexico/BajaSur", "MX" },
|
||||
{ "Mexico/General", "MX" },
|
||||
{ "Middle East Standard Time", "LB" },
|
||||
{ "Montevideo Standard Time", "UY" },
|
||||
{ "Morocco Standard Time", "MA" },
|
||||
{ "Mountain Standard Time", "US" },
|
||||
{ "Mountain Standard Time (Mexico)", "MX" },
|
||||
{ "Myanmar Standard Time", "MM" },
|
||||
{ "N. Central Asia Standard Time", "RU" },
|
||||
{ "NZ", "NZ" },
|
||||
{ "NZ-CHAT", "NZ" },
|
||||
{ "Namibia Standard Time", "NA" },
|
||||
{ "Navajo", "US" },
|
||||
{ "Nepal Standard Time", "NP" },
|
||||
{ "New Zealand Standard Time", "NZ" },
|
||||
{ "Newfoundland Standard Time", "CA" },
|
||||
{ "Norfolk Standard Time", "NF" },
|
||||
{ "North Asia East Standard Time", "RU" },
|
||||
{ "North Asia Standard Time", "RU" },
|
||||
{ "North Korea Standard Time", "KP" },
|
||||
{ "Omsk Standard Time", "RU" },
|
||||
{ "PRC", "CN" },
|
||||
{ "PST8PDT", "US" },
|
||||
{ "Pacific SA Standard Time", "CL" },
|
||||
{ "Pacific Standard Time", "US" },
|
||||
{ "Pacific Standard Time (Mexico)", "MX" },
|
||||
{ "Pacific/Apia", "WS" },
|
||||
{ "Pacific/Auckland", "NZ" },
|
||||
{ "Pacific/Bougainville", "PG" },
|
||||
{ "Pacific/Chatham", "NZ" },
|
||||
{ "Pacific/Chuuk", "FM" },
|
||||
{ "Pacific/Easter", "CL" },
|
||||
{ "Pacific/Efate", "VU" },
|
||||
{ "Pacific/Enderbury", "KI" },
|
||||
{ "Pacific/Fakaofo", "TK" },
|
||||
{ "Pacific/Fiji", "FJ" },
|
||||
{ "Pacific/Funafuti", "TV" },
|
||||
{ "Pacific/Galapagos", "EC" },
|
||||
{ "Pacific/Gambier", "PF" },
|
||||
{ "Pacific/Guadalcanal", "SB" },
|
||||
{ "Pacific/Guam", "GU" },
|
||||
{ "Pacific/Honolulu", "US" },
|
||||
{ "Pacific/Johnston", "US" },
|
||||
{ "Pacific/Kanton", "KI" },
|
||||
{ "Pacific/Kiritimati", "KI" },
|
||||
{ "Pacific/Kosrae", "FM" },
|
||||
{ "Pacific/Kwajalein", "MH" },
|
||||
{ "Pacific/Majuro", "MH" },
|
||||
{ "Pacific/Marquesas", "PF" },
|
||||
{ "Pacific/Midway", "UM" },
|
||||
{ "Pacific/Nauru", "NR" },
|
||||
{ "Pacific/Niue", "NU" },
|
||||
{ "Pacific/Norfolk", "NF" },
|
||||
{ "Pacific/Noumea", "NC" },
|
||||
{ "Pacific/Pago_Pago", "AS" },
|
||||
{ "Pacific/Palau", "PW" },
|
||||
{ "Pacific/Pitcairn", "PN" },
|
||||
{ "Pacific/Pohnpei", "FM" },
|
||||
{ "Pacific/Ponape", "SB" },
|
||||
{ "Pacific/Port_Moresby", "PG" },
|
||||
{ "Pacific/Rarotonga", "CK" },
|
||||
{ "Pacific/Saipan", "MP" },
|
||||
{ "Pacific/Samoa", "AS" },
|
||||
{ "Pacific/Tahiti", "PF" },
|
||||
{ "Pacific/Tarawa", "KI" },
|
||||
{ "Pacific/Tongatapu", "TO" },
|
||||
{ "Pacific/Truk", "PG" },
|
||||
{ "Pacific/Wake", "UM" },
|
||||
{ "Pacific/Wallis", "WF" },
|
||||
{ "Pacific/Yap", "PG" },
|
||||
{ "Pakistan Standard Time", "PK" },
|
||||
{ "Paraguay Standard Time", "PY" },
|
||||
{ "Poland", "PL" },
|
||||
{ "Portugal", "PT" },
|
||||
{ "Qyzylorda Standard Time", "KZ" },
|
||||
{ "ROC", "TW" },
|
||||
{ "ROK", "KR" },
|
||||
{ "Romance Standard Time", "FR" },
|
||||
{ "Russia Time Zone 10", "RU" },
|
||||
{ "Russia Time Zone 11", "RU" },
|
||||
{ "Russia Time Zone 3", "RU" },
|
||||
{ "Russian Standard Time", "RU" },
|
||||
{ "SA Eastern Standard Time", "GF" },
|
||||
{ "SA Pacific Standard Time", "CO" },
|
||||
{ "SA Western Standard Time", "BO" },
|
||||
{ "SE Asia Standard Time", "TH" },
|
||||
{ "Saint Pierre Standard Time", "PM" },
|
||||
{ "Sakhalin Standard Time", "RU" },
|
||||
{ "Samoa Standard Time", "WS" },
|
||||
{ "Sao Tome Standard Time", "ST" },
|
||||
{ "Saratov Standard Time", "RU" },
|
||||
{ "Singapore", "SG" },
|
||||
{ "Singapore Standard Time", "SG" },
|
||||
{ "South Africa Standard Time", "ZA" },
|
||||
{ "South Sudan Standard Time", "SS" },
|
||||
{ "Sri Lanka Standard Time", "LK" },
|
||||
{ "Sudan Standard Time", "SD" },
|
||||
{ "Syria Standard Time", "SY" },
|
||||
{ "Taipei Standard Time", "TW" },
|
||||
{ "Tasmania Standard Time", "AU" },
|
||||
{ "Tocantins Standard Time", "BR" },
|
||||
{ "Tokyo Standard Time", "JP" },
|
||||
{ "Tomsk Standard Time", "RU" },
|
||||
{ "Tonga Standard Time", "TO" },
|
||||
{ "Transbaikal Standard Time", "RU" },
|
||||
{ "Turkey", "TR" },
|
||||
{ "Turkey Standard Time", "TR" },
|
||||
{ "Turks And Caicos Standard Time", "TC" },
|
||||
{ "US Eastern Standard Time", "US" },
|
||||
{ "US Mountain Standard Time", "US" },
|
||||
{ "US/Alaska", "US" },
|
||||
{ "US/Aleutian", "US" },
|
||||
{ "US/Arizona", "US" },
|
||||
{ "US/Central", "US" },
|
||||
{ "US/East-Indiana", "US" },
|
||||
{ "US/Eastern", "US" },
|
||||
{ "US/Hawaii", "US" },
|
||||
{ "US/Indiana-Starke", "US" },
|
||||
{ "US/Michigan", "US" },
|
||||
{ "US/Mountain", "US" },
|
||||
{ "US/Pacific", "US" },
|
||||
{ "US/Samoa", "AS" },
|
||||
{ "Ulaanbaatar Standard Time", "MN" },
|
||||
{ "Venezuela Standard Time", "VE" },
|
||||
{ "Vladivostok Standard Time", "RU" },
|
||||
{ "Volgograd Standard Time", "RU" },
|
||||
{ "W-SU", "RU" },
|
||||
{ "W. Australia Standard Time", "AU" },
|
||||
{ "W. Central Africa Standard Time", "NG" },
|
||||
{ "W. Europe Standard Time", "DE" },
|
||||
{ "W. Mongolia Standard Time", "MN" },
|
||||
{ "WET", "PT" },
|
||||
{ "West Asia Standard Time", "UZ" },
|
||||
{ "West Bank Standard Time", "PS" },
|
||||
{ "West Pacific Standard Time", "PG" },
|
||||
{ "Yakutsk Standard Time", "RU" },
|
||||
{ "Yukon Standard Time", "CA" },
|
||||
};
|
||||
|
||||
internal static string Resolve(string timeZoneId)
|
||||
{
|
||||
return timeZoneId != null && Countries.TryGetValue(timeZoneId, out string country) ? country : string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using BepInEx.Logging;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace Bd2LocalIdentity;
|
||||
|
||||
// Neo's device country is derived directly from the OS time-zone identifier.
|
||||
// Maps an explicit zone identifier to its representative territory, never a UTC offset.
|
||||
// Unmapped identifiers have no inferred country.
|
||||
internal static class SystemTimeZoneRegion
|
||||
{
|
||||
private static ManualLogSource log;
|
||||
private static string lastLoggedIdentifier;
|
||||
|
||||
internal static void Install(Harmony harmony, ManualLogSource logger)
|
||||
{
|
||||
log = logger;
|
||||
// NeoMobilePlatform is internal in the original SDK; typeof cannot reference it.
|
||||
Type platform = Assembly.Load("Neo.Unity.Device").GetType("Neo.Unity.Platform.NeoMobilePlatform", false);
|
||||
MethodInfo method = platform?.GetMethod("GetDeviceCountryIso", BindingFlags.Instance | BindingFlags.Public,
|
||||
null, Type.EmptyTypes, null);
|
||||
if (method == null || method.ReturnType != typeof(string))
|
||||
throw new MissingMethodException("NeoMobilePlatform.GetDeviceCountryIso was not found");
|
||||
harmony.Patch(method, prefix: new HarmonyMethod(typeof(SystemTimeZoneRegion), nameof(GetDeviceCountryPrefix)));
|
||||
logger.LogInfo("Neo device country uses the operating system time zone");
|
||||
}
|
||||
|
||||
private static bool GetDeviceCountryPrefix(ref string __result)
|
||||
{
|
||||
string identifier = ReadId();
|
||||
__result = SystemTimeZoneCountries.Resolve(identifier);
|
||||
if (!string.Equals(lastLoggedIdentifier, identifier, StringComparison.Ordinal))
|
||||
{
|
||||
lastLoggedIdentifier = identifier;
|
||||
if (__result.Length == 0)
|
||||
log?.LogWarning("OS time zone country is unknown: source=OS, timeZone=" + identifier);
|
||||
else
|
||||
log?.LogInfo("OS time zone country: source=OS, timeZone=" + identifier + ", country=" + __result);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string ReadId()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
DynamicTimeZoneInformation information;
|
||||
if (GetDynamicTimeZoneInformation(out information) == uint.MaxValue) return string.Empty;
|
||||
return information.TimeZoneKeyName ?? string.Empty;
|
||||
}
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
IntPtr zone = CFTimeZoneCopySystem();
|
||||
if (zone == IntPtr.Zero) return string.Empty;
|
||||
try
|
||||
{
|
||||
IntPtr name = CFTimeZoneGetName(zone);
|
||||
if (name == IntPtr.Zero) return string.Empty;
|
||||
long length = CFStringGetLength(name).ToInt64();
|
||||
if (length <= 0 || length > 1024) return string.Empty;
|
||||
ushort[] characters = new ushort[(int)length];
|
||||
CFStringGetCharacters(name, new CFRange { Length = new IntPtr(length) }, characters);
|
||||
char[] text = new char[characters.Length];
|
||||
for (int i = 0; i < text.Length; i++) text[i] = (char)characters[i];
|
||||
return new string(text);
|
||||
}
|
||||
finally { CFRelease(zone); }
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
log?.LogWarning("Could not read the operating system time zone");
|
||||
}
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SystemTime { public ushort Year, Month, DayOfWeek, Day, Hour, Minute, Second, Milliseconds; }
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct DynamicTimeZoneInformation
|
||||
{
|
||||
public int Bias;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string StandardName;
|
||||
public SystemTime StandardDate;
|
||||
public int StandardBias;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string DaylightName;
|
||||
public SystemTime DaylightDate;
|
||||
public int DaylightBias;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string TimeZoneKeyName;
|
||||
public byte DynamicDaylightTimeDisabled;
|
||||
}
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern uint GetDynamicTimeZoneInformation(out DynamicTimeZoneInformation information);
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct CFRange { public IntPtr Location, Length; }
|
||||
private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
|
||||
[DllImport(CoreFoundation)] private static extern IntPtr CFTimeZoneCopySystem();
|
||||
[DllImport(CoreFoundation)] private static extern IntPtr CFTimeZoneGetName(IntPtr zone);
|
||||
[DllImport(CoreFoundation)] private static extern IntPtr CFStringGetLength(IntPtr value);
|
||||
[DllImport(CoreFoundation)] private static extern void CFStringGetCharacters(IntPtr value, CFRange range, [Out] ushort[] characters);
|
||||
[DllImport(CoreFoundation)] private static extern void CFRelease(IntPtr value);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
using static Bd2LoginUI.LoginRuntime;
|
||||
using static Bd2LoginUI.SessionRecovery;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
// Runs native HTTP work and delivers results to Unity coroutines with owner cancellation.
|
||||
internal static class ControlRequests
|
||||
{
|
||||
internal sealed class ControlProbeResult
|
||||
{
|
||||
public bool Success;
|
||||
public string Body;
|
||||
public string Error;
|
||||
public int StatusCode;
|
||||
public bool RefreshInvalid;
|
||||
}
|
||||
|
||||
// Native HTTP avoids UnityTls for owned auth/control endpoints. The coroutine
|
||||
// applies completed results on the Unity thread and rejects cancelled generations.
|
||||
internal static IEnumerator RequestControlEndpoint(Uri uri, string method, byte[] body,
|
||||
string authorization, int timeoutSeconds, CancellationToken lifetime, CancellationToken generation,
|
||||
Action<ControlProbeResult> completed)
|
||||
{
|
||||
try { ValidateOAuthTransport(uri); }
|
||||
catch
|
||||
{
|
||||
if (body != null) Array.Clear(body, 0, body.Length);
|
||||
throw;
|
||||
}
|
||||
using (CancellationTokenSource cancel = CancellationTokenSource.CreateLinkedTokenSource(lifetime, generation))
|
||||
{
|
||||
byte[] payload = body == null ? null : (byte[])body.Clone();
|
||||
if (body != null) Array.Clear(body, 0, body.Length);
|
||||
System.Threading.Tasks.Task<PlatformControlHttp.Response> task = null;
|
||||
bool responseConsumed = false;
|
||||
try
|
||||
{
|
||||
task = PlatformControlHttp.Send(uri, method, payload, authorization, timeoutSeconds, cancel.Token, generation);
|
||||
// Native work may finish after its Unity owner is destroyed. Observe it
|
||||
// and clear the upload clone only when transport no longer reads it.
|
||||
task.ContinueWith(finished =>
|
||||
{
|
||||
if (payload != null) Array.Clear(payload, 0, payload.Length);
|
||||
if (finished.IsFaulted) { _ = finished.Exception; }
|
||||
}, System.Threading.Tasks.TaskScheduler.Default);
|
||||
while (!task.IsCompleted && !cancel.IsCancellationRequested)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
if (cancel.IsCancellationRequested) yield break;
|
||||
if (task.IsFaulted || task.IsCanceled)
|
||||
{
|
||||
completed(new ControlProbeResult { Error = task.IsCanceled ? "Cancelled" : "NativeTransportFailure" });
|
||||
yield break;
|
||||
}
|
||||
PlatformControlHttp.Response result = task.GetAwaiter().GetResult();
|
||||
try
|
||||
{
|
||||
completed(new ControlProbeResult
|
||||
{
|
||||
Success = result.Success,
|
||||
StatusCode = result.StatusCode,
|
||||
Body = result.Body,
|
||||
RefreshInvalid = result.RefreshInvalid,
|
||||
Error = result.Error
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearNativeResponse(result);
|
||||
responseConsumed = true;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
cancel.Cancel();
|
||||
if (task == null && payload != null) Array.Clear(payload, 0, payload.Length);
|
||||
if (task != null && !responseConsumed)
|
||||
{
|
||||
task.ContinueWith(finished =>
|
||||
{
|
||||
if (finished.Status == System.Threading.Tasks.TaskStatus.RanToCompletion) ClearNativeResponse(finished.Result);
|
||||
if (finished.IsFaulted) { _ = finished.Exception; }
|
||||
}, System.Threading.Tasks.TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ClearNativeResponse(PlatformControlHttp.Response response)
|
||||
{
|
||||
if (response == null) return;
|
||||
if (response.Data != null) Array.Clear(response.Data, 0, response.Data.Length);
|
||||
response.Data = null;
|
||||
response.Body = null;
|
||||
}
|
||||
|
||||
public sealed class AuthenticationRequestLifetime : MonoBehaviour
|
||||
{
|
||||
internal readonly CancellationTokenSource Lifetime = new CancellationTokenSource();
|
||||
private void OnDestroy()
|
||||
{
|
||||
Lifetime.Cancel();
|
||||
Lifetime.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal static IEnumerator AuthRequest(object introUI, Uri uri, string method, byte[] body,
|
||||
string authorization, Action<ControlProbeResult> completed)
|
||||
{
|
||||
MonoBehaviour intro = introUI as MonoBehaviour;
|
||||
if (intro == null)
|
||||
{
|
||||
if (body != null) Array.Clear(body, 0, body.Length);
|
||||
yield break;
|
||||
}
|
||||
AuthenticationRequestLifetime owner = intro.GetComponent<AuthenticationRequestLifetime>() ??
|
||||
intro.gameObject.AddComponent<AuthenticationRequestLifetime>();
|
||||
int expectedGeneration = Volatile.Read(ref RecoveryGeneration);
|
||||
CancellationToken lifetime = owner.Lifetime.Token;
|
||||
ControlProbeResult result = null;
|
||||
yield return RequestControlEndpoint(uri, method, body, authorization, 15, lifetime, ControlProbeCancellation.Token,
|
||||
response => result = response);
|
||||
if (result != null && intro != null && !lifetime.IsCancellationRequested &&
|
||||
expectedGeneration == Volatile.Read(ref RecoveryGeneration)) completed(result);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using BD2.GameNames;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
using static BD2.GameNames.Game;
|
||||
using static Bd2LoginUI.LoginPanel;
|
||||
using static Bd2LoginUI.LoginRuntime;
|
||||
using static Bd2LoginUI.ControlRequests;
|
||||
using static Bd2LoginUI.SessionRecovery;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
// Owns server authentication policy, browser/device login and refresh rotation state.
|
||||
internal static class LoginController
|
||||
{
|
||||
internal static void InitializeCredentials()
|
||||
{
|
||||
AccessTokens = new MemoryAccessTokenStore();
|
||||
RefreshCredentials = PlatformRefreshCredentialStore.Create();
|
||||
}
|
||||
|
||||
private const string LocalAccessToken = "bd2-local-development-user";
|
||||
internal static ServerAuthentication Authentication;
|
||||
internal static string AuthenticationLoadingOrigin;
|
||||
internal static bool ContinueMaintenance;
|
||||
internal static bool LoginInProgress;
|
||||
internal static MemoryAccessTokenStore AccessTokens;
|
||||
internal static IRefreshCredentialStore RefreshCredentials;
|
||||
internal static bool AccessTokenPrefix(ref string __result)
|
||||
{
|
||||
if (Authentication != null && Authentication.mode == "oauth")
|
||||
{
|
||||
__result = AccessTokens.Get();
|
||||
return false;
|
||||
}
|
||||
__result = LocalAccessToken;
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static void ClearPCLocalDataPostfix()
|
||||
{
|
||||
AccessTokens.Clear();
|
||||
EstablishedGameSession = false;
|
||||
RuntimeProbeFailures = 0;
|
||||
ServerInstanceID = null;
|
||||
PlayerPrefs.DeleteKey("AccessToken");
|
||||
DeleteCurrentRefresh();
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
internal static bool SendMaintenancePrefix(object __instance, bool __0)
|
||||
{
|
||||
if (!__0 || ContinueMaintenance)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!(__instance is Component))
|
||||
{
|
||||
throw new InvalidOperationException("IntroUI is not a Unity component");
|
||||
}
|
||||
Uri maintenance = CurrentMaintenanceUri();
|
||||
Uri currentRoot = new Uri(maintenance, "/");
|
||||
if (ServerRoot == null || !SameOrigin(ServerRoot, currentRoot))
|
||||
{
|
||||
AccessTokens.Clear();
|
||||
Authentication = null;
|
||||
LoginInProgress = false;
|
||||
EstablishedGameSession = false;
|
||||
RuntimeProbeFailures = 0;
|
||||
ServerInstanceID = null;
|
||||
DisposeGameRelay();
|
||||
ServerRoot = currentRoot;
|
||||
EnsureGameRelay();
|
||||
PlayerPrefs.DeleteKey("AccessToken");
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) != 0 && Authentication != null &&
|
||||
Authentication.mode == "oauth" && AccessTokens.IsUsable(NormalizedServerOrigin()))
|
||||
{
|
||||
ContinueWithMaintenance(__instance, true);
|
||||
return false;
|
||||
}
|
||||
EnsureGameRelay();
|
||||
if (Authentication != null)
|
||||
{
|
||||
ApplyAuthenticationPolicy(__instance);
|
||||
return false;
|
||||
}
|
||||
string origin = NormalizedServerOrigin();
|
||||
if (AuthenticationLoadingOrigin == origin)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
AuthenticationLoadingOrigin = origin;
|
||||
StartIntroCoroutine(__instance, LoadAuthenticationPolicy(__instance, currentRoot, origin));
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogError("Could not request the server authentication policy: " + ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator LoadAuthenticationPolicy(object introUI, Uri expectedRoot, string expectedOrigin)
|
||||
{
|
||||
Uri endpoint = new Uri(expectedRoot, "auth/config");
|
||||
int generation = Volatile.Read(ref RecoveryGeneration);
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
if (generation != Volatile.Read(ref RecoveryGeneration)) yield break;
|
||||
ControlProbeResult request = null;
|
||||
yield return AuthRequest(introUI, endpoint, UnityWebRequest.kHttpVerbGET, null, null, result => request = result);
|
||||
if (request == null || ServerRoot == null || !SameOrigin(ServerRoot, expectedRoot))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
if (!request.Success)
|
||||
{
|
||||
Log?.LogWarning("Authentication policy request failed: " + request.Error);
|
||||
bool transient = request.StatusCode == 0 || request.StatusCode == 429 || request.StatusCode >= 500;
|
||||
if (transient && attempt < 2)
|
||||
{
|
||||
yield return new WaitForSecondsRealtime(attempt + 1);
|
||||
continue;
|
||||
}
|
||||
if (AuthenticationLoadingOrigin == expectedOrigin) AuthenticationLoadingOrigin = null;
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
|
||||
{
|
||||
BeginSessionRecovery("authentication policy request failed");
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
if (AuthenticationLoadingOrigin == expectedOrigin) AuthenticationLoadingOrigin = null;
|
||||
try
|
||||
{
|
||||
ServerAuthentication policy = JsonUtility.FromJson<ServerAuthentication>(request.Body);
|
||||
ValidateAuthentication(policy);
|
||||
Authentication = policy;
|
||||
Log?.LogInfo("Control HTTP /auth/config succeeded (native platform HTTP with certificate validation)");
|
||||
Log?.LogInfo("Server authentication mode: " + policy.mode);
|
||||
ApplyAuthenticationPolicy(introUI);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogError("Server returned an invalid authentication policy: " + ex.GetType().Name);
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
|
||||
{
|
||||
BeginSessionRecovery("authentication policy response was invalid");
|
||||
}
|
||||
}
|
||||
finally { request.Body = null; }
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyAuthenticationPolicy(object introUI)
|
||||
{
|
||||
if (Authentication.mode == "local")
|
||||
{
|
||||
try
|
||||
{
|
||||
AccessTokens.Clear();
|
||||
PlayerPrefs.DeleteKey("AccessToken");
|
||||
PlayerPrefs.Save();
|
||||
ContinueMaintenance = true;
|
||||
SendMaintenance.Invoke(introUI, new object[] { true });
|
||||
}
|
||||
finally
|
||||
{
|
||||
ContinueMaintenance = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
ValidateOAuthTransport(ServerRoot);
|
||||
// Earlier development builds stored both local identifiers and OAuth
|
||||
// access credentials in this key. OAuth credentials now live only in
|
||||
// process memory, so remove any legacy plaintext before proceeding.
|
||||
PlayerPrefs.DeleteKey("AccessToken");
|
||||
if (!RefreshCredentials.IsSupported)
|
||||
{
|
||||
PlayerPrefs.SetInt("IsAutoLogin", 0);
|
||||
PlayerPrefs.SetInt("StandaloneAutoLogin", 0);
|
||||
Log?.LogWarning("Secure refresh credential storage is unavailable; automatic login is disabled on this platform");
|
||||
}
|
||||
PlayerPrefs.Save();
|
||||
if (LoginInProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
|
||||
{
|
||||
if (AccessTokens.IsUsable(NormalizedServerOrigin()))
|
||||
{
|
||||
ContinueWithMaintenance(introUI, true);
|
||||
return;
|
||||
}
|
||||
if (RefreshCredentials.IsSupported)
|
||||
{
|
||||
LoginInProgress = true;
|
||||
StartIntroCoroutine(introUI, RefreshSession(introUI));
|
||||
return;
|
||||
}
|
||||
FinishRecovery(false, "no usable credential is available");
|
||||
ShowLoginPanel(introUI);
|
||||
return;
|
||||
}
|
||||
if (PlayerPrefs.GetInt("IsAutoLogin", 0) != 0 &&
|
||||
PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0 &&
|
||||
CanAttemptAutomaticLogin())
|
||||
{
|
||||
LoginInProgress = true;
|
||||
StartIntroCoroutine(introUI, RefreshSession(introUI));
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowLoginPanel(introUI);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowLoginPanel(object introUI)
|
||||
{
|
||||
AccessTokens.Clear();
|
||||
LoginInProgress = false;
|
||||
EstablishedGameSession = false;
|
||||
ConfigureLoginPanel(introUI);
|
||||
Type stateType = SetIntroState.GetParameters()[0].ParameterType;
|
||||
SetIntroState.Invoke(introUI, new[] { Enum.ToObject(stateType, 1) });
|
||||
Log?.LogInfo("Waiting for server-authorized third-party authentication");
|
||||
}
|
||||
|
||||
internal static void OpenLogin(object introUI, string provider)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (LoginInProgress)
|
||||
{
|
||||
return;
|
||||
}
|
||||
PropertyInfo canInteraction = introUI?.GetType().GetGameProperty(
|
||||
"CanInteraction",
|
||||
BindingFlags.Instance | BindingFlags.Public);
|
||||
if (canInteraction != null && canInteraction.PropertyType == typeof(bool) &&
|
||||
!(bool)canInteraction.GetValue(introUI, null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ProviderEnabled(provider))
|
||||
{
|
||||
throw new InvalidOperationException("Provider is not enabled by this server");
|
||||
}
|
||||
LoginInProgress = true;
|
||||
StartIntroCoroutine(introUI, DeviceLogin(introUI, provider));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogError("Could not start " + provider + " authentication: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator DeviceLogin(object introUI, string provider)
|
||||
{
|
||||
string endpoint = new Uri(ServerRoot, "auth/device").AbsoluteUri;
|
||||
byte[] body = Encoding.UTF8.GetBytes(JsonUtility.ToJson(new DeviceRequest { provider = provider }));
|
||||
{
|
||||
ControlProbeResult request = null;
|
||||
yield return AuthRequest(introUI, new Uri(endpoint), UnityWebRequest.kHttpVerbPOST, body, null, result => request = result);
|
||||
if (request == null) yield break;
|
||||
if (!request.Success)
|
||||
{
|
||||
LoginInProgress = false;
|
||||
Log?.LogError("Could not create login transaction: " + request.Error);
|
||||
yield break;
|
||||
}
|
||||
DeviceStart start;
|
||||
try
|
||||
{
|
||||
start = JsonUtility.FromJson<DeviceStart>(request.Body);
|
||||
request.Body = null;
|
||||
if (start == null || string.IsNullOrEmpty(start.transaction_id) || string.IsNullOrEmpty(start.device_secret) || string.IsNullOrEmpty(start.start_url))
|
||||
{
|
||||
throw new InvalidDataException("incomplete transaction response");
|
||||
}
|
||||
ValidateBrowserURL(start.start_url);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoginInProgress = false;
|
||||
Log?.LogError("Invalid login transaction: " + ex.GetType().Name);
|
||||
yield break;
|
||||
}
|
||||
Application.OpenURL(start.start_url);
|
||||
yield return PollDevice(introUI, start);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator PollDevice(object introUI, DeviceStart start)
|
||||
{
|
||||
int generation = Volatile.Read(ref RecoveryGeneration);
|
||||
int delay = Math.Max(1, start.poll_interval);
|
||||
float deadline = Time.realtimeSinceStartup + Math.Max(30, start.expires_in);
|
||||
string endpoint = new Uri(ServerRoot, "auth/device/" + Uri.EscapeDataString(start.transaction_id) + "/poll").AbsoluteUri;
|
||||
while (Time.realtimeSinceStartup < deadline)
|
||||
{
|
||||
yield return new WaitForSecondsRealtime(delay);
|
||||
if (generation != Volatile.Read(ref RecoveryGeneration)) yield break;
|
||||
{
|
||||
ControlProbeResult request = null;
|
||||
yield return AuthRequest(introUI, new Uri(endpoint), UnityWebRequest.kHttpVerbPOST, Array.Empty<byte>(), "Device " + start.device_secret, result => request = result);
|
||||
if (request == null) yield break;
|
||||
if (request.StatusCode == 202)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!request.Success)
|
||||
{
|
||||
LoginInProgress = false;
|
||||
Log?.LogError("Login transaction failed: " + request.Error);
|
||||
yield break;
|
||||
}
|
||||
TokenResult result;
|
||||
try
|
||||
{
|
||||
result = JsonUtility.FromJson<TokenResult>(request.Body);
|
||||
request.Body = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LoginInProgress = false;
|
||||
Log?.LogError("Login transaction returned invalid credentials: " + ex.GetType().Name);
|
||||
yield break;
|
||||
}
|
||||
if (!ValidTokenResult(result) || !ProviderEnabled(result.provider))
|
||||
{
|
||||
LoginInProgress = false;
|
||||
Log?.LogError("Login transaction returned incomplete credentials");
|
||||
yield break;
|
||||
}
|
||||
CompleteInteractiveLogin(introUI, result);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
LoginInProgress = false;
|
||||
Log?.LogError("Login transaction expired");
|
||||
}
|
||||
|
||||
private static void CompleteInteractiveLogin(object introUI, TokenResult result)
|
||||
{
|
||||
if (!RefreshCredentials.IsSupported)
|
||||
{
|
||||
AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in);
|
||||
result.access_token = null;
|
||||
result.refresh_token = null;
|
||||
PlayerPrefs.SetInt("IsAutoLogin", 0);
|
||||
PlayerPrefs.SetInt("StandaloneAutoLogin", 0);
|
||||
PlayerPrefs.DeleteKey("AccessToken");
|
||||
PlayerPrefs.Save();
|
||||
Log?.LogWarning("Login succeeded, but automatic login remains disabled because this platform has no supported secure credential store");
|
||||
ContinueWithMaintenance(introUI, false);
|
||||
return;
|
||||
}
|
||||
Action confirmed = delegate
|
||||
{
|
||||
try
|
||||
{
|
||||
bool autoLogin = PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0;
|
||||
if (autoLogin)
|
||||
{
|
||||
StoreRefresh(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
DeleteCurrentRefresh();
|
||||
result.refresh_token = null;
|
||||
}
|
||||
AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in);
|
||||
result.access_token = null;
|
||||
PlayerPrefs.SetInt("IsAutoLogin", autoLogin ? 1 : 0);
|
||||
PlayerPrefs.DeleteKey("AccessToken");
|
||||
PlayerPrefs.Save();
|
||||
ContinueWithMaintenance(introUI, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.access_token = null;
|
||||
result.refresh_token = null;
|
||||
Log?.LogError("Could not finish interactive login: " + ex.Message);
|
||||
AccessTokens.Clear();
|
||||
LoginInProgress = false;
|
||||
ShowLoginPanel(introUI);
|
||||
}
|
||||
};
|
||||
try
|
||||
{
|
||||
OpenPCLoginPopup.Invoke(null, new object[] { confirmed });
|
||||
}
|
||||
catch
|
||||
{
|
||||
result.access_token = null;
|
||||
result.refresh_token = null;
|
||||
LoginInProgress = false;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator RefreshSession(object introUI)
|
||||
{
|
||||
int generation = Volatile.Read(ref RecoveryGeneration);
|
||||
RefreshCredential saved = null;
|
||||
while (saved == null)
|
||||
{
|
||||
if (generation != Volatile.Read(ref RecoveryGeneration))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
InvalidDataException invalid = null;
|
||||
Exception transient = null;
|
||||
try
|
||||
{
|
||||
saved = PrepareRefreshAttempt();
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
invalid = ex;
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
invalid = new InvalidDataException("saved automatic-login credential was not found", ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transient = ex;
|
||||
}
|
||||
if (invalid != null)
|
||||
{
|
||||
Log?.LogError("Saved automatic login is invalid: " + invalid.Message);
|
||||
ClearSavedLogin();
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
|
||||
{
|
||||
FinishRecovery(false, "saved automatic-login credential is invalid", generation);
|
||||
}
|
||||
ShowLoginPanel(introUI);
|
||||
yield break;
|
||||
}
|
||||
if (transient != null)
|
||||
{
|
||||
Log?.LogWarning("Secure automatic-login storage is temporarily unavailable: " + transient.Message);
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
|
||||
{
|
||||
LoginInProgress = false;
|
||||
ShowLoginPanel(introUI);
|
||||
yield break;
|
||||
}
|
||||
yield return new WaitForSecondsRealtime(2f);
|
||||
}
|
||||
}
|
||||
string refreshToken = saved.pending_refresh_token;
|
||||
string attemptID = saved.pending_attempt_id;
|
||||
saved.refresh_token = null;
|
||||
saved.pending_refresh_token = null;
|
||||
saved.pending_attempt_id = null;
|
||||
float retryDelay = 1f;
|
||||
while (true)
|
||||
{
|
||||
byte[] body = BuildRefreshRequest(refreshToken, attemptID);
|
||||
{
|
||||
ControlProbeResult request = null;
|
||||
yield return AuthRequest(introUI, new Uri(ServerRoot, "auth/session/refresh"), UnityWebRequest.kHttpVerbPOST, body, null, result => request = result);
|
||||
if (request == null) yield break;
|
||||
if (generation != Volatile.Read(ref RecoveryGeneration))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
bool credentialRejected = (request.StatusCode == 401 || request.StatusCode == 409) && request.RefreshInvalid;
|
||||
if (credentialRejected)
|
||||
{
|
||||
refreshToken = null;
|
||||
attemptID = null;
|
||||
ClearSavedLogin();
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
|
||||
{
|
||||
FinishRecovery(false, "saved automatic-login credential was rejected", generation);
|
||||
}
|
||||
ShowLoginPanel(introUI);
|
||||
yield break;
|
||||
}
|
||||
if (!request.Success)
|
||||
{
|
||||
Log?.LogWarning("Automatic login temporarily unavailable; the same refresh attempt will be retried: " + request.Error);
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
|
||||
{
|
||||
LoginInProgress = false;
|
||||
ShowLoginPanel(introUI);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
TokenResult result = null;
|
||||
try
|
||||
{
|
||||
result = JsonUtility.FromJson<TokenResult>(request.Body);
|
||||
request.Body = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("Automatic login returned an unreadable response; the same refresh attempt will be retried: " + ex.GetType().Name);
|
||||
}
|
||||
if (ValidRefreshResult(result) && ProviderEnabled(result.provider))
|
||||
{
|
||||
try
|
||||
{
|
||||
StoreRefresh(result);
|
||||
if (result.access_expires_in <= 30)
|
||||
{
|
||||
result.access_token = null;
|
||||
saved = PrepareRefreshAttempt();
|
||||
refreshToken = saved.pending_refresh_token;
|
||||
attemptID = saved.pending_attempt_id;
|
||||
saved.refresh_token = null;
|
||||
saved.pending_refresh_token = null;
|
||||
saved.pending_attempt_id = null;
|
||||
retryDelay = 1f;
|
||||
continue;
|
||||
}
|
||||
AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in);
|
||||
result.access_token = null;
|
||||
PlayerPrefs.DeleteKey("AccessToken");
|
||||
PlayerPrefs.Save();
|
||||
refreshToken = null;
|
||||
attemptID = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.access_token = null;
|
||||
result.refresh_token = null;
|
||||
Log?.LogWarning("Could not persist the rotated automatic-login credential; the committed attempt will be retrieved again: " + ex.Message);
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
|
||||
{
|
||||
LoginInProgress = false;
|
||||
ShowLoginPanel(introUI);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
if (AccessTokens.IsUsable(NormalizedServerOrigin()))
|
||||
{
|
||||
ContinueWithMaintenance(introUI, true);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
Log?.LogWarning("Automatic login returned incomplete credentials; the same refresh attempt will be retried");
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) == 0)
|
||||
{
|
||||
LoginInProgress = false;
|
||||
ShowLoginPanel(introUI);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
yield return new WaitForSecondsRealtime(retryDelay);
|
||||
retryDelay = Math.Min(retryDelay * 2f, 5f);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ContinueWithMaintenance(object introUI, bool automatic)
|
||||
{
|
||||
try
|
||||
{
|
||||
ContinueMaintenance = true;
|
||||
SendMaintenance.Invoke(introUI, new object[] { automatic });
|
||||
}
|
||||
finally
|
||||
{
|
||||
ContinueMaintenance = false;
|
||||
LoginInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void StartIntroCoroutine(object introUI, IEnumerator routine)
|
||||
{
|
||||
if (!(introUI is MonoBehaviour owner) || owner == null)
|
||||
{
|
||||
throw new InvalidOperationException("IntroUI coroutine owner is unavailable");
|
||||
}
|
||||
owner.StartCoroutine(routine ?? throw new ArgumentNullException(nameof(routine)));
|
||||
}
|
||||
|
||||
private static bool CanAttemptAutomaticLogin()
|
||||
{
|
||||
if (!RefreshCredentials.IsSupported)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
return RefreshCredentials.Contains(NormalizedServerOrigin());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("Could not inspect the secure automatic-login credential: " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void StoreRefresh(TokenResult result)
|
||||
{
|
||||
string origin = NormalizedServerOrigin();
|
||||
RefreshCredential credential = new RefreshCredential
|
||||
{
|
||||
version = 2,
|
||||
origin = origin,
|
||||
provider = result.provider,
|
||||
refresh_token = result.refresh_token,
|
||||
expires_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + result.refresh_expires_in
|
||||
};
|
||||
RefreshCredentials.Save(origin, credential);
|
||||
credential.refresh_token = null;
|
||||
result.refresh_token = null;
|
||||
}
|
||||
|
||||
private static RefreshCredential LoadRefresh()
|
||||
{
|
||||
string origin = NormalizedServerOrigin();
|
||||
RefreshCredential credential = RefreshCredentials.Load(origin);
|
||||
if (credential == null || credential.version < 1 || credential.version > 2 || credential.origin != origin ||
|
||||
!ProviderEnabled(credential.provider) || string.IsNullOrEmpty(credential.refresh_token) ||
|
||||
credential.expires_at <= DateTimeOffset.UtcNow.ToUnixTimeSeconds())
|
||||
{
|
||||
if (credential != null)
|
||||
{
|
||||
credential.refresh_token = null;
|
||||
}
|
||||
throw new InvalidDataException("saved automatic-login credential is invalid, expired, or belongs to another server");
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
private static RefreshCredential PrepareRefreshAttempt()
|
||||
{
|
||||
string origin = NormalizedServerOrigin();
|
||||
RefreshCredential credential = LoadRefresh();
|
||||
bool missingAttempt = string.IsNullOrEmpty(credential.pending_attempt_id) ||
|
||||
string.IsNullOrEmpty(credential.pending_refresh_token);
|
||||
if (missingAttempt)
|
||||
{
|
||||
credential.version = 2;
|
||||
credential.pending_attempt_id = System.Guid.NewGuid().ToString("N");
|
||||
credential.pending_refresh_token = credential.refresh_token;
|
||||
RefreshCredentials.Save(origin, credential);
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
private static byte[] BuildRefreshRequest(string token, string attemptID)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(attemptID))
|
||||
{
|
||||
throw new InvalidDataException("refresh token or attempt ID is empty");
|
||||
}
|
||||
foreach (char item in token + attemptID)
|
||||
{
|
||||
bool safe = item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' ||
|
||||
item >= '0' && item <= '9' || item == '-' || item == '_';
|
||||
if (!safe)
|
||||
{
|
||||
throw new InvalidDataException("refresh token contains an unexpected character");
|
||||
}
|
||||
}
|
||||
return Encoding.UTF8.GetBytes("{\"refresh_token\":\"" + token + "\",\"attempt_id\":\"" + attemptID + "\"}");
|
||||
}
|
||||
|
||||
private static void ClearSavedLogin()
|
||||
{
|
||||
AccessTokens.Clear();
|
||||
PlayerPrefs.SetInt("IsAutoLogin", 0);
|
||||
PlayerPrefs.SetInt("StandaloneAutoLogin", 0);
|
||||
PlayerPrefs.DeleteKey("AccessToken");
|
||||
DeleteCurrentRefresh();
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
private static void DeleteCurrentRefresh()
|
||||
{
|
||||
if (ServerRoot == null || RefreshCredentials == null || !RefreshCredentials.IsSupported)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
RefreshCredentials.Delete(NormalizedServerOrigin());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("Could not delete the secure automatic-login credential: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ValidTokenResult(TokenResult result)
|
||||
{
|
||||
return result != null && !string.IsNullOrEmpty(result.provider) &&
|
||||
!string.IsNullOrEmpty(result.access_token) && result.access_expires_in > 0 &&
|
||||
!string.IsNullOrEmpty(result.refresh_token) && result.refresh_expires_in > 0;
|
||||
}
|
||||
|
||||
private static bool ValidRefreshResult(TokenResult result)
|
||||
{
|
||||
return result != null && !string.IsNullOrEmpty(result.provider) &&
|
||||
!string.IsNullOrEmpty(result.access_token) && result.access_expires_in >= 0 &&
|
||||
!string.IsNullOrEmpty(result.refresh_token) && result.refresh_expires_in > 0;
|
||||
}
|
||||
|
||||
internal static bool ProviderEnabled(string provider)
|
||||
{
|
||||
if (Authentication?.providers == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (string enabled in Authentication.providers)
|
||||
{
|
||||
if (enabled == provider)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ValidateAuthentication(ServerAuthentication policy)
|
||||
{
|
||||
if (policy == null || (policy.mode != "local" && policy.mode != "oauth") || policy.providers == null)
|
||||
{
|
||||
throw new InvalidDataException("missing or unknown authentication mode");
|
||||
}
|
||||
if (policy.mode == "local" && policy.providers.Length != 0)
|
||||
{
|
||||
throw new InvalidDataException("local mode enabled providers");
|
||||
}
|
||||
if (policy.mode == "oauth" && policy.providers.Length == 0)
|
||||
{
|
||||
throw new InvalidDataException("oauth mode omitted providers");
|
||||
}
|
||||
for (int i = 0; i < policy.providers.Length; i++)
|
||||
{
|
||||
string provider = policy.providers[i];
|
||||
if (provider != "discord" && provider != "google")
|
||||
{
|
||||
throw new InvalidDataException("unsupported provider " + provider);
|
||||
}
|
||||
for (int j = 0; j < i; j++)
|
||||
{
|
||||
if (policy.providers[j] == provider)
|
||||
{
|
||||
throw new InvalidDataException("duplicate provider " + provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class DeviceRequest
|
||||
{
|
||||
public string provider;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class DeviceStart
|
||||
{
|
||||
public string transaction_id = null;
|
||||
public string device_secret = null;
|
||||
public string start_url = null;
|
||||
public int expires_in = 0;
|
||||
public int poll_interval = 0;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class TokenResult
|
||||
{
|
||||
public string provider = null;
|
||||
public string access_token = null;
|
||||
public long access_expires_in = 0;
|
||||
public string refresh_token = null;
|
||||
public long refresh_expires_in = 0;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
internal sealed class ServerAuthentication
|
||||
{
|
||||
public string mode = null;
|
||||
public string[] providers = null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using BD2.GameNames;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.UI;
|
||||
|
||||
using static BD2.GameNames.Game;
|
||||
using static Bd2LoginUI.LoginRuntime;
|
||||
using static Bd2LoginUI.LoginController;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
// Owns login branding, provider layout and button listeners.
|
||||
internal static class LoginPanel
|
||||
{
|
||||
private const string SymbolResource = "Bd2LoginUI.Assets.Discord-Symbol.png";
|
||||
private const string WordmarkResource = "Bd2LoginUI.Assets.Discord-Wordmark.png";
|
||||
private static readonly Color DiscordBlurple = new Color32(88, 101, 242, 255);
|
||||
|
||||
private static Sprite DiscordSymbol;
|
||||
private static Sprite DiscordWordmark;
|
||||
internal static void ConfigureLoginPanel(object introUI)
|
||||
{
|
||||
if (Authentication == null || Authentication.mode != "oauth")
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Component component = introUI as Component;
|
||||
Transform panel = component == null ? null : FindDescendant(component.transform, "SignInWithAccount");
|
||||
if (panel == null)
|
||||
{
|
||||
throw new MissingMemberException("IntroUI/SignInWithAccount was not found");
|
||||
}
|
||||
|
||||
Button google = FindButton(panel, "Button - Google");
|
||||
Button discord = FindButton(panel, "Button - Facebook");
|
||||
if (discord == null)
|
||||
{
|
||||
discord = FindButton(panel, "Button - Discord");
|
||||
}
|
||||
if (google == null || discord == null)
|
||||
{
|
||||
throw new MissingMemberException("Google or Facebook/Discord login button was not found");
|
||||
}
|
||||
|
||||
Image box = FindImage(discord.transform, "Image - Box");
|
||||
Image logo = FindImage(discord.transform, "Image - Logo");
|
||||
Image title = FindImage(discord.transform, "Image - Title");
|
||||
if (box == null || logo == null || title == null)
|
||||
{
|
||||
throw new MissingMemberException("Discord button images were not found");
|
||||
}
|
||||
|
||||
SetActive(panel, "Button - Apple", false);
|
||||
SetActive(panel, "Button - Email", false);
|
||||
SetActive(panel, "Button - Mail", false);
|
||||
SetActive(panel, "Button - Guest", false);
|
||||
|
||||
google.gameObject.SetActive(ProviderEnabled("google"));
|
||||
discord.gameObject.SetActive(ProviderEnabled("discord"));
|
||||
int providerCount = (google.gameObject.activeSelf ? 1 : 0) +
|
||||
(discord.gameObject.activeSelf ? 1 : 0);
|
||||
discord.transform.SetSiblingIndex(0);
|
||||
google.transform.SetSiblingIndex(1);
|
||||
discord.gameObject.name = "Button - Discord";
|
||||
|
||||
ReplaceClick(google, introUI, "google");
|
||||
ReplaceClick(discord, introUI, "discord");
|
||||
ApplyDiscordBrand(box, logo, title);
|
||||
ConfigureProviderGrid(panel, providerCount);
|
||||
|
||||
Canvas.ForceUpdateCanvases();
|
||||
if (panel is RectTransform panelRect)
|
||||
{
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(panelRect);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogError("Could not configure login panel: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureProviderGrid(Transform panel, int providerCount)
|
||||
{
|
||||
GridLayoutGroup grid = panel.GetComponentInChildren<GridLayoutGroup>(true);
|
||||
if (grid == null)
|
||||
{
|
||||
throw new MissingMemberException("Login provider grid was not found");
|
||||
}
|
||||
int columns = Math.Max(1, providerCount);
|
||||
grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
|
||||
grid.constraintCount = columns;
|
||||
if (grid.transform is RectTransform gridRect)
|
||||
{
|
||||
float width = grid.padding.horizontal + grid.cellSize.x * columns +
|
||||
grid.spacing.x * Math.Max(0, columns - 1);
|
||||
UpdateBetterGridProfiles(grid, columns);
|
||||
UpdateBetterLocatorProfiles(grid, width);
|
||||
gridRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width);
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate(gridRect);
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateBetterGridProfiles(GridLayoutGroup grid, int columns)
|
||||
{
|
||||
Type type = grid.GetType();
|
||||
if (type.FullName != "TheraBytes.BetterUi.BetterGridLayoutGroup")
|
||||
{
|
||||
return;
|
||||
}
|
||||
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
UpdateBetterGridSettings(type.GetGameField("settingsFallback", flags)?.GetValue(grid), columns);
|
||||
object collection = type.GetGameField("customSettings", flags)?.GetValue(grid);
|
||||
IEnumerable items = collection?.GetType().GetGameProperty("Items", flags)?.GetValue(collection, null) as IEnumerable;
|
||||
if (items == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (object settings in items)
|
||||
{
|
||||
UpdateBetterGridSettings(settings, columns);
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateBetterGridSettings(object settings, int columns)
|
||||
{
|
||||
if (settings == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Type type = settings.GetType();
|
||||
FieldInfo constraint = type.GetGameField("Constraint", BindingFlags.Instance | BindingFlags.Public);
|
||||
FieldInfo count = type.GetGameField("ConstraintCount", BindingFlags.Instance | BindingFlags.Public);
|
||||
if (constraint != null)
|
||||
{
|
||||
constraint.SetValue(settings, Enum.ToObject(constraint.FieldType, (int)GridLayoutGroup.Constraint.FixedColumnCount));
|
||||
}
|
||||
count?.SetValue(settings, columns);
|
||||
}
|
||||
|
||||
private static void UpdateBetterLocatorProfiles(GridLayoutGroup grid, float width)
|
||||
{
|
||||
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
foreach (Component component in grid.GetComponents<Component>())
|
||||
{
|
||||
Type type = component?.GetType();
|
||||
if (type?.FullName != "TheraBytes.BetterUi.BetterLocator")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
UpdateBetterRectTransformData(type.GetGameField("transformFallback", flags)?.GetValue(component), width);
|
||||
object collection = type.GetGameField("transformConfigs", flags)?.GetValue(component);
|
||||
IEnumerable items = collection?.GetType().GetGameProperty("Items", flags)?.GetValue(collection, null) as IEnumerable;
|
||||
if (items == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (object data in items)
|
||||
{
|
||||
UpdateBetterRectTransformData(data, width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateBetterRectTransformData(object data, float width)
|
||||
{
|
||||
FieldInfo sizeField = data?.GetType().GetGameField("SizeDelta", BindingFlags.Instance | BindingFlags.Public);
|
||||
if (sizeField == null || sizeField.FieldType != typeof(Vector2))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector2 size = (Vector2)sizeField.GetValue(data);
|
||||
size.x = width;
|
||||
sizeField.SetValue(data, size);
|
||||
}
|
||||
|
||||
private static void ReplaceClick(Button button, object introUI, string provider)
|
||||
{
|
||||
// Assigning a fresh event removes both serialized persistent calls and
|
||||
// the listeners that IntroUI.Awake adds at runtime.
|
||||
button.onClick = new Button.ButtonClickedEvent();
|
||||
button.onClick.AddListener(new UnityAction(delegate { OpenLogin(introUI, provider); }));
|
||||
button.interactable = true;
|
||||
}
|
||||
|
||||
private static void ApplyDiscordBrand(Image box, Image logo, Image title)
|
||||
{
|
||||
box.color = DiscordBlurple;
|
||||
logo.sprite = DiscordSymbol;
|
||||
logo.color = Color.white;
|
||||
logo.preserveAspect = true;
|
||||
title.sprite = DiscordWordmark;
|
||||
title.color = Color.white;
|
||||
title.preserveAspect = true;
|
||||
DisableSpriteLocalizer(logo.gameObject);
|
||||
DisableSpriteLocalizer(title.gameObject);
|
||||
}
|
||||
|
||||
private static void DisableSpriteLocalizer(GameObject target)
|
||||
{
|
||||
foreach (Behaviour behaviour in target.GetComponents<Behaviour>())
|
||||
{
|
||||
if (behaviour is SpriteLocalizer)
|
||||
{
|
||||
behaviour.enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Sprite LoadSprite(string resourceName, string name)
|
||||
{
|
||||
using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName);
|
||||
if (stream == null)
|
||||
{
|
||||
throw new FileNotFoundException("Embedded login asset is missing", resourceName);
|
||||
}
|
||||
byte[] bytes = new byte[stream.Length];
|
||||
int offset = 0;
|
||||
while (offset < bytes.Length)
|
||||
{
|
||||
int read = stream.Read(bytes, offset, bytes.Length - offset);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new EndOfStreamException("Unexpected end of embedded login asset " + resourceName);
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
Texture2D texture = new Texture2D(2, 2, TextureFormat.RGBA32, false, false)
|
||||
{
|
||||
name = name,
|
||||
filterMode = FilterMode.Bilinear,
|
||||
wrapMode = TextureWrapMode.Clamp
|
||||
};
|
||||
if (!ImageConversion.LoadImage(texture, bytes, true))
|
||||
{
|
||||
UnityEngine.Object.Destroy(texture);
|
||||
throw new InvalidDataException("Could not decode embedded login asset " + resourceName);
|
||||
}
|
||||
Sprite sprite = Sprite.Create(
|
||||
texture,
|
||||
new Rect(0f, 0f, texture.width, texture.height),
|
||||
new Vector2(0.5f, 0.5f),
|
||||
100f);
|
||||
sprite.name = name;
|
||||
return sprite;
|
||||
}
|
||||
|
||||
private static Button FindButton(Transform root, string name)
|
||||
{
|
||||
Transform match = FindDescendant(root, name);
|
||||
return match == null ? null : match.GetComponent<Button>();
|
||||
}
|
||||
|
||||
private static Image FindImage(Transform root, string name)
|
||||
{
|
||||
Transform match = FindDescendant(root, name);
|
||||
return match == null ? null : match.GetComponent<Image>();
|
||||
}
|
||||
|
||||
internal static void SetActive(Transform root, string name, bool active)
|
||||
{
|
||||
Transform match = FindDescendant(root, name);
|
||||
if (match != null)
|
||||
{
|
||||
match.gameObject.SetActive(active);
|
||||
}
|
||||
}
|
||||
|
||||
private static Transform FindDescendant(Transform root, string name)
|
||||
{
|
||||
if (root == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (root.name == name)
|
||||
{
|
||||
return root;
|
||||
}
|
||||
for (int i = 0; i < root.childCount; i++)
|
||||
{
|
||||
Transform match = FindDescendant(root.GetChild(i), name);
|
||||
if (match != null)
|
||||
{
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static void InitializeBranding()
|
||||
{
|
||||
DiscordSymbol = LoadSprite(SymbolResource, "BD2 Discord Symbol");
|
||||
DiscordWordmark = LoadSprite(WordmarkResource, "BD2 Discord Wordmark");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using BD2.GameNames;
|
||||
using BepInEx.Logging;
|
||||
|
||||
using static BD2.GameNames.Game;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
// Shared server origin and resolved client transition bindings; feature state stays in its owner.
|
||||
internal static class LoginRuntime
|
||||
{
|
||||
internal static ManualLogSource Log;
|
||||
internal static MethodInfo SetIntroState;
|
||||
internal static MethodInfo SendMaintenance;
|
||||
internal static Uri ServerRoot;
|
||||
internal static MethodInfo OpenPCLoginPopup;
|
||||
internal static MethodInfo EnterGame;
|
||||
internal static string NormalizedServerOrigin()
|
||||
{
|
||||
return NormalizeOrigin(ServerRoot);
|
||||
}
|
||||
|
||||
internal static bool SameOrigin(Uri left, Uri right)
|
||||
{
|
||||
return string.Equals(NormalizeOrigin(left), NormalizeOrigin(right), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string NormalizeOrigin(Uri uri)
|
||||
{
|
||||
if (uri == null || !uri.IsAbsoluteUri || string.IsNullOrEmpty(uri.Host))
|
||||
{
|
||||
throw new InvalidOperationException("authentication server origin is unavailable");
|
||||
}
|
||||
string host = uri.IdnHost.ToLowerInvariant();
|
||||
int port = uri.IsDefaultPort ? -1 : uri.Port;
|
||||
UriBuilder builder = new UriBuilder(uri.Scheme.ToLowerInvariant(), host, port);
|
||||
return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
|
||||
}
|
||||
|
||||
internal static void ValidateOAuthTransport(Uri uri)
|
||||
{
|
||||
if (uri == null || !uri.IsAbsoluteUri ||
|
||||
(uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
|
||||
{
|
||||
throw new InvalidOperationException("OAuth requires HTTPS except when connecting to a loopback server");
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ValidateBrowserURL(string raw)
|
||||
{
|
||||
if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
|
||||
uri.UserInfo.Length != 0)
|
||||
{
|
||||
throw new InvalidDataException("server returned an unsafe browser login URL");
|
||||
}
|
||||
}
|
||||
|
||||
internal static T FindUnitySingleton<T>() where T : UnityEngine.Object
|
||||
{
|
||||
return UnityEngine.Object.FindObjectOfType<T>();
|
||||
}
|
||||
|
||||
internal static Uri CurrentMaintenanceUri()
|
||||
{
|
||||
Type serverURLInfo = typeof(BDNetwork.ServerURLInfo);
|
||||
FieldInfo maintenanceUri = serverURLInfo?.GetGameField(
|
||||
nameof(BDNetwork.ServerURLInfo.MaintenanceUri),
|
||||
BindingFlags.Static | BindingFlags.Public);
|
||||
Uri result = maintenanceUri?.GetValue(null) as Uri;
|
||||
if (result == null || (result.Scheme != Uri.UriSchemeHttp && result.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidOperationException("current server maintenance URL is unavailable");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static MethodInfo FindOpenPCLoginPopup()
|
||||
{
|
||||
Type uiManager = typeof(UIManager);
|
||||
MethodInfo method = uiManager.GetGameMethod(
|
||||
nameof(UIManager.OpenPCLoginPopupUI),
|
||||
BindingFlags.Static | BindingFlags.Public,
|
||||
null,
|
||||
new[] { typeof(Action) },
|
||||
null);
|
||||
return method != null && method.ReturnType == typeof(void) ? method : null;
|
||||
}
|
||||
|
||||
internal static MethodInfo FindAccessTokenGetter()
|
||||
{
|
||||
MethodInfo getter = Game.Getter(() => BDNetwork.CommonPacket.AccessToken);
|
||||
return getter != null && getter.IsStatic && getter.ReturnType == typeof(string) &&
|
||||
getter.GetParameters().Length == 0 ? getter : null;
|
||||
}
|
||||
|
||||
internal static MethodInfo FindClearPCLocalData()
|
||||
{
|
||||
Type platformManager = typeof(gamfs.Platform.PlatformManager);
|
||||
MethodInfo method = platformManager?.GetGameMethod(
|
||||
nameof(gamfs.Platform.PlatformManager.ClearPCLocalData),
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
null,
|
||||
Type.EmptyTypes,
|
||||
null);
|
||||
return method != null && method.ReturnType == typeof(void) ? method : null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,12 +6,7 @@
|
||||
<AssemblyName>BD2LoginUI</AssemblyName>
|
||||
<RootNamespace>Bd2LoginUI</RootNamespace>
|
||||
<BD2PluginVersionKey>login_ui</BD2PluginVersionKey>
|
||||
<BD2ManagedDir Condition="'$(BD2ManagedDir)' == ''">$(GameDir)\BrownDust II_Data\Managed</BD2ManagedDir>
|
||||
<BD2BepInExDir Condition="'$(BD2BepInExDir)' == ''">$(GameDir)\BepInEx</BD2BepInExDir>
|
||||
</PropertyGroup>
|
||||
<Target Name="RequireGameDir" BeforeTargets="ResolveReferences" Condition="'$(GameDir)' == ''">
|
||||
<Error Text="GameDir is required. Build with -p:GameDir=<local-client-directory>." />
|
||||
</Target>
|
||||
<ItemGroup>
|
||||
<Reference Include="BepInEx">
|
||||
<HintPath>$(BD2BepInExDir)\core\BepInEx.dll</HintPath>
|
||||
@@ -58,5 +53,4 @@
|
||||
<EmbeddedResource Include="Assets\Discord-Symbol.png" LogicalName="Bd2LoginUI.Assets.Discord-Symbol.png" />
|
||||
<EmbeddedResource Include="Assets\Discord-Wordmark.png" LogicalName="Bd2LoginUI.Assets.Discord-Wordmark.png" />
|
||||
</ItemGroup>
|
||||
<Import Project="..\VersionSource.targets" />
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
// Own-origin control requests use OS TLS without Mono/Unity's TLS implementation.
|
||||
internal static class PlatformControlHttp
|
||||
{
|
||||
public static bool IsSupported => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ||
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||
private static readonly object CurlInitializationLock = new object();
|
||||
private static bool curlInitialized;
|
||||
internal sealed class Response
|
||||
{
|
||||
public int StatusCode;
|
||||
public string Body;
|
||||
public string Error;
|
||||
public byte[] Data;
|
||||
public IReadOnlyDictionary<string, string> Headers;
|
||||
public bool RefreshInvalid;
|
||||
public bool Success => Error == null && StatusCode >= 200 && StatusCode < 300;
|
||||
}
|
||||
|
||||
public static Task<Response> Send(Uri uri, string method, byte[] body, string authorization,
|
||||
int timeoutSeconds, CancellationToken lifetime, CancellationToken generation, int maxResponseBytes = 16 * 1024,
|
||||
IReadOnlyDictionary<string, string> requestHeaders = null)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
using (CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime, generation))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (uri == null || !uri.IsAbsoluteUri ||
|
||||
(uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
|
||||
!string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Fragment) ||
|
||||
(method != "GET" && method != "POST" && method != "PUT") || timeoutSeconds <= 0 || timeoutSeconds > 60 ||
|
||||
maxResponseBytes <= 0 || maxResponseBytes > 64 * 1024 * 1024 ||
|
||||
(body != null && body.Length > 64 * 1024 * 1024) ||
|
||||
(authorization != null && (authorization.Length > 16384 || authorization.IndexOf('\r') >= 0 || authorization.IndexOf('\n') >= 0)))
|
||||
return Failure("Invalid control request");
|
||||
cancellation.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
cancellation.Token.ThrowIfCancellationRequested();
|
||||
if (requestHeaders != null)
|
||||
{
|
||||
if (requestHeaders.Count > 64) return Failure("Invalid control request headers");
|
||||
int headerSize = 0;
|
||||
foreach (KeyValuePair<string, string> header in requestHeaders)
|
||||
{
|
||||
if (!AllowedRequestHeader(header.Key) || header.Key.Length > 256 || !ValidHeaderName(header.Key) || header.Value == null || header.Value.Length > 16384 ||
|
||||
header.Value.IndexOf('\r') >= 0 || header.Value.IndexOf('\n') >= 0)
|
||||
return Failure("Invalid control request headers");
|
||||
headerSize += header.Key.Length + header.Value.Length;
|
||||
if (headerSize > 65536) return Failure("Invalid control request headers");
|
||||
}
|
||||
}
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return SendWindows(uri, method, body, authorization, timeoutSeconds, cancellation.Token, maxResponseBytes, requestHeaders);
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return SendMac(uri, method, body, authorization, timeoutSeconds, cancellation.Token, maxResponseBytes, requestHeaders);
|
||||
return Failure("Native control transport is unsupported");
|
||||
}
|
||||
catch (OperationCanceledException) { return Failure("Control request canceled"); }
|
||||
catch (Exception exception) { return Failure("Native control transport failed: " + exception.GetType().Name); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Response Failure(string error) => new Response { Error = error };
|
||||
|
||||
private static Response SendWindows(Uri uri, string method, byte[] body, string authorization,
|
||||
int timeoutSeconds, CancellationToken token, int maxResponseBytes, IReadOnlyDictionary<string, string> requestHeaders)
|
||||
{
|
||||
IntPtr session = IntPtr.Zero, connection = IntPtr.Zero, request = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
// Owned endpoints connect directly; browser OAuth keeps its own proxy settings.
|
||||
session = WinHttpOpen("BD2LoginUI", 1, null, null, 0); // WINHTTP_ACCESS_TYPE_NO_PROXY
|
||||
if (session == IntPtr.Zero) return WindowsFailure();
|
||||
int timeout = checked(timeoutSeconds * 1000);
|
||||
if (!WinHttpSetTimeouts(session, timeout, timeout, timeout, timeout)) return WindowsFailure();
|
||||
connection = WinHttpConnect(session, uri.IdnHost, (ushort)uri.Port, 0);
|
||||
if (connection == IntPtr.Zero) return WindowsFailure();
|
||||
request = WinHttpOpenRequest(connection, method, uri.PathAndQuery, null, null, IntPtr.Zero,
|
||||
uri.Scheme == Uri.UriSchemeHttps ? 0x00800000u : 0u);
|
||||
if (request == IntPtr.Zero) return WindowsFailure();
|
||||
uint disabled = 0x2 | 0x4; // WINHTTP_DISABLE_COOKIES | WINHTTP_DISABLE_REDIRECTS
|
||||
if (!WinHttpSetOption(request, 63, ref disabled, 4)) return WindowsFailure();
|
||||
string headers = "Accept: application/json\r\n";
|
||||
if (body != null && !HasContentType(requestHeaders)) headers += "Content-Type: application/json\r\n";
|
||||
if (authorization != null) headers += "Authorization: " + authorization + "\r\n";
|
||||
if (requestHeaders != null)
|
||||
foreach (KeyValuePair<string, string> header in requestHeaders) headers += header.Key + ": " + header.Value + "\r\n";
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Calls have native timeouts; never close a handle concurrently with a P/Invoke call.
|
||||
if (!WinHttpSendRequest(request, headers, (uint)headers.Length, body,
|
||||
(uint)(body?.Length ?? 0), (uint)(body?.Length ?? 0), UIntPtr.Zero)) return WindowsFailure();
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!WinHttpReceiveResponse(request, IntPtr.Zero)) return WindowsFailure();
|
||||
uint status, size = 4;
|
||||
if (!WinHttpQueryHeaders(request, 19 | 0x20000000, null, out status, ref size, IntPtr.Zero)) return WindowsFailure();
|
||||
Dictionary<string, string> responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
size = 0;
|
||||
WinHttpQueryHeadersText(request, 22, null, IntPtr.Zero, ref size, IntPtr.Zero);
|
||||
if (size > 64 * 1024) return Failure("Control response headers exceeded limit");
|
||||
if (size > 0 && size <= 64 * 1024)
|
||||
{
|
||||
int headerCapacity = (int)size;
|
||||
IntPtr headerBuffer = Marshal.AllocHGlobal((int)size);
|
||||
try
|
||||
{
|
||||
if (WinHttpQueryHeadersText(request, 22, null, headerBuffer, ref size, IntPtr.Zero))
|
||||
foreach (string line in (Marshal.PtrToStringUni(headerBuffer) ?? string.Empty).Split(new[] { "\r\n" }, StringSplitOptions.None))
|
||||
KeepResponseHeader(responseHeaders, line);
|
||||
}
|
||||
finally
|
||||
{
|
||||
for (int index = 0; index < headerCapacity; index++) Marshal.WriteByte(headerBuffer, index, 0);
|
||||
Marshal.FreeHGlobal(headerBuffer);
|
||||
}
|
||||
}
|
||||
using (MemoryStream response = new MemoryStream())
|
||||
{
|
||||
byte[] buffer = new byte[4096];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
uint read;
|
||||
if (!WinHttpReadData(request, buffer, (uint)buffer.Length, out read)) return WindowsFailure();
|
||||
if (read == 0) break;
|
||||
if (response.Length + read > maxResponseBytes) return Failure("Control response exceeded limit");
|
||||
response.Write(buffer, 0, (int)read);
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
byte[] data = response.ToArray();
|
||||
Array.Clear(response.GetBuffer(), 0, (int)response.Length);
|
||||
Array.Clear(buffer, 0, buffer.Length);
|
||||
return new Response { StatusCode = (int)status, Data = data, Headers = responseHeaders, Body = Encoding.UTF8.GetString(data),
|
||||
RefreshInvalid = IsRefreshInvalid(responseHeaders) };
|
||||
}
|
||||
finally
|
||||
{
|
||||
Array.Clear(buffer, 0, buffer.Length);
|
||||
if (response.TryGetBuffer(out ArraySegment<byte> used)) Array.Clear(used.Array, used.Offset, used.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (request != IntPtr.Zero) WinHttpCloseHandle(request);
|
||||
if (connection != IntPtr.Zero) WinHttpCloseHandle(connection);
|
||||
if (session != IntPtr.Zero) WinHttpCloseHandle(session);
|
||||
}
|
||||
}
|
||||
|
||||
private static Response WindowsFailure() => Failure("WinHTTP error " + Marshal.GetLastWin32Error());
|
||||
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern IntPtr WinHttpOpen(string agent, uint access, string proxy, string bypass, uint flags);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern IntPtr WinHttpConnect(IntPtr session, string server, ushort port, uint reserved);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern IntPtr WinHttpOpenRequest(IntPtr connection, string verb, string path, string version,
|
||||
string referer, IntPtr acceptTypes, uint flags);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpSetTimeouts(IntPtr handle, int resolve, int connect, int send, int receive);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpSetOption(IntPtr handle, uint option, ref uint value, uint size);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpSendRequest(IntPtr request, string headers, uint headersLength, byte[] body,
|
||||
uint bodyLength, uint totalLength, UIntPtr context);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpReceiveResponse(IntPtr request, IntPtr reserved);
|
||||
[DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpQueryHeaders(IntPtr request, uint info, string name, out uint value, ref uint size, IntPtr index);
|
||||
[DllImport("winhttp.dll", EntryPoint = "WinHttpQueryHeaders", CharSet = CharSet.Unicode, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpQueryHeadersText(IntPtr request, uint info, string name, IntPtr value, ref uint size, IntPtr index);
|
||||
[DllImport("winhttp.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpReadData(IntPtr request, [Out] byte[] buffer, uint capacity, out uint read);
|
||||
[DllImport("winhttp.dll")] [return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool WinHttpCloseHandle(IntPtr handle);
|
||||
|
||||
private static Response SendMac(Uri uri, string method, byte[] body, string authorization,
|
||||
int timeoutSeconds, CancellationToken token, int maxResponseBytes, IReadOnlyDictionary<string, string> requestHeaders)
|
||||
{
|
||||
IntPtr curl = IntPtr.Zero, headers = IntPtr.Zero, pinnedBody = IntPtr.Zero;
|
||||
using (MemoryStream response = new MemoryStream())
|
||||
{
|
||||
bool exceeded = false;
|
||||
int headerBytes = 0, headerCount = 0;
|
||||
Dictionary<string, string> responseHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
CurlWrite write = (data, size, count, context) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
ulong length = checked(size.ToUInt64() * count.ToUInt64());
|
||||
if (token.IsCancellationRequested || length > (ulong)maxResponseBytes || response.Length + (long)length > maxResponseBytes)
|
||||
{ exceeded = !token.IsCancellationRequested; return UIntPtr.Zero; }
|
||||
byte[] bytes = new byte[(int)length];
|
||||
Marshal.Copy(data, bytes, 0, bytes.Length);
|
||||
response.Write(bytes, 0, bytes.Length);
|
||||
return new UIntPtr(length);
|
||||
}
|
||||
catch { return UIntPtr.Zero; }
|
||||
};
|
||||
CurlProgress progress = (context, downloadTotal, downloadNow, uploadTotal, uploadNow) => token.IsCancellationRequested ? 1 : 0;
|
||||
CurlWrite header = (data, size, count, context) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
ulong length = checked(size.ToUInt64() * count.ToUInt64());
|
||||
if (length > 16384 || token.IsCancellationRequested || headerBytes + (long)length > 65536 || ++headerCount > 256)
|
||||
return UIntPtr.Zero;
|
||||
headerBytes += (int)length;
|
||||
byte[] bytes = new byte[(int)length];
|
||||
Marshal.Copy(data, bytes, 0, bytes.Length);
|
||||
KeepResponseHeader(responseHeaders, Encoding.UTF8.GetString(bytes).TrimEnd('\r', '\n'));
|
||||
return new UIntPtr(length);
|
||||
}
|
||||
catch { return UIntPtr.Zero; }
|
||||
};
|
||||
try
|
||||
{
|
||||
// System libcurl is built with the OS TLS backend and its default trust store.
|
||||
if (!EnsureCurlInitialized()) return Failure("Could not initialize system curl");
|
||||
curl = curl_easy_init();
|
||||
if (curl == IntPtr.Zero) return Failure("Could not create system curl request");
|
||||
SetCurl(curl, 10002, uri.AbsoluteUri);
|
||||
SetCurl(curl, 10036, method);
|
||||
SetCurl(curl, 52, 0L); // never follow redirects
|
||||
SetCurl(curl, 64, 1L); // verify peer chain
|
||||
SetCurl(curl, 81, 2L); // verify hostname
|
||||
SetCurl(curl, 99, 1L); // no signals on worker threads
|
||||
SetCurl(curl, 155, checked(timeoutSeconds * 1000L));
|
||||
SetCurl(curl, 156, checked(timeoutSeconds * 1000L));
|
||||
SetCurl(curl, 181, uri.Scheme == Uri.UriSchemeHttps ? 2L : 3L);
|
||||
SetCurl(curl, 182, 2L); // redirects are disabled
|
||||
SetCurl(curl, 10018, "BD2LoginUI");
|
||||
headers = AddCurlHeader(headers, "Accept: application/json");
|
||||
if (authorization != null) headers = AddCurlHeader(headers, "Authorization: " + authorization);
|
||||
if (requestHeaders != null)
|
||||
foreach (KeyValuePair<string, string> item in requestHeaders) headers = AddCurlHeader(headers, item.Key + ": " + item.Value);
|
||||
if (body != null)
|
||||
{
|
||||
if (!HasContentType(requestHeaders)) headers = AddCurlHeader(headers, "Content-Type: application/json");
|
||||
pinnedBody = Marshal.AllocHGlobal(Math.Max(1, body.Length));
|
||||
if (body.Length > 0) Marshal.Copy(body, 0, pinnedBody, body.Length);
|
||||
SetCurl(curl, 10015, pinnedBody);
|
||||
SetCurl(curl, 60, (long)body.Length);
|
||||
}
|
||||
SetCurl(curl, 10023, headers);
|
||||
SetCurl(curl, 20011, Marshal.GetFunctionPointerForDelegate(write));
|
||||
SetCurl(curl, 20079, Marshal.GetFunctionPointerForDelegate(header));
|
||||
SetCurl(curl, 20219, Marshal.GetFunctionPointerForDelegate(progress));
|
||||
SetCurl(curl, 43, 0L);
|
||||
token.ThrowIfCancellationRequested();
|
||||
int result = curl_easy_perform(curl);
|
||||
GC.KeepAlive(write);
|
||||
GC.KeepAlive(progress);
|
||||
GC.KeepAlive(header);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (result != 0) return Failure(exceeded ? "Control response exceeded limit" : "System curl error " + result);
|
||||
long status;
|
||||
int infoResult = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
|
||||
? curl_easy_getinfo_arm64(curl, 0x200002, 0, 0, 0, 0, 0, 0, out status)
|
||||
: curl_easy_getinfo(curl, 0x200002, out status);
|
||||
if (infoResult != 0) return Failure("Could not read system curl status");
|
||||
byte[] data = response.ToArray();
|
||||
Array.Clear(response.GetBuffer(), 0, (int)response.Length);
|
||||
return new Response { StatusCode = (int)status, Data = data, Headers = responseHeaders, Body = Encoding.UTF8.GetString(data),
|
||||
RefreshInvalid = IsRefreshInvalid(responseHeaders) };
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (curl != IntPtr.Zero) curl_easy_cleanup(curl);
|
||||
if (headers != IntPtr.Zero) curl_slist_free_all(headers);
|
||||
if (pinnedBody != IntPtr.Zero)
|
||||
{
|
||||
for (int index = 0; index < (body?.Length ?? 0); index++) Marshal.WriteByte(pinnedBody, index, 0);
|
||||
Marshal.FreeHGlobal(pinnedBody);
|
||||
}
|
||||
if (response.TryGetBuffer(out ArraySegment<byte> used)) Array.Clear(used.Array, used.Offset, used.Count);
|
||||
GC.KeepAlive(write);
|
||||
GC.KeepAlive(progress);
|
||||
GC.KeepAlive(header);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void KeepResponseHeader(Dictionary<string, string> headers, string line)
|
||||
{
|
||||
int separator = line.IndexOf(':');
|
||||
if (separator <= 0) return;
|
||||
string name = line.Substring(0, separator).Trim();
|
||||
if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || name.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.StartsWith("X-BD2-", StringComparison.OrdinalIgnoreCase))
|
||||
headers[name] = line.Substring(separator + 1).Trim();
|
||||
}
|
||||
|
||||
private static bool HasContentType(IReadOnlyDictionary<string, string> headers)
|
||||
{
|
||||
if (headers == null) return false;
|
||||
foreach (string name in headers.Keys)
|
||||
if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsRefreshInvalid(Dictionary<string, string> headers) =>
|
||||
headers.TryGetValue("X-BD2-Refresh-Invalid", out string value) && value == "1";
|
||||
|
||||
private static bool AllowedRequestHeader(string name) => name != null &&
|
||||
(name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("b2_app_version", StringComparison.OrdinalIgnoreCase) || name.Equals("b2_bundle_version", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("market_type", StringComparison.OrdinalIgnoreCase) || name.StartsWith("X-BD2-", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool ValidHeaderName(string name)
|
||||
{
|
||||
foreach (char character in name)
|
||||
if (!((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
|
||||
(character >= '0' && character <= '9') || character == '-' || character == '_')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IntPtr AddCurlHeader(IntPtr list, string header)
|
||||
{
|
||||
IntPtr next = curl_slist_append(list, header);
|
||||
if (next == IntPtr.Zero) throw new InvalidOperationException("Could not create native request headers");
|
||||
return next;
|
||||
}
|
||||
private static bool EnsureCurlInitialized()
|
||||
{
|
||||
lock (CurlInitializationLock)
|
||||
{
|
||||
if (curlInitialized) return true;
|
||||
if (curl_global_init(3) != 0) return false;
|
||||
curlInitialized = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
private static void SetCurl(IntPtr curl, int option, long value)
|
||||
{
|
||||
int result = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
|
||||
? curl_easy_setopt_arm64_long(curl, option, 0, 0, 0, 0, 0, 0, value)
|
||||
: curl_easy_setopt_long(curl, option, value);
|
||||
if (result != 0) throw new InvalidOperationException("System curl option failed");
|
||||
}
|
||||
private static void SetCurl(IntPtr curl, int option, string value)
|
||||
{
|
||||
if (RuntimeInformation.ProcessArchitecture != Architecture.Arm64)
|
||||
{
|
||||
if (curl_easy_setopt_string(curl, option, value) != 0) throw new InvalidOperationException("System curl option failed");
|
||||
return;
|
||||
}
|
||||
byte[] utf8 = Encoding.UTF8.GetBytes(value + "\0");
|
||||
IntPtr pointer = Marshal.AllocHGlobal(utf8.Length);
|
||||
try { Marshal.Copy(utf8, 0, pointer, utf8.Length); SetCurl(curl, option, pointer); }
|
||||
finally { Array.Clear(utf8, 0, utf8.Length); Marshal.FreeHGlobal(pointer); }
|
||||
}
|
||||
private static void SetCurl(IntPtr curl, int option, IntPtr value)
|
||||
{
|
||||
int result = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
|
||||
? curl_easy_setopt_arm64_pointer(curl, option, 0, 0, 0, 0, 0, 0, value)
|
||||
: curl_easy_setopt_pointer(curl, option, value);
|
||||
if (result != 0) throw new InvalidOperationException("System curl option failed");
|
||||
}
|
||||
private const string Curl = "/usr/lib/libcurl.dylib";
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate UIntPtr CurlWrite(IntPtr data, UIntPtr size, UIntPtr count, IntPtr context);
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int CurlProgress(IntPtr context, long downloadTotal, long downloadNow, long uploadTotal, long uploadNow);
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern int curl_global_init(long flags);
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern IntPtr curl_easy_init();
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern void curl_easy_cleanup(IntPtr curl);
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_perform(IntPtr curl);
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_getinfo(IntPtr curl, int info, out long value);
|
||||
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_setopt_long(IntPtr curl, int option, long value);
|
||||
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_setopt_pointer(IntPtr curl, int option, IntPtr value);
|
||||
// Apple's arm64 variadic ABI puts unnamed arguments on the stack. Occupying
|
||||
// x2..x7 with unused arguments places the value at the first stack slot.
|
||||
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern int curl_easy_setopt_arm64_long(IntPtr curl, int option, ulong x2, ulong x3, ulong x4, ulong x5, ulong x6, ulong x7, long value);
|
||||
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern int curl_easy_setopt_arm64_pointer(IntPtr curl, int option, ulong x2, ulong x3, ulong x4, ulong x5, ulong x6, ulong x7, IntPtr value);
|
||||
[DllImport(Curl, EntryPoint = "curl_easy_getinfo", CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern int curl_easy_getinfo_arm64(IntPtr curl, int info, ulong x2, ulong x3, ulong x4, ulong x5, ulong x6, ulong x7, out long value);
|
||||
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_setopt_string(IntPtr curl, int option, [MarshalAs(UnmanagedType.LPUTF8Str)] string value);
|
||||
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_setopt_write(IntPtr curl, int option, CurlWrite value);
|
||||
[DllImport(Curl, EntryPoint = "curl_easy_setopt", CallingConvention = CallingConvention.Cdecl)] private static extern int curl_easy_setopt_progress(IntPtr curl, int option, CurlProgress value);
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern IntPtr curl_slist_append(IntPtr list, [MarshalAs(UnmanagedType.LPUTF8Str)] string value);
|
||||
[DllImport(Curl, CallingConvention = CallingConvention.Cdecl)] private static extern void curl_slist_free_all(IntPtr list);
|
||||
}
|
||||
+37
-1681
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BepInEx.Logging;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
// Unity keeps its game packet parser; only the HTTPS transport runs through the OS.
|
||||
// This listener binds loopback only and can forward game paths to one fixed origin.
|
||||
internal sealed class SecureGameRelay : IDisposable
|
||||
{
|
||||
private readonly Uri server;
|
||||
private readonly Uri local;
|
||||
private readonly string prefix;
|
||||
private readonly TcpListener listener;
|
||||
private readonly CancellationTokenSource lifetime = new CancellationTokenSource();
|
||||
private readonly SemaphoreSlim slots = new SemaphoreSlim(16, 16);
|
||||
private readonly ManualLogSource log;
|
||||
private int disposed;
|
||||
|
||||
private SecureGameRelay(Uri server, ManualLogSource log)
|
||||
{
|
||||
if (server == null || server.Scheme != Uri.UriSchemeHttps || server.AbsolutePath != "/" ||
|
||||
server.UserInfo.Length != 0 || server.Query.Length != 0 || server.Fragment.Length != 0)
|
||||
throw new ArgumentException("Game relay requires a fixed HTTPS origin");
|
||||
this.server = server;
|
||||
this.log = log;
|
||||
byte[] nonce = new byte[32];
|
||||
using (RandomNumberGenerator random = RandomNumberGenerator.Create()) random.GetBytes(nonce);
|
||||
prefix = "/" + BitConverter.ToString(nonce).Replace("-", "").ToLowerInvariant() + "/";
|
||||
Array.Clear(nonce, 0, nonce.Length);
|
||||
listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
local = new Uri("http://127.0.0.1:" + ((IPEndPoint)listener.LocalEndpoint).Port.ToString(CultureInfo.InvariantCulture) + "/");
|
||||
new Thread(AcceptLoop) { IsBackground = true, Name = "BD2 secure game transport" }.Start();
|
||||
log?.LogInfo("Game HTTPS transport uses native OS networking through a private loopback relay");
|
||||
}
|
||||
|
||||
internal static SecureGameRelay Start(Uri server, ManualLogSource log) => new SecureGameRelay(server, log);
|
||||
|
||||
internal Uri Rewrite(Uri uri)
|
||||
{
|
||||
return IsRelayedUri(uri) ? new Uri(local, prefix + uri.PathAndQuery.TrimStart('/')) : uri;
|
||||
}
|
||||
|
||||
internal bool TryResolve(Uri uri, out Uri original)
|
||||
{
|
||||
original = null;
|
||||
if (uri == null || uri.Scheme != local.Scheme || uri.Host != local.Host || uri.Port != local.Port ||
|
||||
!uri.AbsolutePath.StartsWith(prefix, StringComparison.Ordinal)) return false;
|
||||
Uri resolved = new Uri(server, "/" + uri.PathAndQuery.Substring(prefix.Length));
|
||||
if (!IsRelayedUri(resolved)) return false;
|
||||
original = resolved;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsRelayedUri(Uri uri) => uri != null && uri.Scheme == server.Scheme &&
|
||||
uri.IdnHost == server.IdnHost && uri.Port == server.Port && uri.UserInfo.Length == 0 &&
|
||||
uri.Fragment.Length == 0 && (uri.AbsolutePath.StartsWith("/game/", StringComparison.Ordinal) ||
|
||||
uri.AbsolutePath.Equals("/logs", StringComparison.Ordinal));
|
||||
|
||||
private void AcceptLoop()
|
||||
{
|
||||
while (Volatile.Read(ref disposed) == 0)
|
||||
{
|
||||
TcpClient client;
|
||||
try { client = listener.AcceptTcpClient(); }
|
||||
catch (Exception) { if (Volatile.Read(ref disposed) != 0) return; continue; }
|
||||
if (!slots.Wait(0)) { client.Dispose(); continue; }
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try { using (client) await Serve(client).ConfigureAwait(false); }
|
||||
catch (Exception ex) when (ex is IOException || ex is SocketException || ex is OperationCanceledException || ex is InvalidDataException)
|
||||
{ /* Close failed transport: the game's existing ConnectionError recovery handles it. */ }
|
||||
catch (Exception ex) { log?.LogWarning("Native game transport failed: " + ex.GetType().Name); }
|
||||
finally { slots.Release(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Serve(TcpClient client)
|
||||
{
|
||||
client.ReceiveTimeout = 5000;
|
||||
client.SendTimeout = 5000;
|
||||
NetworkStream stream = client.GetStream();
|
||||
byte[] header = new byte[16 * 1024];
|
||||
int used = 0;
|
||||
while (used < header.Length)
|
||||
{
|
||||
int next = stream.ReadByte();
|
||||
if (next < 0) throw new IOException("Incomplete relay headers");
|
||||
header[used++] = (byte)next;
|
||||
if (used >= 4 && header[used - 4] == 13 && header[used - 3] == 10 && header[used - 2] == 13 && header[used - 1] == 10) break;
|
||||
}
|
||||
if (used == header.Length) throw new InvalidDataException("Relay header limit");
|
||||
string[] lines = Encoding.ASCII.GetString(header, 0, used).Split(new[] { "\r\n" }, StringSplitOptions.None);
|
||||
Array.Clear(header, 0, header.Length);
|
||||
string[] start = lines[0].Split(' ');
|
||||
if (start.Length != 3 || (start[0] != "PUT" && start[0] != "POST" && start[0] != "GET") ||
|
||||
!start[1].StartsWith(prefix, StringComparison.Ordinal) || !start[2].StartsWith("HTTP/1.", StringComparison.Ordinal))
|
||||
throw new InvalidDataException("Invalid relay request");
|
||||
if (!Uri.TryCreate(local, start[1], out Uri target) || !TryResolve(target, out Uri remote))
|
||||
throw new InvalidDataException("Invalid relay target");
|
||||
Dictionary<string, string> headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
int length = 0;
|
||||
bool lengthSeen = false;
|
||||
foreach (string line in lines)
|
||||
{
|
||||
int separator = line.IndexOf(':');
|
||||
if (separator <= 0) continue;
|
||||
string name = line.Substring(0, separator).Trim(), value = line.Substring(separator + 1).Trim();
|
||||
if (name.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Chunked relay requests are not supported");
|
||||
if (name.Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (lengthSeen || !int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out length) || length > 8 * 1024 * 1024)
|
||||
throw new InvalidDataException("Invalid relay content length");
|
||||
lengthSeen = true;
|
||||
}
|
||||
if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || name.Equals("Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("b2_app_version", StringComparison.OrdinalIgnoreCase) || name.Equals("b2_bundle_version", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("market_type", StringComparison.OrdinalIgnoreCase)) headers[name] = value;
|
||||
}
|
||||
byte[] body = length == 0 ? null : new byte[length];
|
||||
PlatformControlHttp.Response response = null;
|
||||
try
|
||||
{
|
||||
for (int offset = 0; offset < length;)
|
||||
{
|
||||
int count = stream.Read(body, offset, length - offset);
|
||||
if (count <= 0) throw new IOException("Incomplete relay body");
|
||||
offset += count;
|
||||
}
|
||||
response = await PlatformControlHttp.Send(remote, start[0], body, null, 4, lifetime.Token,
|
||||
lifetime.Token, 64 * 1024 * 1024, headers).ConfigureAwait(false);
|
||||
if (response.StatusCode == 0 || response.Data == null) throw new IOException("Native game transport unavailable");
|
||||
StringBuilder output = new StringBuilder("HTTP/1.1 ").Append(response.StatusCode.ToString(CultureInfo.InvariantCulture)).Append(" Response\r\nConnection: close\r\nCache-Control: no-store\r\n");
|
||||
foreach (KeyValuePair<string, string> item in response.Headers)
|
||||
{
|
||||
if ((item.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || item.Key.Equals("Set-Cookie", StringComparison.OrdinalIgnoreCase) ||
|
||||
item.Key.StartsWith("X-BD2-", StringComparison.OrdinalIgnoreCase)) && item.Value.IndexOfAny(new[] { '\r', '\n' }) < 0)
|
||||
output.Append(item.Key).Append(": ").Append(item.Value).Append("\r\n");
|
||||
}
|
||||
output.Append("Content-Length: ").Append(response.Data.Length.ToString(CultureInfo.InvariantCulture)).Append("\r\n\r\n");
|
||||
byte[] replyHeader = Encoding.ASCII.GetBytes(output.ToString());
|
||||
stream.Write(replyHeader, 0, replyHeader.Length);
|
||||
stream.Write(response.Data, 0, response.Data.Length);
|
||||
Array.Clear(replyHeader, 0, replyHeader.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (body != null) Array.Clear(body, 0, body.Length);
|
||||
if (response?.Data != null) Array.Clear(response.Data, 0, response.Data.Length);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) != 0) return;
|
||||
lifetime.Cancel();
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using BD2.GameNames;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using UnityEngine.UI;
|
||||
|
||||
using static BD2.GameNames.Game;
|
||||
using static Bd2LoginUI.LoginPanel;
|
||||
using static Bd2LoginUI.LoginRuntime;
|
||||
using static Bd2LoginUI.ControlRequests;
|
||||
using static Bd2LoginUI.LoginController;
|
||||
|
||||
namespace Bd2LoginUI;
|
||||
|
||||
// Owns recovery generations, monitor coroutines, game relay and recovery overlay.
|
||||
internal static class SessionRecovery
|
||||
{
|
||||
internal static int SessionRecoveryInProgress;
|
||||
private static RecoveryHost Owner;
|
||||
private static SecureGameRelay GameRelay;
|
||||
private static readonly List<WeakReference<UnityWebRequest>> GameRequests = new List<WeakReference<UnityWebRequest>>();
|
||||
internal static bool EstablishedGameSession;
|
||||
private static GameObject RecoveryOverlay;
|
||||
private static Text RecoveryText;
|
||||
internal static string ServerInstanceID;
|
||||
private static int RecoveryRestartScheduled;
|
||||
private static int RecoveryEnterScheduled;
|
||||
internal static int RecoveryGeneration;
|
||||
internal static int RuntimeProbeFailures;
|
||||
internal static CancellationTokenSource ControlProbeCancellation = new CancellationTokenSource();
|
||||
|
||||
internal static void DisposeGameRelay()
|
||||
{
|
||||
SecureGameRelay relay = GameRelay;
|
||||
GameRelay = null;
|
||||
relay?.Dispose();
|
||||
}
|
||||
|
||||
internal static void EnsureGameRelay()
|
||||
{
|
||||
if (GameRelay == null && ServerRoot != null && ServerRoot.Scheme == Uri.UriSchemeHttps && PlatformControlHttp.IsSupported)
|
||||
{
|
||||
GameRelay = SecureGameRelay.Start(ServerRoot, Log);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetOwnedRequestUri(string url, out Uri canonical)
|
||||
{
|
||||
canonical = null;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri parsed)) return false;
|
||||
if (GameRelay != null && GameRelay.TryResolve(parsed, out Uri original)) parsed = original;
|
||||
if (ServerRoot == null || !SameOrigin(ServerRoot, parsed)) return false;
|
||||
canonical = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void CancelControlProbes()
|
||||
{
|
||||
CancellationTokenSource previous = ControlProbeCancellation;
|
||||
ControlProbeCancellation = new CancellationTokenSource();
|
||||
previous.Cancel();
|
||||
previous.Dispose();
|
||||
}
|
||||
|
||||
internal static void IntroAwakePostfix(object __instance)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureRecoveryHost();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogError("Could not restore login recovery host: " + ex);
|
||||
}
|
||||
ConfigureLoginPanel(__instance);
|
||||
}
|
||||
|
||||
// The BepInEx plugin component can disappear while its static Harmony hooks remain.
|
||||
// Keep coroutines on a scene-independent host and recreate it if Unity destroys it.
|
||||
internal static void EnsureRecoveryHost()
|
||||
{
|
||||
if (Owner != null && Owner.gameObject.activeInHierarchy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Owner != null)
|
||||
{
|
||||
UnityEngine.Object.Destroy(Owner.gameObject);
|
||||
}
|
||||
GameObject host = new GameObject("BD2 Login Recovery Host");
|
||||
UnityEngine.Object.DontDestroyOnLoad(host);
|
||||
Owner = host.AddComponent<RecoveryHost>();
|
||||
Log?.LogInfo("Created persistent login recovery coroutine host");
|
||||
CancelControlProbes();
|
||||
Interlocked.Increment(ref RecoveryGeneration);
|
||||
Interlocked.Exchange(ref RecoveryRestartScheduled, 0);
|
||||
Interlocked.Exchange(ref RecoveryEnterScheduled, 0);
|
||||
Owner.StartCoroutine(RuntimeMonitor());
|
||||
}
|
||||
|
||||
public sealed class RecoveryHost : MonoBehaviour
|
||||
{
|
||||
internal readonly CancellationTokenSource Lifetime = new CancellationTokenSource();
|
||||
private void OnDestroy()
|
||||
{
|
||||
Lifetime.Cancel();
|
||||
Lifetime.Dispose();
|
||||
Log?.LogWarning("Login recovery coroutine host was destroyed");
|
||||
}
|
||||
}
|
||||
|
||||
internal static void SendWebRequestPrefix(UnityWebRequest __instance)
|
||||
{
|
||||
if (__instance == null || !TryGetOwnedRequestUri(__instance.url, out Uri canonical)) return;
|
||||
bool gameRequest = canonical.AbsolutePath.StartsWith("/game/", StringComparison.Ordinal);
|
||||
bool telemetryRequest = canonical.AbsolutePath.Equals("/logs", StringComparison.Ordinal);
|
||||
if (!gameRequest && !telemetryRequest) return;
|
||||
EnsureGameRelay();
|
||||
if (gameRequest)
|
||||
{
|
||||
GameRequests.RemoveAll(reference => !reference.TryGetTarget(out _));
|
||||
if (GameRequests.Count >= 128) GameRequests.RemoveAt(0);
|
||||
GameRequests.Add(new WeakReference<UnityWebRequest>(__instance));
|
||||
}
|
||||
if (GameRelay != null)
|
||||
{
|
||||
__instance.url = GameRelay.Rewrite(canonical).AbsoluteUri;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void DisposeWebRequestPrefix(UnityWebRequest __instance)
|
||||
{
|
||||
if (__instance == null || !__instance.isDone)
|
||||
{
|
||||
return;
|
||||
}
|
||||
GameRequests.RemoveAll(reference => !reference.TryGetTarget(out UnityWebRequest request) || ReferenceEquals(request, __instance));
|
||||
InspectCompletedGameRequest(__instance);
|
||||
}
|
||||
|
||||
private static void InspectCompletedGameRequest(UnityWebRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!IsCurrentGameRequest(request, out Uri requestUri))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (requestUri.AbsolutePath.Equals("/game/LoginUser", StringComparison.Ordinal) &&
|
||||
request.responseCode >= 200 && request.responseCode < 300)
|
||||
{
|
||||
EnsureRecoveryHost();
|
||||
EstablishedGameSession = true;
|
||||
SetRecoveryMessage("正在同步玩家数据……\nSynchronizing player data…");
|
||||
return;
|
||||
}
|
||||
bool accessExpired = requestUri.AbsolutePath.Equals("/game/LoginUser", StringComparison.Ordinal) &&
|
||||
request.responseCode == 401 &&
|
||||
string.Equals(request.GetResponseHeader("X-BD2-Access-Expired"), "1", StringComparison.Ordinal);
|
||||
if (accessExpired)
|
||||
{
|
||||
AccessTokens.Clear();
|
||||
BeginSessionRecovery("expired game access credential");
|
||||
return;
|
||||
}
|
||||
bool sessionExpired = request.responseCode == 401 &&
|
||||
string.Equals(request.GetResponseHeader("X-BD2-Session-Expired"), "1", StringComparison.Ordinal);
|
||||
bool serverRestarting = request.responseCode == 503 &&
|
||||
string.Equals(request.GetResponseHeader("X-BD2-Reconnect"), "1", StringComparison.Ordinal);
|
||||
if (!sessionExpired && !serverRestarting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
BeginSessionRecovery(serverRestarting ? "server restart" : "expired game session");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogError("Could not inspect the completed game request: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCurrentGameRequest(UnityWebRequest request, out Uri requestUri)
|
||||
{
|
||||
requestUri = null;
|
||||
if (request == null || !TryGetOwnedRequestUri(request.url, out Uri canonical) ||
|
||||
!canonical.AbsolutePath.StartsWith("/game/", StringComparison.Ordinal)) return false;
|
||||
requestUri = canonical;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool BeginSessionRecovery(string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ServerRoot == null)
|
||||
{
|
||||
Log?.LogWarning("Cannot recover game session without a configured server");
|
||||
return false;
|
||||
}
|
||||
EnsureRecoveryHost();
|
||||
if (Interlocked.CompareExchange(ref SessionRecoveryInProgress, 1, 0) == 0)
|
||||
{
|
||||
Log?.LogWarning("Starting automatic game-session recovery: " + reason);
|
||||
ShowRecoveryOverlay("正在重新连接服务器……\nReconnecting to server…");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log?.LogWarning("Restarting automatic game-session recovery: " + reason);
|
||||
}
|
||||
ScheduleRecoveryRestart();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FinishRecovery(false, "could not start recovery: " + ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool ExponentialBackoffPrefix(object __0)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ServerRoot == null || !IsConfiguredServerFailure(__0) || !IsTransportFailure(__0))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
|
||||
{
|
||||
return !BeginSessionRecovery("transport failure during recovery");
|
||||
}
|
||||
return !EstablishedGameSession || !BeginSessionRecovery("transport failure");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogError("Could not classify game request failure: " + ex);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTransportFailure(object packetException)
|
||||
{
|
||||
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
object packet = packetException.GetType().GetGameProperty("PacketData", flags)?.GetValue(packetException, null);
|
||||
string server = packet?.GetType().GetGameField("RequestServerURL", flags)?.GetValue(packet) as string;
|
||||
string path = packet?.GetType().GetGameProperty("SendPath", flags)?.GetValue(packet, null) as string;
|
||||
if (!Uri.TryCreate(server + path, UriKind.Absolute, out Uri packetUri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
UnityWebRequest match = null;
|
||||
foreach (WeakReference<UnityWebRequest> reference in GameRequests)
|
||||
{
|
||||
if (!reference.TryGetTarget(out UnityWebRequest request) || !request.isDone ||
|
||||
!TryGetOwnedRequestUri(request.url, out Uri requestUri) || requestUri != packetUri)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (match != null)
|
||||
{
|
||||
return false; // Ambiguous concurrent requests must retain client error handling.
|
||||
}
|
||||
match = request;
|
||||
}
|
||||
// HTTP errors (including 501) are ProtocolError, even though the game calls
|
||||
// ExponetialBackOff for every non-Success result. Do not infer status from error text.
|
||||
return match != null && match.result == UnityWebRequest.Result.ConnectionError;
|
||||
}
|
||||
|
||||
private static bool IsConfiguredServerFailure(object packetException)
|
||||
{
|
||||
if (packetException == null || ServerRoot == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
Type exceptionType = packetException.GetType();
|
||||
string url = exceptionType.GetGameProperty("Url", flags)?.GetValue(packetException, null) as string;
|
||||
object packet = exceptionType.GetGameProperty("PacketData", flags)?.GetValue(packetException, null);
|
||||
string requestServer = packet?.GetType().GetGameField("RequestServerURL", flags)?.GetValue(packet) as string;
|
||||
foreach (string candidate in new[] { requestServer, url })
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out Uri parsed))
|
||||
{
|
||||
parsed = new Uri(ServerRoot, candidate.TrimStart('/'));
|
||||
}
|
||||
if (SameOrigin(ServerRoot, parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ScheduleRecoveryRestart()
|
||||
{
|
||||
CancelControlProbes();
|
||||
Interlocked.Increment(ref RecoveryGeneration);
|
||||
AuthenticationLoadingOrigin = null;
|
||||
LoginInProgress = false;
|
||||
ContinueMaintenance = false;
|
||||
EstablishedGameSession = false;
|
||||
RuntimeProbeFailures = 0;
|
||||
ServerInstanceID = null;
|
||||
Interlocked.Exchange(ref RecoveryEnterScheduled, 0);
|
||||
if (Interlocked.CompareExchange(ref RecoveryRestartScheduled, 1, 0) == 0)
|
||||
{
|
||||
Owner.StartCoroutine(StartLatestRecoveryGeneration());
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator StartLatestRecoveryGeneration()
|
||||
{
|
||||
int generation;
|
||||
do
|
||||
{
|
||||
generation = Volatile.Read(ref RecoveryGeneration);
|
||||
yield return new WaitForSecondsRealtime(0.25f);
|
||||
}
|
||||
while (generation != Volatile.Read(ref RecoveryGeneration));
|
||||
Interlocked.Exchange(ref RecoveryRestartScheduled, 0);
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
|
||||
{
|
||||
Owner.StartCoroutine(WaitForServerAndRestart(generation));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerator WaitForServerAndRestart(int generation)
|
||||
{
|
||||
float delay = 0.5f;
|
||||
string lastError = null;
|
||||
CancellationToken lifetime = Owner.Lifetime.Token;
|
||||
while (Volatile.Read(ref SessionRecoveryInProgress) != 0 && generation == Volatile.Read(ref RecoveryGeneration))
|
||||
{
|
||||
SetRecoveryMessage("等待服务器启动……\nWaiting for server…");
|
||||
ControlProbeResult result = null;
|
||||
yield return RequestControlEndpoint(new Uri(ServerRoot, "readyz"), UnityWebRequest.kHttpVerbGET, null, null, 6,
|
||||
lifetime, ControlProbeCancellation.Token, response => result = response);
|
||||
if (result == null || generation != Volatile.Read(ref RecoveryGeneration) || lifetime.IsCancellationRequested) yield break;
|
||||
if (result.Success && result.StatusCode == 200) break;
|
||||
if (result.Error != lastError)
|
||||
{
|
||||
Log?.LogWarning("Server readiness probe failed: " + result.Error);
|
||||
lastError = result.Error;
|
||||
}
|
||||
yield return new WaitForSecondsRealtime(delay);
|
||||
delay = Math.Min(delay * 2f, 5f);
|
||||
}
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) == 0 || generation != Volatile.Read(ref RecoveryGeneration))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
Log?.LogInfo("Server became ready; restarting client to restore session");
|
||||
SetRecoveryMessage("正在恢复登录会话……\nRestoring session…");
|
||||
if (!RestartClientForRecovery())
|
||||
{
|
||||
FinishRecovery(false, "client restart methods are unavailable", generation);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RestartClientForRecovery()
|
||||
{
|
||||
BDNetwork.NetworkManager network = FindUnitySingleton<BDNetwork.NetworkManager>();
|
||||
AppManager app = FindUnitySingleton<AppManager>();
|
||||
MethodInfo refresh = network?.GetType().GetGameMethod(nameof(BDNetwork.NetworkManager.Refresh), BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null);
|
||||
MethodInfo restart = app?.GetType().GetGameMethod(nameof(AppManager.AppReStart), BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null);
|
||||
if (refresh == null || restart == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
AuthenticationLoadingOrigin = null;
|
||||
LoginInProgress = false;
|
||||
ContinueMaintenance = false;
|
||||
Interlocked.Exchange(ref RecoveryEnterScheduled, 0);
|
||||
ServerInstanceID = null;
|
||||
refresh.Invoke(network, null);
|
||||
restart.Invoke(app, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IEnumerator RuntimeMonitor()
|
||||
{
|
||||
WaitForSecondsRealtime interval = new WaitForSecondsRealtime(5f);
|
||||
while (true)
|
||||
{
|
||||
if (ServerRoot == null || !EstablishedGameSession || Volatile.Read(ref SessionRecoveryInProgress) != 0)
|
||||
{
|
||||
yield return interval;
|
||||
continue;
|
||||
}
|
||||
int generation = Volatile.Read(ref RecoveryGeneration);
|
||||
CancellationToken lifetime = Owner.Lifetime.Token;
|
||||
{
|
||||
ControlProbeResult result = null;
|
||||
yield return RequestControlEndpoint(new Uri(ServerRoot, "client/runtime"), UnityWebRequest.kHttpVerbGET, null, null, 6,
|
||||
lifetime, ControlProbeCancellation.Token, response => result = response);
|
||||
if (lifetime.IsCancellationRequested) yield break;
|
||||
if (result == null || generation != Volatile.Read(ref RecoveryGeneration) || Volatile.Read(ref SessionRecoveryInProgress) != 0) continue;
|
||||
if (!result.Success)
|
||||
{
|
||||
Log?.LogWarning("Server runtime probe failed: " + result.Error);
|
||||
RuntimeProbeFailures++;
|
||||
if (RuntimeProbeFailures >= 3)
|
||||
{
|
||||
BeginSessionRecovery("runtime probe failed three consecutive times");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
RuntimeProbeFailures = 0;
|
||||
RuntimeStatus status = null;
|
||||
try
|
||||
{
|
||||
status = JsonUtility.FromJson<RuntimeStatus>(result.Body);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.LogWarning("Server runtime response was invalid: " + ex.Message);
|
||||
}
|
||||
if (status != null && !string.IsNullOrEmpty(status.instance_id))
|
||||
{
|
||||
if (ServerInstanceID == null)
|
||||
{
|
||||
ServerInstanceID = status.instance_id;
|
||||
}
|
||||
else if (ServerInstanceID != status.instance_id || status.status == "draining")
|
||||
{
|
||||
ServerInstanceID = status.instance_id;
|
||||
BeginSessionRecovery(status.status == "draining" ? "server draining" : "server instance changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
yield return interval;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool SuppressNetworkErrorDuringRecovery()
|
||||
{
|
||||
return Volatile.Read(ref SessionRecoveryInProgress) == 0;
|
||||
}
|
||||
|
||||
internal static void SetIntroStatePostfix(object __instance, object __0)
|
||||
{
|
||||
if (Volatile.Read(ref SessionRecoveryInProgress) == 0 || __0 == null || Convert.ToInt32(__0) != 9 || Owner == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Interlocked.CompareExchange(ref RecoveryEnterScheduled, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Owner.StartCoroutine(EnterAfterAuthoritativeLoad(__instance, Volatile.Read(ref RecoveryGeneration)));
|
||||
}
|
||||
|
||||
private static IEnumerator EnterAfterAuthoritativeLoad(object introUI, int generation)
|
||||
{
|
||||
SetRecoveryMessage("正在返回安全场景……\nReturning to a safe scene…");
|
||||
yield return null;
|
||||
if (generation != Volatile.Read(ref RecoveryGeneration))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
try
|
||||
{
|
||||
EnterGame.Invoke(introUI, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
BeginSessionRecovery("could not enter safe scene: " + ex.Message);
|
||||
yield break;
|
||||
}
|
||||
float deadline = Time.realtimeSinceStartup + 30f;
|
||||
bool fieldLoaded = false;
|
||||
while (Time.realtimeSinceStartup < deadline && generation == Volatile.Read(ref RecoveryGeneration))
|
||||
{
|
||||
GameFieldManager field = FindUnitySingleton<GameFieldManager>();
|
||||
PropertyInfo loaded = field?.GetType().GetGameProperty(nameof(GameFieldManager.IsLoadedField), BindingFlags.Instance | BindingFlags.Public);
|
||||
if (loaded != null && loaded.GetValue(field) is bool ready && ready)
|
||||
{
|
||||
fieldLoaded = true;
|
||||
break;
|
||||
}
|
||||
if (IsPackCollectionActive())
|
||||
{
|
||||
fieldLoaded = true;
|
||||
break;
|
||||
}
|
||||
yield return new WaitForSecondsRealtime(0.25f);
|
||||
}
|
||||
if (!fieldLoaded)
|
||||
{
|
||||
if (generation == Volatile.Read(ref RecoveryGeneration))
|
||||
{
|
||||
BeginSessionRecovery("safe scene load timed out");
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
FinishRecovery(true, null, generation);
|
||||
}
|
||||
|
||||
private static bool IsPackCollectionActive()
|
||||
{
|
||||
Type uiManager = typeof(UIManager);
|
||||
MethodInfo getUI = uiManager?.GetGameMethod(
|
||||
"GetUI",
|
||||
BindingFlags.Static | BindingFlags.Public,
|
||||
null,
|
||||
new[] { typeof(string) },
|
||||
null);
|
||||
Component collection = getUI?.Invoke(null, new object[] { "PackCollectionUI" }) as Component;
|
||||
return collection != null && collection.gameObject != null && collection.gameObject.activeInHierarchy;
|
||||
}
|
||||
|
||||
private static void ShowRecoveryOverlay(string message)
|
||||
{
|
||||
if (RecoveryOverlay == null)
|
||||
{
|
||||
RecoveryOverlay = new GameObject(
|
||||
"BD2 Recovery Overlay",
|
||||
typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster), typeof(Image));
|
||||
UnityEngine.Object.DontDestroyOnLoad(RecoveryOverlay);
|
||||
Canvas canvas = RecoveryOverlay.GetComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = short.MaxValue;
|
||||
Image background = RecoveryOverlay.GetComponent<Image>();
|
||||
background.color = new Color(0.025f, 0.035f, 0.055f, 0.94f);
|
||||
RectTransform root = RecoveryOverlay.GetComponent<RectTransform>();
|
||||
root.anchorMin = Vector2.zero;
|
||||
root.anchorMax = Vector2.one;
|
||||
root.offsetMin = root.offsetMax = Vector2.zero;
|
||||
GameObject label = new GameObject("Status", typeof(RectTransform), typeof(Text));
|
||||
label.transform.SetParent(RecoveryOverlay.transform, false);
|
||||
RecoveryText = label.GetComponent<Text>();
|
||||
RecoveryText.font = Resources.GetBuiltinResource<Font>("Arial.ttf");
|
||||
RecoveryText.fontSize = 28;
|
||||
RecoveryText.alignment = TextAnchor.MiddleCenter;
|
||||
RecoveryText.color = Color.white;
|
||||
RectTransform rect = label.GetComponent<RectTransform>();
|
||||
rect.anchorMin = new Vector2(0.15f, 0.35f);
|
||||
rect.anchorMax = new Vector2(0.85f, 0.65f);
|
||||
rect.offsetMin = rect.offsetMax = Vector2.zero;
|
||||
}
|
||||
RecoveryOverlay.SetActive(true);
|
||||
SetRecoveryMessage(message);
|
||||
}
|
||||
|
||||
private static void SetRecoveryMessage(string message)
|
||||
{
|
||||
if (RecoveryText != null)
|
||||
{
|
||||
RecoveryText.text = message;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void FinishRecovery(bool success, string error, int expectedGeneration = 0)
|
||||
{
|
||||
if (expectedGeneration != 0 && expectedGeneration != Volatile.Read(ref RecoveryGeneration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!success)
|
||||
{
|
||||
Log?.LogError("Automatic session recovery failed: " + error);
|
||||
}
|
||||
if (RecoveryOverlay != null)
|
||||
{
|
||||
UnityEngine.Object.Destroy(RecoveryOverlay);
|
||||
RecoveryOverlay = null;
|
||||
RecoveryText = null;
|
||||
}
|
||||
CancelControlProbes();
|
||||
Interlocked.Increment(ref RecoveryGeneration);
|
||||
Interlocked.Exchange(ref SessionRecoveryInProgress, 0);
|
||||
Interlocked.Exchange(ref RecoveryRestartScheduled, 0);
|
||||
Interlocked.Exchange(ref RecoveryEnterScheduled, 0);
|
||||
Log?.LogInfo(success ? "Automatic session recovery completed" : "Automatic session recovery stopped");
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class RuntimeStatus
|
||||
{
|
||||
public string status = null;
|
||||
public string instance_id = null;
|
||||
public int retry_after_ms = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<BD2PackageVersion>0.2.1</BD2PackageVersion>
|
||||
<Version Condition="'$(Version)' == ''">$(BD2PackageVersion)</Version>
|
||||
<Authors>Flechazo</Authors>
|
||||
<RepositoryUrl>https://github.com/Flechazo098/bd2</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageProjectUrl>https://github.com/Flechazo098/bd2</PackageProjectUrl>
|
||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
|
||||
<Deterministic>true</Deterministic>
|
||||
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<None Include="$(MSBuildThisFileDirectory)..\LICENSE" Pack="true" PackagePath="" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+13
-1
@@ -1,5 +1,7 @@
|
||||
# 客户端插件工程
|
||||
|
||||
对外开发库采用 NuGet `PackageReference` 导入;第三方插件无需本仓库源码或官方映射。导入、项目配置、Harmony/字符串 API 示例及部署步骤见 [BD2.GameSdk 使用说明](GameSdk/README.md)。维护者运行 `GameSdk/Pack.ps1` 生成 `BD2.GameSdk` 和 `BD2.GameNames` 两个版本配套的 `.nupkg`。
|
||||
|
||||
- `LocalIdentity/`:连接根目录 `versions.json` 所选客户端与本地服务器;构建产物为 `BD2LocalIdentity.dll`。
|
||||
- `LoginUI/`:把客户端登录面板收敛为 Discord 与 Google 两个本服认证入口;构建产物为 `BD2LoginUI.dll`。
|
||||
- `CaptureEnvironment/`:独立原版对照客户端抓包使用;构建产物为 `BD2CaptureEnvironment.dll`。
|
||||
@@ -7,7 +9,11 @@
|
||||
`LocalIdentity` 与 `LoginUI` 用于本地服客户端;`CaptureEnvironment` 用于独立原版抓包环境。原版抓包环境不得安装 `BD2LocalIdentity.dll` 或 `BD2LoginUI.dll`。
|
||||
客户端、资源与插件版本都来自根目录 `versions.json`;MSBuild 在中间目录生成 C# 常量,源码不保存第二份版本值。
|
||||
|
||||
构建时必须显式传入对应客户端目录:
|
||||
SDK 和共享运行时已内嵌当前游戏版本的同一份名字表。源码构建只需客户端目录,无需提供官方映射。`.NET 8 SDK` 构建工具;插件为 `netstandard2.1`,运行时为 `netstandard2.0`。构建自动生成可读引用、完整源码和内嵌源码 PDB,编译插件、执行 reobf 并验证真实 DLL 的引用。首次生成耗时数分钟,三个插件和 Debug/Release 共用本机缓存;IDE 转到定义可以查看方法体,`Game Sources` 文件夹提供全量浏览与搜索。IDE 设置见 [完整源码导航](GameSdk/README.md#完整源码导航)。
|
||||
|
||||
仓库公共路径和默认值集中在 `plugins/Directory.Build.props`;配置/框架相关的输出路径与构建步骤由 `Directory.Build.targets` 自动导入。三个插件 `.csproj` 只保留插件属性和需要的程序集引用。首次开发复制 `Directory.Build.local.props.example` 为 `Directory.Build.local.props`,填写 `BD2LocalGameDir` 和可选的 `BD2CaptureGameDir`;本机文件被 Git 忽略。配置后 IDE 与命令行共用这些路径,不需要在三个子目录分别放一份 `Directory.Build.props`。命令行 `-p:GameDir=...` 仍可覆盖本机设置。
|
||||
|
||||
`BD2GameSdkCache` 可放在本机 props 中指定共享源码缓存。特殊目录布局可以设置 `BD2ManagedDir`、`BD2BepInExDir`;`BD2SdkDir` 可覆盖中间产物目录。新增子目录 `Directory.Build.props` 会阻断 MSBuild 自动查找父文件,确有需要时应显式导入公共 `plugins/Directory.Build.props`。
|
||||
|
||||
```powershell
|
||||
dotnet build .\plugins\LocalIdentity\LocalIdentity.csproj -c Release -p:GameDir="<本地服客户端目录>"
|
||||
@@ -15,6 +21,12 @@ dotnet build .\plugins\LoginUI\LoginUI.csproj -c Release -p:GameDir="<本地服
|
||||
dotnet build .\plugins\CaptureEnvironment\CaptureEnvironment.csproj -c Release -p:GameDir="<原版客户端目录>"
|
||||
```
|
||||
|
||||
`bin` 中的插件是回映射后的运行产物,部署时同时复制相邻的 `BD2.GameNames.dll`。壳和明文表缓存在 `obj`,不部署。客户端开发入口 `go run .\cmd\bd2client --dev run` 自动使用内嵌表;发布和原版抓包启动脚本也携带共享库。
|
||||
|
||||
唯一名字数据源为 `GameNames/Mappings/names.json.gz`。只有维护者更新游戏版本的表时才需要官方 `.obfuscate`,执行 `GameSdk/UpdateNames.ps1`,验证后提交新表,再发布新版本 SDK。
|
||||
|
||||
开发方式、工具命令、字符串与编译期引用的边界见 [GameSdk/README.md](GameSdk/README.md)。现有 `tools/python/deobfuscate_client_source.py` 继续生成阅读镜像;它的全局标识符替换不作为程序集映射表使用。
|
||||
|
||||
认证策略由服务端同目录的 `authentication.json` 决定。`mode=local` 保持本地自动登录;公网或联机服可设为 `oauth`,并在 `providers` 中开启 `discord`、`google` 或两者。LoginUI 从当前连接的服务器读取这项策略,客户端不能自行启用服务端未开放的 provider。
|
||||
|
||||
OAuth access token 只保存在客户端进程内存中。自动登录 refresh credential 按规范化服务器 origin 隔离:Windows 使用当前用户作用域的 DPAPI 加密,再把密文按 origin 哈希键写入 PlayerPrefs 对应的用户注册表;macOS 使用系统 Keychain Services。没有受支持安全凭据存储的平台会禁用自动登录,不会回退到明文文件或 PlayerPrefs。除 Windows 的 DPAPI 密文外,PlayerPrefs 只保存原版的 `IsAutoLogin` 与 `StandaloneAutoLogin` 非敏感选择。
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<BD2VersionConfig>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\versions.json'))</BD2VersionConfig>
|
||||
<BD2GeneratedVersionSource>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\obj\$(Configuration)\$(TargetFramework)\Bd2Versions.g.cs'))</BD2GeneratedVersionSource>
|
||||
</PropertyGroup>
|
||||
<Target Name="GenerateBD2VersionSource" BeforeTargets="CoreCompile">
|
||||
<Error Condition="!Exists('$(BD2VersionConfig)')" Text="Missing repository version config: $(BD2VersionConfig)" />
|
||||
<Exec Command="powershell -NoProfile -ExecutionPolicy Bypass -File "$(MSBuildThisFileDirectory)Generate-VersionSource.ps1" -Config "$(BD2VersionConfig)" -Output "$(BD2GeneratedVersionSource)" -Plugin "$(BD2PluginVersionKey)"" />
|
||||
|
||||
@@ -43,11 +43,13 @@ $exe = Join-Path $GameDir 'BrownDust II.exe'
|
||||
$pluginDir = Join-Path $GameDir 'BepInEx\plugins'
|
||||
$localRes = Join-Path $pluginDir 'PluginLocalRes.dll'
|
||||
$capturePlugin = Join-Path $pluginDir 'BD2CaptureEnvironment.dll'
|
||||
$gameNamesLibrary = Join-Path $pluginDir 'BD2.GameNames.dll'
|
||||
$identityPlugin = Join-Path $pluginDir 'BD2LocalIdentity.dll'
|
||||
$loginUIPlugin = Join-Path $pluginDir 'BD2LoginUI.dll'
|
||||
$doorstop = Join-Path $GameDir 'doorstop_config.ini'
|
||||
$isolatedData = Join-Path $GameDir 'IsolatedUserData'
|
||||
$stagedCapturePlugin = Join-Path $PSScriptRoot '..\..\plugins\CaptureEnvironment\bin\Release\netstandard2.1\BD2CaptureEnvironment.dll'
|
||||
$stagedGameNamesLibrary = Join-Path (Split-Path -Parent $stagedCapturePlugin) 'BD2.GameNames.dll'
|
||||
$stagedLocalResConfig = Join-Path $PSScriptRoot '..\..\plugins\CaptureEnvironment\bd2.localres.cfg'
|
||||
$localResConfig = Join-Path $GameDir 'BepInEx\config\bd2.localres.cfg'
|
||||
|
||||
@@ -62,6 +64,11 @@ foreach ($requiredDirectory in @($GameDir, $ResourceRoot, $SharedDataDir)) {
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $stagedCapturePlugin -PathType Leaf) {
|
||||
if (-not (Test-Path -LiteralPath $stagedGameNamesLibrary -PathType Leaf)) {
|
||||
throw "Capture build is missing its shared runtime: $stagedGameNamesLibrary"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $pluginDir | Out-Null
|
||||
Copy-Item -LiteralPath $stagedGameNamesLibrary -Destination $gameNamesLibrary -Force
|
||||
$installedHash = if (Test-Path -LiteralPath $capturePlugin -PathType Leaf) {
|
||||
(Get-FileHash -LiteralPath $capturePlugin -Algorithm SHA256).Hash
|
||||
}
|
||||
@@ -73,7 +80,7 @@ if (Test-Path -LiteralPath $stagedCapturePlugin -PathType Leaf) {
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($required in @($exe, $doorstop, $localRes, $capturePlugin)) {
|
||||
foreach ($required in @($exe, $doorstop, $localRes, $capturePlugin, $gameNamesLibrary)) {
|
||||
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
|
||||
throw "Required file is missing: $required"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user