8a94ffd58f
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
501 lines
15 KiB
Go
501 lines
15 KiB
Go
// 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 <game>.kvp it reads the sibling <game>ports.json /
|
|
// <game>updates.json / <game>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 <game>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 <game>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 <game>metaconfig.json entries (only the file path).
|
|
type ampMetaConfig struct {
|
|
ConfigFile string `json:"ConfigFile"`
|
|
}
|
|
|
|
// moduleInfo is what we scrape from an existing modules/<id>/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 (?<name>...) to Go (?P<name>...).
|
|
// 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)
|
|
}
|
|
}
|
|
}
|