575 lines
16 KiB
Go
575 lines
16 KiB
Go
// Command build is the shared implementation behind the repository wrappers.
|
|
// It uses only the standard library and caches no game or account state.
|
|
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type buildOptions struct {
|
|
gameDir string
|
|
skipTests, schedulesOnly bool
|
|
}
|
|
|
|
type releaseVersions struct {
|
|
Game string `json:"game_version"`
|
|
Server string `json:"server_version"`
|
|
Client string `json:"client_version"`
|
|
}
|
|
|
|
type target struct{ platform, goos, architecture, goarch, goarm string }
|
|
|
|
type task struct {
|
|
root string
|
|
target target
|
|
}
|
|
|
|
func main() {
|
|
if err := run(os.Args[1:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, "bd2w:", err)
|
|
var child *exec.ExitError
|
|
if errors.As(err, &child) && child.ExitCode() > 0 {
|
|
os.Exit(child.ExitCode())
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Println(`Usage:
|
|
bd2w build [-GameDir directory] [-SkipTests] [-SchedulesOnly]
|
|
bd2w runClient [client options]
|
|
bd2w runServer [server options]
|
|
|
|
Windows: .\bd2w build
|
|
Linux/macOS: ./bd2w build
|
|
Build automatically selects the native platform and architecture.
|
|
The wrappers compile go/build/build.go only when their cached executable is absent.`)
|
|
}
|
|
|
|
func run(args []string) error {
|
|
if len(args) == 0 || isHelp(args[0]) {
|
|
usage()
|
|
return nil
|
|
}
|
|
if args[0] != "build" && args[0] != "runClient" && args[0] != "runServer" {
|
|
return fmt.Errorf("unknown task %q; use build, runClient or runServer", args[0])
|
|
}
|
|
if args[0] == "build" && len(args) == 2 && isHelp(args[1]) {
|
|
usage()
|
|
return nil
|
|
}
|
|
root, err := repositoryRoot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
native, err := nativeTarget()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t := task{root, native}
|
|
switch args[0] {
|
|
case "runClient":
|
|
return t.command("go", append([]string{"run", "-tags", "production", "./cmd/bd2client", "--dev", "run"}, args[1:]...)...)
|
|
case "runServer":
|
|
return t.command("go", append([]string{"run", "./cmd/bd2server", "--dev", "run"}, args[1:]...)...)
|
|
default:
|
|
opts, err := parseOptions(args[1:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return t.build(opts)
|
|
}
|
|
}
|
|
|
|
func isHelp(s string) bool {
|
|
return s == "help" || s == "-h" || s == "--help" || strings.EqualFold(s, "-Help")
|
|
}
|
|
|
|
func repositoryRoot() (string, error) {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for dir := filepath.Dir(exe); ; dir = filepath.Dir(dir) {
|
|
if regular(filepath.Join(dir, "versions.json")) && regular(filepath.Join(dir, "go", "go.mod")) {
|
|
return dir, nil
|
|
}
|
|
if filepath.Dir(dir) == dir {
|
|
break
|
|
}
|
|
}
|
|
return "", errors.New("repository root not found beside the cached build tool; launch through bd2w")
|
|
}
|
|
|
|
func nativeTarget() (target, error) {
|
|
arch := runtime.GOARCH
|
|
if runtime.GOOS == "windows" {
|
|
machine := os.Getenv("PROCESSOR_ARCHITEW6432")
|
|
if machine == "" {
|
|
machine = os.Getenv("PROCESSOR_ARCHITECTURE")
|
|
}
|
|
switch strings.ToLower(machine) {
|
|
case "amd64":
|
|
arch = "amd64"
|
|
case "arm64":
|
|
arch = "arm64"
|
|
case "x86":
|
|
arch = "386"
|
|
}
|
|
} else {
|
|
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
|
arch = strings.TrimSpace(string(out))
|
|
}
|
|
}
|
|
return platformTarget(runtime.GOOS, arch)
|
|
}
|
|
|
|
func platformTarget(goos, arch string) (target, error) {
|
|
t := target{goos: goos}
|
|
switch goos {
|
|
case "windows", "linux":
|
|
t.platform = goos
|
|
case "darwin":
|
|
t.platform = "macos"
|
|
default:
|
|
return t, fmt.Errorf("unsupported platform %q", goos)
|
|
}
|
|
switch strings.ToLower(arch) {
|
|
case "amd64", "x86_64", "x64":
|
|
t.architecture = "x64"
|
|
t.goarch = "amd64"
|
|
case "aarch64", "arm64":
|
|
t.architecture = "arm64"
|
|
t.goarch = "arm64"
|
|
case "386", "i386", "i486", "i586", "i686", "x86":
|
|
t.architecture = "x86"
|
|
t.goarch = "386"
|
|
case "arm", "armv7", "armv7l":
|
|
t.architecture = "armv7"
|
|
t.goarch = "arm"
|
|
t.goarm = "7"
|
|
default:
|
|
return t, fmt.Errorf("unsupported architecture %q", arch)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func parseOptions(args []string) (buildOptions, error) {
|
|
var opts buildOptions
|
|
for i := 0; i < len(args); i++ {
|
|
name, value, assigned := strings.Cut(args[i], "=")
|
|
switch strings.ToLower(name) {
|
|
case "-gamedir", "--game-dir":
|
|
if !assigned {
|
|
i++
|
|
if i >= len(args) {
|
|
return opts, errors.New("-GameDir requires a directory")
|
|
}
|
|
value = args[i]
|
|
}
|
|
if strings.TrimSpace(value) == "" {
|
|
return opts, errors.New("-GameDir requires a directory")
|
|
}
|
|
opts.gameDir = value
|
|
case "-skiptests", "--skip-tests":
|
|
if assigned {
|
|
return opts, fmt.Errorf("unexpected value for %s", name)
|
|
}
|
|
opts.skipTests = true
|
|
case "-schedulesonly", "--schedules-only":
|
|
if assigned {
|
|
return opts, fmt.Errorf("unexpected value for %s", name)
|
|
}
|
|
opts.schedulesOnly = true
|
|
default:
|
|
return opts, fmt.Errorf("unknown build option %q", args[i])
|
|
}
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
// Every child receives a process-local native target and repository-local cache.
|
|
func (t task) command(program string, args ...string) error {
|
|
cmd := exec.Command(program, args...)
|
|
cmd.Dir = filepath.Join(t.root, "go")
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Env = replaceEnvironment(os.Environ(), map[string]string{"GOCACHE": filepath.Join(t.root, "go", ".cache", "go-build"), "GOOS": t.target.goos, "GOARCH": t.target.goarch, "GOARM": t.target.goarm})
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("%s %s: %w", program, strings.Join(args, " "), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func replaceEnvironment(env []string, values map[string]string) []string {
|
|
out := make([]string, 0, len(env)+len(values))
|
|
for _, entry := range env {
|
|
key, _, _ := strings.Cut(entry, "=")
|
|
remove := false
|
|
for wanted := range values {
|
|
if strings.EqualFold(key, wanted) {
|
|
remove = true
|
|
break
|
|
}
|
|
}
|
|
if !remove {
|
|
out = append(out, entry)
|
|
}
|
|
}
|
|
for key, value := range values {
|
|
if value != "" {
|
|
out = append(out, key+"="+value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func readJSON(path string, v any, strict bool) error {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d := json.NewDecoder(bytes.NewReader(bytes.TrimPrefix(raw, []byte{0xef, 0xbb, 0xbf})))
|
|
if strict {
|
|
d.DisallowUnknownFields()
|
|
}
|
|
if err = d.Decode(v); err != nil {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
if err = d.Decode(new(any)); err != io.EOF {
|
|
return fmt.Errorf("%s must contain one JSON object", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t task) versions() (releaseVersions, error) {
|
|
var v releaseVersions
|
|
if err := readJSON(filepath.Join(t.root, "versions.json"), &v, false); err != nil {
|
|
return v, err
|
|
}
|
|
valid := regexp.MustCompile(`^[A-Za-z0-9.+_-]+$`)
|
|
for _, s := range []string{v.Game, v.Server, v.Client} {
|
|
if !valid.MatchString(s) {
|
|
return v, fmt.Errorf("invalid release version %q", s)
|
|
}
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func (t task) schedules(gameVersion string) ([]string, string, error) {
|
|
files, err := filepath.Glob(filepath.Join(t.root, "schedules", "*.bd2schedule"))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if len(files) == 0 {
|
|
return nil, "", errors.New("no project schedule files found")
|
|
}
|
|
sort.Slice(files, func(i, j int) bool {
|
|
return strings.ToLower(filepath.Base(files[i])) < strings.ToLower(filepath.Base(files[j]))
|
|
})
|
|
var lines []string
|
|
for _, file := range files {
|
|
raw, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%s:%X", filepath.Base(file), sha256.Sum256(raw)))
|
|
}
|
|
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(lines, "\n"))))[:12]
|
|
return files, "bd2schedules-" + gameVersion + "-" + hash, nil
|
|
}
|
|
|
|
func (t task) gameDirectory(explicit string) (string, string, string, error) {
|
|
if explicit == "" {
|
|
var c struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
GameDirectory string `json:"game_directory"`
|
|
}
|
|
if err := readJSON(filepath.Join(t.root, "go", "config.json"), &c, true); err != nil {
|
|
return "", "", "", fmt.Errorf("read go/config.json or pass -GameDir: %w", err)
|
|
}
|
|
if c.SchemaVersion != 1 || strings.TrimSpace(c.GameDirectory) == "" {
|
|
return "", "", "", errors.New("go/config.json requires schema_version 1 and game_directory")
|
|
}
|
|
explicit = c.GameDirectory
|
|
}
|
|
game, err := filepath.Abs(explicit)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
managed := filepath.Join(game, "BrownDust II_Data", "Managed")
|
|
bep := filepath.Join(game, "BepInEx")
|
|
if !regular(filepath.Join(managed, "Assembly-CSharp.dll")) {
|
|
app := game
|
|
if !strings.EqualFold(filepath.Ext(app), ".app") {
|
|
app = filepath.Join(game, "BrownDust II.app")
|
|
}
|
|
managed = filepath.Join(app, "Contents", "Resources", "Data", "Managed")
|
|
bep = filepath.Join(filepath.Dir(app), "BepInEx")
|
|
inside := filepath.Join(app, "Contents", "BepInEx")
|
|
if directory(inside) && !directory(bep) {
|
|
bep = inside
|
|
}
|
|
}
|
|
if !regular(filepath.Join(managed, "Assembly-CSharp.dll")) {
|
|
return "", "", "", fmt.Errorf("Assembly-CSharp.dll missing in %s", game)
|
|
}
|
|
if !regular(filepath.Join(bep, "core", "BepInEx.dll")) {
|
|
return "", "", "", fmt.Errorf("BepInEx/core/BepInEx.dll missing in %s", game)
|
|
}
|
|
return game, managed, bep, nil
|
|
}
|
|
|
|
func (t task) build(opts buildOptions) error {
|
|
v, err := t.versions()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
schedules, scheduleName, err := t.schedules(v.Game)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
buildRoot := filepath.Join(t.root, ".build")
|
|
if opts.schedulesOnly {
|
|
out := filepath.Join(buildRoot, scheduleName)
|
|
if err := os.MkdirAll(out, 0755); err != nil {
|
|
return err
|
|
}
|
|
for _, file := range schedules {
|
|
if err := copyFile(file, filepath.Join(out, filepath.Base(file))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Println("Built schedule release:", out)
|
|
return nil
|
|
}
|
|
game, managed, bep, err := t.gameDirectory(opts.gameDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
packageRoot := filepath.Join(buildRoot, "package")
|
|
server := filepath.Join(packageRoot, "bd2server")
|
|
client := filepath.Join(packageRoot, "bd2client")
|
|
suffix := "-" + t.target.platform + "-" + t.target.architecture + ".zip"
|
|
serverZip := filepath.Join(buildRoot, "bd2server-"+v.Server+suffix)
|
|
clientZip := filepath.Join(buildRoot, "bd2client-"+v.Client+suffix)
|
|
if err := os.MkdirAll(buildRoot, 0755); err != nil {
|
|
return err
|
|
}
|
|
for _, path := range []string{packageRoot, serverZip, clientZip} {
|
|
if err := removeBuildOutput(buildRoot, path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, path := range []string{filepath.Join(server, "go"), filepath.Join(server, "data", "state"), filepath.Join(server, "schedules"), filepath.Join(client, "plugins")} {
|
|
if err := os.MkdirAll(path, 0755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if !opts.skipTests {
|
|
for _, args := range [][]string{{"test", "./..."}, {"vet", "./..."}, {"test", "-tags", "release,production", "./..."}, {"vet", "-tags", "release,production", "./..."}} {
|
|
if err := t.command("go", args...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
ext := ""
|
|
clientFlags := "-s -w"
|
|
if t.target.goos == "windows" {
|
|
ext = ".exe"
|
|
clientFlags = "-H windowsgui -s -w"
|
|
}
|
|
if err := t.command("go", "build", "-tags", "release", "-trimpath", "-ldflags", "-s -w", "-o", filepath.Join(server, "bd2server"+ext), "./cmd/bd2server"); err != nil {
|
|
return err
|
|
}
|
|
if err := t.command("go", "build", "-tags", "release,production", "-trimpath", "-ldflags", clientFlags, "-o", filepath.Join(client, "bd2client"+ext), "./cmd/bd2client"); err != nil {
|
|
return err
|
|
}
|
|
var shared []byte
|
|
for _, plugin := range []string{"LocalIdentity", "LoginUI", "CashShop"} {
|
|
project := filepath.Join(t.root, "plugins", plugin, plugin+".csproj")
|
|
if err := t.command("dotnet", "build", project, "-c", "Release", "-p:GameDir="+game, "-p:BD2ManagedDir="+managed, "-p:BD2BepInExDir="+bep, "--nologo"); err != nil {
|
|
return err
|
|
}
|
|
out := filepath.Join(t.root, "plugins", plugin, "bin", "Release", "netstandard2.1")
|
|
raw, err := os.ReadFile(filepath.Join(out, "BD2.GameNames.dll"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if shared != nil && !bytes.Equal(shared, raw) {
|
|
return errors.New("plugins were built with different BD2.GameNames libraries")
|
|
}
|
|
shared = raw
|
|
for _, name := range []string{"BD2" + plugin + ".dll", "BD2.GameNames.dll"} {
|
|
if err := copyFile(filepath.Join(out, name), filepath.Join(client, "plugins", name)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if err := copyTree(filepath.Join(t.root, "go", "seed"), filepath.Join(server, "go", "seed")); err != nil {
|
|
return err
|
|
}
|
|
for _, file := range schedules {
|
|
if err := copyFile(file, filepath.Join(server, "schedules", filepath.Base(file))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, name := range []string{"versions.json", "README.md", "LICENSE"} {
|
|
for _, out := range []string{server, client} {
|
|
if err := copyFile(filepath.Join(t.root, name), filepath.Join(out, name)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for _, name := range []string{"AUTHENTICATION.md", "RESOURCES.md", "GAME_CONFIGURATION.md"} {
|
|
if err := copyFile(filepath.Join(t.root, name), filepath.Join(server, name)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := archiveDirectory(server, serverZip); err != nil {
|
|
return err
|
|
}
|
|
if err := archiveDirectory(client, clientZip); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("Built server archive:", serverZip)
|
|
fmt.Println("Built client archive:", clientZip)
|
|
return nil
|
|
}
|
|
|
|
func regular(path string) bool { s, err := os.Stat(path); return err == nil && s.Mode().IsRegular() }
|
|
func directory(path string) bool { s, err := os.Stat(path); return err == nil && s.IsDir() }
|
|
|
|
func removeBuildOutput(root, path string) error {
|
|
base, err := filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(base, resolved)
|
|
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return fmt.Errorf("refusing to remove output outside .build: %s", path)
|
|
}
|
|
return os.RemoveAll(path)
|
|
}
|
|
|
|
func copyFile(source, destination string) error {
|
|
in, err := os.Open(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
info, err := in.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return fmt.Errorf("not a regular file: %s", source)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil {
|
|
return err
|
|
}
|
|
out, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, writeErr := io.Copy(out, in)
|
|
return errors.Join(writeErr, out.Close())
|
|
}
|
|
|
|
func copyTree(source, destination string) error {
|
|
return filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(source, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := filepath.Join(destination, rel)
|
|
if entry.IsDir() {
|
|
return os.MkdirAll(out, 0755)
|
|
}
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("unexpected seed symlink: %s", path)
|
|
}
|
|
return copyFile(path, out)
|
|
})
|
|
}
|
|
|
|
func archiveDirectory(source, destination string) error {
|
|
file, err := os.CreateTemp(filepath.Dir(destination), ".bd2-archive-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
temp := file.Name()
|
|
defer os.Remove(temp)
|
|
archive := zip.NewWriter(file)
|
|
err = filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("unexpected package symlink: %s", path)
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
header, err := zip.FileInfoHeader(info)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(filepath.Dir(source), path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
header.Name = filepath.ToSlash(rel)
|
|
if entry.IsDir() {
|
|
header.Name += "/"
|
|
_, err = archive.CreateHeader(header)
|
|
return err
|
|
}
|
|
header.Method = zip.Deflate
|
|
writer, err := archive.CreateHeader(header)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
in, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, writeErr := io.Copy(writer, in)
|
|
return errors.Join(writeErr, in.Close())
|
|
})
|
|
err = errors.Join(err, archive.Close(), file.Close())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(temp, destination)
|
|
}
|