// 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_.acf in an instance volume. // 2. Reading the per-branch latest buildids out of `steamcmd // +app_info_print ` 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_.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 ` 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 }