Files
panel/pkg/steamvdf/steamvdf.go
T
dbledeez 8a94ffd58f panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:13:02 -07:00

308 lines
7.7 KiB
Go

// 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
}