panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
// Package steamvdf is a small, tolerant parser for Valve's text KeyValues
|
||||
// ("VDF") format, sized for exactly two panel jobs:
|
||||
//
|
||||
// 1. Reading the installed buildid + beta branch out of a SteamCMD
|
||||
// appmanifest_<appid>.acf in an instance volume.
|
||||
// 2. Reading the per-branch latest buildids out of `steamcmd
|
||||
// +app_info_print <appid>` output.
|
||||
//
|
||||
// Tolerant means: unquoted tokens are accepted, // comments are skipped,
|
||||
// stray junk lines around the top-level object (SteamCMD banner output)
|
||||
// are ignored, and duplicate keys take last-wins. It is NOT a general
|
||||
// binary-VDF or #include-aware parser.
|
||||
package steamvdf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KV is one parsed VDF object: values are either string or KV.
|
||||
type KV map[string]any
|
||||
|
||||
// Get walks a path of nested keys (case-insensitive, matching Steam's own
|
||||
// behaviour) and returns the string value at the leaf, or "" if any hop
|
||||
// is missing or is an object where a string was expected.
|
||||
func (kv KV) Get(path ...string) string {
|
||||
cur := kv
|
||||
for i, key := range path {
|
||||
v, ok := lookup(cur, key)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if i == len(path)-1 {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
next, ok := v.(KV)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Obj walks a path and returns the nested object at the leaf, or nil.
|
||||
func (kv KV) Obj(path ...string) KV {
|
||||
cur := kv
|
||||
for _, key := range path {
|
||||
v, ok := lookup(cur, key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
next, ok := v.(KV)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
func lookup(kv KV, key string) (any, bool) {
|
||||
if v, ok := kv[key]; ok {
|
||||
return v, true
|
||||
}
|
||||
for k, v := range kv {
|
||||
if strings.EqualFold(k, key) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Parse parses VDF text into a KV. The top-level KV maps the root key(s)
|
||||
// to their objects (an ACF parses to {"AppState": {...}}). Junk lines
|
||||
// outside the first root object (SteamCMD banners, progress lines) are
|
||||
// skipped as long as they don't contain an unbalanced brace-quote mix
|
||||
// that swallows real content — in practice SteamCMD noise never does.
|
||||
func Parse(src string) (KV, error) {
|
||||
toks := tokenize(stripLeadingNoise(src))
|
||||
root := KV{}
|
||||
i := 0
|
||||
for i < len(toks) {
|
||||
// Expect: key, then either "{" (object) or value.
|
||||
if toks[i].kind == tokOpen || toks[i].kind == tokClose {
|
||||
// Stray brace at top level — skip it (tolerance).
|
||||
i++
|
||||
continue
|
||||
}
|
||||
key := toks[i].text
|
||||
i++
|
||||
if i >= len(toks) {
|
||||
break // trailing bare token (noise) — ignore
|
||||
}
|
||||
switch toks[i].kind {
|
||||
case tokOpen:
|
||||
obj, n, err := parseObject(toks, i+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root[key] = obj
|
||||
i = n
|
||||
case tokStr:
|
||||
root[key] = toks[i].text
|
||||
i++
|
||||
default:
|
||||
i++ // key followed by "}" — noise, skip
|
||||
}
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func parseObject(toks []token, i int) (KV, int, error) {
|
||||
obj := KV{}
|
||||
for i < len(toks) {
|
||||
switch toks[i].kind {
|
||||
case tokClose:
|
||||
return obj, i + 1, nil
|
||||
case tokOpen:
|
||||
return nil, i, fmt.Errorf("steamvdf: unexpected '{' without a key")
|
||||
}
|
||||
key := toks[i].text
|
||||
i++
|
||||
if i >= len(toks) {
|
||||
return nil, i, fmt.Errorf("steamvdf: truncated after key %q", key)
|
||||
}
|
||||
switch toks[i].kind {
|
||||
case tokOpen:
|
||||
sub, n, err := parseObject(toks, i+1)
|
||||
if err != nil {
|
||||
return nil, n, err
|
||||
}
|
||||
obj[key] = sub
|
||||
i = n
|
||||
case tokStr:
|
||||
obj[key] = toks[i].text
|
||||
i++
|
||||
case tokClose:
|
||||
// key with no value right before '}' — tolerate, drop key.
|
||||
return obj, i + 1, nil
|
||||
}
|
||||
}
|
||||
return nil, i, fmt.Errorf("steamvdf: unterminated object")
|
||||
}
|
||||
|
||||
// stripLeadingNoise drops SteamCMD banner/progress lines that precede the
|
||||
// actual VDF (e.g. "Loading Steam API...OK", "AppID : 294420, change
|
||||
// number : …"). Those lines tokenize into stray strings that misalign
|
||||
// key/value pairing. The real document starts at the first line that is
|
||||
// a lone quoted key whose next non-blank line opens a brace.
|
||||
func stripLeadingNoise(src string) string {
|
||||
lines := strings.Split(src, "\n")
|
||||
for i, line := range lines {
|
||||
t := strings.TrimSpace(line)
|
||||
if len(t) < 2 || t[0] != '"' || t[len(t)-1] != '"' || strings.Count(t, `"`) != 2 {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(lines); j++ {
|
||||
nt := strings.TrimSpace(lines[j])
|
||||
if nt == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(nt, "{") {
|
||||
return strings.Join(lines[i:], "\n")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return src // no root-key/brace pattern found — parse as-is
|
||||
}
|
||||
|
||||
type tokKind int
|
||||
|
||||
const (
|
||||
tokStr tokKind = iota
|
||||
tokOpen
|
||||
tokClose
|
||||
)
|
||||
|
||||
type token struct {
|
||||
kind tokKind
|
||||
text string
|
||||
}
|
||||
|
||||
// tokenize splits VDF text into quoted/unquoted strings and braces.
|
||||
// Handles \" escapes inside quoted strings and skips // line comments.
|
||||
func tokenize(src string) []token {
|
||||
var toks []token
|
||||
i, n := 0, len(src)
|
||||
for i < n {
|
||||
c := src[i]
|
||||
switch {
|
||||
case c == ' ' || c == '\t' || c == '\r' || c == '\n':
|
||||
i++
|
||||
case c == '/' && i+1 < n && src[i+1] == '/':
|
||||
for i < n && src[i] != '\n' {
|
||||
i++
|
||||
}
|
||||
case c == '{':
|
||||
toks = append(toks, token{kind: tokOpen})
|
||||
i++
|
||||
case c == '}':
|
||||
toks = append(toks, token{kind: tokClose})
|
||||
i++
|
||||
case c == '"':
|
||||
i++
|
||||
var b strings.Builder
|
||||
for i < n && src[i] != '"' {
|
||||
if src[i] == '\\' && i+1 < n {
|
||||
i++
|
||||
}
|
||||
b.WriteByte(src[i])
|
||||
i++
|
||||
}
|
||||
i++ // closing quote
|
||||
toks = append(toks, token{kind: tokStr, text: b.String()})
|
||||
default:
|
||||
start := i
|
||||
for i < n && !strings.ContainsRune(" \t\r\n{}\"", rune(src[i])) {
|
||||
i++
|
||||
}
|
||||
toks = append(toks, token{kind: tokStr, text: src[start:i]})
|
||||
}
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
// AppManifest is the subset of appmanifest_<appid>.acf the panel cares
|
||||
// about for update checks.
|
||||
type AppManifest struct {
|
||||
AppID string
|
||||
BuildID string
|
||||
// BetaKey is the Steam branch the install came from ("" = public).
|
||||
// SteamCMD records it under UserConfig/betakey (and sometimes
|
||||
// MountedConfig/betakey after a finished install).
|
||||
BetaKey string
|
||||
Name string
|
||||
}
|
||||
|
||||
// ParseAppManifest extracts buildid + betakey from ACF text.
|
||||
func ParseAppManifest(src string) (*AppManifest, error) {
|
||||
kv, err := Parse(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app := kv.Obj("AppState")
|
||||
if app == nil {
|
||||
return nil, fmt.Errorf("steamvdf: no AppState object in manifest")
|
||||
}
|
||||
m := &AppManifest{
|
||||
AppID: app.Get("appid"),
|
||||
BuildID: app.Get("buildid"),
|
||||
Name: app.Get("name"),
|
||||
}
|
||||
if bk := app.Get("UserConfig", "betakey"); bk != "" {
|
||||
m.BetaKey = bk
|
||||
} else if bk := app.Get("MountedConfig", "betakey"); bk != "" {
|
||||
m.BetaKey = bk
|
||||
}
|
||||
if m.BuildID == "" {
|
||||
return m, fmt.Errorf("steamvdf: manifest has no buildid")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ParseAppInfoBranches extracts the depots→branches map (branch name →
|
||||
// buildid) from `steamcmd +app_info_print <appid>` output. The output
|
||||
// has SteamCMD banner/progress noise around a root object keyed by the
|
||||
// numeric appid; the tolerant Parse skips the noise.
|
||||
func ParseAppInfoBranches(src, appID string) (map[string]string, error) {
|
||||
kv, err := Parse(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app := kv.Obj(appID)
|
||||
if app == nil {
|
||||
// Some SteamCMD builds print the root without us knowing the key
|
||||
// (e.g. when the caller passed "" or output nests differently).
|
||||
// Fall back to the first object that has depots/branches.
|
||||
for _, v := range kv {
|
||||
if o, ok := v.(KV); ok && o.Obj("depots", "branches") != nil {
|
||||
app = o
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if app == nil {
|
||||
return nil, fmt.Errorf("steamvdf: app %s not found in app_info output", appID)
|
||||
}
|
||||
branches := app.Obj("depots", "branches")
|
||||
if branches == nil {
|
||||
return nil, fmt.Errorf("steamvdf: no depots.branches in app_info for %s", appID)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for name, v := range branches {
|
||||
if o, ok := v.(KV); ok {
|
||||
if bid := o.Get("buildid"); bid != "" {
|
||||
out[name] = bid
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("steamvdf: branches map empty for %s", appID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user