diff --git a/.gitignore b/.gitignore
index 4557ea7..31497dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,9 @@
.env.*
!.env.example
go/config.json
+plugins/**/Directory.Build.props
+!plugins/Directory.Build.props
+plugins/Directory.Build.local.props
# Project notes and local/runtime data are intentionally outside source control
docs/
diff --git a/Dockerfile.server b/Dockerfile.server
index b6df7de..50acca8 100644
--- a/Dockerfile.server
+++ b/Dockerfile.server
@@ -17,7 +17,7 @@ RUN apt-get update \
WORKDIR /app
COPY --from=build /out/bd2server /app/bd2server
-COPY versions.json authentication.json resources.json /app/
+COPY versions.json authentication.json resources.json game.json /app/
COPY go/seed /app/go/seed
RUN mkdir -p /app/data && chown 10001:10001 /app/data
diff --git a/GAME_CONFIGURATION.md b/GAME_CONFIGURATION.md
new file mode 100644
index 0000000..9ee19dd
--- /dev/null
+++ b/GAME_CONFIGURATION.md
@@ -0,0 +1,33 @@
+# 服务端游戏规则配置
+
+`game.json` 是服务端统一的游戏规则配置入口。后续可配置的游戏规则在此按功能分组扩展;认证、资源源、版本清单仍分别使用现有配置文件。
+
+发布部署时,将文件放在 `bd2server.exe`(Linux/macOS 为 `bd2server`)旁。开发入口读取仓库根目录的同名文件。修改后重启服务端生效,客户端无需配置此文件。发布包只在服务端包中提供 `game.json` 和本指南。
+
+默认内容:
+
+```json
+{
+ "schema_version": 1,
+ "gacha": {
+ "include_collaboration_ur_weapons": false
+ }
+}
+```
+
+## 联动 UR 专武
+
+`gacha.include_collaboration_ur_weapons` 必须是 JSON 布尔值:
+
+| 值 | UR 必得装备券的结果 |
+|---|---|
+| `false`(默认) | 使用当前 GameData 的原始 UR 必得装备券池。 |
+| `true` | 在原池中加入 14 种联动角色 UR 专武(装备 ID `943200` 至 `943213`)。 |
+
+开关仅作用于 UR 必得装备券。新增装备沿用同星级原有装备的单件权重,原有星级权重规则保持不变;加入候选后,同星级内的概率按扩展后的权重总和计算。该设置只改变券池候选,不会直接发放装备。专武绑定角色,同一角色的多个服装共用专武。
+
+## 校验与升级
+
+`schema_version` 当前必须为 `1`。字段拼写错误、未知字段、错误类型(例如字符串 `"true"`)、`null`、格式错误或文件末尾多出的 JSON 都会使服务端拒绝启动,并在错误中给出文件路径。省略 `gacha` 或此布尔开关时使用 `false`。
+
+旧安装缺少整个文件时使用默认规则,服务端不会自动写入或覆盖文件。升级服务端时保留服主已经修改的 `game.json`;新发布包中的文件可用于比对新增字段。Docker 部署可将自定义文件只读挂载到 `/app/game.json`,重启容器后生效。
diff --git a/README.md b/README.md
index 149269d..ee5173e 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ $env:GOCACHE = (Join-Path $PWD '.cache\go-build')
go run .\cmd\bd2client --dev run
```
-服务端开发入口会自动使用仓库根目录的版本、认证、资源、数据和存档配置;客户端开发入口会直接使用仓库版本清单和插件产物,并从本机开发配置读取游戏目录,随后增量构建两个客户端插件。首次开发前创建该配置:
+服务端开发入口会自动使用仓库根目录的版本、认证、资源、数据和存档配置;客户端开发入口会直接使用仓库版本清单和插件产物,并从本机开发配置读取游戏目录,随后用 SDK 内嵌的当前版本名字表增量构建两个客户端插件,无需提供官方映射文件。首次开发前创建该配置:
```powershell
Copy-Item .\go\config.example.json .\go\config.json
@@ -44,7 +44,7 @@ Copy-Item .\go\config.example.json .\go\config.json
.\build-release.ps1
```
-脚本在 Windows 本机构建版本化归档,例如 `.build\bd2server-2.35.10+server.0.1.0-windows-x64.zip` 和 `.build\bd2client-2.35.10+client.0.1.0-windows-x64.zip`。纯服务端包只包含 `bd2server.exe`、seed、认证/资源策略和服务端数据目录;客户端包只包含 `bd2client.exe`、客户端插件和版本文件。
+脚本在 Windows 本机构建版本化归档,例如 `.build\bd2server-2.35.10+server.0.1.0-windows-x64.zip` 和 `.build\bd2client-2.35.10+client.0.1.0-windows-x64.zip`。纯服务端包只包含 `bd2server.exe`、seed、认证/资源策略、游戏规则配置和服务端数据目录;客户端包只包含 `bd2client.exe`、客户端插件和版本文件。
macOS 客户端使用系统 Cocoa / WKWebView,需要 macOS 原生构建环境。本地 Windows 发布脚本不会交叉编译或生成 macOS 发布包;找到 macOS 测试者或准备公开发布时,再通过 GitHub Actions 的 macOS runner 完成原生构建、测试及 `.app` 打包。签名和公证也应在该流程中配置。
@@ -61,7 +61,7 @@ BD2 Client Studio 是使用 Wails 的独立桌面客户端设置工具。Windows
- 选择并验证 Brown Dust II 安装目录。
- 填写服务器 origin 和端口,自动写入游戏目录下的 `BepInEx/config/bd2.client.json`。
- 应用带原文件备份的客户端入口补丁。
-- 检查 BepInEx 后安装或更新 `BD2LocalIdentity.dll` 与 `BD2LoginUI.dll`。
+- 检查 BepInEx 后安装或更新 `BD2LocalIdentity.dll`、`BD2LoginUI.dll` 及其共享运行时 `BD2.GameNames.dll`。
- 选择官方 CDN、服务器资源源或本地已下载资源。
服主同步官方资源、自建静态 CDN 或配置缓存反代的步骤见 [资源与 CDN 配置指南](RESOURCES.md)。
@@ -77,6 +77,8 @@ BD2 Client Studio 是使用 Wails 的独立桌面客户端设置工具。Windows
认证策略由 `bd2server.exe` 同目录的 `authentication.json` 权威决定。`mode=local`(默认)不依赖第三方登录,单人本地服保持不动,联机服可设为 `oauth` 来让玩家通过 Discord/Google 登录。
+游戏规则统一使用服务端同目录的 `game.json`,修改后重启生效;开发入口读取仓库根目录的文件。联动 UR 专武是否加入 UR 必得装备券池及升级保留配置的方法见 [服务端游戏规则配置](GAME_CONFIGURATION.md)。
+
### 启动客户端
先手动安装 [BepInEx](https://github.com/BepInEx/BepInEx/releases)。然后打开我们的客户端工具 `bd2client.exe`。
diff --git a/RELEASE.md b/RELEASE.md
index 92df680..8bb6cde 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -32,4 +32,6 @@
`authentication.json` 和 `resources.json` 必须与 `bd2server.exe` 保持在同一目录。默认 `local` 认证模式继续自动本地登录;公网或联机服可以启用 OAuth,并把资源策略设为官方 CDN 或统一的服务器资源源。服主自建与反代使用相同的 `server` 模式;玩家本地资源目录仅由 `bd2client.exe` 配置。逐步配置方法见发布包内的 [服主第三方登录配置指南](AUTHENTICATION.md) 和 [服主资源与 CDN 配置指南](RESOURCES.md)。
+游戏规则使用同目录的 `game.json`,修改后重启服务端生效。默认关闭联动 UR 专武扩展;需要将 14 种联动角色专武加入 UR 必得装备券池时,按 [服务端游戏规则配置](GAME_CONFIGURATION.md) 设置。升级时保留已修改的文件;旧安装缺少文件时使用默认规则。
+
OAuth access token 只在客户端内存中;Windows refresh credential 使用当前用户 DPAPI 加密后写入 PlayerPrefs 注册表,macOS 使用 Keychain Services。服务端 `auth.db` 只保存 token、device secret、OAuth state 及 provider subject 的 HMAC,临时可恢复材料使用 AES-256-GCM。
diff --git a/build-release.ps1 b/build-release.ps1
index 5ddd5fc..22931ec 100644
--- a/build-release.ps1
+++ b/build-release.ps1
@@ -17,6 +17,7 @@ $clientPluginDir = Join-Path $clientPackage 'plugins'
$versionConfig = Join-Path $root 'versions.json'
$authenticationConfig = Join-Path $root 'authentication.json'
$resourceConfig = Join-Path $root 'resources.json'
+$gameConfig = Join-Path $root 'game.json'
try {
$releaseVersions = Get-Content -LiteralPath $versionConfig -Raw | ConvertFrom-Json -ErrorAction Stop
@@ -91,20 +92,31 @@ $clientPlugins = @(
@{ Name = 'LoginUI'; Project = Join-Path $root 'plugins\LoginUI\LoginUI.csproj'; Output = Join-Path $root 'plugins\LoginUI\bin\Release\netstandard2.1\BD2LoginUI.dll'; FileName = 'BD2LoginUI.dll' }
)
foreach ($plugin in $clientPlugins) {
- dotnet build $plugin.Project -c Release "-p:GameDir=$GameDir" --nologo
+ $pluginBuildArgs = @('build', $plugin.Project, '-c', 'Release', "-p:GameDir=$GameDir", '--nologo')
+ dotnet @pluginBuildArgs
if ($LASTEXITCODE -ne 0) { throw "$($plugin.Name) build failed with exit code $LASTEXITCODE" }
if (-not (Test-Path -LiteralPath $plugin.Output -PathType Leaf)) { throw "$($plugin.Name) build output is missing" }
Copy-Item -LiteralPath $plugin.Output -Destination (Join-Path $clientPluginDir $plugin.FileName) -Force
+ $sharedRuntime = Join-Path (Split-Path -Parent $plugin.Output) 'BD2.GameNames.dll'
+ if (-not (Test-Path -LiteralPath $sharedRuntime -PathType Leaf)) { throw "Shared game names runtime is missing: $sharedRuntime" }
+ $packagedRuntime = Join-Path $clientPluginDir 'BD2.GameNames.dll'
+ if ((Test-Path -LiteralPath $packagedRuntime) -and
+ (Get-FileHash -LiteralPath $packagedRuntime).Hash -ne (Get-FileHash -LiteralPath $sharedRuntime).Hash) {
+ throw 'Client plugins were built with different BD2.GameNames libraries.'
+ }
+ Copy-Item -LiteralPath $sharedRuntime -Destination $packagedRuntime -Force
}
Copy-Item -LiteralPath (Join-Path $goRoot 'seed') -Destination $serverGoDir -Recurse -Force
Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $serverPackage 'versions.json') -Force
Copy-Item -LiteralPath $authenticationConfig -Destination (Join-Path $serverPackage 'authentication.json') -Force
Copy-Item -LiteralPath $resourceConfig -Destination (Join-Path $serverPackage 'resources.json') -Force
+Copy-Item -LiteralPath $gameConfig -Destination (Join-Path $serverPackage 'game.json') -Force
Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $clientPackage 'versions.json') -Force
Copy-Item -LiteralPath (Join-Path $root 'RELEASE.md') -Destination (Join-Path $serverPackage 'README.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'AUTHENTICATION.md') -Destination (Join-Path $serverPackage 'AUTHENTICATION.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'RESOURCES.md') -Destination (Join-Path $serverPackage 'RESOURCES.md') -Force
+Copy-Item -LiteralPath (Join-Path $root 'GAME_CONFIGURATION.md') -Destination (Join-Path $serverPackage 'GAME_CONFIGURATION.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'docs\CLIENT.md') -Destination (Join-Path $clientPackage 'README.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $serverPackage 'LICENSE') -Force
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $clientPackage 'LICENSE') -Force
diff --git a/game.json b/game.json
new file mode 100644
index 0000000..971cd85
--- /dev/null
+++ b/game.json
@@ -0,0 +1,6 @@
+{
+ "schema_version": 1,
+ "gacha": {
+ "include_collaboration_ur_weapons": false
+ }
+}
diff --git a/go/cmd/bd2client/dev_sdk_integration_test.go b/go/cmd/bd2client/dev_sdk_integration_test.go
new file mode 100644
index 0000000..f98f9a3
--- /dev/null
+++ b/go/cmd/bd2client/dev_sdk_integration_test.go
@@ -0,0 +1,28 @@
+//go:build !release
+
+package main
+
+import (
+ "os"
+ "testing"
+)
+
+// Opt in on a developer machine with the game installed. Exercise the exact
+// --dev run preparation path without starting the interactive Wails window.
+func TestDevelopmentRunBuildsPluginsWithEmbeddedNames(t *testing.T) {
+ if os.Getenv("BD2_TEST_DEVELOPMENT_BUILD") != "1" {
+ t.Skip("set BD2_TEST_DEVELOPMENT_BUILD=1 to build against go/config.json")
+ }
+ args, options, err := developmentRunOptions([]string{"--dev", "run"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(args) != 0 {
+ t.Fatalf("unexpected runtime arguments: %v", args)
+ }
+ for _, path := range []string{options.localIdentityPlugin, options.loginUIPlugin} {
+ if !clientDevelopmentFile(path) {
+ t.Fatalf("development plugin output missing: %s", path)
+ }
+ }
+}
diff --git a/go/cmd/bd2server/dev_command.go b/go/cmd/bd2server/dev_command.go
index 3e3ec58..685f0e8 100644
--- a/go/cmd/bd2server/dev_command.go
+++ b/go/cmd/bd2server/dev_command.go
@@ -25,6 +25,7 @@ func runDevelopmentCommand(args []string) (bool, error) {
serveArgs = appendDefaultFlag(serveArgs, "--version-config", filepath.Join(root, "versions.json"))
serveArgs = appendDefaultFlag(serveArgs, "--authentication-config", filepath.Join(root, "authentication.json"))
serveArgs = appendDefaultFlag(serveArgs, "--resource-config", filepath.Join(root, "resources.json"))
+ serveArgs = appendDefaultFlag(serveArgs, "--game-config", filepath.Join(root, "game.json"))
serveArgs = appendDefaultFlag(serveArgs, "--data-dir", filepath.Join(root, "data"))
serveArgs = appendDefaultFlag(serveArgs, "--state", filepath.Join(root, "data", "state", "state.db"))
return true, serve(serveArgs)
diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go
index 068a08c..df233b6 100644
--- a/go/cmd/bd2server/main.go
+++ b/go/cmd/bd2server/main.go
@@ -24,6 +24,7 @@ import (
"bd2server/internal/server/deck"
"bd2server/internal/server/feature"
"bd2server/internal/server/gacha"
+ "bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/lifecycle"
"bd2server/internal/server/mail"
@@ -80,6 +81,7 @@ func serve(args []string) (serveErr error) {
versionConfigPath := fs.String("version-config", "", "repository versions.json override")
authConfigPath := fs.String("authentication-config", "", "authentication.json override for development")
resourceConfigPath := fs.String("resource-config", "", "resources.json override for development")
+ gameConfigPath := fs.String("game-config", "", "game.json server gameplay configuration override")
listen := fs.String("listen", "127.0.0.1:8080", "local listen address")
dataDir := fs.String("data-dir", "", "server data directory (defaults beside the executable)")
gameDataVersion := fs.String("game-data-version", "", "validated GameData version (defaults to versions.json)")
@@ -118,6 +120,16 @@ func serve(args []string) (serveErr error) {
}
}
versionconfig.Use(versions)
+ if *gameConfigPath == "" {
+ *gameConfigPath, err = gameconfig.BesideExecutable()
+ if err != nil {
+ return err
+ }
+ }
+ gameRules, err := gameconfig.Load(*gameConfigPath)
+ if err != nil {
+ return err
+ }
if *authConfigPath == "" {
*authConfigPath, err = authconfig.BesideExecutable()
if err != nil {
@@ -220,6 +232,13 @@ func serve(args []string) (serveErr error) {
if err != nil {
return fmt.Errorf("load active gacha GameData: %w", err)
}
+ if gameRules.Gacha.IncludeCollaborationURWeapons {
+ if err := equipmentGacha.IncludeCollaborationURWeapons(gameData, *gameDataVersion); err != nil {
+ return fmt.Errorf("apply collaboration UR weapon game rule: %w", err)
+ }
+ }
+ slog.Info("server gameplay rules loaded", "config", *gameConfigPath,
+ "include_collaboration_ur_weapons", gameRules.Gacha.IncludeCollaborationURWeapons)
limitedCostumes, err := gamedata.LoadLimitedCostumes(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load limited costume GameData: %w", err)
@@ -519,6 +538,14 @@ func serve(args []string) (serveErr error) {
if err != nil {
return err
}
+ costumeBurstDesign, err := gamedata.LoadCostumeBurstDesign(gameData, *gameDataVersion)
+ if err != nil {
+ return fmt.Errorf("load costume burst GameData: %w", err)
+ }
+ costumeBurstService, err := player.NewCostumeBurstService(costumeBurstDesign, collection, ownedItems, wallet)
+ if err != nil {
+ return err
+ }
battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID)
battleService.AttachTutorialWin(func() error {
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113})
@@ -538,6 +565,7 @@ func serve(args []string) (serveErr error) {
inventorySlots,
charAwakeService,
costumePotentialService,
+ costumeBurstService,
starter,
mailService,
gachaService,
diff --git a/go/cmd/bd2server/preflight.go b/go/cmd/bd2server/preflight.go
index f2a161a..461a9b5 100644
--- a/go/cmd/bd2server/preflight.go
+++ b/go/cmd/bd2server/preflight.go
@@ -11,6 +11,7 @@ import (
"bd2server/internal/server/authconfig"
"bd2server/internal/server/deck"
"bd2server/internal/server/gacha"
+ "bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
@@ -28,6 +29,7 @@ func preflight(args []string) error {
versionPath := fs.String("version-config", "", "repository versions.json override")
authPath := fs.String("authentication-config", "", "authentication.json override")
resourcePath := fs.String("resource-config", "", "resources.json override")
+ gamePath := fs.String("game-config", "", "game.json server gameplay configuration override")
if err := fs.Parse(args); err != nil {
return err
}
@@ -39,6 +41,16 @@ func preflight(args []string) error {
return err
}
versionconfig.Use(versions)
+ if *gamePath == "" {
+ *gamePath, err = gameconfig.BesideExecutable()
+ if err != nil {
+ return err
+ }
+ }
+ gameRules, err := gameconfig.Load(*gamePath)
+ if err != nil {
+ return err
+ }
if *authPath == "" {
*authPath, err = authconfig.BesideExecutable()
if err != nil {
@@ -106,15 +118,24 @@ func preflight(args []string) error {
for _, window := range schedule.StepUps {
steps = append(steps, window.GroupID)
}
- if _, _, err := gamedata.LoadActiveGachaForSchedules(gameData, versions.GameDataVersion, groups, steps); err != nil {
+ _, equipment, err := gamedata.LoadActiveGachaForSchedules(gameData, versions.GameDataVersion, groups, steps)
+ if err != nil {
return err
}
+ if gameRules.Gacha.IncludeCollaborationURWeapons {
+ if err := equipment.IncludeCollaborationURWeapons(gameData, versions.GameDataVersion); err != nil {
+ return fmt.Errorf("preflight collaboration UR weapon game rule: %w", err)
+ }
+ }
if _, err := gamedata.LoadFirstGacha(gameData, versions.GameDataVersion); err != nil {
return err
}
if _, err := gamedata.LoadLimitedCostumes(gameData, versions.GameDataVersion); err != nil {
return err
}
+ if _, err := gamedata.LoadCostumeBurstDesign(gameData, versions.GameDataVersion); err != nil {
+ return err
+ }
return nil
}
diff --git a/go/internal/client/plugin/install.go b/go/internal/client/plugin/install.go
index 07656a8..6644205 100644
--- a/go/internal/client/plugin/install.go
+++ b/go/internal/client/plugin/install.go
@@ -23,6 +23,7 @@ type Spec struct {
var (
LocalIdentity = Spec{fileName: "BD2LocalIdentity.dll"}
LoginUI = Spec{fileName: "BD2LoginUI.dll"}
+ GameNames = Spec{fileName: "BD2.GameNames.dll"}
)
func (s Spec) FileName() string { return s.fileName }
@@ -54,8 +55,8 @@ func ResolvePackaged(spec Spec, explicit string) (string, error) {
}
// Install verifies that the user installed BepInEx, then atomically stages the
-// packaged plugin into its plugins directory. It never installs or downloads
-// BepInEx itself.
+// packaged plugin and its adjacent shared game-names runtime into its plugins
+// directory. It never installs or downloads BepInEx itself.
func Install(spec Spec, gameDir, source string) (Result, error) {
if err := spec.validate(); err != nil {
return Result{}, err
@@ -80,7 +81,33 @@ func Install(spec Spec, gameDir, source string) (Result, error) {
if len(sourceData) == 0 {
return Result{}, fmt.Errorf("clientplugin: packaged %s is empty", spec.fileName)
}
- pluginDir := installation.Plugins
+ // Read both artifacts before touching the installation. The runtime table
+ // library is shipped beside every plugin, including explicit development paths.
+ var runtimeData []byte
+ if spec == LocalIdentity || spec == LoginUI {
+ runtimeSource := filepath.Join(filepath.Dir(source), GameNames.fileName)
+ runtimeData, err = os.ReadFile(runtimeSource)
+ if err != nil {
+ return Result{}, fmt.Errorf("clientplugin: read shared runtime %s: %w", runtimeSource, err)
+ }
+ if len(runtimeData) == 0 {
+ return Result{}, fmt.Errorf("clientplugin: packaged %s is empty", GameNames.fileName)
+ }
+ }
+ changed := false
+ if runtimeData != nil {
+ runtimeResult, err := installData(GameNames, installation.Plugins, runtimeData)
+ if err != nil {
+ return Result{}, err
+ }
+ changed = runtimeResult.Changed
+ }
+ result, err := installData(spec, installation.Plugins, sourceData)
+ result.Changed = result.Changed || changed
+ return result, err
+}
+
+func installData(spec Spec, pluginDir string, sourceData []byte) (Result, error) {
destination := filepath.Join(pluginDir, spec.fileName)
if installed, err := os.ReadFile(destination); err == nil {
if bytes.Equal(hash(installed), hash(sourceData)) {
diff --git a/go/internal/client/plugin/install_test.go b/go/internal/client/plugin/install_test.go
index 280e365..4c4e695 100644
--- a/go/internal/client/plugin/install_test.go
+++ b/go/internal/client/plugin/install_test.go
@@ -23,6 +23,9 @@ func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
}
}
source := filepath.Join(t.TempDir(), spec.FileName())
+ if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
+ t.Fatal(err)
+ }
if err := os.WriteFile(source, []byte("plugin"), 0o600); err != nil {
t.Fatal(err)
}
@@ -54,6 +57,9 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
}
}
source := filepath.Join(t.TempDir(), spec.FileName())
+ if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
+ t.Fatal(err)
+ }
if err := os.WriteFile(source, []byte("v1"), 0o600); err != nil {
t.Fatal(err)
}
@@ -65,6 +71,18 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
if err != nil || second.Changed {
t.Fatalf("idempotent install=%+v err=%v", second, err)
}
+ runtimeSource := filepath.Join(filepath.Dir(source), GameNames.FileName())
+ if err := os.WriteFile(runtimeSource, []byte("names-v2"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ runtimeUpdate, err := Install(spec, gameDir, source)
+ if err != nil || !runtimeUpdate.Changed {
+ t.Fatalf("runtime-only update=%+v err=%v", runtimeUpdate, err)
+ }
+ installedRuntime, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", GameNames.FileName()))
+ if err != nil || string(installedRuntime) != "names-v2" {
+ t.Fatalf("installed runtime=%q err=%v", installedRuntime, err)
+ }
if err := os.WriteFile(source, []byte("v2"), 0o600); err != nil {
t.Fatal(err)
}
@@ -80,6 +98,34 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
}
}
+func TestInstallMissingSharedRuntimeDoesNotChangePlugin(t *testing.T) {
+ gameDir := t.TempDir()
+ for path, data := range map[string][]byte{
+ filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
+ filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
+ filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
+ filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()): []byte("old-plugin"),
+ } {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ source := filepath.Join(t.TempDir(), LocalIdentity.FileName())
+ if err := os.WriteFile(source, []byte("new-plugin"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Install(LocalIdentity, gameDir, source); err == nil || !strings.Contains(err.Error(), GameNames.FileName()) {
+ t.Fatalf("missing runtime error=%v", err)
+ }
+ installed, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()))
+ if err != nil || string(installed) != "old-plugin" {
+ t.Fatalf("installed=%q err=%v", installed, err)
+ }
+}
+
func TestInstallKeepsPluginsSeparate(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
@@ -96,6 +142,9 @@ func TestInstallKeepsPluginsSeparate(t *testing.T) {
}
for _, spec := range []Spec{LocalIdentity, LoginUI} {
source := filepath.Join(t.TempDir(), spec.FileName())
+ if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
+ t.Fatal(err)
+ }
if err := os.WriteFile(source, []byte(spec.FileName()), 0o600); err != nil {
t.Fatal(err)
}
diff --git a/go/internal/server/gacha/permanent_equipment_test.go b/go/internal/server/gacha/permanent_equipment_test.go
new file mode 100644
index 0000000..9db4d21
--- /dev/null
+++ b/go/internal/server/gacha/permanent_equipment_test.go
@@ -0,0 +1,79 @@
+package gacha
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "testing"
+
+ "bd2server/internal/server/gamedata"
+ "bd2server/internal/server/player"
+ "bd2server/internal/server/stateio"
+ "bd2server/internal/server/wire"
+)
+
+func TestPermanentEquipmentBuyUsesActiveCatalogWithoutSchedule(t *testing.T) {
+ root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
+ if root == "" {
+ t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
+ }
+ regular, catalog, err := gamedata.LoadActiveGachaForSchedules(root, "20260923193640", nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, ticketCount := range []uint64{5, 10} {
+ t.Run(fmt.Sprintf("tickets_%d", ticketCount), func(t *testing.T) {
+ storage := stateio.NewMemory()
+ service := newMultiBuyTestService(t, storage)
+ service.regular = regular
+ service.schedule.Schedules = nil
+ equipment, err := player.OpenEquipmentInventory(storage)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service.AttachEquipmentGacha(catalog, equipment)
+ inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ items, err := inventory.GrantOnce("tickets", []gamedata.BattleReward{{Type: 8, ID: 1000, Count: ticketCount + 1}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ service.AttachInventory(inventory)
+ ticket := items[0]
+ ticket.Count = ticketCount
+ request := wire.AppendVarint(nil, 1, 90)
+ request = wire.AppendVarint(request, 2, 201)
+ request = wire.AppendVarint(request, 3, 1)
+ request = wire.AppendBytes(request, 4, player.ItemWire(ticket))
+ code, response, handled, err := service.Handle("/GachaBuy", request)
+ if err != nil || !handled || code != 146 {
+ t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
+ }
+ bundle, _, _ := wire.Bytes(response, 1)
+ if countFields(bundle, 4) != 10 || len(equipment.All()) != 10 {
+ t.Fatal("ten equipment instances missing")
+ }
+ if got := service.wallet.Snapshot().FreeJewelry; got != 1000-(10-ticketCount)*200 {
+ t.Fatalf("free diamonds=%d", got)
+ }
+ user := service.collection.GachaUser(10002)
+ if user.Point != 10 || user.TotalBuyCount != 10 {
+ t.Fatalf("user=%+v", user)
+ }
+ if countFields(response, 3) != 4 {
+ t.Fatal("shared pity snapshot missing")
+ }
+ for _, item := range inventory.All() {
+ if item.InvenIndex == ticket.InvenIndex && item.Count != 1 {
+ t.Fatalf("remaining tickets=%d", item.Count)
+ }
+ }
+ _, replay, _, err := service.Handle("/GachaBuy", request)
+ if err != nil || !bytes.Equal(response, replay) || len(equipment.All()) != 10 {
+ t.Fatalf("retry changed draw: %v", err)
+ }
+ })
+ }
+}
diff --git a/go/internal/server/gameconfig/config.go b/go/internal/server/gameconfig/config.go
new file mode 100644
index 0000000..a416e7e
--- /dev/null
+++ b/go/internal/server/gameconfig/config.go
@@ -0,0 +1,95 @@
+// Package gameconfig loads server-authoritative gameplay rules.
+package gameconfig
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+const FileName = "game.json"
+
+type Config struct {
+ SchemaVersion int `json:"schema_version"`
+ Gacha GachaConfig `json:"gacha"`
+}
+
+type GachaConfig struct {
+ IncludeCollaborationURWeapons bool `json:"include_collaboration_ur_weapons"`
+}
+
+func Default() Config { return Config{SchemaVersion: 1} }
+
+// Load reads one explicit path. Older installations without the file retain
+// the default rules; an existing invalid file must never silently fall back.
+func Load(path string) (Config, error) {
+ data, err := os.ReadFile(filepath.Clean(path))
+ if errors.Is(err, os.ErrNotExist) {
+ return Default(), nil
+ }
+ if err != nil {
+ return Config{}, fmt.Errorf("game configuration: read %s: %w", path, err)
+ }
+ var cfg Config
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&cfg); err != nil {
+ return Config{}, fmt.Errorf("game configuration: decode %s: %w", path, err)
+ }
+ var trailing any
+ if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
+ return Config{}, fmt.Errorf("game configuration: trailing data in %s", path)
+ }
+ // encoding/json accepts null for scalar and struct fields. These rules have
+ // explicit defaults for omissions, while null is a configuration error.
+ var document any
+ if err := json.Unmarshal(data, &document); err != nil {
+ return Config{}, fmt.Errorf("game configuration: decode %s: %w", path, err)
+ }
+ if containsNull(document) {
+ return Config{}, fmt.Errorf("game configuration: null is not allowed in %s", path)
+ }
+ if err := cfg.Validate(); err != nil {
+ return Config{}, fmt.Errorf("game configuration: %s: %w", path, err)
+ }
+ return cfg, nil
+}
+
+func containsNull(value any) bool {
+ switch value := value.(type) {
+ case nil:
+ return true
+ case map[string]any:
+ for _, child := range value {
+ if containsNull(child) {
+ return true
+ }
+ }
+ case []any:
+ for _, child := range value {
+ if containsNull(child) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (c Config) Validate() error {
+ if c.SchemaVersion != 1 {
+ return fmt.Errorf("unsupported schema_version %d (expected 1)", c.SchemaVersion)
+ }
+ return nil
+}
+
+func BesideExecutable() (string, error) {
+ executable, err := os.Executable()
+ if err != nil {
+ return "", fmt.Errorf("game configuration: resolve server executable: %w", err)
+ }
+ return filepath.Join(filepath.Dir(executable), FileName), nil
+}
diff --git a/go/internal/server/gameconfig/config_test.go b/go/internal/server/gameconfig/config_test.go
new file mode 100644
index 0000000..afdd963
--- /dev/null
+++ b/go/internal/server/gameconfig/config_test.go
@@ -0,0 +1,76 @@
+package gameconfig
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoad(t *testing.T) {
+ for _, tc := range []struct {
+ name, text string
+ wantTrue, wantError bool
+ }{
+ {"enabled", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":true}}`, true, false},
+ {"disabled", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":false}}`, false, false},
+ {"omitted bool", `{"schema_version":1,"gacha":{}}`, false, false},
+ {"omitted gacha", `{"schema_version":1}`, false, false},
+ {"string bool", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":"true"}}`, false, true},
+ {"unknown root", `{"schema_version":1,"unknown":true}`, false, true},
+ {"unknown rule", `{"schema_version":1,"gacha":{"typo":true}}`, false, true},
+ {"trailing object", `{"schema_version":1} {}`, false, true},
+ {"trailing garbage", `{"schema_version":1} invalid`, false, true},
+ {"null root", `null`, false, true},
+ {"null section", `{"schema_version":1,"gacha":null}`, false, true},
+ {"null bool", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":null}}`, false, true},
+ {"null schema", `{"schema_version":null}`, false, true},
+ {"wrong version", `{"schema_version":2}`, false, true},
+ {"missing version", `{}`, false, true},
+ {"malformed", `{`, false, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), FileName)
+ if err := os.WriteFile(path, []byte(tc.text), 0600); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := Load(path)
+ if (err != nil) != tc.wantError {
+ t.Fatalf("Load error = %v, wantError %v", err, tc.wantError)
+ }
+ if err == nil && (cfg.SchemaVersion != 1 || cfg.Gacha.IncludeCollaborationURWeapons != tc.wantTrue) {
+ t.Fatalf("unexpected config: %+v", cfg)
+ }
+ })
+ }
+}
+
+func TestMissingFileDefaultsWithoutCreating(t *testing.T) {
+ path := filepath.Join(t.TempDir(), FileName)
+ cfg, err := Load(path)
+ if err != nil || cfg != Default() {
+ t.Fatalf("config = %+v, error = %v", cfg, err)
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("Load created missing config: %v", err)
+ }
+}
+
+func TestReadError(t *testing.T) {
+ if _, err := Load(t.TempDir()); err == nil {
+ t.Fatal("directory must not use defaults")
+ }
+}
+
+func TestBesideExecutable(t *testing.T) {
+ path, err := BesideExecutable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ executable, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != filepath.Join(filepath.Dir(executable), FileName) {
+ t.Fatalf("unexpected path %s", path)
+ }
+}
diff --git a/go/internal/server/gamedata/costume_burst.go b/go/internal/server/gamedata/costume_burst.go
new file mode 100644
index 0000000..f4f4e1e
--- /dev/null
+++ b/go/internal/server/gamedata/costume_burst.go
@@ -0,0 +1,139 @@
+package gamedata
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+)
+
+// CostumeBurstLevel is one row of CostumeBurstTable. Level is the database
+// row id (and is the level unlocked by upgrading from Level-1).
+type CostumeBurstLevel struct {
+ CostumeID uint64
+ Level uint64
+ Costs []PromotionCost
+}
+
+// CostumeBurstUpgradeRule is the exact cost for the next burst level.
+type CostumeBurstUpgradeRule struct {
+ CostumeID uint64
+ CurrentLevel uint64
+ NextLevel uint64
+ MaxLevel uint64
+ Costs []PromotionCost
+}
+
+// CostumeBurstDesign contains only costumes which have a complete, contiguous
+// CostumeBurstTable group. GroupId is the CostumeTable id for burst-enabled
+// costumes (the table also carries CostumeType for newer costume families).
+type CostumeBurstDesign struct {
+ Levels map[uint64]map[uint64]CostumeBurstLevel
+}
+
+func LoadCostumeBurstDesign(root, version string) (*CostumeBurstDesign, error) {
+ db, closeDB, err := openStatDatabase(root, version)
+ if err != nil {
+ return nil, err
+ }
+ defer closeDB()
+ return loadCostumeBurstDesign(db)
+}
+
+func loadCostumeBurstDesign(db *sql.DB) (*CostumeBurstDesign, error) {
+ if db == nil {
+ return nil, errors.New("gamedata: nil costume burst database")
+ }
+ design := &CostumeBurstDesign{Levels: make(map[uint64]map[uint64]CostumeBurstLevel)}
+ rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM CostumeBurstTable ORDER BY groupId,id")
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var groupID, level uint64
+ var raw []byte
+ if err := rows.Scan(&groupID, &level, &raw); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if groupID == 0 || level == 0 {
+ rows.Close()
+ return nil, fmt.Errorf("gamedata: invalid costume burst identity %d/%d", groupID, level)
+ }
+ groups, groupErr := packedInts(raw, 8)
+ ids, idErr := packedInts(raw, 9)
+ counts, countErr := packedInts(raw, 10)
+ itemIDs, itemIDErr := packedInts(raw, 11)
+ types, typeErr := packedInts(raw, 12)
+ if groupErr != nil || idErr != nil || countErr != nil || itemIDErr != nil || typeErr != nil {
+ rows.Close()
+ return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid fields", groupID, level)
+ }
+ if len(groups) != 1 || groups[0] != groupID || len(ids) != 1 || ids[0] != level {
+ rows.Close()
+ return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched proto identity", groupID, level)
+ }
+ if len(counts) == 0 || len(counts) != len(itemIDs) || len(itemIDs) != len(types) {
+ rows.Close()
+ return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched costs", groupID, level)
+ }
+ entry := CostumeBurstLevel{CostumeID: groupID, Level: level}
+ for i := range counts {
+ if counts[i] == 0 || (types[i] == 4 && itemIDs[i] != 0) ||
+ (types[i] == 8 && itemIDs[i] == 0) || (types[i] != 4 && types[i] != 8) {
+ rows.Close()
+ return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid cost", groupID, level)
+ }
+ entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: itemIDs[i], Count: counts[i]})
+ }
+ if _, exists := design.Levels[groupID][level]; exists {
+ rows.Close()
+ return nil, fmt.Errorf("gamedata: duplicate costume burst %d/%d", groupID, level)
+ }
+ if design.Levels[groupID] == nil {
+ design.Levels[groupID] = make(map[uint64]CostumeBurstLevel)
+ }
+ design.Levels[groupID][level] = entry
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if len(design.Levels) == 0 {
+ return nil, errors.New("gamedata: CostumeBurstTable is empty")
+ }
+ for costumeID, levels := range design.Levels {
+ for level := uint64(1); level <= uint64(len(levels)); level++ {
+ if _, ok := levels[level]; !ok {
+ return nil, fmt.Errorf("gamedata: costume burst %d missing level %d", costumeID, level)
+ }
+ }
+ }
+ return design, nil
+}
+
+// UpgradeRule returns the costs for currentLevel -> currentLevel+1.
+func (d *CostumeBurstDesign) UpgradeRule(costumeID, currentLevel uint64) (CostumeBurstUpgradeRule, error) {
+ if d == nil || costumeID == 0 {
+ return CostumeBurstUpgradeRule{}, errors.New("gamedata: invalid costume burst lookup")
+ }
+ levels, ok := d.Levels[costumeID]
+ if !ok {
+ return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d has no burst design", costumeID)
+ }
+ maxLevel := uint64(len(levels))
+ if currentLevel >= maxLevel {
+ return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d burst is already at maximum level %d", costumeID, maxLevel)
+ }
+ next := currentLevel + 1
+ entry, ok := levels[next]
+ if !ok {
+ return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d missing burst level %d", costumeID, next)
+ }
+ return CostumeBurstUpgradeRule{
+ CostumeID: costumeID, CurrentLevel: currentLevel, NextLevel: next,
+ MaxLevel: maxLevel, Costs: append([]PromotionCost(nil), entry.Costs...),
+ }, nil
+}
diff --git a/go/internal/server/gamedata/costume_burst_test.go b/go/internal/server/gamedata/costume_burst_test.go
new file mode 100644
index 0000000..170e75f
--- /dev/null
+++ b/go/internal/server/gamedata/costume_burst_test.go
@@ -0,0 +1,119 @@
+package gamedata
+
+import (
+ "database/sql"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestCostumeBurstDesignAndUpgradeRule(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`CREATE TABLE CostumeBurstTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))`); err != nil {
+ t.Fatal(err)
+ }
+ for level := 1; level <= 3; level++ {
+ raw := burstProto(8, 42, 9, uint64(level), 10, 60, 11, 710, 12, 8)
+ if _, err := db.Exec(`INSERT INTO CostumeBurstTable(groupId,id,ProtoBuf) VALUES(?,?,?)`, 42, level, raw); err != nil {
+ t.Fatal(err)
+ }
+ }
+ design, err := loadCostumeBurstDesign(db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rule, err := design.UpgradeRule(42, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rule.NextLevel != 2 || rule.MaxLevel != 3 || len(rule.Costs) != 1 || rule.Costs[0] != (PromotionCost{Type: 8, ID: 710, Count: 60}) {
+ t.Fatalf("unexpected rule: %+v", rule)
+ }
+ if _, err := design.UpgradeRule(42, 3); err == nil {
+ t.Fatal("expected max-level error")
+ }
+}
+
+func TestCostumeBurstRejectsInvalidIdentityCostAndGap(t *testing.T) {
+ cases := []struct {
+ name string
+ raw []byte
+ rows [][2]int
+ }{
+ {"identity", burstProto(8, 99, 9, 1, 10, 1, 11, 710, 12, 8), [][2]int{{42, 1}}},
+ {"zero-count", burstProto(8, 42, 9, 1, 10, 0, 11, 710, 12, 8), [][2]int{{42, 1}}},
+ {"gold-id", burstProto(8, 42, 9, 1, 10, 1, 11, 710, 12, 4), [][2]int{{42, 1}}},
+ {"gap", burstProto(8, 42, 9, 2, 10, 1, 11, 710, 12, 8), [][2]int{{42, 2}}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ if _, err := db.Exec(`CREATE TABLE CostumeBurstTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))`); err != nil {
+ t.Fatal(err)
+ }
+ for _, row := range tc.rows {
+ if _, err := db.Exec(`INSERT INTO CostumeBurstTable(groupId,id,ProtoBuf) VALUES(?,?,?)`, row[0], row[1], tc.raw); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := loadCostumeBurstDesign(db); err == nil || !strings.Contains(err.Error(), "costume burst") {
+ t.Fatalf("expected costume burst validation error, got %v", err)
+ }
+ })
+ }
+}
+
+func TestCostumeBurstAgainstInstalledCurrentVersion(t *testing.T) {
+ root := os.Getenv("BD2_REAL_GAMEDATA")
+ if root == "" {
+ t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
+ }
+ design, err := LoadCostumeBurstDesign(root, "20260923193640")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(design.Levels) != 56 {
+ t.Fatalf("installed burst groups=%d, want 56", len(design.Levels))
+ }
+ rows := 0
+ for _, levels := range design.Levels {
+ rows += len(levels)
+ }
+ if rows != 168 {
+ t.Fatalf("installed burst rows=%d, want 168", rows)
+ }
+ rule, err := design.UpgradeRule(4202, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []PromotionCost{{Type: 4, Count: 1_500_000}, {Type: 8, ID: 710, Count: 60}}
+ if rule.NextLevel != 1 || rule.MaxLevel != 3 || len(rule.Costs) != len(want) || rule.Costs[0] != want[0] || rule.Costs[1] != want[1] {
+ t.Fatalf("installed rule=%+v", rule)
+ }
+}
+
+// burstProto builds the small varint-only fields used by the loader tests.
+func burstProto(fields ...uint64) []byte {
+ if len(fields)%2 != 0 {
+ panic("burstProto requires field/value pairs")
+ }
+ var out []byte
+ for i := 0; i < len(fields); i += 2 {
+ field, value := fields[i], fields[i+1]
+ out = append(out, byte(field<<3))
+ for value >= 0x80 {
+ out = append(out, byte(value)|0x80)
+ value >>= 7
+ }
+ out = append(out, byte(value))
+ }
+ return out
+}
diff --git a/go/internal/server/gamedata/equipment_collaboration.go b/go/internal/server/gamedata/equipment_collaboration.go
new file mode 100644
index 0000000..770f2ca
--- /dev/null
+++ b/go/internal/server/gamedata/equipment_collaboration.go
@@ -0,0 +1,160 @@
+package gamedata
+
+import (
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+)
+
+// IncludeCollaborationURWeapons extends only the UR equipment ticket pool.
+// Limited costumes identify eligible owners; equipment IDs remain version data.
+func (c *EquipmentGachaCatalog) IncludeCollaborationURWeapons(root, version string) error {
+ plain, err := ReadQuestDatabase(root, version)
+ if err != nil {
+ return err
+ }
+ dir, err := os.MkdirTemp("", "bd2-collaboration-equipment-")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(dir)
+ path := filepath.Join(dir, "common.db")
+ if err := os.WriteFile(path, plain, 0o600); err != nil {
+ return err
+ }
+ db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
+ if err != nil {
+ return err
+ }
+ defer db.Close()
+ return c.includeCollaborationURWeapons(db)
+}
+
+func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error {
+ if c == nil || c.equipment == nil {
+ return fmt.Errorf("gamedata: missing equipment catalog")
+ }
+ g, ok := c.Gachas[71200001]
+ if !ok || !g.TicketOnly || len(g.TicketIDs) != 1 || g.TicketIDs[0] != 1104 || len(g.Pool) != 3 {
+ return fmt.Errorf("gamedata: missing or malformed UR equipment ticket pool")
+ }
+ readRows := func(query string) (map[uint64][]byte, error) {
+ rows, err := db.Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := map[uint64][]byte{}
+ for rows.Next() {
+ var id uint64
+ var raw []byte
+ if err := rows.Scan(&id, &raw); err != nil {
+ return nil, err
+ }
+ out[id] = raw
+ }
+ return out, rows.Err()
+ }
+ scalar := func(raw []byte, field int) (uint64, error) {
+ v, err := packedInts(raw, field)
+ if err != nil || len(v) != 1 || v[0] == 0 {
+ return 0, fmt.Errorf("gamedata: invalid collaboration equipment field %d", field)
+ }
+ return v[0], nil
+ }
+ owners, err := readRows("SELECT DISTINCT c.useUniqueCharId,c.ProtoBuf FROM LimitedCostumeTable l JOIN CostumeTable c ON c.id=l.id ORDER BY c.useUniqueCharId")
+ if err != nil {
+ return err
+ }
+ chars, err := readRows("SELECT id,ProtoBuf FROM CharTable WHERE uniqueCharId IN (SELECT privateUniqueCharId FROM EquipmentTable WHERE privateUniqueCharId>0) ORDER BY id")
+ if err != nil {
+ return err
+ }
+ grades := map[uint64]uint64{}
+ for _, raw := range chars {
+ owner, err := scalar(raw, 20)
+ if err != nil {
+ return err
+ }
+ grade, err := scalar(raw, 9)
+ if err != nil {
+ return err
+ }
+ if previous, exists := grades[owner]; exists && previous != grade {
+ return fmt.Errorf("gamedata: conflicting character grade for %d", owner)
+ }
+ grades[owner] = grade
+ }
+ equipment, err := readRows("SELECT id,ProtoBuf FROM EquipmentTable ORDER BY id")
+ if err != nil {
+ return err
+ }
+ pool := make([]WeightedEquipment, len(g.Pool))
+ branchForGrade := map[uint64]int{}
+ seen := map[uint64]bool{}
+ for index, branch := range g.Pool {
+ if branch.ID != 0 || branch.Weight == 0 || len(branch.Children) == 0 {
+ return fmt.Errorf("gamedata: malformed UR ticket branch")
+ }
+ pool[index] = WeightedEquipment{Weight: branch.Weight, Children: append([]WeightedEquipment(nil), branch.Children...)}
+ var branchGrade uint64
+ for _, item := range branch.Children {
+ if item.ID == 0 || item.Weight != 1 || len(item.Children) != 0 || seen[item.ID] {
+ return fmt.Errorf("gamedata: malformed or duplicate UR ticket candidate %d", item.ID)
+ }
+ seen[item.ID] = true
+ owner, err := scalar(equipment[item.ID], 16)
+ if err != nil {
+ return err
+ }
+ grade := grades[owner]
+ if grade < 3 || grade > 5 || (branchGrade != 0 && branchGrade != grade) {
+ return fmt.Errorf("gamedata: unknown or mixed UR ticket character tier")
+ }
+ branchGrade = grade
+ }
+ if _, exists := branchForGrade[branchGrade]; exists {
+ return fmt.Errorf("gamedata: duplicate UR ticket tier")
+ }
+ branchForGrade[branchGrade] = index
+ }
+ // Collect designs before publishing the cloned pool, so a failed load leaves
+ // the catalog unchanged. Order by ID to keep repeated loads deterministic.
+ designs := map[uint64]EquipmentDesign{}
+ ids := make([]uint64, 0, len(equipment))
+ for id := range equipment {
+ ids = append(ids, id)
+ }
+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+ for _, id := range ids {
+ raw := equipment[id]
+ grade, _ := packedInts(raw, 3)
+ quality, _ := packedInts(raw, 18)
+ owner, _ := packedInts(raw, 16)
+ if len(grade) != 1 || grade[0] != 4 || len(quality) != 1 || quality[0] != 3 || len(owner) != 1 {
+ continue
+ }
+ if _, eligible := owners[owner[0]]; !eligible || seen[id] {
+ continue
+ }
+ index, exists := branchForGrade[grades[owner[0]]]
+ if !exists {
+ return fmt.Errorf("gamedata: unknown collaboration equipment tier for %d", id)
+ }
+ design, err := loadEquipmentDesign(db, id)
+ if err != nil {
+ return err
+ }
+ designs[id] = design
+ pool[index].Children = append(pool[index].Children, WeightedEquipment{ID: id, Weight: 1})
+ seen[id] = true
+ }
+ for id, design := range designs {
+ c.equipment[id] = design
+ }
+ g.Pool = pool
+ c.Gachas[g.ID] = g
+ return nil
+}
diff --git a/go/internal/server/gamedata/equipment_collaboration_test.go b/go/internal/server/gamedata/equipment_collaboration_test.go
new file mode 100644
index 0000000..18415ac
--- /dev/null
+++ b/go/internal/server/gamedata/equipment_collaboration_test.go
@@ -0,0 +1,149 @@
+package gamedata
+
+import (
+ "database/sql"
+ "encoding/binary"
+ "os"
+ "reflect"
+ "testing"
+)
+
+func TestIncludeCollaborationURWeaponsInstalled(t *testing.T) {
+ root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
+ if root == "" {
+ t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
+ }
+ c, err := LoadEquipmentGacha(root, "20260923193640")
+ if err != nil {
+ t.Fatal(err)
+ }
+ before := map[uint64]EquipmentGacha{}
+ for id, g := range c.Gachas {
+ before[id] = g
+ }
+ if err := c.IncludeCollaborationURWeapons(root, "20260923193640"); err != nil {
+ t.Fatal(err)
+ }
+ g := c.Gachas[71200001]
+ oldIDs := map[uint64]bool{}
+ for _, branch := range before[g.ID].Pool {
+ for _, item := range branch.Children {
+ oldIDs[item.ID] = true
+ }
+ }
+ added := 0
+ for _, branch := range g.Pool {
+ for _, item := range branch.Children {
+ if !oldIDs[item.ID] {
+ added++
+ if item.ID < 943200 || item.ID > 943213 {
+ t.Fatalf("unexpected added collaboration weapon %d", item.ID)
+ }
+ }
+ }
+ }
+ if len(oldIDs) != 73 || added != 14 {
+ t.Fatalf("original=%d added=%d", len(oldIDs), added)
+ }
+ for index, want := range []int{64, 9, 14} {
+ if len(g.Pool[index].Children) != want || g.Pool[index].Weight != before[g.ID].Pool[index].Weight {
+ t.Fatalf("branch %d: %+v", index, g.Pool[index])
+ }
+ }
+ if len(before[g.ID].Pool[0].Children) != 50 {
+ t.Fatal("original pool mutated")
+ }
+ for id, old := range before {
+ if id != g.ID && !reflect.DeepEqual(old, c.Gachas[id]) {
+ t.Fatalf("other product %d changed", id)
+ }
+ }
+ for _, branch := range g.Pool {
+ for _, item := range branch.Children {
+ if _, _, _, err := c.RollOptions(item.ID); err != nil {
+ t.Fatalf("options %d: %v", item.ID, err)
+ }
+ }
+ }
+ if err := c.IncludeCollaborationURWeapons(root, "20260923193640"); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(g, c.Gachas[g.ID]) {
+ t.Fatal("second inclusion changed pool")
+ }
+}
+
+func TestIncludeCollaborationURWeaponsSynthetic(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ db.SetMaxOpenConns(1)
+ for _, schema := range []string{"CREATE TABLE LimitedCostumeTable(id INTEGER)", "CREATE TABLE CostumeTable(id INTEGER,useUniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE CharTable(id INTEGER,uniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE EquipmentTable(id INTEGER,privateUniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE EquipmentOptionTable(id INTEGER,GroupId INTEGER,ProtoBuf BLOB)"} {
+ if _, err := db.Exec(schema); err != nil {
+ t.Fatal(err)
+ }
+ }
+ proto := func(values ...uint64) []byte {
+ var b []byte
+ for i := 0; i < len(values); i += 2 {
+ b = binary.AppendUvarint(b, values[i]<<3)
+ b = binary.AppendUvarint(b, values[i+1])
+ }
+ return b
+ }
+ for i := uint64(1); i <= 4; i++ {
+ grade := uint64(6 - i)
+ if i == 4 {
+ grade = 5
+ }
+ if _, err := db.Exec("INSERT INTO CharTable VALUES(?,?,?)", i, i, proto(20, i, 9, grade)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec("INSERT INTO EquipmentTable VALUES(?,?,?)", 100+i, i, proto(6, 100+i, 16, i, 3, 4, 18, 3)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := db.Exec("INSERT INTO LimitedCostumeTable VALUES(40),(41)"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec("INSERT INTO CostumeTable VALUES(40,4,?),(41,4,?)", proto(27, 4), proto(27, 4)); err != nil {
+ t.Fatal(err)
+ }
+ pool := []WeightedEquipment{{Weight: 150, Children: []WeightedEquipment{{ID: 101, Weight: 1}}}, {Weight: 350, Children: []WeightedEquipment{{ID: 102, Weight: 1}}}, {Weight: 500, Children: []WeightedEquipment{{ID: 103, Weight: 1}}}}
+ c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{71200001: {ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, Pool: pool}, 77: {ID: 77, Pool: pool}}, equipment: map[uint64]EquipmentDesign{}}
+ if err := c.includeCollaborationURWeapons(db); err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Gachas[71200001].Pool[0].Children) != 2 || len(c.Gachas[77].Pool[0].Children) != 1 {
+ t.Fatal("extension or isolation failed")
+ }
+ if _, ok := c.equipment[104]; !ok {
+ t.Fatal("new equipment design not loaded")
+ }
+ if err := c.includeCollaborationURWeapons(db); err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Gachas[71200001].Pool[0].Children) != 2 {
+ t.Fatal("duplicate owner or repeated inclusion duplicated equipment")
+ }
+ if _, err := db.Exec("UPDATE CharTable SET ProtoBuf=? WHERE id=4", proto(20, 4, 9, 2)); err != nil {
+ t.Fatal(err)
+ }
+ delete(c.Gachas, 71200001)
+ c.Gachas[71200001] = EquipmentGacha{ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, Pool: pool}
+ if err := c.includeCollaborationURWeapons(db); err == nil {
+ t.Fatal("unknown tier accepted")
+ }
+ if len(c.Gachas[71200001].Pool[0].Children) != 1 {
+ t.Fatal("failed inclusion published partial pool")
+ }
+ invalid := c.Gachas[71200001]
+ invalid.Pool = append([]WeightedEquipment(nil), pool...)
+ invalid.Pool[0].Children = []WeightedEquipment{{ID: 101, Weight: 1}, {ID: 101, Weight: 1}}
+ c.Gachas[71200001] = invalid
+ if err := c.includeCollaborationURWeapons(db); err == nil {
+ t.Fatal("duplicate existing candidate accepted")
+ }
+}
diff --git a/go/internal/server/gamedata/gacha.go b/go/internal/server/gamedata/gacha.go
index 6e6ffb1..6249b46 100644
--- a/go/internal/server/gamedata/gacha.go
+++ b/go/internal/server/gamedata/gacha.go
@@ -917,17 +917,17 @@ func LoadActiveGachaForSchedules(root, version string, scheduleGroupIDs, stepUpG
return LoadActiveGacha(root, version, costume, equipment, stepUpGroupIDs)
}
-// permanentGachaGroupIDs returns static selection groups. ScheduleType=0
+// permanentGachaGroupIDs returns static selection and ordinary equipment groups. ScheduleType=0
// groups are deliberately absent from the dynamic schedule response; the
-// selection metadata is what distinguishes permanent 12PICK-style banners
-// from standalone content-ticket banners handled by other flows.
+// selection or shared pity/point metadata distinguishes these banners from
+// content-ticket banners handled by other flows.
func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
- rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable WHERE scheduleType=0 AND gachaType=1 ORDER BY id")
+ rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable WHERE scheduleType=0 AND gachaType IN (1,2) ORDER BY id")
if err != nil {
return nil, fmt.Errorf("gamedata: list permanent gacha groups: %w", err)
}
@@ -941,7 +941,10 @@ func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
}
subTypes, _ := packedInts(raw, 16)
selectCounts, _ := packedInts(raw, 29)
- if len(subTypes) == 1 && subTypes[0] == 1 && len(selectCounts) == 1 && selectCounts[0] != 0 {
+ types, _ := packedInts(raw, 17)
+ if len(types) == 1 && types[0] == 1 && len(subTypes) == 1 && subTypes[0] == 1 && len(selectCounts) == 1 && selectCounts[0] != 0 {
+ result = append(result, id)
+ } else if isPermanentEquipmentGachaGroup(raw) {
result = append(result, id)
}
}
@@ -951,6 +954,22 @@ func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
return result, nil
}
+func isPermanentEquipmentGachaGroup(raw []byte) bool {
+ types, _ := packedInts(raw, 17)
+ subTypes, _ := packedInts(raw, 16)
+ selectCounts, _ := packedInts(raw, 29)
+ fixed, _ := packedInts(raw, 10)
+ points, _ := packedInts(raw, 27)
+ one, _ := packedInts(raw, 24)
+ ten, _ := packedInts(raw, 33)
+ // Static content-ticket groups also have one/ten IDs, but have neither
+ // shared pity nor draw points. They must not enter the ordinary catalog.
+ // LoadEquipmentGachaGroups validates the referenced products and pools.
+ return len(types) == 1 && types[0] == 2 && (len(subTypes) == 0 || len(subTypes) == 1 && subTypes[0] == 0) && len(selectCounts) == 0 &&
+ len(fixed) == 1 && fixed[0] != 0 && len(points) == 1 && points[0] != 0 &&
+ len(one) == 1 && one[0] != 0 && len(ten) == 1 && ten[0] != 0
+}
+
// ClassifyActiveGachaGroups reads static type metadata for groups already
// selected by a captured schedule. Resemara is owned by LoadInfiniteGacha and
// is intentionally omitted from both ordinary catalogs.
diff --git a/go/internal/server/gamedata/gacha_active_test.go b/go/internal/server/gamedata/gacha_active_test.go
index db0cf66..148ef34 100644
--- a/go/internal/server/gamedata/gacha_active_test.go
+++ b/go/internal/server/gamedata/gacha_active_test.go
@@ -3,8 +3,29 @@ package gamedata
import (
"os"
"testing"
+
+ "bd2server/internal/server/wire"
)
+func TestPermanentEquipmentGroupRequiresPityAndPoints(t *testing.T) {
+ // Content-ticket groups have both products too; those alone do not make
+ // a group an ordinary permanent equipment draw.
+ content := wire.AppendVarint(nil, 17, 2)
+ content = wire.AppendVarint(content, 24, 70100001)
+ content = wire.AppendVarint(content, 33, 71000001)
+ if isPermanentEquipmentGachaGroup(content) {
+ t.Fatal("content-ticket group classified as ordinary equipment")
+ }
+ ordinary := wire.AppendVarint(content, 10, 1)
+ ordinary = wire.AppendVarint(ordinary, 27, 1)
+ if !isPermanentEquipmentGachaGroup(ordinary) {
+ t.Fatal("permanent equipment group with shared pity and points omitted")
+ }
+ if isPermanentEquipmentGachaGroup(wire.AppendVarint(ordinary, 16, 1)) {
+ t.Fatal("equipment selection group classified as ordinary equipment")
+ }
+}
+
func TestSingleSidedCostumeFixedThresholdsDoNotTriggerZeroSide(t *testing.T) {
pool := []WeightedCostume{
{Weight: 150, ID: 5001}, {Weight: 150, ID: 5002},
@@ -162,11 +183,26 @@ func TestActiveGachaAgainstInstalledVersion23510(t *testing.T) {
t.Errorf("step-up %d=%+v ok=%v", groupID, stepUp, ok)
}
}
- for _, id := range []uint64{20100082, 21000082, 20100034, 21000034, 20100070, 21000070, 20100083, 21000083} {
+ for _, id := range []uint64{200, 201, 20100082, 21000082, 20100034, 21000034, 20100070, 21000070, 20100083, 21000083} {
if _, ok := equipment.Gacha(id); !ok {
t.Errorf("equipment gacha %d missing", id)
}
}
+ permanentEquipment, ok := equipment.Group(10002)
+ if !ok || permanentEquipment.OneTimeGachaID != 200 || permanentEquipment.TenTimeGachaID != 201 || permanentEquipment.FixedID != 1 || permanentEquipment.PointCount != 1 {
+ t.Fatalf("permanent equipment group=%+v ok=%v", permanentEquipment, ok)
+ }
+ for _, id := range []uint64{200, 201} {
+ product, _ := equipment.Gacha(id)
+ if product.TicketOnly || product.PriceType != 3 || product.Price != uint64(product.Count)*200 || len(product.TicketIDs) != 1 || product.TicketIDs[0] != 1000 || len(product.Pool) != 8 {
+ t.Fatalf("permanent equipment product %d=%+v", id, product)
+ }
+ }
+ for _, groupID := range []uint64{1003, 1004, 1005, 1006} {
+ if _, ok := equipment.Group(groupID); ok {
+ t.Errorf("content-ticket equipment group %d became ordinary permanent draw", groupID)
+ }
+ }
}
func TestLegacyRegularGachaLoaderAgainstInstalledVersion23510(t *testing.T) {
diff --git a/go/internal/server/player/collection.go b/go/internal/server/player/collection.go
index b56e727..3ab3a52 100644
--- a/go/internal/server/player/collection.go
+++ b/go/internal/server/player/collection.go
@@ -1,17 +1,21 @@
package player
import (
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
+ "math"
"sort"
"strconv"
+ "strings"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
+ "bd2server/internal/server/wire"
)
type CostumeUpgrade struct {
@@ -106,32 +110,46 @@ type CharAwakeProgress struct {
IsAwake bool `json:"is_awake"`
}
+// CostumeBurstUpgradeRecord is the durable result of one costume burst level
+// transition. Digest identifies the exact protobuf request whose successful
+// response is stored in Body, allowing a committed request to be replayed
+// after a process restart without charging its materials again.
+type CostumeBurstUpgradeRecord struct {
+ CostumeID uint64 `json:"costume_id"`
+ Level uint64 `json:"level"`
+ Digest string `json:"digest"`
+ Code int `json:"code"`
+ Body []byte `json:"body"`
+}
+
// FirstGachaCompletedIdentity is the persisted account flag represented in
// the existing collection grant ledger. It is written atomically with the
// official GachaSubType=3 first-pick transaction.
const FirstGachaCompletedIdentity = "account:first-gacha-completed"
type collectionSnapshot struct {
- Version string `json:"version"`
- NextCharacterIndex uint64 `json:"next_character_index"`
- NextCostumeIndex uint64 `json:"next_costume_index"`
- LatestPreview []uint64 `json:"latest_preview"`
- PreviewEventIndex uint64 `json:"preview_event_index"`
- PreviewLocked bool `json:"preview_locked"`
- Characters []Character `json:"characters,omitempty"`
- Costumes []Costume `json:"costumes,omitempty"`
- BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"`
- CostumePotential map[string][]uint64 `json:"costume_potential,omitempty"`
- CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
- GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
- GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
- StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
- GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
- GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
- GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
- GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
- GachaCountCorrected bool `json:"gacha_count_corrected"`
- Grants map[string]CollectionGrant `json:"grants,omitempty"`
+ Version string `json:"version"`
+ NextCharacterIndex uint64 `json:"next_character_index"`
+ NextCostumeIndex uint64 `json:"next_costume_index"`
+ LatestPreview []uint64 `json:"latest_preview"`
+ PreviewEventIndex uint64 `json:"preview_event_index"`
+ PreviewLocked bool `json:"preview_locked"`
+ Characters []Character `json:"characters,omitempty"`
+ Costumes []Costume `json:"costumes,omitempty"`
+ BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"`
+ CostumePotential map[string][]uint64 `json:"costume_potential,omitempty"`
+ CostumeBurstLevels map[string]uint64 `json:"-"`
+ CostumeBurstUpgrades map[string]CostumeBurstUpgradeRecord `json:"-"`
+ CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
+ GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
+ GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
+ StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
+ GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
+ GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
+ GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
+ GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
+ GachaCountCorrected bool `json:"gacha_count_corrected"`
+ Grants map[string]CollectionGrant `json:"grants,omitempty"`
}
// CollectionStore owns non-stackable character/costume rewards as one atomic
@@ -157,9 +175,10 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
s := &CollectionStore{store: entries, base: append([]Costume(nil), base...), data: collectionSnapshot{
Version: versionconfig.State(), NextCharacterIndex: 920000001, NextCostumeIndex: 930000001,
BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{}, GachaSelectionChanges: map[string]uint64{},
- CostumePotential: map[string][]uint64{},
- CharAwake: map[string]CharAwakeProgress{},
- StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
+ CostumePotential: map[string][]uint64{},
+ CostumeBurstLevels: map[string]uint64{}, CostumeBurstUpgrades: map[string]CostumeBurstUpgradeRecord{},
+ CharAwake: map[string]CharAwakeProgress{},
+ StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{},
}}
b, err := store.Load("collection")
@@ -193,6 +212,9 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
if s.data.CharAwake == nil {
return nil, errors.New("player: collection save requires char_awake; migrate the development save")
}
+ if err := s.validateCostumeBurstStorage(); err != nil {
+ return nil, err
+ }
for key, progress := range s.data.CharAwake {
uniqueID, parseErr := strconv.ParseUint(key, 10, 64)
if parseErr != nil || uniqueID == 0 || (progress.ImprintLevels == [3]uint64{} && !progress.IsAwake) {
@@ -811,20 +833,191 @@ func (s *CollectionStore) UpdateCharacter(oldID uint64, character Character) err
func (s *CollectionStore) Costumes() []Costume {
s.mu.Lock()
defer s.mu.Unlock()
+ return s.costumesLocked()
+}
+
+func (s *CollectionStore) costumesLocked() []Costume {
result := append([]Costume(nil), s.base...)
for i := range result {
- if level := s.data.BaseCostumeLevels[strconv.FormatUint(result[i].InvenIndex, 10)]; level > result[i].Level {
+ key := strconv.FormatUint(result[i].InvenIndex, 10)
+ if level := s.data.BaseCostumeLevels[key]; level > result[i].Level {
result[i].Level = level
}
- result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(result[i].InvenIndex, 10)]...)
+ if level, found := s.data.CostumeBurstLevels[key]; found {
+ result[i].BurstLevel = level
+ }
+ result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[key]...)
}
collection := append([]Costume(nil), s.data.Costumes...)
for i := range collection {
- collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(collection[i].InvenIndex, 10)]...)
+ key := strconv.FormatUint(collection[i].InvenIndex, 10)
+ if level, found := s.data.CostumeBurstLevels[key]; found {
+ collection[i].BurstLevel = level
+ }
+ collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[key]...)
}
return append(result, collection...)
}
+// CostumeByID finds the single owned costume with the logical CostumeTable ID.
+// A duplicate logical ID is invalid ownership state and is deliberately not
+// resolved by choosing an arbitrary inventory instance.
+func (s *CollectionStore) CostumeByID(id uint64) (Costume, bool) {
+ if id == 0 {
+ return Costume{}, false
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var result Costume
+ found := false
+ for _, costume := range s.costumesLocked() {
+ if costume.ID != id {
+ continue
+ }
+ if found {
+ return Costume{}, false
+ }
+ result, found = costume, true
+ }
+ return result, found
+}
+
+// CostumeBurstReplay returns an owned copy of the durable response recorded
+// for one inventory instance and target burst level.
+func (s *CollectionStore) CostumeBurstReplay(invenIndex, target uint64) (CostumeBurstUpgradeRecord, bool) {
+ if invenIndex == 0 || target == 0 {
+ return CostumeBurstUpgradeRecord{}, false
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ record, found := s.data.CostumeBurstUpgrades[costumeBurstUpgradeKey(invenIndex, target)]
+ record.Body = append([]byte(nil), record.Body...)
+ return record, found
+}
+
+// ApplyCostumeBurst atomically advances one owned costume and stores the exact
+// successful reply. expectedCurrent is a compare-and-swap guard against a
+// stale request overwriting a newer level.
+func (s *CollectionStore) ApplyCostumeBurst(invenIndex, expectedCurrent, target uint64, record CostumeBurstUpgradeRecord) error {
+ if invenIndex == 0 || expectedCurrent == math.MaxUint64 || target != expectedCurrent+1 {
+ return errors.New("player: invalid costume burst transition")
+ }
+ if record.Level != target || record.CostumeID == 0 {
+ return errors.New("player: inconsistent costume burst record")
+ }
+ if err := validateCostumeBurstRecord(record); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ costume, found := s.costumeByIndexLocked(invenIndex)
+ if !found {
+ return fmt.Errorf("player: costume %d not found", invenIndex)
+ }
+ if costume.ID != record.CostumeID {
+ return errors.New("player: costume burst record design mismatch")
+ }
+ if costume.BurstLevel != expectedCurrent {
+ return errors.New("player: stale costume burst level")
+ }
+ ledgerKey := costumeBurstUpgradeKey(invenIndex, target)
+ if _, exists := s.data.CostumeBurstUpgrades[ledgerKey]; exists {
+ return errors.New("player: costume burst transition already recorded")
+ }
+ next := cloneCollection(s.data)
+ next.CostumeBurstLevels[strconv.FormatUint(invenIndex, 10)] = target
+ record.Body = append([]byte(nil), record.Body...)
+ next.CostumeBurstUpgrades[ledgerKey] = record
+ return s.commit(next)
+}
+
+func (s *CollectionStore) costumeByIndexLocked(index uint64) (Costume, bool) {
+ for _, costume := range s.costumesLocked() {
+ if costume.InvenIndex == index {
+ return costume, true
+ }
+ }
+ return Costume{}, false
+}
+
+func costumeBurstUpgradeKey(invenIndex, target uint64) string {
+ return strconv.FormatUint(invenIndex, 10) + ":" + strconv.FormatUint(target, 10)
+}
+
+func parseCostumeBurstUpgradeKey(key string) (uint64, uint64, bool) {
+ left, right, found := strings.Cut(key, ":")
+ if !found || strings.Contains(right, ":") {
+ return 0, 0, false
+ }
+ index, indexErr := strconv.ParseUint(left, 10, 64)
+ level, levelErr := strconv.ParseUint(right, 10, 64)
+ return index, level, indexErr == nil && levelErr == nil && index != 0 && level != 0 && key == costumeBurstUpgradeKey(index, level)
+}
+
+func validateCostumeBurstRecord(record CostumeBurstUpgradeRecord) error {
+ if record.CostumeID == 0 || record.Level == 0 || record.Code != 578 || len(record.Digest) != costumeBurstDigestHexSize {
+ return errors.New("player: invalid costume burst upgrade record")
+ }
+ if _, err := hex.DecodeString(record.Digest); err != nil {
+ return errors.New("player: invalid costume burst upgrade digest")
+ }
+ level, found, err := wire.Varint(record.Body, 1)
+ if err != nil || !found || level != record.Level {
+ return errors.New("player: invalid costume burst upgrade response")
+ }
+ fieldCount := 0
+ if err := wire.Walk(record.Body, func(field wire.Field) error {
+ if field.Number != 1 || field.Type != 0 {
+ return errors.New("unexpected costume burst response field")
+ }
+ fieldCount++
+ return nil
+ }); err != nil || fieldCount != 1 {
+ return errors.New("player: invalid costume burst upgrade response")
+ }
+ return nil
+}
+
+const costumeBurstDigestHexSize = 64
+
+func (s *CollectionStore) validateCostumeBurstStorage() error {
+ if s.data.CostumeBurstLevels == nil || s.data.CostumeBurstUpgrades == nil {
+ return errors.New("player: collection save requires costume burst ledgers")
+ }
+ owned := make(map[uint64]Costume, len(s.base)+len(s.data.Costumes))
+ for _, costume := range append(append([]Costume(nil), s.base...), s.data.Costumes...) {
+ if costume.InvenIndex == 0 || costume.ID == 0 {
+ return errors.New("player: invalid costume burst ownership")
+ }
+ if _, exists := owned[costume.InvenIndex]; exists {
+ return errors.New("player: duplicate costume burst inventory index")
+ }
+ owned[costume.InvenIndex] = costume
+ }
+ for key, level := range s.data.CostumeBurstLevels {
+ index, err := strconv.ParseUint(key, 10, 64)
+ costume, found := owned[index]
+ if err != nil || index == 0 || key != strconv.FormatUint(index, 10) || level == 0 || !found || level < costume.BurstLevel {
+ return fmt.Errorf("player: invalid costume burst level entry %q", key)
+ }
+ }
+ for key, record := range s.data.CostumeBurstUpgrades {
+ index, level, valid := parseCostumeBurstUpgradeKey(key)
+ costume, found := owned[index]
+ current := costume.BurstLevel
+ if overlay, exists := s.data.CostumeBurstLevels[strconv.FormatUint(index, 10)]; exists {
+ current = overlay
+ }
+ if !valid || !found || record.CostumeID != costume.ID || record.Level != level || level > current {
+ return fmt.Errorf("player: invalid costume burst upgrade entry %q", key)
+ }
+ if err := validateCostumeBurstRecord(record); err != nil {
+ return fmt.Errorf("player: invalid costume burst upgrade entry %q: %w", key, err)
+ }
+ }
+ return nil
+}
+
func (s *CollectionStore) ValidateCostumePotentialActivation(costumeIndex uint64, nodes []uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -948,12 +1141,9 @@ func (s *CollectionStore) CharacterByIndex(index uint64) (Character, bool) {
}
func (s *CollectionStore) CostumeByIndex(index uint64) (Costume, bool) {
- for _, costume := range s.Costumes() {
- if costume.InvenIndex == index {
- return costume, true
- }
- }
- return Costume{}, false
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.costumeByIndexLocked(index)
}
func findCostume(costumes []Costume, id uint64) (int, bool) {
@@ -1005,6 +1195,15 @@ func cloneCollection(in collectionSnapshot) collectionSnapshot {
for k, v := range in.CostumePotential {
out.CostumePotential[k] = append([]uint64(nil), v...)
}
+ out.CostumeBurstLevels = make(map[string]uint64, len(in.CostumeBurstLevels))
+ for k, v := range in.CostumeBurstLevels {
+ out.CostumeBurstLevels[k] = v
+ }
+ out.CostumeBurstUpgrades = make(map[string]CostumeBurstUpgradeRecord, len(in.CostumeBurstUpgrades))
+ for k, v := range in.CostumeBurstUpgrades {
+ v.Body = append([]byte(nil), v.Body...)
+ out.CostumeBurstUpgrades[k] = v
+ }
out.CharAwake = make(map[string]CharAwakeProgress, len(in.CharAwake))
for k, v := range in.CharAwake {
out.CharAwake[k] = v
diff --git a/go/internal/server/player/collection_entries.go b/go/internal/server/player/collection_entries.go
index feca994..9ff57a5 100644
--- a/go/internal/server/player/collection_entries.go
+++ b/go/internal/server/player/collection_entries.go
@@ -15,7 +15,8 @@ const collectionDomain = "collection"
var collectionEntryBuckets = [...]string{
"characters", "costumes", "grants", "gacha_applied", "gacha_users",
"gacha_fixed", "step_up_progress", "gacha_point_exchanges",
- "gacha_selections", "gacha_selection_changes", "costume_potential", "char_awake",
+ "gacha_selections", "gacha_selection_changes", "costume_potential",
+ "costume_burst_levels", "costume_burst_upgrades", "char_awake",
}
func rejectInlineCollectionEntries(raw []byte) error {
@@ -88,6 +89,12 @@ func loadCollectionEntries(store stateio.EntryStore, data *collectionSnapshot) e
if data.CostumePotential, err = loadEntryMap[[]uint64](store, "costume_potential"); err != nil {
return err
}
+ if data.CostumeBurstLevels, err = loadEntryMap[uint64](store, "costume_burst_levels"); err != nil {
+ return err
+ }
+ if data.CostumeBurstUpgrades, err = loadEntryMap[CostumeBurstUpgradeRecord](store, "costume_burst_upgrades"); err != nil {
+ return err
+ }
if data.CharAwake, err = loadEntryMap[CharAwakeProgress](store, "char_awake"); err != nil {
return err
}
@@ -127,6 +134,8 @@ func collectionCore(in collectionSnapshot) collectionSnapshot {
in.GachaSelections = nil
in.GachaSelectionChanges = nil
in.CostumePotential = nil
+ in.CostumeBurstLevels = nil
+ in.CostumeBurstUpgrades = nil
in.CharAwake = nil
return in
}
@@ -226,6 +235,12 @@ func diffCollectionEntries(before, after collectionSnapshot) ([]stateio.EntryMut
if err := diffEntryMap("costume_potential", before.CostumePotential, after.CostumePotential, &changes); err != nil {
return nil, err
}
+ if err := diffEntryMap("costume_burst_levels", before.CostumeBurstLevels, after.CostumeBurstLevels, &changes); err != nil {
+ return nil, err
+ }
+ if err := diffEntryMap("costume_burst_upgrades", before.CostumeBurstUpgrades, after.CostumeBurstUpgrades, &changes); err != nil {
+ return nil, err
+ }
if err := diffEntryMap("char_awake", before.CharAwake, after.CharAwake, &changes); err != nil {
return nil, err
}
diff --git a/go/internal/server/player/costume_burst.go b/go/internal/server/player/costume_burst.go
new file mode 100644
index 0000000..d3b38c3
--- /dev/null
+++ b/go/internal/server/player/costume_burst.go
@@ -0,0 +1,206 @@
+package player
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "math"
+ "strconv"
+ "sync"
+
+ "bd2server/internal/server/gamedata"
+ "bd2server/internal/server/wire"
+)
+
+const costumeBurstPacketCode = 578
+
+type costumeBurstReply struct {
+ Digest string
+ Code int
+ Body []byte
+}
+
+// CostumeBurstService applies one adjacent costume-burst level from the
+// authoritative GameData row. The account request transaction makes the
+// wallet, inventory, and collection writes one atomic SQLite operation.
+type CostumeBurstService struct {
+ mu sync.Mutex
+ design *gamedata.CostumeBurstDesign
+ collection *CollectionStore
+ inventory *Inventory
+ wallet *Wallet
+ sessionID string
+ replies map[string]map[uint64]costumeBurstReply
+}
+
+func NewCostumeBurstService(design *gamedata.CostumeBurstDesign, collection *CollectionStore, inventory *Inventory, wallet *Wallet) (*CostumeBurstService, error) {
+ if design == nil || collection == nil || inventory == nil || wallet == nil {
+ return nil, errors.New("player: incomplete costume burst service")
+ }
+ return &CostumeBurstService{
+ design: design, collection: collection, inventory: inventory, wallet: wallet,
+ replies: make(map[string]map[uint64]costumeBurstReply),
+ }, nil
+}
+
+// BeginSession scopes protobuf sequence replay without forgetting older live
+// sessions. Session dispatch calls it before each authenticated request.
+func (s *CostumeBurstService) BeginSession(id string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if id == "" || id == s.sessionID {
+ return
+ }
+ s.sessionID = id
+ if s.replies[id] != nil {
+ return
+ }
+ if len(s.replies) >= 1024 {
+ for oldID := range s.replies {
+ if oldID != id {
+ delete(s.replies, oldID)
+ break
+ }
+ }
+ }
+ s.replies[id] = make(map[uint64]costumeBurstReply)
+}
+
+func (s *CostumeBurstService) Handle(path string, request []byte) (int, []byte, bool, error) {
+ if path != "/CostumeBurst" {
+ return 0, nil, false, nil
+ }
+ seq, found, err := wire.Varint(request, 1)
+ if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
+ return 0, nil, true, errors.New("player: CostumeBurst missing sequence")
+ }
+ costumeID, found, err := wire.Varint(request, 2)
+ if err != nil || !found || costumeID == 0 || costumeID > math.MaxInt32 {
+ return 0, nil, true, errors.New("player: CostumeBurst missing costume")
+ }
+ materials, err := equipmentRequestItems(request, 3, "CostumeBurst")
+ if err != nil {
+ return 0, nil, true, err
+ }
+ for _, material := range materials {
+ if material.InvenIndex > math.MaxInt64 || material.ID > math.MaxInt32 || material.Type > math.MaxInt32 || material.Count > math.MaxInt32 ||
+ material.KeepFlag > math.MaxInt32 || material.TimeValue > math.MaxInt64 || material.ExpiryTime > math.MaxInt64 ||
+ material.SortID > math.MaxInt32 || material.UseCount > math.MaxInt32 {
+ return 0, nil, true, errors.New("player: CostumeBurst material exceeds protocol range")
+ }
+ }
+
+ digestBytes := sha256.Sum256(request)
+ digest := hex.EncodeToString(digestBytes[:])
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ sessionID := s.sessionID
+ if sessionID == "" {
+ sessionID = "__direct__"
+ }
+ if s.replies[sessionID] == nil {
+ s.replies[sessionID] = make(map[uint64]costumeBurstReply)
+ }
+ if reply, ok := s.replies[sessionID][seq]; ok {
+ if reply.Digest != digest {
+ return 0, nil, true, errors.New("player: CostumeBurst sequence reused with different request")
+ }
+ return reply.Code, append([]byte(nil), reply.Body...), true, nil
+ }
+
+ costume, found := s.collection.CostumeByID(costumeID)
+ if !found {
+ return 0, nil, true, fmt.Errorf("player: CostumeBurst costume %d is not owned", costumeID)
+ }
+ if costume.BurstLevel != 0 {
+ if applied, ok := s.collection.CostumeBurstReplay(costume.InvenIndex, costume.BurstLevel); ok && applied.Digest == digest {
+ reply := costumeBurstReply{Digest: digest, Code: applied.Code, Body: append([]byte(nil), applied.Body...)}
+ s.replies[sessionID][seq] = reply
+ return reply.Code, append([]byte(nil), reply.Body...), true, nil
+ }
+ }
+ rule, err := s.design.UpgradeRule(costume.ID, costume.BurstLevel)
+ if err != nil {
+ return 0, nil, true, fmt.Errorf("player: resolve CostumeBurst rule: %w", err)
+ }
+ items, gold, err := validateCostumeBurstMaterials(rule.Costs, materials)
+ if err != nil {
+ return 0, nil, true, err
+ }
+ if gold != 0 && !s.wallet.CanSpendGold(gold) {
+ return 0, nil, true, errors.New("player: insufficient gold for CostumeBurst")
+ }
+ if len(items) != 0 {
+ if err := s.inventory.CanConsume(items); err != nil {
+ return 0, nil, true, fmt.Errorf("player: validate CostumeBurst items: %w", err)
+ }
+ }
+
+ identity := "costume-burst:" + strconv.FormatUint(costume.InvenIndex, 10) + ":" + strconv.FormatUint(rule.NextLevel, 10)
+ if gold != 0 {
+ if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
+ return 0, nil, true, fmt.Errorf("player: consume CostumeBurst gold: %w", err)
+ }
+ }
+ if len(items) != 0 {
+ if err := s.inventory.Consume(items); err != nil {
+ return 0, nil, true, fmt.Errorf("player: consume CostumeBurst items: %w", err)
+ }
+ }
+ body := wire.AppendVarint(nil, 1, rule.NextLevel)
+ record := CostumeBurstUpgradeRecord{CostumeID: costume.ID, Level: rule.NextLevel, Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)}
+ if err := s.collection.ApplyCostumeBurst(costume.InvenIndex, costume.BurstLevel, rule.NextLevel, record); err != nil {
+ return 0, nil, true, fmt.Errorf("player: persist CostumeBurst: %w", err)
+ }
+ reply := costumeBurstReply{Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)}
+ s.replies[sessionID][seq] = reply
+ return reply.Code, append([]byte(nil), reply.Body...), true, nil
+}
+
+func validateCostumeBurstMaterials(costs []gamedata.PromotionCost, materials []Item) ([]Item, uint64, error) {
+ if len(costs) == 0 || len(materials) == 0 {
+ return nil, 0, errors.New("player: CostumeBurst has no material")
+ }
+ want := make(map[[2]uint64]uint64, len(costs))
+ for _, cost := range costs {
+ if cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || (cost.Type == 8 && cost.ID == 0) || (cost.Type != 4 && cost.Type != 8) {
+ return nil, 0, errors.New("player: invalid CostumeBurst GameData cost")
+ }
+ key := [2]uint64{cost.Type, cost.ID}
+ if want[key] > math.MaxUint64-cost.Count {
+ return nil, 0, errors.New("player: CostumeBurst GameData cost overflow")
+ }
+ want[key] += cost.Count
+ }
+ got := make(map[[2]uint64]uint64, len(materials))
+ items := make([]Item, 0, len(materials))
+ var gold uint64
+ for _, material := range materials {
+ if material.Type != 4 && material.Type != 8 {
+ return nil, 0, fmt.Errorf("player: unsupported CostumeBurst material type %d", material.Type)
+ }
+ key := [2]uint64{material.Type, material.ID}
+ if got[key] > math.MaxUint64-material.Count {
+ return nil, 0, errors.New("player: CostumeBurst submitted material overflow")
+ }
+ got[key] += material.Count
+ if material.Type == 4 {
+ if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
+ return nil, 0, errors.New("player: invalid CostumeBurst currency")
+ }
+ gold = material.Count
+ } else {
+ items = append(items, material)
+ }
+ }
+ if len(got) != len(want) {
+ return nil, 0, errors.New("player: CostumeBurst material kinds mismatch")
+ }
+ for key, count := range want {
+ if got[key] != count {
+ return nil, 0, fmt.Errorf("player: CostumeBurst material %d/%d=%d want %d", key[0], key[1], got[key], count)
+ }
+ }
+ return items, gold, nil
+}
diff --git a/go/internal/server/player/costume_burst_collection_test.go b/go/internal/server/player/costume_burst_collection_test.go
new file mode 100644
index 0000000..d498889
--- /dev/null
+++ b/go/internal/server/player/costume_burst_collection_test.go
@@ -0,0 +1,185 @@
+package player
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "bd2server/internal/server/stateio"
+ "bd2server/internal/server/wire"
+)
+
+func testCostumeBurstRecord(costumeID, level uint64) CostumeBurstUpgradeRecord {
+ return CostumeBurstUpgradeRecord{
+ CostumeID: costumeID,
+ Level: level,
+ Digest: strings.Repeat("ab", 32),
+ Code: 578,
+ Body: wire.AppendVarint(nil, 1, level),
+ }
+}
+
+func TestCollectionCostumeBurstPersistsOverlayAndReplayForEveryCostumeSource(t *testing.T) {
+ storage := &collectionWriteSpy{Memory: stateio.NewMemory()}
+ base := []Costume{{InvenIndex: 11, ID: 101, BurstLevel: 0}}
+ collection, err := OpenCollectionStore(storage, base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := collection.EnsurePersisted(); err != nil {
+ t.Fatal(err)
+ }
+ next := cloneCollection(collection.data)
+ next.Costumes = []Costume{{InvenIndex: 22, ID: 202}}
+ if err := collection.commit(next); err != nil {
+ t.Fatal(err)
+ }
+
+ baseRecord := testCostumeBurstRecord(101, 1)
+ before := len(storage.mutations)
+ if err := collection.ApplyCostumeBurst(11, 0, 1, baseRecord); err != nil {
+ t.Fatal(err)
+ }
+ if len(storage.mutations) != before+1 || len(storage.mutations[before]) != 2 {
+ t.Fatalf("burst level and replay were not one collection write: %+v", storage.mutations[before:])
+ }
+ if storage.mutations[before][0].Bucket != "costume_burst_levels" || storage.mutations[before][1].Bucket != "costume_burst_upgrades" {
+ t.Fatalf("unexpected burst mutations: %+v", storage.mutations[before])
+ }
+ if err := collection.ApplyCostumeBurst(22, 0, 1, testCostumeBurstRecord(202, 1)); err != nil {
+ t.Fatal(err)
+ }
+ if err := collection.ApplyCostumeBurst(22, 1, 2, testCostumeBurstRecord(202, 2)); err != nil {
+ t.Fatal(err)
+ }
+
+ if got, found := collection.CostumeByID(101); !found || got.InvenIndex != 11 || got.BurstLevel != 1 {
+ t.Fatalf("base costume overlay=%+v found=%v", got, found)
+ }
+ if got, found := collection.CostumeByIndex(22); !found || got.ID != 202 || got.BurstLevel != 2 {
+ t.Fatalf("collection costume overlay=%+v found=%v", got, found)
+ }
+ replay, found := collection.CostumeBurstReplay(22, 2)
+ if !found || replay.CostumeID != 202 || replay.Level != 2 || replay.Code != 578 {
+ t.Fatalf("replay=%+v found=%v", replay, found)
+ }
+ replay.Body[0] = 0
+ if again, _ := collection.CostumeBurstReplay(22, 2); len(again.Body) == 0 || again.Body[0] == 0 {
+ t.Fatal("CostumeBurstReplay returned an aliased response body")
+ }
+
+ core, err := storage.Load("collection")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(core), "costume_burst") {
+ t.Fatalf("burst entries leaked into collection core: %s", core)
+ }
+ restarted, err := OpenCollectionStore(storage, base)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := restarted.CostumeByIndex(11); got.BurstLevel != 1 {
+ t.Fatalf("restarted base burst level=%d", got.BurstLevel)
+ }
+ if got, _ := restarted.CostumeByID(202); got.BurstLevel != 2 {
+ t.Fatalf("restarted collection burst level=%d", got.BurstLevel)
+ }
+ if got, ok := restarted.CostumeBurstReplay(22, 1); !ok || got.Level != 1 {
+ t.Fatalf("restarted first replay=%+v found=%v", got, ok)
+ }
+}
+
+func TestCollectionCostumeBurstCASAndRecordValidation(t *testing.T) {
+ collection, err := OpenCollectionStore(stateio.NewMemory(), []Costume{{InvenIndex: 11, ID: 101}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := testCostumeBurstRecord(101, 1)
+ if err := collection.ApplyCostumeBurst(11, 0, 1, record); err != nil {
+ t.Fatal(err)
+ }
+ for name, call := range map[string]func() error{
+ "stale current": func() error { return collection.ApplyCostumeBurst(11, 0, 1, record) },
+ "skipped level": func() error { return collection.ApplyCostumeBurst(11, 1, 3, testCostumeBurstRecord(101, 3)) },
+ "wrong costume": func() error { return collection.ApplyCostumeBurst(11, 1, 2, testCostumeBurstRecord(102, 2)) },
+ "unknown instance": func() error { return collection.ApplyCostumeBurst(99, 0, 1, record) },
+ } {
+ t.Run(name, func(t *testing.T) {
+ if err := call(); err == nil {
+ t.Fatal("invalid costume burst transition was accepted")
+ }
+ })
+ }
+ if got, _ := collection.CostumeByIndex(11); got.BurstLevel != 1 {
+ t.Fatalf("rejected transitions mutated level to %d", got.BurstLevel)
+ }
+ if _, found := collection.CostumeBurstReplay(11, 2); found {
+ t.Fatal("rejected transition wrote a replay")
+ }
+}
+
+func TestCollectionCostumeByIDRejectsAmbiguousOwnership(t *testing.T) {
+ collection, err := OpenCollectionStore(stateio.NewMemory(), []Costume{{InvenIndex: 11, ID: 101}, {InvenIndex: 12, ID: 101}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if costume, found := collection.CostumeByID(101); found || costume.InvenIndex != 0 || costume.ID != 0 {
+ t.Fatalf("ambiguous logical ID resolved to %+v", costume)
+ }
+}
+
+func TestCollectionRejectsMalformedCostumeBurstEntries(t *testing.T) {
+ validRecord, err := json.Marshal(testCostumeBurstRecord(101, 1))
+ if err != nil {
+ t.Fatal(err)
+ }
+ tests := []struct {
+ name string
+ bucket string
+ key string
+ payload []byte
+ seed Costume
+ prepare func(*stateio.Memory)
+ }{
+ {name: "noncanonical level key", bucket: "costume_burst_levels", key: "011", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101}},
+ {name: "orphan level", bucket: "costume_burst_levels", key: "99", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101}},
+ {name: "zero level", bucket: "costume_burst_levels", key: "11", payload: []byte("0"), seed: Costume{InvenIndex: 11, ID: 101}},
+ {name: "level decreases seed", bucket: "costume_burst_levels", key: "11", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101, BurstLevel: 2}},
+ {name: "noncanonical replay key", bucket: "costume_burst_upgrades", key: "011:1", payload: validRecord, seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
+ {name: "replay above current", bucket: "costume_burst_upgrades", key: "11:1", payload: validRecord, seed: Costume{InvenIndex: 11, ID: 101}},
+ {name: "invalid replay digest", bucket: "costume_burst_upgrades", key: "11:1", payload: func() []byte {
+ r := testCostumeBurstRecord(101, 1)
+ r.Digest = strings.Repeat("zz", 32)
+ b, _ := json.Marshal(r)
+ return b
+ }(), seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
+ {name: "mismatched response", bucket: "costume_burst_upgrades", key: "11:1", payload: func() []byte {
+ r := testCostumeBurstRecord(101, 1)
+ r.Body = wire.AppendVarint(nil, 1, 2)
+ b, _ := json.Marshal(r)
+ return b
+ }(), seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ storage := stateio.NewMemory()
+ collection, err := OpenCollectionStore(storage, []Costume{test.seed})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := collection.EnsurePersisted(); err != nil {
+ t.Fatal(err)
+ }
+ if test.prepare != nil {
+ test.prepare(storage)
+ }
+ if err := storage.PutEntry("collection", test.bucket, test.key, test.payload); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := OpenCollectionStore(storage, []Costume{test.seed}); err == nil {
+ t.Fatal("malformed costume burst storage was accepted")
+ }
+ })
+ }
+}
diff --git a/go/internal/server/player/costume_burst_test.go b/go/internal/server/player/costume_burst_test.go
new file mode 100644
index 0000000..2ec233c
--- /dev/null
+++ b/go/internal/server/player/costume_burst_test.go
@@ -0,0 +1,256 @@
+package player
+
+import (
+ "math"
+ "path/filepath"
+ "testing"
+
+ "bd2server/internal/server/accountstate"
+ "bd2server/internal/server/gamedata"
+ "bd2server/internal/server/wire"
+)
+
+func testCostumeBurstDesign() *gamedata.CostumeBurstDesign {
+ return &gamedata.CostumeBurstDesign{Levels: map[uint64]map[uint64]gamedata.CostumeBurstLevel{
+ 4202: {
+ 1: {CostumeID: 4202, Level: 1, Costs: []gamedata.PromotionCost{{Type: 4, Count: 100}, {Type: 8, ID: 710, Count: 2}}},
+ 2: {CostumeID: 4202, Level: 2, Costs: []gamedata.PromotionCost{{Type: 4, Count: 200}, {Type: 8, ID: 710, Count: 2}}},
+ },
+ }}
+}
+
+func costumeBurstRequest(seq, costumeID uint64, materials ...Item) []byte {
+ request := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, costumeID)
+ for _, material := range materials {
+ request = wire.AppendBytes(request, 3, ItemWire(material))
+ }
+ return request
+}
+
+func newCostumeBurstFixture(t *testing.T, storePath string, stacks []gamedata.BattleReward) (*CostumeBurstService, *CollectionStore, *Inventory, *Wallet, []Item) {
+ t.Helper()
+ store := testStore(storePath)
+ starter := &Starter{Version: "2.35.10"}
+ inventory, err := OpenInventory(store, starter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ items, err := inventory.GrantOnce("costume-burst-materials", stacks)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := OpenWallet(store, Currency{Gold: 1000})
+ if err != nil {
+ t.Fatal(err)
+ }
+ collection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 88, ID: 4202, DesignID: 9911}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ service, err := NewCostumeBurstService(testCostumeBurstDesign(), collection, inventory, wallet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return service, collection, inventory, wallet, items
+}
+
+func TestCostumeBurstConsumesExactSplitCostsPersistsAndReplays(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.json")
+ service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, path, []gamedata.BattleReward{
+ {Type: 8, ID: 710, Count: 1},
+ {Type: 8, ID: 710, Count: 1},
+ })
+ service.BeginSession("login-a")
+ request := costumeBurstRequest(7, 4202, stacks[1], Item{Type: 4, Count: 100}, stacks[0])
+ code, body, handled, err := service.Handle("/CostumeBurst", request)
+ if err != nil || !handled || code != costumeBurstPacketCode {
+ t.Fatalf("CostumeBurst code=%d handled=%v body=%x err=%v", code, handled, body, err)
+ }
+ level, found, err := wire.Varint(body, 1)
+ if err != nil || !found || level != 1 {
+ t.Fatalf("response level=%d found=%v err=%v body=%x", level, found, err, body)
+ }
+ got, found := collection.CostumeByID(4202)
+ if !found || got.InvenIndex != 88 || got.DesignID != 9911 || got.BurstLevel != 1 {
+ t.Fatalf("upgraded costume=%+v found=%v", got, found)
+ }
+ if wallet.Snapshot().Gold != 900 {
+ t.Fatalf("gold=%d", wallet.Snapshot().Gold)
+ }
+ for _, stack := range stacks {
+ if err := inventory.CanConsume([]Item{stack}); err == nil {
+ t.Fatalf("material stack %d survived", stack.InvenIndex)
+ }
+ }
+
+ code2, body2, handled, err := service.Handle("/CostumeBurst", request)
+ if err != nil || !handled || code2 != code || string(body2) != string(body) {
+ t.Fatalf("same-session replay code=%d handled=%v body=%x err=%v", code2, handled, body2, err)
+ }
+ if wallet.Snapshot().Gold != 900 || collection.Costumes()[0].BurstLevel != 1 {
+ t.Fatal("same-session replay mutated state")
+ }
+
+ store := testStore(path)
+ reloadedInventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ reloadedWallet, err := OpenWallet(store, Currency{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ reloadedCollection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 88, ID: 4202, DesignID: 9911}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ restarted, err := NewCostumeBurstService(testCostumeBurstDesign(), reloadedCollection, reloadedInventory, reloadedWallet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ restarted.BeginSession("after-restart")
+ code2, body2, handled, err = restarted.Handle("/CostumeBurst", request)
+ if err != nil || !handled || code2 != code || string(body2) != string(body) {
+ t.Fatalf("durable replay code=%d handled=%v body=%x err=%v", code2, handled, body2, err)
+ }
+ if reloadedWallet.Snapshot().Gold != 900 || reloadedCollection.Costumes()[0].BurstLevel != 1 {
+ t.Fatal("durable replay charged or advanced again")
+ }
+}
+
+func TestCostumeBurstRequiresLogicalCostumeIDAndProtectsSessionSequence(t *testing.T) {
+ service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, filepath.Join(t.TempDir(), "state.json"), []gamedata.BattleReward{{Type: 8, ID: 710, Count: 4}})
+ material := stacks[0]
+ material.Count = 2
+ for _, wrong := range []uint64{88, 9911} {
+ request := costumeBurstRequest(wrong, wrong, material, Item{Type: 4, Count: 100})
+ if _, _, handled, err := service.Handle("/CostumeBurst", request); err == nil || !handled {
+ t.Fatalf("non-logical costume ID %d accepted: handled=%v err=%v", wrong, handled, err)
+ }
+ }
+ service.BeginSession("login-a")
+ request := costumeBurstRequest(9, 4202, material, Item{Type: 4, Count: 100})
+ if _, _, _, err := service.Handle("/CostumeBurst", request); err != nil {
+ t.Fatal(err)
+ }
+ different := costumeBurstRequest(9, 4202, material, Item{Type: 4, Count: 200})
+ if _, _, handled, err := service.Handle("/CostumeBurst", different); err == nil || !handled {
+ t.Fatalf("sequence reuse accepted: handled=%v err=%v", handled, err)
+ }
+ if collection.Costumes()[0].BurstLevel != 1 || wallet.Snapshot().Gold != 900 {
+ t.Fatal("rejected sequence reuse mutated state")
+ }
+ remaining := material
+ remaining.Count = 2
+ if err := inventory.CanConsume([]Item{remaining}); err != nil {
+ t.Fatalf("rejected request consumed remaining items: %v", err)
+ }
+}
+
+func TestCostumeBurstRejectsInvalidMaterialsAndProtocolRanges(t *testing.T) {
+ tests := []struct {
+ name string
+ materials func(Item) []Item
+ }{
+ {"short", func(stack Item) []Item { stack.Count = 1; return []Item{stack, {Type: 4, Count: 100}} }},
+ {"extra", func(stack Item) []Item {
+ stack.Count = 2
+ return []Item{stack, {Type: 4, Count: 100}, {InvenIndex: 900, Type: 8, ID: 711, Count: 1}}
+ }},
+ {"wrong-gold", func(stack Item) []Item { stack.Count = 2; return []Item{stack, {Type: 4, Count: 99}} }},
+ {"duplicate-overdraw", func(stack Item) []Item { stack.Count = 1; return []Item{stack, stack, {Type: 4, Count: 100}} }},
+ {"range", func(stack Item) []Item {
+ stack.Count = 2
+ stack.ExpiryTime = math.MaxInt64 + 1
+ return []Item{stack, {Type: 4, Count: 100}}
+ }},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ stackCount := uint64(2)
+ if test.name == "duplicate-overdraw" {
+ stackCount = 1
+ }
+ service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, filepath.Join(t.TempDir(), "state.json"), []gamedata.BattleReward{{Type: 8, ID: 710, Count: stackCount}})
+ request := costumeBurstRequest(1, 4202, test.materials(stacks[0])...)
+ if _, _, handled, err := service.Handle("/CostumeBurst", request); err == nil || !handled {
+ t.Fatalf("invalid request accepted: handled=%v err=%v", handled, err)
+ }
+ if collection.Costumes()[0].BurstLevel != 0 || wallet.Snapshot().Gold != 1000 {
+ t.Fatal("invalid request mutated level or wallet")
+ }
+ if err := inventory.CanConsume([]Item{stacks[0]}); err != nil {
+ t.Fatalf("invalid request consumed material: %v", err)
+ }
+ })
+ }
+}
+
+func TestCostumeBurstSQLiteRollbackIsAtomic(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.db")
+ repository, err := accountstate.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ starter := &Starter{Version: "2.35.10"}
+ inventory, err := OpenInventory(repository, starter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stacks, err := inventory.GrantOnce("rollback-material", []gamedata.BattleReward{{Type: 8, ID: 710, Count: 2}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := OpenWallet(repository, Currency{Gold: 1000})
+ if err != nil {
+ t.Fatal(err)
+ }
+ collection, err := OpenCollectionStore(repository, []Costume{{InvenIndex: 88, ID: 4202}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, ensure := range []func() error{inventory.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted} {
+ if err := ensure(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ service, _ := NewCostumeBurstService(testCostumeBurstDesign(), collection, inventory, wallet)
+ request := costumeBurstRequest(1, 4202, stacks[0], Item{Type: 4, Count: 100})
+ operation, err := repository.BeginOperation()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if code, _, handled, err := service.Handle("/CostumeBurst", request); err != nil || !handled || code != costumeBurstPacketCode {
+ t.Fatalf("transactional request code=%d handled=%v err=%v", code, handled, err)
+ }
+ if err := operation.Rollback(); err == nil {
+ t.Fatal("dirty request rollback did not fence published in-memory state")
+ }
+ if err := repository.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ reopened, err := accountstate.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reopened.Close()
+ reloadedInventory, err := OpenInventory(reopened, starter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ reloadedWallet, err := OpenWallet(reopened, Currency{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ reloadedCollection, err := OpenCollectionStore(reopened, []Costume{{InvenIndex: 88, ID: 4202}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reloadedWallet.Snapshot().Gold != 1000 || reloadedCollection.Costumes()[0].BurstLevel != 0 {
+ t.Fatalf("rolled back state gold=%d costume=%+v", reloadedWallet.Snapshot().Gold, reloadedCollection.Costumes()[0])
+ }
+ if err := reloadedInventory.CanConsume([]Item{stacks[0]}); err != nil {
+ t.Fatalf("rolled back inventory did not restore materials: %v", err)
+ }
+}
diff --git a/plugins/CaptureEnvironment/CaptureEnvironment.csproj b/plugins/CaptureEnvironment/CaptureEnvironment.csproj
index 6b1388b..e2fcd7a 100644
--- a/plugins/CaptureEnvironment/CaptureEnvironment.csproj
+++ b/plugins/CaptureEnvironment/CaptureEnvironment.csproj
@@ -7,34 +7,30 @@
Bd2CaptureEnvironment
capture_environment
-
-
-
- $(GameDir)\BepInEx\core\BepInEx.dll
+ $(BD2BepInExDir)/core/BepInEx.dll
false
- $(GameDir)\BepInEx\core\0Harmony.dll
+ $(BD2BepInExDir)/core/0Harmony.dll
false
- $(GameDir)\BrownDust II_Data\Managed\UnityEngine.CoreModule.dll
+ $(BD2ManagedDir)/UnityEngine.CoreModule.dll
false
- $(GameDir)\BrownDust II_Data\Managed\UnityEngine.dll
+ $(BD2ManagedDir)/UnityEngine.dll
false
- $(GameDir)\BrownDust II_Data\Managed\UnityEngine.UnityWebRequestModule.dll
+ $(BD2ManagedDir)/UnityEngine.UnityWebRequestModule.dll
false
- $(GameDir)\BrownDust II_Data\Managed\Google.Protobuf.dll
+ $(BD2ManagedDir)/Google.Protobuf.dll
false
-
diff --git a/plugins/CaptureEnvironment/CaptureStorageIsolation.cs b/plugins/CaptureEnvironment/CaptureStorageIsolation.cs
new file mode 100644
index 0000000..6e5d08f
--- /dev/null
+++ b/plugins/CaptureEnvironment/CaptureStorageIsolation.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using BD2.GameNames;
+using BepInEx.Logging;
+using HarmonyLib;
+using UnityEngine;
+using static BD2.GameNames.Game;
+
+namespace Bd2CaptureEnvironment;
+
+internal static class CaptureStorageIsolation
+{
+ private const string PlayerPrefsPrefix = "BD2OfficialCapture:" + Bd2Build.Versions.Game + ":";
+ private static ManualLogSource Log;
+ internal static string DataDirectory { get; private set; }
+ internal static string GameDataDirectory { get; private set; }
+ internal static void Initialize(string root, ManualLogSource log)
+ {
+ Log = log;
+ DataDirectory = Path.GetFullPath(Path.Combine(root, "IsolatedUserData"));
+ GameDataDirectory = Path.Combine(DataDirectory, "Data", "t");
+ EnsurePrivateGameDataDirectory();
+ }
+ private static void EnsurePrivateGameDataDirectory()
+ {
+ foreach (string path in new[]
+ {
+ DataDirectory,
+ Path.Combine(DataDirectory, "Data"),
+ GameDataDirectory
+ })
+ {
+ if (Directory.Exists(path) &&
+ (new DirectoryInfo(path).Attributes & FileAttributes.ReparsePoint) != 0)
+ throw new InvalidOperationException("Isolated GameData path is linked: " + path);
+ Directory.CreateDirectory(path);
+ }
+ }
+
+ internal static void Install(Harmony harmony)
+ {
+ PropertyInfo persistent = typeof(Application).GetGameProperty(
+ "persistentDataPath", BindingFlags.Static | BindingFlags.Public);
+ MethodInfo getter = persistent?.GetGetMethod();
+ if (getter == null)
+ throw new MissingMethodException("Application.persistentDataPath getter not found");
+ harmony.Patch(getter, prefix: new HarmonyMethod(
+ typeof(CaptureStorageIsolation), nameof(PersistentDataPathPrefix)));
+
+ var names = new HashSet(StringComparer.Ordinal)
+ {
+ "GetString", "SetString", "GetInt", "SetInt", "GetFloat", "SetFloat",
+ "HasKey", "DeleteKey"
+ };
+ foreach (MethodInfo method in typeof(PlayerPrefs).GetMethods(
+ BindingFlags.Static | BindingFlags.Public))
+ {
+ ParameterInfo[] parameters = method.GetParameters();
+ if (!names.Contains(method.Name) || parameters.Length == 0 ||
+ parameters[0].ParameterType != typeof(string))
+ continue;
+ harmony.Patch(method, prefix: new HarmonyMethod(
+ typeof(CaptureStorageIsolation), nameof(PlayerPrefsKeyPrefix)));
+ }
+
+ MethodInfo deleteAll = typeof(PlayerPrefs).GetGameMethod(
+ "DeleteAll", BindingFlags.Static | BindingFlags.Public,
+ null, Type.EmptyTypes, null);
+ if (deleteAll != null)
+ harmony.Patch(deleteAll, prefix: new HarmonyMethod(
+ typeof(CaptureStorageIsolation), nameof(BlockPlayerPrefsDeleteAll)));
+ }
+
+ private static bool PersistentDataPathPrefix(ref string __result)
+ {
+ __result = DataDirectory;
+ return false;
+ }
+
+ private static void PlayerPrefsKeyPrefix(ref string key)
+ {
+ if (key != null && !key.StartsWith(PlayerPrefsPrefix, StringComparison.Ordinal))
+ key = PlayerPrefsPrefix + key;
+ }
+
+ private static bool BlockPlayerPrefsDeleteAll()
+ {
+ Log?.LogWarning("Blocked PlayerPrefs.DeleteAll to protect the primary client profile");
+ return false;
+ }
+
+ internal static void InstallGameDataPath(Harmony harmony)
+ {
+ MethodInfo gameDataPath = Game.Method(network => network.GetPachedGameDataPath());
+ if (gameDataPath == null || gameDataPath.ReturnType != typeof(string))
+ throw new MissingMethodException("NetworkManager.GetPachedGameDataPath not found");
+ harmony.Patch(gameDataPath, prefix: new HarmonyMethod(
+ typeof(CaptureStorageIsolation), nameof(GameDataPathPrefix)));
+ Log?.LogInfo("Private installed GameData path hook installed");
+ }
+
+ private static bool GameDataPathPrefix(ref string __result)
+ {
+ __result = GameDataDirectory;
+ return false;
+ }
+
+}
diff --git a/plugins/CaptureEnvironment/CaptureWriter.cs b/plugins/CaptureEnvironment/CaptureWriter.cs
new file mode 100644
index 0000000..401046a
--- /dev/null
+++ b/plugins/CaptureEnvironment/CaptureWriter.cs
@@ -0,0 +1,283 @@
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using BepInEx.Logging;
+
+namespace Bd2CaptureEnvironment;
+
+internal static class CaptureWriter
+{
+ private const int MaxBodyBytes = 16 * 1024 * 1024;
+ private const int MaxQueuedRecords = 256;
+ private const int WriterShutdownSeconds = 30;
+ private static readonly object FailureFileLock = new object();
+ private static readonly BlockingCollection WriteQueue =
+ new BlockingCollection(new ConcurrentQueue(), MaxQueuedRecords);
+ private static ManualLogSource Log;
+ internal static string DirectoryPath { get; private set; }
+ private static string JsonlPath;
+ private static string ReadableLogPath;
+ private static Thread WriterThread;
+ private static int WriterFailed;
+ private static int RejectionLogged;
+ private static int StopRequested;
+ internal static void Initialize(string root, ManualLogSource log)
+ {
+ Log = log;
+ DirectoryPath = Path.GetFullPath(Path.Combine(root, "Capture", DateTime.Now.ToString("yyyyMMdd-HHmmss")));
+ JsonlPath = Path.Combine(DirectoryPath, "capture.jsonl");
+ ReadableLogPath = Path.Combine(DirectoryPath, "capture.log");
+ Directory.CreateDirectory(Path.Combine(DirectoryPath, "bodies"));
+ StartWriter();
+ }
+ private sealed class CaptureRecord
+ {
+ public DateTime Timestamp;
+ public long Id;
+ public string Direction;
+ public string Path;
+ public string Type;
+ public byte[] Body;
+ public int Length;
+ public string Note;
+ }
+
+ internal static void QueueCapture(long id, string direction, string path,
+ string type, byte[] body)
+ {
+ if (Volatile.Read(ref WriterFailed) != 0)
+ {
+ LogRejectedPacket(id, direction, path,
+ "capture writer is unavailable");
+ return;
+ }
+ if (WriteQueue.IsAddingCompleted)
+ {
+ string closedReason = "capture queue was already closed when packet " +
+ id + " " + direction + " " + path + " arrived";
+ MarkIncomplete(closedReason, null);
+ LogRejectedPacket(id, direction, path, closedReason);
+ return;
+ }
+ int length = body?.Length ?? 0;
+ string note = body == null ? "protobuf body is null" : null;
+ byte[] owned = null;
+ if (length <= MaxBodyBytes)
+ {
+ if (length != 0)
+ {
+ owned = new byte[length];
+ Buffer.BlockCopy(body, 0, owned, 0, length);
+ }
+ }
+ else
+ {
+ note = "body exceeds " + MaxBodyBytes + " byte capture limit";
+ }
+ var record = new CaptureRecord
+ {
+ Timestamp = DateTime.Now,
+ Id = id,
+ Direction = direction,
+ Path = path,
+ Type = type,
+ Body = owned,
+ Length = length,
+ Note = note
+ };
+ try
+ {
+ if (WriteQueue.TryAdd(record)) return;
+ }
+ catch (InvalidOperationException)
+ {
+ // CompleteAdding can race a producer during application shutdown.
+ }
+ string reason = WriteQueue.IsAddingCompleted
+ ? "capture queue closed before packet " + id + " " + direction +
+ " " + path + " could be queued"
+ : "capture queue capacity " + MaxQueuedRecords +
+ " was exceeded; packet " + id + " " + direction + " " + path +
+ " was not captured";
+ MarkIncomplete(reason, null);
+ LogRejectedPacket(id, direction, path, reason);
+ }
+
+ private static void StartWriter()
+ {
+ WriterThread = new Thread(WriterLoop)
+ {
+ IsBackground = true,
+ Name = "BD2 capture writer"
+ };
+ WriterThread.Start();
+ }
+
+ internal static void Stop()
+ {
+ if (Interlocked.Exchange(ref StopRequested, 1) != 0) return;
+ CompleteWriterQueue();
+ if (WriterThread == null || !WriterThread.IsAlive) return;
+ if (WriterThread.Join(TimeSpan.FromSeconds(WriterShutdownSeconds))) return;
+ MarkIncomplete(
+ "capture writer did not drain " + WriteQueue.Count +
+ " queued records within " + WriterShutdownSeconds +
+ " seconds during shutdown", null);
+ }
+
+ private static void WriterLoop()
+ {
+ try
+ {
+ using var json = new StreamWriter(JsonlPath, false, new UTF8Encoding(false));
+ using var readable = new StreamWriter(
+ ReadableLogPath, false, new UTF8Encoding(false));
+ readable.WriteLine("timestamp id dir path protobuf type bytes body");
+ readable.WriteLine(new string('-', 160));
+ foreach (CaptureRecord record in WriteQueue.GetConsumingEnumerable())
+ {
+ try
+ {
+ string bodyFile = WriteBody(record);
+ json.WriteLine(RecordJson(record, bodyFile));
+ readable.WriteLine(ReadableLine(record, bodyFile));
+ json.Flush();
+ readable.Flush();
+ }
+ catch (Exception ex)
+ {
+ MarkIncomplete(
+ "capture writer failed while writing packet " + record.Id +
+ " " + record.Direction + " " + record.Path, ex);
+ return;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ MarkIncomplete("capture writer failed to initialize or finalize", ex);
+ }
+ }
+
+ private static void CompleteWriterQueue()
+ {
+ if (WriteQueue.IsAddingCompleted) return;
+ try { WriteQueue.CompleteAdding(); }
+ catch (InvalidOperationException) { }
+ }
+
+ internal static void MarkIncomplete(string reason, Exception error)
+ {
+ if (Interlocked.CompareExchange(ref WriterFailed, 1, 0) != 0) return;
+ CompleteWriterQueue();
+ string detail = DateTime.Now.ToString("O") + Environment.NewLine + reason;
+ if (error != null) detail += Environment.NewLine + error;
+ detail += Environment.NewLine;
+ try
+ {
+ if (!string.IsNullOrEmpty(DirectoryPath))
+ {
+ Directory.CreateDirectory(DirectoryPath);
+ lock (FailureFileLock)
+ {
+ File.WriteAllText(
+ Path.Combine(DirectoryPath, "INCOMPLETE.txt"),
+ detail, new UTF8Encoding(false));
+ }
+ }
+ }
+ catch (Exception markerError)
+ {
+ Log?.LogError("could not write capture INCOMPLETE marker: " + markerError);
+ }
+ Log?.LogError("CAPTURE IS INCOMPLETE: " + reason +
+ (error == null ? "" : Environment.NewLine + error));
+ }
+
+ private static void LogRejectedPacket(long id, string direction, string path,
+ string reason)
+ {
+ if (Interlocked.Exchange(ref RejectionLogged, 1) != 0) return;
+ Log?.LogError("CAPTURE PACKETS ARE BEING REJECTED: packet " + id + " " +
+ direction + " " + path + "; " + reason);
+ }
+
+ private static string WriteBody(CaptureRecord record)
+ {
+ if (record.Body == null || record.Body.Length == 0) return null;
+ string type = record.Type ?? record.Path ?? "protobuf";
+ int dot = type.LastIndexOf('.');
+ if (dot >= 0) type = type.Substring(dot + 1);
+ string safe = new string(type.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_')
+ .ToArray()).Trim('_');
+ if (safe.Length == 0) safe = "protobuf";
+ string name = record.Id.ToString("D6") + "_" + record.Direction +
+ "_" + safe + ".pb";
+ File.WriteAllBytes(Path.Combine(DirectoryPath, "bodies", name), record.Body);
+ return "bodies/" + name;
+ }
+
+ private static string RecordJson(CaptureRecord record, string bodyFile)
+ {
+ return "{" +
+ "\"timestamp\":\"" + Escape(record.Timestamp.ToString("O")) + "\"," +
+ "\"id\":" + record.Id + "," +
+ "\"direction\":\"" + Escape(record.Direction) + "\"," +
+ "\"path\":\"" + Escape(record.Path) + "\"," +
+ "\"protobuf_type\":\"" + Escape(record.Type) + "\"," +
+ "\"length\":" + record.Length + "," +
+ "\"body\":" + JsonString(bodyFile) + "," +
+ "\"note\":" + JsonString(record.Note) + "}";
+ }
+
+ private static string ReadableLine(CaptureRecord record, string bodyFile)
+ {
+ string direction = record.Direction == "request" ? "REQ" : "RESP";
+ return string.Format("{0,-33} {1,6:D6} {2,-4} {3,-40} {4,-50} {5,8} {6}",
+ record.Timestamp.ToString("O"), record.Id, direction,
+ Truncate(record.Path, 40), Truncate(record.Type, 50), record.Length,
+ bodyFile ?? record.Note ?? "-");
+ }
+
+ private static string Truncate(string value, int width)
+ {
+ value ??= "";
+ return value.Length <= width ? value : value.Substring(0, width - 1) + "…";
+ }
+
+ private static string JsonString(string value)
+ {
+ return value == null ? "null" : "\"" + Escape(value) + "\"";
+ }
+
+ private static string Escape(string value)
+ {
+ if (value == null) return "";
+ var escaped = new StringBuilder(value.Length + 16);
+ foreach (char character in value)
+ {
+ switch (character)
+ {
+ case '\"': escaped.Append("\\\""); break;
+ case '\\': escaped.Append("\\\\"); break;
+ case '\b': escaped.Append("\\b"); break;
+ case '\f': escaped.Append("\\f"); break;
+ case '\n': escaped.Append("\\n"); break;
+ case '\r': escaped.Append("\\r"); break;
+ case '\t': escaped.Append("\\t"); break;
+ default:
+ if (character <= '\u001f')
+ escaped.Append("\\u").Append(((int)character).ToString("x4"));
+ else
+ escaped.Append(character);
+ break;
+ }
+ }
+ return escaped.ToString();
+ }
+
+
+}
diff --git a/plugins/CaptureEnvironment/PacketCapture.cs b/plugins/CaptureEnvironment/PacketCapture.cs
new file mode 100644
index 0000000..26db24c
--- /dev/null
+++ b/plugins/CaptureEnvironment/PacketCapture.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using BD2.GameNames;
+using BepInEx.Logging;
+using Google.Protobuf;
+using HarmonyLib;
+using static BD2.GameNames.Game;
+
+namespace Bd2CaptureEnvironment;
+
+internal static class PacketCapture
+{
+ private static readonly object CorrelationLock = new object();
+ private static readonly Dictionary> PendingRequests =
+ new Dictionary>(StringComparer.Ordinal);
+ private static ManualLogSource Log;
+ private static long Sequence;
+ private sealed class PendingRequest
+ {
+ public long Id;
+ public string Type;
+ }
+
+ internal static void Install(Harmony harmony, ManualLogSource log)
+ {
+ Log = log;
+ Type manager = typeof(BDNetwork.NetworkManager);
+ MethodInfo send = manager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
+ .SingleOrDefault(method => method.IsGameMethod("Send") &&
+ method.ReturnType == typeof(void) &&
+ method.GetParameters().Length == 6 &&
+ typeof(IMessage).IsAssignableFrom(method.GetParameters()[0].ParameterType));
+ if (send != null)
+ {
+ harmony.Patch(send, prefix: new HarmonyMethod(
+ typeof(PacketCapture), nameof(GameSendPrefix)));
+ Log?.LogInfo("Plaintext request capture installed");
+ }
+ else
+ {
+ Log?.LogWarning("NetworkManager.Send plaintext hook not found");
+ }
+
+ Type responseData = typeof(BDNetwork.ResponseData);
+ MethodInfo responseCheck = manager?.GetMethods(
+ BindingFlags.Instance | BindingFlags.NonPublic)
+ .SingleOrDefault(method =>
+ {
+ ParameterInfo[] parameters = method.GetParameters();
+ return method.IsGameMethod("ResponseCheck") && method.ReturnType == typeof(bool) && parameters.Length == 4 &&
+ parameters[0].ParameterType == typeof(string) &&
+ parameters[2].ParameterType == responseData &&
+ parameters[3].ParameterType == typeof(byte[]);
+ });
+ if (responseCheck != null)
+ {
+ harmony.Patch(responseCheck, prefix: new HarmonyMethod(
+ typeof(PacketCapture), nameof(ResponseCheckPrefix)));
+ Log?.LogInfo("Path-correlated plaintext response capture installed");
+ }
+ else
+ {
+ Log?.LogWarning("NetworkManager.ResponseCheck plaintext hook not found");
+ }
+ }
+
+ private static void GameSendPrefix(IMessage __0)
+ {
+ try
+ {
+ if (__0 == null) return;
+ byte[] body = __0.ToByteArray();
+ string type = __0.GetType().FullName ?? __0.GetType().Name;
+ string path = RequestPath(__0.GetType().Name);
+ long id = Interlocked.Increment(ref Sequence);
+ EnqueueRequest(path, new PendingRequest { Id = id, Type = type });
+ CaptureWriter.QueueCapture(id, "request", path, type, body);
+ }
+ catch (Exception ex)
+ {
+ Log?.LogWarning("plaintext request capture failed: " + ex.Message);
+ }
+ }
+
+ private static void ResponseCheckPrefix(string __0, byte[] __3)
+ {
+ try
+ {
+ string path = NormalizePath(__0);
+ PendingRequest request = DequeueRequest(path);
+ long id = request?.Id ?? Interlocked.Increment(ref Sequence);
+ string type = ResponseType(request?.Type, path);
+ CaptureWriter.QueueCapture(id, "response", path, type, __3);
+ }
+ catch (Exception ex)
+ {
+ Log?.LogWarning("plaintext response capture failed: " + ex.Message);
+ }
+ }
+
+ private static string RequestPath(string typeName)
+ {
+ const string suffix = "Request";
+ if (typeName != null && typeName.EndsWith(suffix, StringComparison.Ordinal))
+ typeName = typeName.Substring(0, typeName.Length - suffix.Length);
+ return NormalizePath(typeName);
+ }
+
+ private static string NormalizePath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return "/unknown";
+ return path[0] == '/' ? path : "/" + path;
+ }
+
+ private static string ResponseType(string requestType, string path)
+ {
+ const string suffix = "Request";
+ if (!string.IsNullOrEmpty(requestType) &&
+ requestType.EndsWith(suffix, StringComparison.Ordinal))
+ return requestType.Substring(0, requestType.Length - suffix.Length) + "Response";
+ return "Proto.Net." + path.TrimStart('/') + "Response";
+ }
+
+ private static void EnqueueRequest(string path, PendingRequest request)
+ {
+ lock (CorrelationLock)
+ {
+ if (!PendingRequests.TryGetValue(path, out Queue requests))
+ {
+ requests = new Queue();
+ PendingRequests.Add(path, requests);
+ }
+ requests.Enqueue(request);
+ }
+ }
+
+ private static PendingRequest DequeueRequest(string path)
+ {
+ lock (CorrelationLock)
+ {
+ if (PendingRequests.TryGetValue(path, out Queue requests) &&
+ requests.Count != 0)
+ {
+ PendingRequest request = requests.Dequeue();
+ if (requests.Count == 0) PendingRequests.Remove(path);
+ return request;
+ }
+ }
+ return null;
+ }
+
+}
diff --git a/plugins/CaptureEnvironment/Plugin.cs b/plugins/CaptureEnvironment/Plugin.cs
index 2bc7874..878ecd2 100644
--- a/plugins/CaptureEnvironment/Plugin.cs
+++ b/plugins/CaptureEnvironment/Plugin.cs
@@ -1,14 +1,8 @@
using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
using System.IO;
-using System.Linq;
-using System.Reflection;
using System.Text;
-using System.Threading;
+using BD2.GameNames;
using BepInEx;
-using BepInEx.Logging;
-using Google.Protobuf;
using HarmonyLib;
using UnityEngine;
@@ -21,548 +15,46 @@ public sealed class Plugin : BaseUnityPlugin
public const string Name = "BD2 Capture Environment";
public const string Version = Bd2Build.Versions.Plugin;
- private const int MaxBodyBytes = 16 * 1024 * 1024;
- private const int MaxQueuedRecords = 256;
- private const int WriterShutdownSeconds = 30;
- private const string PlayerPrefsPrefix = "BD2OfficialCapture:" + Bd2Build.Versions.Game + ":";
- private static readonly object CorrelationLock = new object();
- private static readonly object FailureFileLock = new object();
- private static readonly Dictionary> PendingRequests =
- new Dictionary>(StringComparer.Ordinal);
- private static readonly BlockingCollection WriteQueue =
- new BlockingCollection(
- new ConcurrentQueue(), MaxQueuedRecords);
- private static ManualLogSource Log;
- private static string CaptureDirectory;
- private static string JsonlPath;
- private static string ReadableLogPath;
- private static string IsolatedDataDirectory;
- private static string IsolatedGameDataDirectory;
- private static long Sequence;
- private static Thread WriterThread;
- private static int WriterFailed;
- private static int RejectionLogged;
- private static int StopRequested;
-
private void Awake()
{
try
{
- Log = Logger;
- string root = Paths.GameRootPath;
- IsolatedDataDirectory = Path.GetFullPath(Path.Combine(root, "IsolatedUserData"));
- IsolatedGameDataDirectory = Path.Combine(IsolatedDataDirectory, "Data", "t");
- CaptureDirectory = Path.GetFullPath(Path.Combine(
- root, "Capture", DateTime.Now.ToString("yyyyMMdd-HHmmss")));
- JsonlPath = Path.Combine(CaptureDirectory, "capture.jsonl");
- ReadableLogPath = Path.Combine(CaptureDirectory, "capture.log");
- EnsurePrivateGameDataDirectory();
- Directory.CreateDirectory(Path.Combine(CaptureDirectory, "bodies"));
- StartWriter();
+ Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message));
+ CaptureStorageIsolation.Initialize(Paths.GameRootPath, Logger);
+ CaptureWriter.Initialize(Paths.GameRootPath, Logger);
var harmony = new Harmony(Guid);
- InstallStorageIsolation(harmony);
+ CaptureStorageIsolation.Install(harmony);
string effectiveDataPath = Application.persistentDataPath;
if (!string.Equals(
Path.GetFullPath(effectiveDataPath).TrimEnd(Path.DirectorySeparatorChar),
- IsolatedDataDirectory.TrimEnd(Path.DirectorySeparatorChar),
+ CaptureStorageIsolation.DataDirectory.TrimEnd(Path.DirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
"persistentDataPath isolation verification failed: " + effectiveDataPath);
- InstallPlaintextCapture(harmony);
+ CaptureStorageIsolation.InstallGameDataPath(harmony);
+ PacketCapture.Install(harmony, Logger);
WriteMetadata();
Logger.LogInfo("Official capture environment active");
- Logger.LogInfo("persistentDataPath => " + IsolatedDataDirectory);
- Logger.LogInfo("isolated GameData => " + IsolatedGameDataDirectory);
- Logger.LogInfo("capture => " + CaptureDirectory);
+ Logger.LogInfo("persistentDataPath => " + CaptureStorageIsolation.DataDirectory);
+ Logger.LogInfo("isolated GameData => " + CaptureStorageIsolation.GameDataDirectory);
+ Logger.LogInfo("capture => " + CaptureWriter.DirectoryPath);
}
catch (Exception ex)
{
Logger.LogError("Capture environment setup failed: " + ex);
- MarkCaptureIncomplete("capture environment setup failed", ex);
+ CaptureWriter.MarkIncomplete("capture environment setup failed", ex);
}
}
private void OnApplicationQuit()
{
- StopWriter();
- }
-
- private sealed class PendingRequest
- {
- public long Id;
- public string Type;
- }
-
- private sealed class CaptureRecord
- {
- public DateTime Timestamp;
- public long Id;
- public string Direction;
- public string Path;
- public string Type;
- public byte[] Body;
- public int Length;
- public string Note;
- }
-
- private static void EnsurePrivateGameDataDirectory()
- {
- foreach (string path in new[]
- {
- IsolatedDataDirectory,
- Path.Combine(IsolatedDataDirectory, "Data"),
- IsolatedGameDataDirectory
- })
- {
- if (Directory.Exists(path) &&
- (new DirectoryInfo(path).Attributes & FileAttributes.ReparsePoint) != 0)
- throw new InvalidOperationException("Isolated GameData path is linked: " + path);
- Directory.CreateDirectory(path);
- }
- }
-
- private static void InstallStorageIsolation(Harmony harmony)
- {
- PropertyInfo persistent = typeof(Application).GetProperty(
- "persistentDataPath", BindingFlags.Static | BindingFlags.Public);
- MethodInfo getter = persistent?.GetGetMethod();
- if (getter == null)
- throw new MissingMethodException("Application.persistentDataPath getter not found");
- harmony.Patch(getter, prefix: new HarmonyMethod(
- typeof(Plugin), nameof(PersistentDataPathPrefix)));
-
- var names = new HashSet(StringComparer.Ordinal)
- {
- "GetString", "SetString", "GetInt", "SetInt", "GetFloat", "SetFloat",
- "HasKey", "DeleteKey"
- };
- foreach (MethodInfo method in typeof(PlayerPrefs).GetMethods(
- BindingFlags.Static | BindingFlags.Public))
- {
- ParameterInfo[] parameters = method.GetParameters();
- if (!names.Contains(method.Name) || parameters.Length == 0 ||
- parameters[0].ParameterType != typeof(string))
- continue;
- harmony.Patch(method, prefix: new HarmonyMethod(
- typeof(Plugin), nameof(PlayerPrefsKeyPrefix)));
- }
-
- MethodInfo deleteAll = typeof(PlayerPrefs).GetMethod(
- "DeleteAll", BindingFlags.Static | BindingFlags.Public,
- null, Type.EmptyTypes, null);
- if (deleteAll != null)
- harmony.Patch(deleteAll, prefix: new HarmonyMethod(
- typeof(Plugin), nameof(BlockPlayerPrefsDeleteAll)));
- }
-
- private static bool PersistentDataPathPrefix(ref string __result)
- {
- __result = IsolatedDataDirectory;
- return false;
- }
-
- private static void PlayerPrefsKeyPrefix(ref string key)
- {
- if (key != null && !key.StartsWith(PlayerPrefsPrefix, StringComparison.Ordinal))
- key = PlayerPrefsPrefix + key;
- }
-
- private static bool BlockPlayerPrefsDeleteAll()
- {
- Log?.LogWarning("Blocked PlayerPrefs.DeleteAll to protect the primary client profile");
- return false;
- }
-
- private static void InstallPlaintextCapture(Harmony harmony)
- {
- Type manager = FindType("BDNetwork.NetworkManager");
- MethodInfo gameDataPath = manager?.GetMethod("GetPachedGameDataPath",
- BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null);
- if (gameDataPath == null || gameDataPath.ReturnType != typeof(string))
- throw new MissingMethodException("NetworkManager.GetPachedGameDataPath not found");
- harmony.Patch(gameDataPath, prefix: new HarmonyMethod(
- typeof(Plugin), nameof(GameDataPathPrefix)));
- Log?.LogInfo("Private installed GameData path hook installed");
-
- MethodInfo send = manager?.GetMethods(BindingFlags.Instance | BindingFlags.Public)
- .FirstOrDefault(method => method.Name == "Send" &&
- method.ReturnType == typeof(void) &&
- method.GetParameters().Length == 6 &&
- typeof(IMessage).IsAssignableFrom(method.GetParameters()[0].ParameterType));
- if (send != null)
- {
- harmony.Patch(send, prefix: new HarmonyMethod(
- typeof(Plugin), nameof(GameSendPrefix)));
- Log?.LogInfo("Plaintext request capture installed");
- }
- else
- {
- Log?.LogWarning("NetworkManager.Send plaintext hook not found");
- }
-
- Type responseData = FindType("BDNetwork.ResponseData");
- MethodInfo responseCheck = manager?.GetMethods(
- BindingFlags.Instance | BindingFlags.NonPublic)
- .SingleOrDefault(method =>
- {
- ParameterInfo[] parameters = method.GetParameters();
- return method.ReturnType == typeof(bool) && parameters.Length == 4 &&
- parameters[0].ParameterType == typeof(string) &&
- parameters[2].ParameterType == responseData &&
- parameters[3].ParameterType == typeof(byte[]);
- });
- if (responseCheck != null)
- {
- harmony.Patch(responseCheck, prefix: new HarmonyMethod(
- typeof(Plugin), nameof(ResponseCheckPrefix)));
- Log?.LogInfo("Path-correlated plaintext response capture installed");
- }
- else
- {
- Log?.LogWarning("NetworkManager.ResponseCheck plaintext hook not found");
- }
- }
-
- private static bool GameDataPathPrefix(ref string __result)
- {
- __result = IsolatedGameDataDirectory;
- return false;
- }
-
- private static void GameSendPrefix(IMessage __0)
- {
- try
- {
- if (__0 == null) return;
- byte[] body = __0.ToByteArray();
- string type = __0.GetType().FullName ?? __0.GetType().Name;
- string path = RequestPath(__0.GetType().Name);
- long id = Interlocked.Increment(ref Sequence);
- EnqueueRequest(path, new PendingRequest { Id = id, Type = type });
- QueueCapture(id, "request", path, type, body);
- }
- catch (Exception ex)
- {
- Log?.LogWarning("plaintext request capture failed: " + ex.Message);
- }
- }
-
- private static void ResponseCheckPrefix(string __0, byte[] __3)
- {
- try
- {
- string path = NormalizePath(__0);
- PendingRequest request = DequeueRequest(path);
- long id = request?.Id ?? Interlocked.Increment(ref Sequence);
- string type = ResponseType(request?.Type, path);
- QueueCapture(id, "response", path, type, __3);
- }
- catch (Exception ex)
- {
- Log?.LogWarning("plaintext response capture failed: " + ex.Message);
- }
- }
-
- private static string RequestPath(string typeName)
- {
- const string suffix = "Request";
- if (typeName != null && typeName.EndsWith(suffix, StringComparison.Ordinal))
- typeName = typeName.Substring(0, typeName.Length - suffix.Length);
- return NormalizePath(typeName);
- }
-
- private static string NormalizePath(string path)
- {
- if (string.IsNullOrWhiteSpace(path)) return "/unknown";
- return path[0] == '/' ? path : "/" + path;
- }
-
- private static string ResponseType(string requestType, string path)
- {
- const string suffix = "Request";
- if (!string.IsNullOrEmpty(requestType) &&
- requestType.EndsWith(suffix, StringComparison.Ordinal))
- return requestType.Substring(0, requestType.Length - suffix.Length) + "Response";
- return "Proto.Net." + path.TrimStart('/') + "Response";
- }
-
- private static void EnqueueRequest(string path, PendingRequest request)
- {
- lock (CorrelationLock)
- {
- if (!PendingRequests.TryGetValue(path, out Queue requests))
- {
- requests = new Queue();
- PendingRequests.Add(path, requests);
- }
- requests.Enqueue(request);
- }
- }
-
- private static PendingRequest DequeueRequest(string path)
- {
- lock (CorrelationLock)
- {
- if (PendingRequests.TryGetValue(path, out Queue requests) &&
- requests.Count != 0)
- {
- PendingRequest request = requests.Dequeue();
- if (requests.Count == 0) PendingRequests.Remove(path);
- return request;
- }
- }
- return null;
- }
-
- private static void QueueCapture(long id, string direction, string path,
- string type, byte[] body)
- {
- if (Volatile.Read(ref WriterFailed) != 0)
- {
- LogRejectedPacket(id, direction, path,
- "capture writer is unavailable");
- return;
- }
- if (WriteQueue.IsAddingCompleted)
- {
- string closedReason = "capture queue was already closed when packet " +
- id + " " + direction + " " + path + " arrived";
- MarkCaptureIncomplete(closedReason, null);
- LogRejectedPacket(id, direction, path, closedReason);
- return;
- }
- int length = body?.Length ?? 0;
- string note = body == null ? "protobuf body is null" : null;
- byte[] owned = null;
- if (length <= MaxBodyBytes)
- {
- if (length != 0)
- {
- owned = new byte[length];
- Buffer.BlockCopy(body, 0, owned, 0, length);
- }
- }
- else
- {
- note = "body exceeds " + MaxBodyBytes + " byte capture limit";
- }
- var record = new CaptureRecord
- {
- Timestamp = DateTime.Now,
- Id = id,
- Direction = direction,
- Path = path,
- Type = type,
- Body = owned,
- Length = length,
- Note = note
- };
- try
- {
- if (WriteQueue.TryAdd(record)) return;
- }
- catch (InvalidOperationException)
- {
- // CompleteAdding can race a producer during application shutdown.
- }
- string reason = WriteQueue.IsAddingCompleted
- ? "capture queue closed before packet " + id + " " + direction +
- " " + path + " could be queued"
- : "capture queue capacity " + MaxQueuedRecords +
- " was exceeded; packet " + id + " " + direction + " " + path +
- " was not captured";
- MarkCaptureIncomplete(reason, null);
- LogRejectedPacket(id, direction, path, reason);
- }
-
- private static void StartWriter()
- {
- WriterThread = new Thread(WriterLoop)
- {
- IsBackground = true,
- Name = "BD2 capture writer"
- };
- WriterThread.Start();
- }
-
- private static void StopWriter()
- {
- if (Interlocked.Exchange(ref StopRequested, 1) != 0) return;
- CompleteWriterQueue();
- if (WriterThread == null || !WriterThread.IsAlive) return;
- if (WriterThread.Join(TimeSpan.FromSeconds(WriterShutdownSeconds))) return;
- MarkCaptureIncomplete(
- "capture writer did not drain " + WriteQueue.Count +
- " queued records within " + WriterShutdownSeconds +
- " seconds during shutdown", null);
- }
-
- private static void WriterLoop()
- {
- try
- {
- using var json = new StreamWriter(JsonlPath, false, new UTF8Encoding(false));
- using var readable = new StreamWriter(
- ReadableLogPath, false, new UTF8Encoding(false));
- readable.WriteLine("timestamp id dir path protobuf type bytes body");
- readable.WriteLine(new string('-', 160));
- foreach (CaptureRecord record in WriteQueue.GetConsumingEnumerable())
- {
- try
- {
- string bodyFile = WriteBody(record);
- json.WriteLine(RecordJson(record, bodyFile));
- readable.WriteLine(ReadableLine(record, bodyFile));
- json.Flush();
- readable.Flush();
- }
- catch (Exception ex)
- {
- MarkCaptureIncomplete(
- "capture writer failed while writing packet " + record.Id +
- " " + record.Direction + " " + record.Path, ex);
- return;
- }
- }
- }
- catch (Exception ex)
- {
- MarkCaptureIncomplete("capture writer failed to initialize or finalize", ex);
- }
- }
-
- private static void CompleteWriterQueue()
- {
- if (WriteQueue.IsAddingCompleted) return;
- try { WriteQueue.CompleteAdding(); }
- catch (InvalidOperationException) { }
- }
-
- private static void MarkCaptureIncomplete(string reason, Exception error)
- {
- if (Interlocked.CompareExchange(ref WriterFailed, 1, 0) != 0) return;
- CompleteWriterQueue();
- string detail = DateTime.Now.ToString("O") + Environment.NewLine + reason;
- if (error != null) detail += Environment.NewLine + error;
- detail += Environment.NewLine;
- try
- {
- if (!string.IsNullOrEmpty(CaptureDirectory))
- {
- Directory.CreateDirectory(CaptureDirectory);
- lock (FailureFileLock)
- {
- File.WriteAllText(
- Path.Combine(CaptureDirectory, "INCOMPLETE.txt"),
- detail, new UTF8Encoding(false));
- }
- }
- }
- catch (Exception markerError)
- {
- Log?.LogError("could not write capture INCOMPLETE marker: " + markerError);
- }
- Log?.LogError("CAPTURE IS INCOMPLETE: " + reason +
- (error == null ? "" : Environment.NewLine + error));
- }
-
- private static void LogRejectedPacket(long id, string direction, string path,
- string reason)
- {
- if (Interlocked.Exchange(ref RejectionLogged, 1) != 0) return;
- Log?.LogError("CAPTURE PACKETS ARE BEING REJECTED: packet " + id + " " +
- direction + " " + path + "; " + reason);
- }
-
- private static string WriteBody(CaptureRecord record)
- {
- if (record.Body == null || record.Body.Length == 0) return null;
- string type = record.Type ?? record.Path ?? "protobuf";
- int dot = type.LastIndexOf('.');
- if (dot >= 0) type = type.Substring(dot + 1);
- string safe = new string(type.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_')
- .ToArray()).Trim('_');
- if (safe.Length == 0) safe = "protobuf";
- string name = record.Id.ToString("D6") + "_" + record.Direction +
- "_" + safe + ".pb";
- File.WriteAllBytes(Path.Combine(CaptureDirectory, "bodies", name), record.Body);
- return "bodies/" + name;
- }
-
- private static string RecordJson(CaptureRecord record, string bodyFile)
- {
- return "{" +
- "\"timestamp\":\"" + Escape(record.Timestamp.ToString("O")) + "\"," +
- "\"id\":" + record.Id + "," +
- "\"direction\":\"" + Escape(record.Direction) + "\"," +
- "\"path\":\"" + Escape(record.Path) + "\"," +
- "\"protobuf_type\":\"" + Escape(record.Type) + "\"," +
- "\"length\":" + record.Length + "," +
- "\"body\":" + JsonString(bodyFile) + "," +
- "\"note\":" + JsonString(record.Note) + "}";
- }
-
- private static string ReadableLine(CaptureRecord record, string bodyFile)
- {
- string direction = record.Direction == "request" ? "REQ" : "RESP";
- return string.Format("{0,-33} {1,6:D6} {2,-4} {3,-40} {4,-50} {5,8} {6}",
- record.Timestamp.ToString("O"), record.Id, direction,
- Truncate(record.Path, 40), Truncate(record.Type, 50), record.Length,
- bodyFile ?? record.Note ?? "-");
- }
-
- private static string Truncate(string value, int width)
- {
- value ??= "";
- return value.Length <= width ? value : value.Substring(0, width - 1) + "…";
- }
-
- private static string JsonString(string value)
- {
- return value == null ? "null" : "\"" + Escape(value) + "\"";
- }
-
- private static string Escape(string value)
- {
- if (value == null) return "";
- var escaped = new StringBuilder(value.Length + 16);
- foreach (char character in value)
- {
- switch (character)
- {
- case '\"': escaped.Append("\\\""); break;
- case '\\': escaped.Append("\\\\"); break;
- case '\b': escaped.Append("\\b"); break;
- case '\f': escaped.Append("\\f"); break;
- case '\n': escaped.Append("\\n"); break;
- case '\r': escaped.Append("\\r"); break;
- case '\t': escaped.Append("\\t"); break;
- default:
- if (character <= '\u001f')
- escaped.Append("\\u").Append(((int)character).ToString("x4"));
- else
- escaped.Append(character);
- break;
- }
- }
- return escaped.ToString();
- }
-
- private static Type FindType(string fullName)
- {
- foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
- {
- Type type = assembly.GetType(fullName, false);
- if (type != null) return type;
- }
- try { return Assembly.Load("Assembly-CSharp")?.GetType(fullName, false); }
- catch { return null; }
+ CaptureWriter.Stop();
}
private static void WriteMetadata()
{
- File.WriteAllText(Path.Combine(CaptureDirectory, "README.txt"),
+ File.WriteAllText(Path.Combine(CaptureWriter.DirectoryPath, "README.txt"),
"BD2 " + Bd2Build.Versions.Game + " official API capture.\r\n" +
"All NetworkManager plaintext protobuf requests and responses are recorded.\r\n" +
"capture.jsonl is machine-readable; capture.log is the aligned human-readable index.\r\n" +
@@ -570,8 +62,8 @@ public sealed class Plugin : BaseUnityPlugin
"No Cookie/Authorization headers or URL query strings are recorded.\r\n" +
"Raw protobuf/account bodies can still contain private account data. Do not share this directory.\r\n" +
"clientVersion=" + Application.version + "\r\n" +
- "persistentDataPath=" + IsolatedDataDirectory + "\r\n" +
- "gameDataPath=" + IsolatedGameDataDirectory + "\r\n",
+ "persistentDataPath=" + CaptureStorageIsolation.DataDirectory + "\r\n" +
+ "gameDataPath=" + CaptureStorageIsolation.GameDataDirectory + "\r\n",
new UTF8Encoding(false));
}
}
diff --git a/plugins/CaptureEnvironment/README.md b/plugins/CaptureEnvironment/README.md
index 732302b..98c8a58 100644
--- a/plugins/CaptureEnvironment/README.md
+++ b/plugins/CaptureEnvironment/README.md
@@ -2,6 +2,19 @@
This BepInEx plugin is only for the separately extracted official client selected by the repository-root `versions.json`.
+The SDK embeds the version-locked names table. Build with the game directory; the build generates the readable reference assembly and reobfuscates the plugin automatically:
+
+```powershell
+dotnet build plugins/CaptureEnvironment/CaptureEnvironment.csproj -c Release `
+ '-p:GameDir='
+```
+
+Deploy the runtime `BD2CaptureEnvironment.dll` together with the adjacent
+`BD2.GameNames.dll`. The launcher copies both from the staged build and requires
+both to exist. The shared library validates the plugin/table version and game
+binary before game patches are installed. See [the GameSdk guide](../GameSdk/README.md)
+for the compiler/runtime name contract; the readable shell stays in `obj`.
+
- redirects `Application.persistentDataPath` to `/IsolatedUserData`;
- prefixes game `PlayerPrefs` keys with a namespace derived from the configured client version;
- keeps account/profile/Neo/Intro and the installed GameData working set in
diff --git a/plugins/Directory.Build.local.props.example b/plugins/Directory.Build.local.props.example
new file mode 100644
index 0000000..1272ffe
--- /dev/null
+++ b/plugins/Directory.Build.local.props.example
@@ -0,0 +1,9 @@
+
+
+ E:\Games\BrownDustII
+
+ E:\Games\BrownDustII-Capture
+
+
+
+
diff --git a/plugins/Directory.Build.props b/plugins/Directory.Build.props
new file mode 100644
index 0000000..f787693
--- /dev/null
+++ b/plugins/Directory.Build.props
@@ -0,0 +1,21 @@
+
+
+
+ $(MSBuildThisFileDirectory)
+ $([System.IO.Path]::GetFullPath('$(BD2PluginsDir)..'))/
+
+
+
+ $(BD2CaptureGameDir)
+ $(BD2LocalGameDir)
+ $(GameDir)/BrownDust II_Data/Managed
+ $(GameDir)/BepInEx
+ $(BD2RepositoryDir)versions.json
+ $(BD2PluginsDir)GameSdk/GameSdk.csproj
+ $(BD2PluginsDir)GameNames/GameNames.csproj
+ Release
+ $(BD2PluginsDir)GameSdk/bin/$(BD2SdkConfiguration)/net8.0/GameSdk.dll
+ game-sdk
+ $(BD2_GAME_SDK_CACHE)
+
+
diff --git a/plugins/Directory.Build.targets b/plugins/Directory.Build.targets
new file mode 100644
index 0000000..7d112bf
--- /dev/null
+++ b/plugins/Directory.Build.targets
@@ -0,0 +1,9 @@
+
+
+
+ $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)$(BD2SdkIntermediateFolder)'))
+ $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)Bd2Versions.g.cs'))
+
+
+
+
diff --git a/plugins/GameNames/Game.cs b/plugins/GameNames/Game.cs
new file mode 100644
index 0000000..d83ab4e
--- /dev/null
+++ b/plugins/GameNames/Game.cs
@@ -0,0 +1,243 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Reflection;
+using System.Runtime.Serialization.Json;
+using System.Security.Cryptography;
+using BD2.GameNames.Internal;
+
+namespace BD2.GameNames;
+
+/// Readable names for string-based reflection. Unknown names retain their literal spelling.
+public static class Game
+{
+ private sealed class Index
+ {
+ internal readonly NameTable Table;
+ internal readonly Dictionary Types;
+ internal readonly Dictionary Tokens;
+ internal Index()
+ {
+ using (var stream = typeof(Game).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz"))
+ using (var gzip = new GZipStream(stream ?? throw new InvalidDataException("BD2.GameNames: embedded table missing"), CompressionMode.Decompress))
+ Table = (NameTable)new DataContractJsonSerializer(typeof(NameTable)).ReadObject(gzip);
+ if (Table.schema_version != 1) throw new InvalidDataException("BD2.GameNames: unsupported table schema");
+ Types = Table.types.ToDictionary(t => t.readable, StringComparer.Ordinal);
+ Tokens = Table.members.ToDictionary(m => m.token);
+ }
+ }
+ private static readonly Lazy Names = new Lazy(() => new Index());
+ private static readonly ConcurrentDictionary TypeCache = new ConcurrentDictionary();
+ private static readonly ConcurrentDictionary MemberCache = new ConcurrentDictionary();
+ private static readonly object ValidationLock = new object();
+ private static string ValidatedStamp;
+ /// Gets the game version embedded in this package.
+ public static string GameVersion => Names.Value.Table.game_version;
+
+ /// Translates a full readable type name. Unknown names retain their literal spelling.
+ public static string TypeName(string readableFullName)
+ {
+ if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName));
+ return Names.Value.Types.TryGetValue(readableFullName.Replace('/', '+'), out var entry) ? entry.original : readableFullName;
+ }
+
+ /// Finds a readable type in Assembly-CSharp or loaded assemblies; returns null when absent.
+ public static Type FindType(string readableFullName)
+ {
+ if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName));
+ if (TypeCache.TryGetValue(readableFullName, out var cached)) return cached;
+ string original = TypeName(readableFullName);
+ Type found = GameAssembly().GetType(original, false);
+ if (found == null)
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ found = assembly.GetType(original, false);
+ if (found != null) break;
+ }
+ if (found != null) TypeCache.TryAdd(readableFullName, found);
+ return found;
+ }
+
+ private static Assembly GameAssembly() => Assembly.Load("Assembly-CSharp");
+ private static bool Matches(MemberInfo member, string kind, string readable)
+ {
+ if (member == null) throw new ArgumentNullException(nameof(member));
+ if (readable == null) throw new ArgumentNullException(nameof(readable));
+ // Metadata tokens retain declaration identity through inheritance and closed generics.
+ // A hidden literal member must not be mistaken for a renamed member of a base class.
+ if (member.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(member.MetadataToken, out var entry))
+ return entry.kind == kind && entry.readable == readable;
+ return member.Name == readable;
+ }
+
+ /// Translate coroutine/member strings. Overloads with different runtime names require Method/MethodName(MethodInfo).
+ public static string MemberName(Type type, string readable, GameMemberKind kind = GameMemberKind.Method)
+ {
+ if (type == null) throw new ArgumentNullException(nameof(type));
+ if (readable == null) throw new ArgumentNullException(nameof(readable));
+ string key = kind switch
+ {
+ GameMemberKind.Method => "method",
+ GameMemberKind.Field => "field",
+ GameMemberKind.Property => "property",
+ GameMemberKind.Event => "event",
+ _ => throw new ArgumentOutOfRangeException(nameof(kind))
+ };
+ const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
+ var reflected = kind switch
+ {
+ GameMemberKind.Method => type.GetMethods(all).Cast(),
+ GameMemberKind.Field => type.GetFields(all).Cast(),
+ GameMemberKind.Property => type.GetProperties(all).Cast(),
+ GameMemberKind.Event => type.GetEvents(all).Cast(),
+ _ => throw new ArgumentOutOfRangeException(nameof(kind))
+ };
+ var names = reflected.Where(m => Matches(m, key, readable)).Select(m => m.Name).Distinct().ToArray();
+ if (names.Length == 0) return readable;
+ if (names.Length != 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature");
+ return names[0];
+ }
+ /// Returns the actual CLR method name for APIs that accept strings.
+ public static string MethodName(MethodInfo method) => method?.Name ?? throw new ArgumentNullException(nameof(method));
+ /// Tests a reflected method against its readable name, preserving overload identity.
+ public static bool IsGameMethod(this MethodInfo method, string readable) => Matches(method, "method", readable);
+ /// Translates a readable parameter name using the method declaration; unknown names remain unchanged.
+ public static string ParameterName(MethodBase method, string readable)
+ {
+ if (method == null) throw new ArgumentNullException(nameof(method));
+ if (readable == null) throw new ArgumentNullException(nameof(readable));
+ if (method.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(method.MetadataToken, out var entry))
+ {
+ var p = entry.parameters.SingleOrDefault(n => n.readable == readable);
+ if (p != null) return p.original;
+ }
+ return readable;
+ }
+
+ /// Extracts a MethodInfo from a call expression without executing the expression.
+ public static MethodInfo Method(Expression> expression) => Call(expression);
+ /// Extracts a MethodInfo from a call expression without executing the expression.
+ public static MethodInfo Method(Expression expression) => Call(expression);
+ /// Extracts a property getter from an expression without evaluating the property.
+ public static MethodInfo Getter(Expression> expression) => PropertyGetter(expression);
+ /// Extracts a property getter from an expression without evaluating the property.
+ public static MethodInfo Getter(Expression> expression) => PropertyGetter(expression);
+
+ private static MethodInfo Call(LambdaExpression expression)
+ {
+ if (expression == null) throw new ArgumentNullException(nameof(expression));
+ return expression.Body is MethodCallExpression call ? call.Method : throw new ArgumentException("Expected a method call", nameof(expression));
+ }
+ private static MethodInfo PropertyGetter(LambdaExpression expression)
+ {
+ if (expression == null) throw new ArgumentNullException(nameof(expression));
+ return expression.Body is MemberExpression member && member.Member is PropertyInfo property
+ ? property.GetGetMethod(true) : throw new ArgumentException("Expected a property access", nameof(expression));
+ }
+
+ private static T Cached(Type type, string kind, string readable, BindingFlags flags, Type[] parameters, Func resolve) where T : MemberInfo
+ {
+ if (readable == null) throw new ArgumentNullException(nameof(readable));
+ if (type == null) return null;
+ string key = type.AssemblyQualifiedName + "|" + kind + "|" + readable + "|" + (int)flags + "|" + (parameters == null ? "*" : string.Join(";", parameters.Select(p => p.AssemblyQualifiedName)));
+ if (MemberCache.TryGetValue(key, out var cached)) return (T)cached;
+ T found = resolve();
+ if (found != null) MemberCache.TryAdd(key, found);
+ return found;
+ }
+ /// Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw.
+ public static MethodInfo GetGameMethod(this Type type, string readable, BindingFlags flags) => Cached(type, "method", readable, flags, null,
+ () => Single(type.GetMethods(flags).Where(m => Matches(m, "method", readable)), type, readable));
+ /// Finds a method by readable name and binding flags. Returns null when absent; ambiguous matches throw.
+ public static MethodInfo GetGameMethod(this Type type, string readable, BindingFlags flags, Binder binder, Type[] parameters, ParameterModifier[] modifiers)
+ {
+ if (readable == null) throw new ArgumentNullException(nameof(readable));
+ if (parameters == null) throw new ArgumentNullException(nameof(parameters));
+ if (parameters.Any(p => p == null)) throw new ArgumentException("Parameter types cannot contain null", nameof(parameters));
+ if (type == null) return null;
+ MethodInfo Resolve()
+ {
+ var candidates = type.GetMethods(flags).Where(m => Matches(m, "method", readable)).ToArray();
+ if (candidates.Length == 0) return null;
+ return (MethodInfo)(binder ?? Type.DefaultBinder).SelectMethod(flags, candidates, parameters, modifiers);
+ }
+ // Custom binders/modifiers can affect resolution; do not cache those calls.
+ if (binder != null || modifiers != null) return Resolve();
+ return Cached(type, "method", readable, flags, parameters, Resolve);
+ }
+ private static T Single(IEnumerable members, Type type, string readable) where T : MemberInfo
+ {
+ var candidates = members.Take(2).ToArray();
+ if (candidates.Length > 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature");
+ return candidates.FirstOrDefault();
+ }
+ /// Finds a field by readable name and binding flags. Returns null when absent.
+ public static FieldInfo GetGameField(this Type type, string readable, BindingFlags flags) => Cached(type, "field", readable, flags, null,
+ () => Single(type.GetFields(flags).Where(m => Matches(m, "field", readable)), type, readable));
+ /// Finds a property by readable name and binding flags. Returns null when absent; ambiguous matches throw.
+ public static PropertyInfo GetGameProperty(this Type type, string readable, BindingFlags flags) => Cached(type, "property", readable, flags, null,
+ () => Single(type.GetProperties(flags).Where(m => Matches(m, "property", readable)), type, readable));
+ /// Finds an event by readable name and binding flags. Returns null when absent.
+ public static EventInfo GetGameEvent(this Type type, string readable, BindingFlags flags) => Cached(type, "event", readable, flags, null,
+ () => Single(type.GetEvents(flags).Where(m => Matches(m, "event", readable)), type, readable));
+
+ /// Validate plugin/table version, binary fingerprint and known metadata entries before installing patches.
+ public static void Validate(Assembly plugin, Action log = null) => Validate(plugin, GameVersion, log);
+
+ /// Validates only the game binary against the embedded table for plugins that do not use the compiler SDK.
+ public static void ValidateGame(Action log = null) => ValidateCore(null, GameVersion, log);
+
+ /// Validates a compiler SDK plugin, its expected game version, the table and the game binary. Throws on mismatch.
+ public static void Validate(Assembly plugin, string expectedVersion, Action log)
+ {
+ if (plugin == null) throw new ArgumentNullException(nameof(plugin));
+ if (expectedVersion == null) throw new ArgumentNullException(nameof(expectedVersion));
+ ValidateCore(plugin, expectedVersion, log);
+ }
+
+ private static void ValidateCore(Assembly plugin, string expectedVersion, Action log)
+ {
+ try
+ {
+ var table = Names.Value.Table;
+ string stamp = table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256;
+ var metadata = plugin?.GetCustomAttributes().SingleOrDefault(a => a.Key == "BD2.GameNames");
+ if (expectedVersion != table.game_version || plugin != null && metadata?.Value != stamp)
+ throw new InvalidDataException("plugin/table mismatch; rebuild the plugin and BD2.GameNames together");
+ lock (ValidationLock)
+ {
+ if (ValidatedStamp != stamp)
+ {
+ var assembly = GameAssembly();
+ if (assembly.ManifestModule.ModuleVersionId.ToString() != table.assembly_mvid)
+ throw new InvalidDataException("Assembly-CSharp MVID mismatch");
+ using (var sha = SHA256.Create())
+ using (var stream = File.OpenRead(assembly.Location))
+ {
+ string actual = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant();
+ if (actual != table.assembly_sha256) throw new InvalidDataException("Assembly-CSharp SHA-256 mismatch");
+ }
+ const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
+ var app = FindType("AppManager") ?? throw new TypeLoadException("AppManager");
+ var intro = FindType("IntroUI") ?? throw new TypeLoadException("IntroUI");
+ var network = FindType("BDNetwork.NetworkManager") ?? throw new TypeLoadException("BDNetwork.NetworkManager");
+ if (app.GetGameProperty("IsPlatformLogin", all)?.PropertyType != typeof(bool) ||
+ intro.GetGameMethod("SendMaintenanceInfo", all, null, new[] { typeof(bool) }, null) == null ||
+ network.GetGameMethod("GetPachedGameDataPath", all, null, Type.EmptyTypes, null)?.ReturnType != typeof(string))
+ throw new MissingMemberException("known game-name probes failed");
+ ValidatedStamp = stamp;
+ }
+ }
+ log?.Invoke("BD2.GameNames self-check passed: game=" + table.game_version + ", MVID=" + table.assembly_mvid);
+ }
+ catch (Exception ex)
+ {
+ log?.Invoke("BD2.GameNames SELF-CHECK FAILED: expected game=" + expectedVersion + "; " + ex.Message + "; game patches will not be installed");
+ throw;
+ }
+ }
+}
diff --git a/plugins/GameNames/GameMemberKind.cs b/plugins/GameNames/GameMemberKind.cs
new file mode 100644
index 0000000..9830587
--- /dev/null
+++ b/plugins/GameNames/GameMemberKind.cs
@@ -0,0 +1,14 @@
+namespace BD2.GameNames;
+
+/// Specifies the kind of game member whose readable name should be resolved.
+public enum GameMemberKind
+{
+ /// A method, including a coroutine entry point.
+ Method,
+ /// A field.
+ Field,
+ /// A property.
+ Property,
+ /// An event.
+ Event
+}
diff --git a/plugins/GameNames/GameNames.csproj b/plugins/GameNames/GameNames.csproj
new file mode 100644
index 0000000..1a2a9a7
--- /dev/null
+++ b/plugins/GameNames/GameNames.csproj
@@ -0,0 +1,27 @@
+
+
+
+ netstandard2.0
+ latest
+ BD2.GameNames
+ disable
+ false
+ true
+ none
+ $(BaseIntermediateOutputPath)=/_/BD2.GameNames/obj,$(MSBuildProjectDirectory)=/_/BD2.GameNames
+ BD2.GameNames
+ Readable game-name resolution, reflection helpers and compatibility validation for Brown Dust II plugins. Includes a version-locked names table.
+ BD2;BrownDust;Unity;BepInEx;Harmony;modding
+ true
+ 1.0.0.0
+ $(MSBuildThisFileDirectory)Mappings/names.json
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/GameNames/Mappings/names.json.gz b/plugins/GameNames/Mappings/names.json.gz
new file mode 100644
index 0000000..b41384a
Binary files /dev/null and b/plugins/GameNames/Mappings/names.json.gz differ
diff --git a/plugins/GameNames/NameTable.cs b/plugins/GameNames/NameTable.cs
new file mode 100644
index 0000000..6c564fa
--- /dev/null
+++ b/plugins/GameNames/NameTable.cs
@@ -0,0 +1,49 @@
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+
+// DataContractJsonSerializer populates these internal fields from the embedded table.
+#pragma warning disable CS0649
+
+namespace BD2.GameNames.Internal;
+
+[DataContract]
+internal sealed class NameTable
+{
+ [DataMember] public int schema_version = 1;
+ [DataMember] public string game_version;
+ [DataMember] public string assembly_name;
+ [DataMember] public string assembly_mvid;
+ [DataMember] public string assembly_sha256;
+ [DataMember] public string mapping_sha256;
+ [DataMember] public string generator_sha256;
+ [DataMember] public List types = new List();
+ [DataMember] public List members = new List();
+}
+
+[DataContract]
+internal sealed class TypeName
+{
+ [DataMember] public int token;
+ [DataMember] public string readable;
+ [DataMember] public string original;
+}
+
+[DataContract]
+internal sealed class MemberName
+{
+ [DataMember] public int token;
+ [DataMember] public string kind;
+ [DataMember] public string declaring_type;
+ [DataMember] public string readable;
+ [DataMember] public string original;
+ [DataMember] public string signature;
+ [DataMember] public List parameters = new List();
+}
+
+[DataContract]
+internal sealed class ParameterName
+{
+ [DataMember] public int position;
+ [DataMember] public string readable;
+ [DataMember] public string original;
+}
diff --git a/plugins/GameNames/README.md b/plugins/GameNames/README.md
new file mode 100644
index 0000000..6f2cf59
--- /dev/null
+++ b/plugins/GameNames/README.md
@@ -0,0 +1,29 @@
+# BD2.GameNames
+
+Brown Dust II 插件的共享名字解析运行时,目标框架为 `.NET Standard 2.0`。内嵌当前游戏版本的官方名字表,支持字符串反射、Harmony 目标表达式、缓存和插件启动自检。
+
+开发插件建议引用配套的 `BD2.GameSdk` NuGet 包,它会自动引入本包并接入可读引用程序集和构建期 reobf。只有需要字符串反射、完全不引用游戏类型的插件才单独引用本包。
+
+```xml
+
+```
+
+```csharp
+using BD2.GameNames;
+using System.Reflection;
+
+Game.Validate(typeof(MyPlugin).Assembly, message => Logger.LogInfo(message));
+var intro = Game.FindType("IntroUI");
+var enter = intro?.GetGameMethod("Enter", BindingFlags.Instance | BindingFlags.NonPublic,
+ null, Type.EmptyTypes, null);
+```
+
+未知名字保留字面名;查不到成员返回 null,歧义抛出 `AmbiguousMatchException`。表中私有/编译器生成成员可通过字符串通道解析,公开调用可使用 `BD2.GameSdk` 生成的壳接受编译器检查。`nameof` 和协程字符串需显式经过名字表,普通字符串不会被 reobf 改写。
+
+单独引用本包的插件使用 `Game.ValidateGame` 校验游戏和内嵌表;`Game.Validate` 还会校验 SDK 在插件中写入的编译指纹。运行环境只需要共享 DLL,无需 .NET SDK、Mono.Cecil 或构建工具。同一客户端只安装一份 `BD2.GameNames.dll`,不同表指纹的插件会在启动时明确报错。
+
+单独引用本包的 SDK-style 类库在 `.csproj` 中设置 `true`,让运行时 DLL 复制到 `bin`;`BD2.GameSdk` 会自动设置此默认值。
+
+包版本使用 `工具语义版本-game.游戏版本`,例如 `0.2.0-game.2.35.10`;游戏版本由 `Game.GameVersion` 获取。游戏版本变化后必须更新配套 SDK/运行时并重新构建插件。遵循包中附带的 LICENSE;本包并非 MIT 或其他开源许可。
+
+完整导入和构建说明:[BD2.GameSdk 文档](https://github.com/Flechazo098/bd2/blob/main/plugins/GameSdk/README.md)。包目前由仓库打包脚本生成,通过维护者提供的 NuGet 源或本地目录安装。
diff --git a/plugins/GameSdk.NavigationTests/GameSdk.NavigationTests.csproj b/plugins/GameSdk.NavigationTests/GameSdk.NavigationTests.csproj
new file mode 100644
index 0000000..ccd6560
--- /dev/null
+++ b/plugins/GameSdk.NavigationTests/GameSdk.NavigationTests.csproj
@@ -0,0 +1,13 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
diff --git a/plugins/GameSdk.NavigationTests/Program.cs b/plugins/GameSdk.NavigationTests/Program.cs
new file mode 100644
index 0000000..c48ec77
--- /dev/null
+++ b/plugins/GameSdk.NavigationTests/Program.cs
@@ -0,0 +1,116 @@
+using System.Reflection;
+using System.Text.Json;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Host.Mef;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+if (args.Length == 0)
+ throw new ArgumentException("Usage: GameSdk.NavigationTests [game-managed-directory] [--embedded-only]");
+
+var hiddenSources = new List<(string Original, string Hidden)>();
+try
+{
+ // Missing local source proves that navigation uses the embedded documents.
+ if (args.Contains("--embedded-only"))
+ {
+ using var manifest = JsonDocument.Parse(File.ReadAllText(Path.Combine(args[0], "navigation.json")));
+ string root = manifest.RootElement.GetProperty("SourceRoot").GetString()!;
+ string fixtures = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, ".build", "game-sdk-tests")) + Path.DirectorySeparatorChar;
+ if (!Path.GetFullPath(root).StartsWith(fixtures, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("Embedded-only tests may only move generated synthetic fixture sources.");
+ foreach (string source in Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories))
+ {
+ string hidden = source + ".embedded-test";
+ File.Move(source, hidden);
+ hiddenSources.Add((source, hidden));
+ }
+ }
+
+ // Reflection accesses Visual Studio's internal Roslyn service. A mock would
+ // miss the reference-assembly lookup and embedded-document loader failures.
+ var features = Assembly.Load("Microsoft.CodeAnalysis.Features");
+ var host = MefHostServices.Create(MefHostServices.DefaultAssemblies
+ .Add(features).Add(Assembly.Load("Microsoft.CodeAnalysis.CSharp.Features")));
+ var exportProvider = host.GetType().GetInterfaces().Single(t => t.Name == "IMefHostExportProvider");
+ var getExports = exportProvider.GetMethods().Single(m => m.Name == "GetExports" && m.GetGenericArguments().Length == 1);
+ var contract = features.GetType("Microsoft.CodeAnalysis.MetadataAsSource.IMetadataAsSourceFileService")!;
+ var exports = (System.Collections.IEnumerable)getExports.MakeGenericMethod(contract).Invoke(host, null)!;
+ object export = exports.Cast