0f6aea796c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
308 lines
7.7 KiB
Go
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
|
|
}
|