Files
bd2/go/internal/client/introdb/introdb.go
T

487 lines
14 KiB
Go

// Package introdb patches Brown Dust II's embedded Intro database without
// changing the size or layout of Unity's resources.assets file.
package introdb
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
clientlayout "bd2server/internal/client/layout"
)
const (
oldURL = "https://mt.bd2.pmang.cloud/"
)
var salt = Header
// Result describes a completed in-place client patch. BackupPath is the
// immutable pre-patch copy and is never overwritten by a later invocation.
type Result struct {
AssetsPath string
BackupPath string
ObjectPath int64
ObjectSize uint32
OldURL string
NewURL string
Changed bool
}
// VerifyResult is useful to patch-client's --verify mode and to diagnostics.
type VerifyResult struct {
AssetsPath string
ObjectPath int64
ObjectSize uint32
URL string
}
// PatchClient resolves resources.assets below gameDir, verifies the embedded
// TextAsset named Intro, makes a one-time .bak copy, then atomically replaces
// resources.assets. newURL must be exactly as long as the original URL.
func PatchClient(gameDir, newURL string) (Result, error) {
if len(newURL) != len(oldURL) {
return Result{}, fmt.Errorf("introdb: local URL must be exactly %d bytes (got %d): %q", len(oldURL), len(newURL), newURL)
}
if !isASCII(newURL) {
return Result{}, errors.New("introdb: local URL must be ASCII")
}
assets, err := ResourcesPath(gameDir)
if err != nil {
return Result{}, err
}
b, err := os.ReadFile(assets)
if err != nil {
return Result{}, fmt.Errorf("introdb: read assets: %w", err)
}
entry, script, err := findIntro(b)
if err != nil {
return Result{}, err
}
plain, err := DecryptPages(script)
if err != nil {
return Result{}, fmt.Errorf("introdb: decrypt Intro TextAsset: %w", err)
}
// A repeated command is intentionally a no-op. This makes automation safe
// while refusing to overwrite an Intro database that was patched to a
// different endpoint by another tool.
if bytes.Count(plain, []byte(oldURL)) == 0 {
current, findErr := urlInDB(plain)
if findErr != nil {
return Result{}, findErr
}
if current == newURL {
return Result{assets, assets + ".bak", entry.pathID, entry.size, current, newURL, false}, nil
}
return Result{}, fmt.Errorf("introdb: LIVE_URL is already %q, not the expected official URL", current)
}
if err := validateIntroDB(plain, oldURL); err != nil {
return Result{}, err
}
updated := bytes.Replace(plain, []byte(oldURL), []byte(newURL), 1)
ciphertext, err := EncryptPages(updated)
if err != nil {
return Result{}, err
}
copy(b[entry.scriptStart:entry.scriptStart+int64(len(ciphertext))], ciphertext)
// Verify the staged bytes before touching the installed asset. This catches
// a parser/cipher regression even though the payload length never changes.
_, stagedScript, err := findIntro(b)
if err != nil {
return Result{}, fmt.Errorf("introdb: re-read staged Intro: %w", err)
}
stagedPlain, err := DecryptPages(stagedScript)
if err != nil {
return Result{}, fmt.Errorf("introdb: decrypt staged Intro: %w", err)
}
if err := validateIntroDB(stagedPlain, newURL); err != nil {
return Result{}, fmt.Errorf("introdb: staged verification: %w", err)
}
backup := assets + ".bak"
if _, err := os.Stat(backup); errors.Is(err, os.ErrNotExist) {
if err := copyFile(assets, backup); err != nil {
return Result{}, fmt.Errorf("introdb: create backup: %w", err)
}
} else if err != nil {
return Result{}, fmt.Errorf("introdb: inspect backup: %w", err)
}
if err := atomicWrite(assets, b); err != nil {
return Result{}, err
}
return Result{assets, backup, entry.pathID, entry.size, oldURL, newURL, true}, nil
}
// VerifyClient reads and decrypts the embedded Intro TextAsset. It verifies
// the SQLite header plus the expected table/key markers before returning URL.
func VerifyClient(gameDir string) (VerifyResult, error) {
assets, err := ResourcesPath(gameDir)
if err != nil {
return VerifyResult{}, err
}
b, err := os.ReadFile(assets)
if err != nil {
return VerifyResult{}, fmt.Errorf("introdb: read assets: %w", err)
}
e, script, err := findIntro(b)
if err != nil {
return VerifyResult{}, err
}
p, err := DecryptPages(script)
if err != nil {
return VerifyResult{}, fmt.Errorf("introdb: decrypt Intro TextAsset: %w", err)
}
if !bytes.HasPrefix(p, salt) {
return VerifyResult{}, errors.New("introdb: decrypted Intro is not a SQLite database")
}
if !bytes.Contains(p, []byte("ServerURLTable")) || !bytes.Contains(p, []byte("LIVE_URL")) {
return VerifyResult{}, errors.New("introdb: Intro database lacks ServerURLTable/LIVE_URL markers")
}
url, err := urlInDB(p)
if err != nil {
return VerifyResult{}, err
}
return VerifyResult{assets, e.pathID, e.size, url}, nil
}
// ResourcesPath returns the conventional standalone Windows resource location.
func ResourcesPath(gameDir string) (string, error) {
if gameDir == "" {
return "", errors.New("introdb: empty game directory")
}
installation, err := clientlayout.Resolve(gameDir)
if err != nil {
return "", fmt.Errorf("introdb: resolve game layout: %w", err)
}
p := installation.Resources
st, err := os.Stat(p)
if err != nil {
return "", fmt.Errorf("introdb: resources.assets not found at %q: %w", p, err)
}
if st.IsDir() {
return "", fmt.Errorf("introdb: resources.assets path is a directory: %q", p)
}
return p, nil
}
// DecryptPages decrypts the game's independent 4096-byte AES-CBC pages.
func DecryptPages(in []byte) ([]byte, error) { return decryptPages(in) }
// EncryptPages encrypts the game's independent 4096-byte AES-CBC pages.
func EncryptPages(in []byte) ([]byte, error) { return encryptPages(in) }
func validateIntroDB(p []byte, expected string) error {
if !bytes.HasPrefix(p, salt) {
return errors.New("introdb: decrypted Intro is not a SQLite database (wrong cipher parameters or asset)")
}
if !bytes.Contains(p, []byte("ServerURLTable")) || !bytes.Contains(p, []byte("LIVE_URL")) {
return errors.New("introdb: database does not contain ServerURLTable/LIVE_URL")
}
if bytes.Count(p, []byte(expected)) != 1 {
return fmt.Errorf("introdb: expected LIVE_URL value %q exactly once, found %d", expected, bytes.Count(p, []byte(expected)))
}
return nil
}
func urlInDB(p []byte) (string, error) {
if bytes.Count(p, []byte(oldURL)) == 1 {
return oldURL, nil
}
// Patching stays strictly exact-length, so an already-patched value can be
// diagnosed without needing a SQLite C dependency. Search only the record
// containing LIVE_URL; unrelated configuration URLs are also present.
marker := []byte("LIVE_URL")
for start := 0; ; {
i := bytes.Index(p[start:], marker)
if i < 0 {
break
}
i += start + len(marker)
limit := i + 128
if limit > len(p) {
limit = len(p)
}
if j := bytes.Index(p[i:limit], []byte("http")); j >= 0 {
at := i + j
if at+len(oldURL) <= len(p) {
u := p[at : at+len(oldURL)]
if bytes.HasSuffix(u, []byte("/")) && isASCII(string(u)) {
return string(u), nil
}
}
}
start = i
}
return "", errors.New("introdb: cannot locate LIVE_URL value")
}
func isASCII(s string) bool {
for _, c := range []byte(s) {
if c < 0x20 || c > 0x7e {
return false
}
}
return true
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
_, e := io.Copy(out, in)
ce := out.Close()
if e != nil {
return e
}
return ce
}
func atomicWrite(path string, data []byte) error {
tmp := path + ".bd2server.tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("introdb: stage patched assets: %w", err)
}
if _, err := f.Write(data); err != nil {
_ = f.Close()
return fmt.Errorf("introdb: stage patched assets: %w", err)
}
if err := f.Sync(); err != nil {
_ = f.Close()
return fmt.Errorf("introdb: sync staged assets: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("introdb: close staged assets: %w", err)
}
// Go's Windows implementation replaces an existing destination (covered by
// TestPatchClientTransaction). Prefer that one-step replacement. A rollback
// path remains for Windows filesystems that reject replacement by rename.
if err := os.Rename(tmp, path); err == nil {
return nil
}
rollback := path + ".bd2server.rollback"
if _, err := os.Stat(rollback); err == nil {
return fmt.Errorf("introdb: cannot safely replace assets; rollback file exists: %q", rollback)
}
if err := os.Rename(path, rollback); err != nil {
return fmt.Errorf("introdb: replace assets: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
if restoreErr := os.Rename(rollback, path); restoreErr != nil {
return fmt.Errorf("introdb: replacement failed (%v) and rollback restore failed (%v); original is %q", err, restoreErr, rollback)
}
return fmt.Errorf("introdb: replace assets (original restored): %w", err)
}
if err := os.Remove(rollback); err != nil {
return fmt.Errorf("introdb: patched successfully but could not remove rollback copy %q: %w", rollback, err)
}
return nil
}
// Kept here so the object parser and patch transaction stay together.
type textAsset struct {
pathID int64
size uint32
scriptStart int64
scriptSize int
}
type reader struct {
b []byte
off int
little bool
}
func (r *reader) need(n int) error {
if n < 0 || r.off+n > len(r.b) {
return io.ErrUnexpectedEOF
}
return nil
}
func (r *reader) u8() (byte, error) {
if err := r.need(1); err != nil {
return 0, err
}
v := r.b[r.off]
r.off++
return v, nil
}
func (r *reader) u16() (uint16, error) {
if err := r.need(2); err != nil {
return 0, err
}
v := binary.LittleEndian.Uint16(r.b[r.off:])
if !r.little {
v = binary.BigEndian.Uint16(r.b[r.off:])
}
r.off += 2
return v, nil
}
func (r *reader) u32() (uint32, error) {
if err := r.need(4); err != nil {
return 0, err
}
v := binary.LittleEndian.Uint32(r.b[r.off:])
if !r.little {
v = binary.BigEndian.Uint32(r.b[r.off:])
}
r.off += 4
return v, nil
}
func (r *reader) i32() (int32, error) { v, e := r.u32(); return int32(v), e }
func (r *reader) i64() (int64, error) {
if e := r.need(8); e != nil {
return 0, e
}
v := binary.LittleEndian.Uint64(r.b[r.off:])
if !r.little {
v = binary.BigEndian.Uint64(r.b[r.off:])
}
r.off += 8
return int64(v), nil
}
func (r *reader) u64() (uint64, error) { v, e := r.i64(); return uint64(v), e }
func (r *reader) skip(n int) error {
if e := r.need(n); e != nil {
return e
}
r.off += n
return nil
}
func (r *reader) align4() { r.off = (r.off + 3) &^ 3 }
func (r *reader) str() (string, error) {
n, e := r.u32()
if e != nil {
return "", e
}
if n > uint32(len(r.b)-r.off) {
return "", io.ErrUnexpectedEOF
}
s := string(r.b[r.off : r.off+int(n)])
r.off += int(n)
r.align4()
return s, nil
}
func (r *reader) cstr() (string, error) {
start := r.off
for r.off < len(r.b) && r.b[r.off] != 0 {
r.off++
}
if r.off == len(r.b) {
return "", io.ErrUnexpectedEOF
}
s := string(r.b[start:r.off])
r.off++
return s, nil
}
func findIntro(file []byte) (textAsset, []byte, error) {
if len(file) < 48 {
return textAsset{}, nil, errors.New("introdb: truncated Unity serialized file")
}
version := binary.BigEndian.Uint32(file[8:12])
if version < 14 {
return textAsset{}, nil, fmt.Errorf("introdb: Unity serialized version %d is unsupported", version)
}
dataOff := binary.BigEndian.Uint64(file[32:40])
if dataOff >= uint64(len(file)) {
return textAsset{}, nil, errors.New("introdb: invalid Unity data offset")
}
// Unity records endian at byte 16: 0 means little endian for metadata.
r := reader{b: file[48:int(dataOff)], little: file[16] == 0}
if _, e := r.cstr(); e != nil {
return textAsset{}, nil, fmt.Errorf("introdb: user version: %w", e)
}
if _, e := r.i32(); e != nil {
return textAsset{}, nil, e
}
if _, e := r.u8(); e != nil {
return textAsset{}, nil, e
}
typeCount, e := r.i32()
if e != nil || typeCount < 0 || typeCount > 100000 {
return textAsset{}, nil, fmt.Errorf("introdb: invalid type count %d", typeCount)
}
classes := make([]int32, typeCount)
for i := range classes {
c, e := r.i32()
if e != nil {
return textAsset{}, nil, e
}
classes[i] = c
if _, e = r.u8(); e != nil {
return textAsset{}, nil, e
}
if _, e = r.u16(); e != nil {
return textAsset{}, nil, e
}
if c == 114 {
if e = r.skip(16); e != nil {
return textAsset{}, nil, e
}
}
if e = r.skip(16); e != nil {
return textAsset{}, nil, e
}
}
count, e := r.i32()
if e != nil || count < 0 || count > 10000000 {
return textAsset{}, nil, fmt.Errorf("introdb: invalid object count %d", count)
}
var candidates []textAsset
for i := int32(0); i < count; i++ {
// Since serialized version 14, Unity aligns object records to four bytes
// before their 64-bit path ID (not to an eight-byte boundary).
r.off = (r.off + 48 + 3) &^ 3
r.off -= 48
pid, e := r.i64()
if e != nil {
return textAsset{}, nil, e
}
start, e := r.u64()
if e != nil {
return textAsset{}, nil, e
}
size, e := r.u32()
if e != nil {
return textAsset{}, nil, e
}
typ, e := r.i32()
if e != nil {
return textAsset{}, nil, e
}
if typ < 0 || int(typ) >= len(classes) {
return textAsset{}, nil, fmt.Errorf("introdb: object %d has invalid type ID %d", i, typ)
}
if classes[typ] != 49 {
continue
}
abs := int64(dataOff) + int64(start)
if abs < 0 || abs+int64(size) > int64(len(file)) {
return textAsset{}, nil, fmt.Errorf("introdb: TextAsset object %d range outside file (start=%d size=%d)", i, start, size)
}
candidates = append(candidates, textAsset{pathID: pid, size: size, scriptStart: abs})
}
for _, c := range candidates {
object := reader{b: file[c.scriptStart : c.scriptStart+int64(c.size)], little: r.little}
name, e := object.str()
if e != nil {
continue
}
n, e := object.u32()
if e != nil || n > uint32(len(object.b)-object.off) {
continue
}
if name == "Intro" {
c.scriptStart += int64(object.off)
c.scriptSize = int(n)
return c, file[c.scriptStart : c.scriptStart+int64(n)], nil
}
}
return textAsset{}, nil, errors.New("introdb: TextAsset named Intro was not found")
}