243 lines
7.5 KiB
Go
243 lines
7.5 KiB
Go
package setup
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
clientlayout "bd2server/internal/client/layout"
|
|
)
|
|
|
|
const (
|
|
remoteCatalogPrefix = "{BDNetwork.CdnInfo.Info}\\"
|
|
localCatalogPrefix = "{UnityEngine.AddressableAssets.Addressables.RuntimePath}\\"
|
|
localCatalogBackup = "catalog.json.bd2-before-local-sync"
|
|
maximumCatalogBytes = 128 << 20
|
|
)
|
|
|
|
func installCurrentLocalCatalog(gameDir, localRoot, bundleVersion string) error {
|
|
installation, err := clientlayout.Resolve(gameDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
release := filepath.Join(filepath.Clean(localRoot), "ServerData", "StandaloneWindows64", "HD", bundleVersion)
|
|
source := filepath.Join(release, "catalog_alpha.json")
|
|
raw, err := readCatalog(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
aa := filepath.Join(installation.Data, "StreamingAssets", "aa")
|
|
raw, err = localizeCatalog(raw, release, aa)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(aa, "catalog.json")
|
|
current, err := os.ReadFile(target)
|
|
if err != nil {
|
|
return fmt.Errorf("read built-in Addressables catalog: %w", err)
|
|
}
|
|
if bytes.Equal(current, raw) {
|
|
return nil
|
|
}
|
|
backup := filepath.Join(aa, localCatalogBackup)
|
|
if _, err := os.Stat(backup); errors.Is(err, os.ErrNotExist) {
|
|
if err := writeExclusiveFile(backup, current, 0o600); err != nil {
|
|
return fmt.Errorf("back up built-in Addressables catalog: %w", err)
|
|
}
|
|
} else if err != nil {
|
|
return fmt.Errorf("inspect built-in Addressables catalog backup: %w", err)
|
|
}
|
|
if err := replaceCatalog(target, raw); err != nil {
|
|
return fmt.Errorf("install current local Addressables catalog: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readCatalog(path string) ([]byte, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open local ServerData catalog: %w", err)
|
|
}
|
|
defer file.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(file, maximumCatalogBytes+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read local ServerData catalog: %w", err)
|
|
}
|
|
if len(raw) == 0 || len(raw) > maximumCatalogBytes {
|
|
return nil, errors.New("local ServerData catalog has an invalid size")
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func validateLocalCatalog(raw []byte, release string) error {
|
|
_, err := localizeCatalogIDs(raw, release)
|
|
return err
|
|
}
|
|
|
|
func localizeCatalog(raw []byte, release, aa string) ([]byte, error) {
|
|
document, ids, err := decodeCatalog(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
remoteCount := 0
|
|
for index, internalID := range ids {
|
|
if strings.HasPrefix(internalID, remoteCatalogPrefix) {
|
|
relative, err := catalogBundlePath(internalID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
source := filepath.Join(release, relative)
|
|
destination := filepath.Join(aa, relative)
|
|
if err := ensureCatalogBundleLink(source, destination); err != nil {
|
|
return nil, err
|
|
}
|
|
ids[index] = localCatalogPrefix + strings.ReplaceAll(filepath.ToSlash(relative), "/", "\\")
|
|
remoteCount++
|
|
continue
|
|
}
|
|
if strings.HasPrefix(internalID, localCatalogPrefix) {
|
|
relative := strings.TrimPrefix(internalID, localCatalogPrefix)
|
|
relative = filepath.FromSlash(strings.ReplaceAll(relative, "\\", "/"))
|
|
if info, err := os.Stat(filepath.Join(aa, relative)); err != nil || !info.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("built-in Addressables catalog references a missing local bundle: %s", relative)
|
|
}
|
|
}
|
|
}
|
|
if remoteCount == 0 {
|
|
return nil, errors.New("local ServerData catalog has no CDN bundle entries")
|
|
}
|
|
encodedIDs, err := json.Marshal(ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
document["m_InternalIds"] = encodedIDs
|
|
localized, err := json.Marshal(document)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode localized ServerData catalog: %w", err)
|
|
}
|
|
return localized, nil
|
|
}
|
|
|
|
func localizeCatalogIDs(raw []byte, release string) ([]string, error) {
|
|
_, ids, err := decodeCatalog(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
remoteCount := 0
|
|
for _, internalID := range ids {
|
|
if !strings.HasPrefix(internalID, remoteCatalogPrefix) {
|
|
continue
|
|
}
|
|
relative, err := catalogBundlePath(internalID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info, err := os.Stat(filepath.Join(release, relative)); err != nil || !info.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("local ServerData catalog references a missing bundle: %s", relative)
|
|
}
|
|
remoteCount++
|
|
}
|
|
if remoteCount == 0 {
|
|
return nil, errors.New("local ServerData catalog has no CDN bundle entries")
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func decodeCatalog(raw []byte) (map[string]json.RawMessage, []string, error) {
|
|
var document map[string]json.RawMessage
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
if err := decoder.Decode(&document); err != nil {
|
|
return nil, nil, fmt.Errorf("decode local ServerData catalog: %w", err)
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
return nil, nil, errors.New("local ServerData catalog must contain exactly one JSON object")
|
|
}
|
|
var ids []string
|
|
if err := json.Unmarshal(document["m_InternalIds"], &ids); err != nil || len(ids) == 0 {
|
|
return nil, nil, errors.New("local ServerData catalog has no valid internal IDs")
|
|
}
|
|
return document, ids, nil
|
|
}
|
|
|
|
func ensureCatalogBundleLink(source, destination string) error {
|
|
sourceInfo, err := os.Stat(source)
|
|
if err != nil || !sourceInfo.Mode().IsRegular() {
|
|
return fmt.Errorf("local ServerData catalog references a missing bundle: %s", source)
|
|
}
|
|
if destinationInfo, err := os.Stat(destination); err == nil {
|
|
if !destinationInfo.Mode().IsRegular() || !os.SameFile(sourceInfo, destinationInfo) {
|
|
return fmt.Errorf("game Addressables bundle conflicts with the selected local release: %s", destination)
|
|
}
|
|
return nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("inspect game Addressables bundle: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
|
return fmt.Errorf("create game Addressables bundle directory: %w", err)
|
|
}
|
|
if err := os.Link(source, destination); err != nil {
|
|
return fmt.Errorf("hard-link local bundle without duplicating it (keep the game and local resources on the same filesystem): %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func catalogBundlePath(internalID string) (string, error) {
|
|
parts := strings.Split(strings.ReplaceAll(internalID, "\\", "/"), "/")
|
|
if len(parts) < 5 {
|
|
return "", fmt.Errorf("local ServerData catalog has an invalid CDN path: %q", internalID)
|
|
}
|
|
parts = parts[4:]
|
|
for _, part := range parts {
|
|
if part == "" || part == "." || part == ".." || strings.ContainsAny(part, `:{}`) {
|
|
return "", fmt.Errorf("local ServerData catalog has an unsafe bundle path: %q", internalID)
|
|
}
|
|
}
|
|
relative := filepath.Join(parts...)
|
|
if !strings.EqualFold(filepath.Ext(relative), ".bundle") {
|
|
return "", fmt.Errorf("local ServerData catalog CDN path is not a bundle: %q", internalID)
|
|
}
|
|
return relative, nil
|
|
}
|
|
|
|
func writeExclusiveFile(path string, data []byte, mode os.FileMode) error {
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err = file.Write(data); err == nil {
|
|
err = file.Sync()
|
|
}
|
|
if closeErr := file.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
return err
|
|
}
|
|
|
|
func replaceCatalog(path string, data []byte) error {
|
|
temporary, err := os.CreateTemp(filepath.Dir(path), ".catalog-local-*.json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
defer os.Remove(temporaryPath)
|
|
if err = temporary.Chmod(0o600); err == nil {
|
|
_, err = temporary.Write(data)
|
|
}
|
|
if err == nil {
|
|
err = temporary.Sync()
|
|
}
|
|
if closeErr := temporary.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(temporaryPath, path)
|
|
}
|