// Command amp-distill parses the CubeCoders AMPTemplates repository (MIT // licensed, see CREDITS.md) into a single self-contained catalog.json the // panel can serve to its Library UI. It is a build-time tool: run it by hand // whenever the AMPTemplates clone is refreshed. // // Usage: // // go run ./tools/amp-distill \ // -templates C:/Users/dbled/sources/AMPTemplates \ // -modules ./modules \ // -out ./controller/cmd/controller/static/catalog.json // // For every .kvp it reads the sibling ports.json / // updates.json / metaconfig.json and distills one CatalogEntry. // Malformed templates are skipped with a logged reason — the tool never // fails the whole run over one bad template. package main import ( "encoding/json" "flag" "fmt" "os" "path/filepath" "regexp" "sort" "strconv" "strings" ) // CatalogEntry is one distilled game template. type CatalogEntry struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` OS []string `json:"os,omitempty"` Steam *SteamInfo `json:"steam,omitempty"` Ports []PortEntry `json:"ports,omitempty"` RCONAdapter string `json:"rcon_adapter,omitempty"` // source_rcon, telnet, stdio, ... AdminMethod string `json:"admin_method,omitempty"` // raw App.AdminMethod ReadyPattern string `json:"ready_pattern,omitempty"` Events map[string]string `json:"events,omitempty"` // join/leave/chat -> Go regex UpdateStages []UpdateStage `json:"update_stages,omitempty"` ConfigFiles []string `json:"config_files,omitempty"` // SteamLoginRequired: kvp App.SteamUpdateAnonymousLogin=False — // SteamCMD refuses `+login anonymous` for this title (DayZ/Arma class). SteamLoginRequired bool `json:"steam_login_required,omitempty"` ArtSteamAppID string `json:"art_steam_app_id,omitempty"` Wine bool `json:"wine"` // needs Wine/Proton on a Linux host EntrypointHint string `json:"entrypoint_hint,omitempty"` Ported bool `json:"ported,omitempty"` // already exists as a panel module ModuleID string `json:"module_id,omitempty"` Provenance string `json:"provenance"` // source .kvp filename Notes []string `json:"notes,omitempty"` } // SteamInfo carries the SteamCMD install identity. type SteamInfo struct { AppID string `json:"app_id"` // dedicated-server app id GameAppID string `json:"game_app_id,omitempty"` // client/game app id (art, SteamAppId env) Platform string `json:"platform,omitempty"` // "windows" when ForceDownloadPlatform=Windows } // PortEntry is one declared network port. type PortEntry struct { Name string `json:"name"` Proto string `json:"proto"` // tcp / udp / both Port uint32 `json:"port"` } // UpdateStage is one mapped install/update pipeline stage. type UpdateStage struct { Kind string `json:"kind"` // steamcmd, github, direct Name string `json:"name,omitempty"` AppID string `json:"app_id,omitempty"` Platform string `json:"platform,omitempty"` Repo string `json:"repo,omitempty"` AssetRegex string `json:"asset_regex,omitempty"` URL string `json:"url,omitempty"` TargetPath string `json:"target_path,omitempty"` // Conditional stages (AMP UpdateSourceConditionSetting) are optional // extras like ValheimPlus/BepInEx — flagged so the UI can hide them. Conditional bool `json:"conditional,omitempty"` } // ampPort mirrors the shape of ports.json entries (recursive). type ampPort struct { Protocol string `json:"Protocol"` Port json.RawMessage `json:"Port"` Name string `json:"Name"` ChildPorts []ampPort `json:"ChildPorts"` } // ampUpdateStage mirrors updates.json entries. type ampUpdateStage struct { UpdateStageName string `json:"UpdateStageName"` UpdateSourcePlatform string `json:"UpdateSourcePlatform"` UpdateSource string `json:"UpdateSource"` UpdateSourceData string `json:"UpdateSourceData"` UpdateSourceArgs string `json:"UpdateSourceArgs"` UpdateSourceTarget string `json:"UpdateSourceTarget"` ForceDownloadPlatform string `json:"ForceDownloadPlatform"` UpdateSourceConditionSetting string `json:"UpdateSourceConditionSetting"` } // ampMetaConfig mirrors metaconfig.json entries (only the file path). type ampMetaConfig struct { ConfigFile string `json:"ConfigFile"` } // moduleInfo is what we scrape from an existing modules//module.yaml. type moduleInfo struct { ID string Name string AppIDs []string } var namedGroupRe = regexp.MustCompile(`\(\?<([A-Za-z][A-Za-z0-9]*)>`) // toGoRegex converts .NET named groups (?...) to Go (?P...). // Returns the converted pattern and whether it compiles as a Go regexp. func toGoRegex(p string) (string, bool) { if p == "" { return "", true } conv := namedGroupRe.ReplaceAllString(p, `(?P<$1>`) _, err := regexp.Compile(conv) return conv, err == nil } func parseKVP(path string) (map[string]string, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } kv := make(map[string]string) for _, line := range strings.Split(string(raw), "\n") { line = strings.TrimRight(line, "\r") if line == "" || strings.HasPrefix(line, "#") { continue } i := strings.IndexByte(line, '=') if i < 0 { continue } key := strings.TrimSpace(line[:i]) if _, seen := kv[key]; !seen { // first occurrence wins kv[key] = line[i+1:] } } return kv, nil } func flattenPorts(in []ampPort, out *[]PortEntry, notes *[]string) { for _, p := range in { var num uint64 var perr error s := strings.Trim(string(p.Port), `"`) if s != "" && s != "null" { num, perr = strconv.ParseUint(strings.TrimSpace(s), 10, 32) } if perr != nil || num == 0 || num > 65535 { *notes = append(*notes, fmt.Sprintf("skipped port %q: unparseable value %s", p.Name, string(p.Port))) } else { proto := strings.ToLower(p.Protocol) if proto != "tcp" && proto != "udp" { proto = "both" } name := p.Name if name == "" { name = fmt.Sprintf("port-%d", num) } *out = append(*out, PortEntry{Name: name, Proto: proto, Port: uint32(num)}) } flattenPorts(p.ChildPorts, out, notes) } } func adapterFor(adminMethod string) string { switch adminMethod { case "SourceRCON": return "source_rcon" case "TelnetRCON", "Telnet": return "telnet" case "STDIO": return "stdio" case "BattlEyeRCON": return "battleye" case "QuakeRCON": return "quake_rcon" case "GoldSrcRCON": return "goldsrc_rcon" default: return "" } } // norm reduces a name to lowercase alphanumerics for fuzzy matching. func norm(s string) string { var b strings.Builder for _, r := range strings.ToLower(s) { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { b.WriteRune(r) } } return b.String() } // moduleAliases maps our module id -> extra normalized names it answers to, // covering cases where the AMP template id differs from ours. var moduleAliases = map[string][]string{ "7dtd": {"sevendaystodie", "sdtd"}, "ark-sa": {"arksamin", "arksurvivalascended"}, "dragonwilds": {"runescapedragonwilds"}, "empyrion": {"empyriongalacticsurvival"}, "conan-exiles": {"conanexiles"}, } var yamlAppIDRe = regexp.MustCompile(`(?m)^\s*app_id:\s*["']?(\d+)`) var yamlIDRe = regexp.MustCompile(`(?m)^id:\s*["']?([A-Za-z0-9._-]+)`) var yamlNameRe = regexp.MustCompile(`(?m)^name:\s*["']?([^"'\r\n]+)`) func loadModules(dir string) ([]moduleInfo, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err } // Helper/infra modules that are not real game ports — never claim an // AMP template as "ported" via these (steamcmd-test uses HLDS app 90, // which would falsely mark half-life/counter-strike as ported). nonGame := map[string]bool{"demo": true, "steamcmd-test": true, "empyrion-bridge": true} var out []moduleInfo for _, e := range entries { if !e.IsDir() || nonGame[e.Name()] { continue } raw, err := os.ReadFile(filepath.Join(dir, e.Name(), "module.yaml")) if err != nil { continue } mi := moduleInfo{ID: e.Name()} if m := yamlIDRe.FindSubmatch(raw); m != nil { mi.ID = string(m[1]) } if m := yamlNameRe.FindSubmatch(raw); m != nil { mi.Name = strings.TrimSpace(string(m[1])) } for _, m := range yamlAppIDRe.FindAllSubmatch(raw, -1) { mi.AppIDs = append(mi.AppIDs, string(m[1])) } out = append(out, mi) } return out, nil } // matchModule returns the module id an entry corresponds to, or "". func matchModule(e *CatalogEntry, mods []moduleInfo) string { entryNames := map[string]bool{norm(e.ID): true, norm(e.Name): true} for _, m := range mods { // by steam app id (server app id or game app id) if e.Steam != nil { for _, id := range m.AppIDs { if id == e.Steam.AppID || id == e.Steam.GameAppID { return m.ID } } } // by name heuristics names := []string{norm(m.ID), norm(m.Name)} names = append(names, moduleAliases[m.ID]...) for _, n := range names { if n != "" && entryNames[n] { return m.ID } } } return "" } func distillOne(dir, kvpFile string) (*CatalogEntry, error) { id := strings.TrimSuffix(kvpFile, ".kvp") kv, err := parseKVP(filepath.Join(dir, kvpFile)) if err != nil { return nil, fmt.Errorf("read kvp: %w", err) } name := kv["Meta.DisplayName"] if name == "" { name = kv["App.DisplayName"] } if name == "" { return nil, fmt.Errorf("no Meta.DisplayName/App.DisplayName") } e := &CatalogEntry{ ID: id, Name: name, Description: kv["Meta.Description"], AdminMethod: kv["App.AdminMethod"], RCONAdapter: adapterFor(kv["App.AdminMethod"]), Provenance: kvpFile, } if osList := kv["Meta.OS"]; osList != "" { for _, o := range strings.Split(osList, ",") { if o = strings.TrimSpace(o); o != "" { e.OS = append(e.OS, o) } } } e.SteamLoginRequired = strings.EqualFold(strings.TrimSpace(kv["App.SteamUpdateAnonymousLogin"]), "false") if hint := strings.TrimSpace(kv["App.CommandLineArgs"]); hint != "" { e.EntrypointHint = hint } if src := kv["Meta.DisplayImageSource"]; strings.HasPrefix(src, "steam:") { e.ArtSteamAppID = strings.TrimPrefix(src, "steam:") } // Regexes: AppReady + join/leave/chat. if p, ok := toGoRegex(kv["Console.AppReadyRegex"]); ok { e.ReadyPattern = p } else { e.Notes = append(e.Notes, "ready_pattern dropped: does not compile as Go regexp") } e.Events = map[string]string{} for kind, key := range map[string]string{ "join": "Console.UserJoinRegex", "leave": "Console.UserLeaveRegex", "chat": "Console.UserChatRegex", } { if raw := kv[key]; raw != "" { if p, ok := toGoRegex(raw); ok { e.Events[kind] = p } else { e.Notes = append(e.Notes, kind+" regex dropped: does not compile as Go regexp") } } } if len(e.Events) == 0 { e.Events = nil } // Ports. if raw, err := os.ReadFile(filepath.Join(dir, id+"ports.json")); err == nil { var ports []ampPort if err := json.Unmarshal(raw, &ports); err != nil { e.Notes = append(e.Notes, "ports.json unparseable: "+err.Error()) } else { flattenPorts(ports, &e.Ports, &e.Notes) } } // Update stages. wineSeen := false if raw, err := os.ReadFile(filepath.Join(dir, id+"updates.json")); err == nil { var stages []ampUpdateStage if err := json.Unmarshal(raw, &stages); err != nil { e.Notes = append(e.Notes, "updates.json unparseable: "+err.Error()) } else { for _, s := range stages { if strings.Contains(strings.ToLower(s.UpdateSourceArgs), "wine") || strings.Contains(strings.ToLower(s.UpdateStageName), "wine") { wineSeen = true } st := UpdateStage{Name: s.UpdateStageName, Conditional: s.UpdateSourceConditionSetting != ""} switch s.UpdateSource { case "SteamCMD": st.Kind = "steamcmd" st.AppID = s.UpdateSourceData if strings.EqualFold(s.ForceDownloadPlatform, "Windows") { st.Platform = "windows" } if e.Steam == nil && !st.Conditional { e.Steam = &SteamInfo{AppID: s.UpdateSourceData, GameAppID: s.UpdateSourceArgs, Platform: st.Platform} } case "GithubRelease": st.Kind = "github" st.Repo = s.UpdateSourceArgs st.AssetRegex = s.UpdateSourceData st.TargetPath = s.UpdateSourceTarget case "FetchURL": st.Kind = "direct" st.URL = s.UpdateSourceData st.TargetPath = s.UpdateSourceTarget if st.TargetPath == "" { st.TargetPath = s.UpdateSourceArgs } default: continue // Executable/CreateDirectory/etc. — entrypoint territory } e.UpdateStages = append(e.UpdateStages, st) } } } // Config file paths from metaconfig. if raw, err := os.ReadFile(filepath.Join(dir, id+"metaconfig.json")); err == nil { var metas []ampMetaConfig if err := json.Unmarshal(raw, &metas); err == nil { seen := map[string]bool{} for _, m := range metas { if m.ConfigFile != "" && !seen[m.ConfigFile] { seen[m.ConfigFile] = true e.ConfigFiles = append(e.ConfigFiles, m.ConfigFile) } } } else { e.Notes = append(e.Notes, "metaconfig.json unparseable: "+err.Error()) } } // Wine flag: Windows-only template, forced Windows depots, or explicit // wine bootstrap in the update pipeline. linuxNative := false for _, o := range e.OS { if strings.EqualFold(o, "Linux") { linuxNative = true } } e.Wine = wineSeen || !linuxNative || (e.Steam != nil && e.Steam.Platform == "windows") return e, nil } func main() { templates := flag.String("templates", `C:\Users\dbled\sources\AMPTemplates`, "path to the AMPTemplates clone") modules := flag.String("modules", "modules", "path to the panel modules/ directory") out := flag.String("out", filepath.Join("controller", "cmd", "controller", "static", "catalog.json"), "output catalog path") flag.Parse() mods, err := loadModules(*modules) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: load modules: %v\n", err) os.Exit(1) } entries, err := os.ReadDir(*templates) if err != nil { fmt.Fprintf(os.Stderr, "FATAL: read templates dir: %v\n", err) os.Exit(1) } var catalog []*CatalogEntry var skipped []string portedMap := map[string]string{} for _, de := range entries { if de.IsDir() || !strings.HasSuffix(de.Name(), ".kvp") { continue } e, err := distillOne(*templates, de.Name()) if err != nil { skipped = append(skipped, fmt.Sprintf("%s: %v", de.Name(), err)) continue } if mid := matchModule(e, mods); mid != "" { e.Ported = true e.ModuleID = mid portedMap[e.ID] = mid } catalog = append(catalog, e) } sort.Slice(catalog, func(i, j int) bool { return catalog[i].ID < catalog[j].ID }) buf, err := json.MarshalIndent(catalog, "", " ") if err != nil { fmt.Fprintf(os.Stderr, "FATAL: marshal: %v\n", err) os.Exit(1) } buf = append(buf, '\n') if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil { fmt.Fprintf(os.Stderr, "FATAL: mkdir: %v\n", err) os.Exit(1) } if err := os.WriteFile(*out, buf, 0o644); err != nil { fmt.Fprintf(os.Stderr, "FATAL: write: %v\n", err) os.Exit(1) } fmt.Printf("catalog: %d entries -> %s (%d KB)\n", len(catalog), *out, len(buf)/1024) fmt.Printf("ported matches (%d):\n", len(portedMap)) var ids []string for k := range portedMap { ids = append(ids, k) } sort.Strings(ids) for _, k := range ids { fmt.Printf(" %s -> module %s\n", k, portedMap[k]) } if len(skipped) > 0 { fmt.Printf("skipped (%d):\n", len(skipped)) for _, s := range skipped { fmt.Println(" " + s) } } }