Package standalone state tool and client plugin

This commit is contained in:
2026-09-21 17:49:51 +08:00
parent c3d59123ce
commit a7c065eaa5
12 changed files with 333 additions and 96 deletions
+20 -4
View File
@@ -14,6 +14,7 @@ import (
"bd2server/internal/account"
"bd2server/internal/battle"
"bd2server/internal/bootstrap"
"bd2server/internal/clientplugin"
"bd2server/internal/deck"
"bd2server/internal/feature"
"bd2server/internal/gacha"
@@ -71,12 +72,27 @@ func serve(args []string) error {
deckSeed := fs.String("deck-seed", `seed\v2_34_13\decks.json`, "versioned starter deck")
deckState := fs.String("deck-state", `..\data\state\deck.json`, "local deck and waypoint save")
worldSeed := fs.String("world-seed", `seed\v2_34_13\world.json`, "versioned starter world")
stateTool := fs.String("state-tool", "", "optional Haskell state tool override (release builds embed it)")
stateTool := fs.String("state-tool", "", "optional Haskell state tool override (release package includes it)")
gameDir := fs.String("game-dir", "", "Brown Dust II client directory (required)")
identityPlugin := fs.String("identity-plugin", "", "optional BD2LocalIdentity.dll override for development")
if err := fs.Parse(args); err != nil {
return err
}
if *cdn == "" || *gameData == "" || *gameDataVersion == "" {
return errors.New("serve requires --cdn, --game-data, and --game-data-version")
if *cdn == "" || *gameData == "" || *gameDataVersion == "" || *gameDir == "" {
return errors.New("serve requires --game-dir, --cdn, --game-data, and --game-data-version")
}
packagedPlugin, err := clientplugin.ResolvePackaged(*identityPlugin)
if err != nil {
return err
}
pluginResult, err := clientplugin.Install(*gameDir, packagedPlugin)
if err != nil {
return err
}
if pluginResult.Changed {
slog.Info("installed local identity plugin", "path", pluginResult.Destination)
} else {
slog.Info("local identity plugin is current", "path", pluginResult.Destination)
}
base := "http://" + *listen + "/game/"
cfg := bootstrap.Config{
@@ -337,7 +353,7 @@ func usage() {
fmt.Fprintln(os.Stderr, `bd2server - BrownDust II 2.34.13 local development server
Usage:
bd2server serve --cdn DIR --game-data DIR --game-data-version VERSION [options]
bd2server serve --game-dir DIR --cdn DIR --game-data DIR --game-data-version VERSION [options]
bd2server patch-client --game-dir DIR [options]
bd2server state check [options]
bd2server state migrate-v1-v2 [options]
+94
View File
@@ -0,0 +1,94 @@
package clientplugin
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
const (
FileName = "BD2LocalIdentity.dll"
BepInExReleasesURL = "https://github.com/BepInEx/BepInEx/releases"
)
type Result struct {
Destination string
Changed bool
}
func ResolvePackaged(explicit string) (string, error) {
if explicit != "" {
return filepath.Clean(explicit), nil
}
executable, err := os.Executable()
if err != nil {
return "", fmt.Errorf("clientplugin: resolve server executable: %w", err)
}
return filepath.Join(filepath.Dir(executable), "plugins", FileName), nil
}
// Install verifies that the user installed BepInEx, then atomically stages the
// packaged local-identity plugin into its plugins directory. It never installs
// or downloads BepInEx itself.
func Install(gameDir, source string) (Result, error) {
if gameDir == "" || source == "" {
return Result{}, errors.New("clientplugin: game directory and plugin source are required")
}
gameDir = filepath.Clean(gameDir)
source = filepath.Clean(source)
gameExecutable := filepath.Join(gameDir, "BrownDust II.exe")
if info, err := os.Stat(gameExecutable); err != nil || info.IsDir() {
return Result{}, fmt.Errorf("clientplugin: game executable is unavailable at %q", gameExecutable)
}
bepInEx := filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll")
if info, err := os.Stat(bepInEx); err != nil || info.IsDir() {
return Result{}, fmt.Errorf("clientplugin: BepInEx is not installed; install it manually from %s, then restart the server; %s was not copied", BepInExReleasesURL, FileName)
}
sourceData, err := os.ReadFile(source)
if err != nil {
return Result{}, fmt.Errorf("clientplugin: read packaged %s: %w", FileName, err)
}
if len(sourceData) == 0 {
return Result{}, fmt.Errorf("clientplugin: packaged %s is empty", FileName)
}
pluginDir := filepath.Join(gameDir, "BepInEx", "plugins")
destination := filepath.Join(pluginDir, FileName)
if installed, err := os.ReadFile(destination); err == nil {
if bytes.Equal(hash(installed), hash(sourceData)) {
return Result{Destination: destination}, nil
}
} else if !errors.Is(err, os.ErrNotExist) {
return Result{}, fmt.Errorf("clientplugin: inspect installed plugin: %w", err)
}
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
return Result{}, fmt.Errorf("clientplugin: create plugin directory: %w", err)
}
temporary, err := os.CreateTemp(pluginDir, ".BD2LocalIdentity-*.tmp")
if err != nil {
return Result{}, fmt.Errorf("clientplugin: create temporary plugin: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if _, err = io.Copy(temporary, bytes.NewReader(sourceData)); err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return Result{}, fmt.Errorf("clientplugin: stage plugin: %w", err)
}
if err := replaceFile(temporaryPath, destination); err != nil {
return Result{}, fmt.Errorf("clientplugin: install plugin (close the game client first): %w", err)
}
return Result{Destination: destination, Changed: true}, nil
}
func hash(data []byte) []byte {
sum := sha256.Sum256(data)
return sum[:]
}
+64
View File
@@ -0,0 +1,64 @@
package clientplugin
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
gameDir := t.TempDir()
if err := os.WriteFile(filepath.Join(gameDir, "BrownDust II.exe"), []byte("game"), 0o600); err != nil {
t.Fatal(err)
}
source := filepath.Join(t.TempDir(), FileName)
if err := os.WriteFile(source, []byte("plugin"), 0o600); err != nil {
t.Fatal(err)
}
_, err := Install(gameDir, source)
if err == nil || !strings.Contains(err.Error(), BepInExReleasesURL) {
t.Fatalf("missing BepInEx error=%v", err)
}
if _, statErr := os.Stat(filepath.Join(gameDir, "BepInEx", "plugins", FileName)); !os.IsNotExist(statErr) {
t.Fatalf("plugin was copied without BepInEx: %v", statErr)
}
}
func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
} {
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(), FileName)
if err := os.WriteFile(source, []byte("v1"), 0o600); err != nil {
t.Fatal(err)
}
first, err := Install(gameDir, source)
if err != nil || !first.Changed {
t.Fatalf("first install=%+v err=%v", first, err)
}
second, err := Install(gameDir, source)
if err != nil || second.Changed {
t.Fatalf("idempotent install=%+v err=%v", second, err)
}
if err := os.WriteFile(source, []byte("v2"), 0o600); err != nil {
t.Fatal(err)
}
third, err := Install(gameDir, source)
if err != nil || !third.Changed {
t.Fatalf("update=%+v err=%v", third, err)
}
got, err := os.ReadFile(third.Destination)
if err != nil || string(got) != "v2" {
t.Fatalf("installed=%q err=%v", got, err)
}
}
@@ -0,0 +1,9 @@
//go:build !windows
package clientplugin
import "os"
func replaceFile(source, destination string) error {
return os.Rename(source, destination)
}
@@ -0,0 +1,34 @@
//go:build windows
package clientplugin
import (
"os"
"syscall"
"unsafe"
)
var moveFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW")
func replaceFile(source, destination string) error {
sourcePtr, err := syscall.UTF16PtrFromString(source)
if err != nil {
return err
}
destinationPtr, err := syscall.UTF16PtrFromString(destination)
if err != nil {
return err
}
result, _, callErr := moveFileEx.Call(
uintptr(unsafe.Pointer(sourcePtr)),
uintptr(unsafe.Pointer(destinationPtr)),
0x1|0x8, // MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH
)
if result == 0 {
if callErr != syscall.Errno(0) {
return callErr
}
return os.ErrInvalid
}
return nil
}
+3 -8
View File
@@ -6,8 +6,8 @@ import (
"path/filepath"
)
// ResolveTool prefers an explicit development override. Release builds embed
// the Haskell executable and materialize it into a private temporary directory.
// ResolveTool prefers an explicit development override. Release packages place
// bd2-state.exe beside bd2server.exe so both components remain inspectable.
func ResolveTool(explicit string) (string, func(), error) {
if explicit != "" {
return filepath.Clean(explicit), func() {}, nil
@@ -15,16 +15,11 @@ func ResolveTool(explicit string) (string, func(), error) {
if env := os.Getenv("BD2_STATE_TOOL"); env != "" {
return filepath.Clean(env), func() {}, nil
}
if path, cleanup, ok, err := embeddedStateTool(); err != nil {
return "", func() {}, err
} else if ok {
return path, cleanup, nil
}
if executable, err := os.Executable(); err == nil {
sibling := filepath.Join(filepath.Dir(executable), "bd2-state.exe")
if info, statErr := os.Stat(sibling); statErr == nil && !info.IsDir() {
return sibling, func() {}, nil
}
}
return "", func() {}, errors.New("statebridge: no Haskell state tool; use --state-tool/--tool or build the embedded release")
return "", func() {}, errors.New("statebridge: bd2-state.exe is missing beside bd2server.exe; use --state-tool/--tool for a development build")
}
-29
View File
@@ -1,29 +0,0 @@
//go:build embedded_state_tool
package statebridge
import (
"crypto/sha256"
_ "embed"
"encoding/hex"
"os"
"path/filepath"
)
//go:embed tool/bd2-state.exe
var embeddedStateToolBytes []byte
func embeddedStateTool() (string, func(), bool, error) {
sum := sha256.Sum256(embeddedStateToolBytes)
dir, err := os.MkdirTemp("", "bd2-state-"+hex.EncodeToString(sum[:6])+"-")
if err != nil {
return "", func() {}, true, err
}
cleanup := func() { _ = os.RemoveAll(dir) }
path := filepath.Join(dir, "bd2-state.exe")
if err := os.WriteFile(path, embeddedStateToolBytes, 0o700); err != nil {
cleanup()
return "", func() {}, true, err
}
return path, cleanup, true, nil
}
-7
View File
@@ -1,7 +0,0 @@
//go:build !embedded_state_tool
package statebridge
func embeddedStateTool() (string, func(), bool, error) {
return "", func() {}, false, nil
}