panel v0.9.2 — public release
Self-hostable game server control panel: Go controller + agent, 26 game modules, embedded web UI. One-line install via install.sh / install.ps1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
// Package dayzxml merges mod XML snippets into a running DayZ server's
|
||||
// Central Economy mission XMLs (types.xml, events.xml, cfgspawnabletypes.xml,
|
||||
// cfgeventspawns.xml, cfgrandompresets.xml, messages.xml).
|
||||
//
|
||||
// Why this exists:
|
||||
//
|
||||
// Mods almost always ship "snippet" XMLs — a types.xml with just the mod's new
|
||||
// items, or an events.xml with the mod's new dynamic events. Operators are
|
||||
// expected to merge these into their base mission files. The canonical
|
||||
// approach Bohemia documents is to register each mod's folder via
|
||||
// cfgeconomycore.xml and let the engine merge at load time, but most community
|
||||
// operators still pre-merge into a single file per kind to make debugging,
|
||||
// diffing, and rolling back easier. That's what this package does.
|
||||
//
|
||||
// Merge semantics (per Bohemia wiki "DayZ:Central Economy mission files modding"):
|
||||
//
|
||||
// - types.xml, cfgspawnabletypes.xml, cfgeventspawns.xml, cfgrandompresets.xml
|
||||
// → replace existing entry by `name` attribute; new names appended.
|
||||
// - events.xml
|
||||
// → replace the entry's scalar children; APPEND new <child> nodes (events'
|
||||
// children list is append-only in engine merge behavior — there's no
|
||||
// "remove a child" via merge; operators must redefine the entire event).
|
||||
// - messages.xml
|
||||
// → append-only; messages don't have a unique key and there's no
|
||||
// replace-by-id semantic in the engine.
|
||||
//
|
||||
// We use github.com/beevik/etree rather than Go's encoding/xml so element
|
||||
// order, attribute order, and comments are preserved verbatim. DayZ's
|
||||
// engine doesn't care about order, but operators diff these files by hand
|
||||
// and expect them to stay stable across panel edits.
|
||||
|
||||
package dayzxml
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
)
|
||||
|
||||
// FileKind identifies a DayZ CE XML file type whose merge semantics we know.
|
||||
type FileKind string
|
||||
|
||||
const (
|
||||
KindTypes FileKind = "types" // types.xml
|
||||
KindSpawnableTypes FileKind = "spawnabletypes" // cfgspawnabletypes.xml
|
||||
KindEvents FileKind = "events" // events.xml
|
||||
KindEventSpawns FileKind = "eventposdef" // cfgeventspawns.xml
|
||||
KindRandomPresets FileKind = "randompresets" // cfgrandompresets.xml
|
||||
KindMessages FileKind = "messages" // messages.xml
|
||||
)
|
||||
|
||||
// MergeMode controls what happens when a snippet entry collides with an existing one.
|
||||
type MergeMode int
|
||||
|
||||
const (
|
||||
// ReplaceByName overwrites the existing entry wholesale.
|
||||
ReplaceByName MergeMode = iota
|
||||
// AppendOnly ignores the existing entry and just appends the snippet's copy.
|
||||
// Used for files without a unique key (messages.xml).
|
||||
AppendOnly
|
||||
// EventsMerge replaces the entry's scalar attributes + non-child descendants,
|
||||
// and APPENDS <child> nodes from the snippet that don't already exist in
|
||||
// base (matched by `type=` attribute). Matches engine behavior for events.xml.
|
||||
EventsMerge
|
||||
)
|
||||
|
||||
// Spec describes how to identify and merge entries in one file kind.
|
||||
type Spec struct {
|
||||
RootTag string // expected root element tag ("types", "events", ...)
|
||||
EntryTag string // entry element tag ("type", "event"); empty = heterogeneous
|
||||
KeyAttr string // attribute that identifies an entry ("name"); empty = no key
|
||||
Mode MergeMode // how to resolve collisions
|
||||
EntryTags []string // for heterogeneous files (randompresets: "cargo"/"attachments")
|
||||
}
|
||||
|
||||
// defaultSpecs holds the canonical spec per kind. Callers should treat
|
||||
// these as read-only.
|
||||
var defaultSpecs = map[FileKind]Spec{
|
||||
KindTypes: {RootTag: "types", EntryTag: "type", KeyAttr: "name", Mode: ReplaceByName},
|
||||
KindSpawnableTypes: {RootTag: "spawnabletypes", EntryTag: "type", KeyAttr: "name", Mode: ReplaceByName},
|
||||
KindEvents: {RootTag: "events", EntryTag: "event", KeyAttr: "name", Mode: EventsMerge},
|
||||
KindEventSpawns: {RootTag: "eventposdef", EntryTag: "event", KeyAttr: "name", Mode: ReplaceByName},
|
||||
KindRandomPresets: {RootTag: "randompresets", KeyAttr: "name", Mode: ReplaceByName, EntryTags: []string{"cargo", "attachments"}},
|
||||
KindMessages: {RootTag: "messages", EntryTag: "message", Mode: AppendOnly},
|
||||
}
|
||||
|
||||
// SpecFor returns the default Spec for a file kind, or zero + false if unknown.
|
||||
func SpecFor(k FileKind) (Spec, bool) {
|
||||
s, ok := defaultSpecs[k]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// SupportedKinds returns all FileKinds this package knows how to merge, in
|
||||
// a stable order suitable for UI lists.
|
||||
func SupportedKinds() []FileKind {
|
||||
return []FileKind{
|
||||
KindTypes, KindSpawnableTypes, KindEvents, KindEventSpawns,
|
||||
KindRandomPresets, KindMessages,
|
||||
}
|
||||
}
|
||||
|
||||
// Plan is the outcome of a (possibly dry-run) merge.
|
||||
type Plan struct {
|
||||
Kind FileKind `json:"kind"`
|
||||
Added []string `json:"added"` // new entry names appended
|
||||
Replaced []string `json:"replaced"` // existing names overwritten
|
||||
Unchanged []string `json:"unchanged"` // snippet entries byte-identical to base
|
||||
AppendedChildren map[string][]string `json:"appended_children,omitempty"` // event name → child types added
|
||||
Conflicts []string `json:"conflicts,omitempty"` // snippet has same key twice; first wins
|
||||
Warnings []string `json:"warnings,omitempty"` // non-fatal (BOM stripped, EOL adjusted, etc.)
|
||||
}
|
||||
|
||||
// Result is returned by Merge. Bytes is the merged XML (empty in dry-run mode).
|
||||
type Result struct {
|
||||
Plan Plan
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
// DetectKind classifies a raw snippet by its root element tag. Returns
|
||||
// the matching FileKind or an error if the root doesn't match any known spec.
|
||||
func DetectKind(snippet []byte) (FileKind, error) {
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromBytes(stripBOM(snippet)); err != nil {
|
||||
return "", fmt.Errorf("parse snippet: %w", err)
|
||||
}
|
||||
root := doc.Root()
|
||||
if root == nil {
|
||||
return "", fmt.Errorf("snippet has no root element")
|
||||
}
|
||||
for k, s := range defaultSpecs {
|
||||
if s.RootTag == root.Tag {
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unknown root element %q — not a supported DayZ CE XML", root.Tag)
|
||||
}
|
||||
|
||||
// Merge applies the snippet to the base file. If dryRun is true, only the
|
||||
// Plan is populated (Bytes is nil). Otherwise Bytes contains the merged
|
||||
// XML, ready to write.
|
||||
func Merge(kind FileKind, base, snippet []byte, dryRun bool) (*Result, error) {
|
||||
spec, ok := defaultSpecs[kind]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported file kind %q", kind)
|
||||
}
|
||||
baseDoc, warnings, err := parseDoc(base, "base")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snipDoc, snipWarnings, err := parseDoc(snippet, "snippet")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
warnings = append(warnings, snipWarnings...)
|
||||
|
||||
baseRoot := baseDoc.Root()
|
||||
if baseRoot == nil || baseRoot.Tag != spec.RootTag {
|
||||
return nil, fmt.Errorf("base is not a <%s> file (got <%s>)", spec.RootTag, safeRootTag(baseDoc))
|
||||
}
|
||||
snipRoot := snipDoc.Root()
|
||||
if snipRoot == nil || snipRoot.Tag != spec.RootTag {
|
||||
return nil, fmt.Errorf("snippet is not a <%s> file (got <%s>)", spec.RootTag, safeRootTag(snipDoc))
|
||||
}
|
||||
|
||||
plan := Plan{
|
||||
Kind: kind,
|
||||
AppendedChildren: map[string][]string{},
|
||||
}
|
||||
|
||||
// Index base entries by key, preserving insertion order.
|
||||
baseIndex, baseOrder := indexEntries(baseRoot, spec)
|
||||
// Walk the snippet in order.
|
||||
seenInSnippet := map[string]bool{}
|
||||
|
||||
for _, child := range snipRoot.ChildElements() {
|
||||
if !matchesEntry(child, spec) {
|
||||
continue
|
||||
}
|
||||
key := entryKey(child, spec)
|
||||
if spec.Mode == AppendOnly || key == "" {
|
||||
// No dedup; append unconditionally.
|
||||
baseRoot.AddChild(child.Copy())
|
||||
plan.Added = append(plan.Added, fmt.Sprintf("%s#%d", child.Tag, len(plan.Added)+1))
|
||||
continue
|
||||
}
|
||||
if seenInSnippet[key] {
|
||||
plan.Conflicts = append(plan.Conflicts, key)
|
||||
continue
|
||||
}
|
||||
seenInSnippet[key] = true
|
||||
existing, exists := baseIndex[key]
|
||||
if !exists {
|
||||
baseRoot.AddChild(child.Copy())
|
||||
plan.Added = append(plan.Added, key)
|
||||
continue
|
||||
}
|
||||
// Collision — resolve per mode.
|
||||
switch spec.Mode {
|
||||
case ReplaceByName:
|
||||
if elementsEqualish(existing, child) {
|
||||
plan.Unchanged = append(plan.Unchanged, key)
|
||||
continue
|
||||
}
|
||||
replaceElement(baseRoot, existing, child)
|
||||
baseIndex[key] = child.Copy()
|
||||
plan.Replaced = append(plan.Replaced, key)
|
||||
case EventsMerge:
|
||||
appended, changed := mergeEventChildren(existing, child)
|
||||
if !changed {
|
||||
plan.Unchanged = append(plan.Unchanged, key)
|
||||
continue
|
||||
}
|
||||
plan.Replaced = append(plan.Replaced, key)
|
||||
if len(appended) > 0 {
|
||||
plan.AppendedChildren[key] = appended
|
||||
}
|
||||
}
|
||||
_ = baseOrder // preserved via baseRoot ordering
|
||||
}
|
||||
|
||||
// Stable-sort output slices for deterministic UI.
|
||||
sort.Strings(plan.Added)
|
||||
sort.Strings(plan.Replaced)
|
||||
sort.Strings(plan.Unchanged)
|
||||
sort.Strings(plan.Conflicts)
|
||||
plan.Warnings = append(plan.Warnings, warnings...)
|
||||
|
||||
res := &Result{Plan: plan}
|
||||
if dryRun {
|
||||
return res, nil
|
||||
}
|
||||
baseDoc.Indent(4)
|
||||
out, err := baseDoc.WriteToBytes()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render merged xml: %w", err)
|
||||
}
|
||||
// Preserve the base file's line-ending convention.
|
||||
if bytes.Contains(base, []byte("\r\n")) && !bytes.Contains(out, []byte("\r\n")) {
|
||||
out = bytes.ReplaceAll(out, []byte("\n"), []byte("\r\n"))
|
||||
}
|
||||
res.Bytes = out
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func parseDoc(raw []byte, label string) (*etree.Document, []string, error) {
|
||||
warnings := []string{}
|
||||
clean, bomStripped := stripBOMWithFlag(raw)
|
||||
if bomStripped {
|
||||
warnings = append(warnings, label+": stripped UTF-8 BOM")
|
||||
}
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromBytes(clean); err != nil {
|
||||
return nil, warnings, fmt.Errorf("%s: parse: %w", label, err)
|
||||
}
|
||||
return doc, warnings, nil
|
||||
}
|
||||
|
||||
func safeRootTag(d *etree.Document) string {
|
||||
if r := d.Root(); r != nil {
|
||||
return r.Tag
|
||||
}
|
||||
return "(no-root)"
|
||||
}
|
||||
|
||||
// matchesEntry reports whether child is one of the entry tags this spec considers.
|
||||
func matchesEntry(child *etree.Element, spec Spec) bool {
|
||||
if len(spec.EntryTags) > 0 {
|
||||
for _, t := range spec.EntryTags {
|
||||
if child.Tag == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return spec.EntryTag != "" && child.Tag == spec.EntryTag
|
||||
}
|
||||
|
||||
// entryKey returns the merge key for a child element (empty string if no key).
|
||||
func entryKey(child *etree.Element, spec Spec) string {
|
||||
if spec.KeyAttr == "" {
|
||||
return ""
|
||||
}
|
||||
return child.SelectAttrValue(spec.KeyAttr, "")
|
||||
}
|
||||
|
||||
// indexEntries scans root and builds a name→element map of mergeable entries.
|
||||
// Also returns the insertion-order list (useful for deterministic diffs).
|
||||
func indexEntries(root *etree.Element, spec Spec) (map[string]*etree.Element, []string) {
|
||||
m := make(map[string]*etree.Element)
|
||||
order := make([]string, 0, 64)
|
||||
for _, child := range root.ChildElements() {
|
||||
if !matchesEntry(child, spec) {
|
||||
continue
|
||||
}
|
||||
k := entryKey(child, spec)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := m[k]; !dup {
|
||||
order = append(order, k)
|
||||
}
|
||||
m[k] = child
|
||||
}
|
||||
return m, order
|
||||
}
|
||||
|
||||
// replaceElement swaps old in-place with a copy of repl, preserving sibling order.
|
||||
func replaceElement(parent, old, repl *etree.Element) {
|
||||
newEl := repl.Copy()
|
||||
// etree doesn't have an "insert at index"; replicate by collecting children
|
||||
// around `old`, removing all, and re-adding.
|
||||
children := parent.ChildElements()
|
||||
idx := -1
|
||||
for i, c := range children {
|
||||
if c == old {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
parent.AddChild(newEl)
|
||||
return
|
||||
}
|
||||
// Remove old and rebuild tail ordering.
|
||||
parent.RemoveChild(old)
|
||||
// If we removed from the end, AddChild appends at the end — correct.
|
||||
// Otherwise we need to move the trailing children back after newEl. etree's
|
||||
// AddChild appends, so we re-add newEl then move the original tail past it.
|
||||
tail := children[idx+1:]
|
||||
for _, t := range tail {
|
||||
parent.RemoveChild(t)
|
||||
}
|
||||
parent.AddChild(newEl)
|
||||
for _, t := range tail {
|
||||
parent.AddChild(t)
|
||||
}
|
||||
}
|
||||
|
||||
// elementsEqualish is a lenient equality check: same tag, same attrs
|
||||
// (set-compare, order-insensitive), same text content, same child-element
|
||||
// tree by signature dump.
|
||||
func elementsEqualish(a, b *etree.Element) bool {
|
||||
if a.Tag != b.Tag {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(a.Text()) != strings.TrimSpace(b.Text()) {
|
||||
return false
|
||||
}
|
||||
if len(a.Attr) != len(b.Attr) {
|
||||
return false
|
||||
}
|
||||
aAttrs := attrMap(a)
|
||||
bAttrs := attrMap(b)
|
||||
for k, v := range aAttrs {
|
||||
if bAttrs[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return childrenSignature(a) == childrenSignature(b)
|
||||
}
|
||||
|
||||
func attrMap(e *etree.Element) map[string]string {
|
||||
m := make(map[string]string, len(e.Attr))
|
||||
for _, a := range e.Attr {
|
||||
m[a.Key] = a.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func childrenSignature(e *etree.Element) string {
|
||||
var b strings.Builder
|
||||
for _, c := range e.ChildElements() {
|
||||
b.WriteString(c.Tag)
|
||||
b.WriteByte('{')
|
||||
keys := make([]string, 0, len(c.Attr))
|
||||
am := attrMap(c)
|
||||
for k := range am {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
b.WriteString(k)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(am[k])
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString(childrenSignature(c))
|
||||
b.WriteByte('}')
|
||||
t := strings.TrimSpace(c.Text())
|
||||
if t != "" {
|
||||
b.WriteString("#")
|
||||
b.WriteString(t)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mergeEventChildren implements events.xml's special semantics: replace the
|
||||
// scalar attrs + non-children descendants of `base` with those of `snippet`,
|
||||
// then append any `<child type="...">` from snippet whose `type` isn't
|
||||
// already present in base. Returns (appended child types, changed?).
|
||||
func mergeEventChildren(base, snippet *etree.Element) ([]string, bool) {
|
||||
changed := false
|
||||
// 1. Overwrite attributes on the event element.
|
||||
if !sameAttrs(base, snippet) {
|
||||
for _, a := range base.Attr {
|
||||
base.RemoveAttr(a.Key)
|
||||
}
|
||||
for _, a := range snippet.Attr {
|
||||
base.CreateAttr(a.Key, a.Value)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
// 2. Replace scalar direct children (non-<children>) wholesale.
|
||||
for _, sChild := range snippet.ChildElements() {
|
||||
if sChild.Tag == "children" {
|
||||
continue
|
||||
}
|
||||
// find matching scalar in base (by tag)
|
||||
if bChild := base.SelectElement(sChild.Tag); bChild != nil {
|
||||
if !elementsEqualish(bChild, sChild) {
|
||||
base.RemoveChild(bChild)
|
||||
base.AddChild(sChild.Copy())
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
base.AddChild(sChild.Copy())
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
// 3. Append new <child type=...> entries from snippet's <children>.
|
||||
var appended []string
|
||||
sChildren := snippet.SelectElement("children")
|
||||
if sChildren == nil {
|
||||
return appended, changed
|
||||
}
|
||||
bChildren := base.SelectElement("children")
|
||||
if bChildren == nil {
|
||||
bChildren = base.CreateElement("children")
|
||||
changed = true
|
||||
}
|
||||
existingTypes := map[string]bool{}
|
||||
for _, c := range bChildren.ChildElements() {
|
||||
if t := c.SelectAttrValue("type", ""); t != "" {
|
||||
existingTypes[t] = true
|
||||
}
|
||||
}
|
||||
for _, sChildEl := range sChildren.ChildElements() {
|
||||
t := sChildEl.SelectAttrValue("type", "")
|
||||
if t == "" || existingTypes[t] {
|
||||
continue
|
||||
}
|
||||
bChildren.AddChild(sChildEl.Copy())
|
||||
existingTypes[t] = true
|
||||
appended = append(appended, t)
|
||||
changed = true
|
||||
}
|
||||
return appended, changed
|
||||
}
|
||||
|
||||
func sameAttrs(a, b *etree.Element) bool {
|
||||
if len(a.Attr) != len(b.Attr) {
|
||||
return false
|
||||
}
|
||||
am := attrMap(a)
|
||||
for _, at := range b.Attr {
|
||||
if am[at.Key] != at.Value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stripBOM returns input with a leading UTF-8 BOM removed (if present).
|
||||
func stripBOM(b []byte) []byte {
|
||||
out, _ := stripBOMWithFlag(b)
|
||||
return out
|
||||
}
|
||||
|
||||
func stripBOMWithFlag(b []byte) ([]byte, bool) {
|
||||
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
|
||||
return b[3:], true
|
||||
}
|
||||
return b, false
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package dayzxml
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectKind(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want FileKind
|
||||
}{
|
||||
{`<types><type name="X"/></types>`, KindTypes},
|
||||
{`<events><event name="X"/></events>`, KindEvents},
|
||||
{`<spawnabletypes><type name="X"/></spawnabletypes>`, KindSpawnableTypes},
|
||||
{`<messages><message/></messages>`, KindMessages},
|
||||
{`<randompresets><cargo name="X"/></randompresets>`, KindRandomPresets},
|
||||
{`<eventposdef><event name="X"/></eventposdef>`, KindEventSpawns},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := DetectKind([]byte(c.in))
|
||||
if err != nil {
|
||||
t.Errorf("DetectKind(%q) err: %v", c.in, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("DetectKind(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := DetectKind([]byte(`<foo/>`)); err == nil {
|
||||
t.Error("expected error for unknown root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeTypes_AddAndReplace(t *testing.T) {
|
||||
base := []byte(`<types>
|
||||
<type name="AKM"><nominal>5</nominal><min>2</min></type>
|
||||
<type name="M4A1"><nominal>3</nominal><min>1</min></type>
|
||||
</types>`)
|
||||
snippet := []byte(`<types>
|
||||
<type name="AKM"><nominal>8</nominal><min>4</min></type>
|
||||
<type name="NewItem"><nominal>1</nominal><min>1</min></type>
|
||||
</types>`)
|
||||
res, err := Merge(KindTypes, base, snippet, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Plan.Added) != 1 || res.Plan.Added[0] != "NewItem" {
|
||||
t.Errorf("Added = %v, want [NewItem]", res.Plan.Added)
|
||||
}
|
||||
if len(res.Plan.Replaced) != 1 || res.Plan.Replaced[0] != "AKM" {
|
||||
t.Errorf("Replaced = %v, want [AKM]", res.Plan.Replaced)
|
||||
}
|
||||
out := string(res.Bytes)
|
||||
if !strings.Contains(out, `name="NewItem"`) {
|
||||
t.Error("merged output missing NewItem")
|
||||
}
|
||||
if !strings.Contains(out, `<nominal>8</nominal>`) {
|
||||
t.Error("merged output missing updated AKM nominal value")
|
||||
}
|
||||
if !strings.Contains(out, `name="M4A1"`) {
|
||||
t.Error("merged output dropped untouched M4A1 entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeTypes_Unchanged(t *testing.T) {
|
||||
xml := []byte(`<types>
|
||||
<type name="AKM"><nominal>5</nominal></type>
|
||||
</types>`)
|
||||
res, err := Merge(KindTypes, xml, xml, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Plan.Unchanged) != 1 || res.Plan.Unchanged[0] != "AKM" {
|
||||
t.Errorf("Unchanged = %v, want [AKM]", res.Plan.Unchanged)
|
||||
}
|
||||
if len(res.Plan.Replaced) != 0 {
|
||||
t.Errorf("Replaced = %v, want []", res.Plan.Replaced)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeEvents_AppendsChildren(t *testing.T) {
|
||||
base := []byte(`<events>
|
||||
<event name="VehicleSpawn">
|
||||
<nominal>3</nominal>
|
||||
<children>
|
||||
<child type="UAZ_Base" min="1" max="1"/>
|
||||
</children>
|
||||
</event>
|
||||
</events>`)
|
||||
snippet := []byte(`<events>
|
||||
<event name="VehicleSpawn">
|
||||
<nominal>5</nominal>
|
||||
<children>
|
||||
<child type="UAZ_Mod1" min="1" max="1"/>
|
||||
<child type="UAZ_Base" min="1" max="1"/>
|
||||
</children>
|
||||
</event>
|
||||
</events>`)
|
||||
res, err := Merge(KindEvents, base, snippet, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(res.Bytes)
|
||||
// nominal overridden
|
||||
if !strings.Contains(out, `<nominal>5</nominal>`) {
|
||||
t.Error("event scalar child <nominal> not overridden")
|
||||
}
|
||||
// new child appended, existing child not duplicated
|
||||
if strings.Count(out, `type="UAZ_Base"`) != 1 {
|
||||
t.Errorf("existing child duplicated; output:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `type="UAZ_Mod1"`) {
|
||||
t.Error("new child not appended")
|
||||
}
|
||||
if got := res.Plan.AppendedChildren["VehicleSpawn"]; len(got) != 1 || got[0] != "UAZ_Mod1" {
|
||||
t.Errorf("AppendedChildren[VehicleSpawn] = %v, want [UAZ_Mod1]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMessages_AppendOnly(t *testing.T) {
|
||||
base := []byte(`<messages>
|
||||
<message><id>1</id><text>Welcome</text></message>
|
||||
</messages>`)
|
||||
snippet := []byte(`<messages>
|
||||
<message><id>2</id><text>Restart in 10m</text></message>
|
||||
</messages>`)
|
||||
res, err := Merge(KindMessages, base, snippet, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Plan.Added) != 1 {
|
||||
t.Errorf("Added count = %d, want 1", len(res.Plan.Added))
|
||||
}
|
||||
if !strings.Contains(string(res.Bytes), "Restart in 10m") {
|
||||
t.Error("appended message not present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripBOM(t *testing.T) {
|
||||
bom := []byte{0xEF, 0xBB, 0xBF}
|
||||
raw := append(bom, []byte(`<types><type name="X"/></types>`)...)
|
||||
k, err := DetectKind(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if k != KindTypes {
|
||||
t.Errorf("kind = %v, want %v", k, KindTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootMismatch(t *testing.T) {
|
||||
base := []byte(`<types></types>`)
|
||||
snippet := []byte(`<events></events>`)
|
||||
if _, err := Merge(KindTypes, base, snippet, true); err == nil {
|
||||
t.Error("expected error when snippet root doesn't match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreserveCRLF(t *testing.T) {
|
||||
base := []byte("<types>\r\n <type name=\"AKM\"><nominal>5</nominal></type>\r\n</types>")
|
||||
snippet := []byte(`<types><type name="NEW"><nominal>1</nominal></type></types>`)
|
||||
res, err := Merge(KindTypes, base, snippet, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(res.Bytes), "\r\n") {
|
||||
t.Error("output dropped CRLF line endings; base had CRLF")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,389 @@
|
||||
// Package empyrionitems parses Empyrion's ItemsConfig.ecf — Eleon's text
|
||||
// config format — into a flat item index keyed by numeric ID. Used by the
|
||||
// panel controller to populate the EAH-style give-item picker, ID-to-name
|
||||
// lookups for inventory views, and category filtering.
|
||||
//
|
||||
// The format is brace-delimited with `Key: Value` properties, comment lines
|
||||
// starting with #, and entries opened by `{ Item Id: N, Name: X` or
|
||||
// `+ Item Id: N, Name: X` (the `+` form extends a prior block; semantically
|
||||
// the same for our purposes). Values may be quoted strings, raw tokens, or
|
||||
// scalar numbers — we keep them as strings and let callers coerce.
|
||||
//
|
||||
// We deliberately ignore nested `{ Child ... }` blocks except for tracking
|
||||
// the brace depth so we don't accidentally close the outer item early.
|
||||
package empyrionitems
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Item is a single Empyrion item / block / weapon entry. Fields populated
|
||||
// best-effort from properties seen in stock ItemsConfig.ecf; Extras catches
|
||||
// anything we didn't promote to a typed field so callers can still surface
|
||||
// scenario-specific keys (Reforged Eden adds plenty).
|
||||
type Item struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"` // canonical name token (e.g. "EnergyCellHydrogen")
|
||||
Ref string `json:"ref,omitempty"` // template/parent ref
|
||||
Category string `json:"category,omitempty"` // ingredients, components, food, weapons, ammo, etc
|
||||
Group string `json:"group,omitempty"` // displayed UI grouping
|
||||
Mass string `json:"mass,omitempty"` // kg, raw token
|
||||
Volume string `json:"volume,omitempty"` // L
|
||||
MarketPrice string `json:"marketPrice,omitempty"`
|
||||
StackSize string `json:"stack,omitempty"`
|
||||
Class string `json:"class,omitempty"` // Ranged / Tool / Item / Block
|
||||
HoldType string `json:"holdType,omitempty"`
|
||||
CustomIcon string `json:"customIcon,omitempty"`
|
||||
AllowAt string `json:"allowAt,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Extras map[string]string `json:"extras,omitempty"`
|
||||
}
|
||||
|
||||
// Index is a queryable item store.
|
||||
type Index struct {
|
||||
Items []Item
|
||||
byID map[int]*Item
|
||||
byName map[string]*Item
|
||||
}
|
||||
|
||||
// LoadFromFile reads ItemsConfig.ecf from disk.
|
||||
func LoadFromFile(path string) (*Index, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return LoadFromReader(f)
|
||||
}
|
||||
|
||||
// LoadFromReaders parses multiple ECF streams into a single merged Index.
|
||||
// Order matters for ID collisions: later readers win, matching Empyrion's
|
||||
// own load order where BlocksConfig is read after ItemsConfig.
|
||||
func LoadFromReaders(readers ...io.Reader) (*Index, error) {
|
||||
merged := &Index{byID: map[int]*Item{}, byName: map[string]*Item{}}
|
||||
for _, r := range readers {
|
||||
idx, err := LoadFromReader(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range idx.Items {
|
||||
it := idx.Items[i]
|
||||
merged.add(&it)
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// LoadFromReader parses an ItemsConfig.ecf stream.
|
||||
func LoadFromReader(r io.Reader) (*Index, error) {
|
||||
idx := &Index{byID: map[int]*Item{}, byName: map[string]*Item{}}
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
|
||||
var (
|
||||
current *Item
|
||||
depth int // brace depth WITHIN an item (for child blocks like Child Recipe { ... })
|
||||
)
|
||||
|
||||
for sc.Scan() {
|
||||
line := stripComment(sc.Text())
|
||||
trim := strings.TrimSpace(line)
|
||||
if trim == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Open a new top-level entry: `{ Item Id: 12, ...`, `{ Block Id: 220, ...`,
|
||||
// `+ Item Id: ...`, `+ Block Id: ...`. Empyrion's ECF format uses
|
||||
// `Item`, `Block`, `Device` interchangeably for the entries we care
|
||||
// about — they're all addressable via the same numeric ID space.
|
||||
if current == nil && isEntryHeader(trim) {
|
||||
it := parseItemHeader(trim)
|
||||
if it != nil {
|
||||
current = it
|
||||
current.Extras = map[string]string{}
|
||||
depth = 1
|
||||
if strings.HasPrefix(trim, "+") {
|
||||
depth = 0
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Inside an item.
|
||||
if current != nil {
|
||||
// Track child brace depth so we don't close the outer item early.
|
||||
opens := strings.Count(trim, "{")
|
||||
closes := strings.Count(trim, "}")
|
||||
depth += opens - closes
|
||||
if depth <= 0 {
|
||||
idx.add(current)
|
||||
current = nil
|
||||
depth = 0
|
||||
continue
|
||||
}
|
||||
// `+ Item/Block` form has no closing brace; the next entry
|
||||
// header is its terminator.
|
||||
if isEntryHeader(trim) {
|
||||
idx.add(current)
|
||||
current = nil
|
||||
depth = 0
|
||||
it := parseItemHeader(trim)
|
||||
if it != nil {
|
||||
current = it
|
||||
current.Extras = map[string]string{}
|
||||
depth = 1
|
||||
if strings.HasPrefix(trim, "+") {
|
||||
depth = 0
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
parseProperty(current, trim)
|
||||
}
|
||||
}
|
||||
if current != nil {
|
||||
idx.add(current)
|
||||
}
|
||||
if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// isEntryHeader returns true for lines that open a top-level Item / Block /
|
||||
// Device entry. Empyrion's ECF format uses several keywords interchangeably:
|
||||
// `{ Item Id: 12, Name: Foo`
|
||||
// `{ Block Id: 220, Name: MetalPieces`
|
||||
// `{ Device Id: 1234, Name: ConstructorT2`
|
||||
// `+ Item Id: 12, Name: Foo`
|
||||
// `+Block Id: 12, ...`
|
||||
// `{ +Block Name: Foo, Ref: Bar` (additive — name-only, no Id)
|
||||
func isEntryHeader(s string) bool {
|
||||
for _, p := range []string{
|
||||
"{ Item ", "{ Block ", "{ Device ",
|
||||
"+ Item ", "+ Block ", "+ Device ",
|
||||
"{Item ", "{Block ", "{Device ",
|
||||
"+Item ", "+Block ", "+Device ",
|
||||
"{ +Item ", "{ +Block ", "{ +Device ",
|
||||
"{+Item ", "{+Block ", "{+Device ",
|
||||
} {
|
||||
if strings.HasPrefix(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseItemHeader handles entry headers in any of the forms isEntryHeader
|
||||
// recognizes and returns the partial Item populated from Id/Name/Ref keys.
|
||||
func parseItemHeader(line string) *Item {
|
||||
body := line
|
||||
for _, p := range []string{
|
||||
"{ +Item ", "{ +Block ", "{ +Device ",
|
||||
"{+Item ", "{+Block ", "{+Device ",
|
||||
"{ Item ", "{ Block ", "{ Device ",
|
||||
"+ Item ", "+ Block ", "+ Device ",
|
||||
"{Item ", "{Block ", "{Device ",
|
||||
"+Item ", "+Block ", "+Device ",
|
||||
} {
|
||||
body = strings.TrimPrefix(body, p)
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
it := &Item{}
|
||||
for _, kv := range splitTopLevel(body, ',') {
|
||||
parts := strings.SplitN(strings.TrimSpace(kv), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := stripQuotes(strings.TrimSpace(parts[1]))
|
||||
switch key {
|
||||
case "Id":
|
||||
if id, err := strconv.Atoi(val); err == nil {
|
||||
it.ID = id
|
||||
}
|
||||
case "Name":
|
||||
it.Name = val
|
||||
case "Ref":
|
||||
it.Ref = val
|
||||
}
|
||||
}
|
||||
// Name-only `+Block Name: Foo, Ref: Bar` entries (additive overrides) are
|
||||
// kept only if we can resolve their ID later via the Ref-target. For now
|
||||
// we drop them since they're a small minority and don't affect the picker.
|
||||
if it.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return it
|
||||
}
|
||||
|
||||
func parseProperty(it *Item, line string) {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := stripQuotes(strings.TrimSpace(parts[1]))
|
||||
// Trim trailing comment; key/value can be followed by inline `# ...`.
|
||||
val = stripComment(val)
|
||||
val = strings.TrimSpace(val)
|
||||
switch strings.ToLower(key) {
|
||||
case "category":
|
||||
it.Category = val
|
||||
case "group":
|
||||
it.Group = val
|
||||
case "mass":
|
||||
it.Mass = val
|
||||
case "volume":
|
||||
it.Volume = val
|
||||
case "marketprice":
|
||||
it.MarketPrice = val
|
||||
case "stacksize":
|
||||
it.StackSize = val
|
||||
case "class":
|
||||
it.Class = val
|
||||
case "holdtype":
|
||||
it.HoldType = val
|
||||
case "customicon":
|
||||
it.CustomIcon = val
|
||||
case "allowat":
|
||||
it.AllowAt = val
|
||||
case "description":
|
||||
it.Description = val
|
||||
default:
|
||||
if it.Extras == nil {
|
||||
it.Extras = map[string]string{}
|
||||
}
|
||||
it.Extras[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
func (idx *Index) add(it *Item) {
|
||||
if it == nil || it.Name == "" {
|
||||
return
|
||||
}
|
||||
// duplicate item IDs use the LAST seen entry (matches Empyrion's own override semantics).
|
||||
if it.ID > 0 {
|
||||
idx.byID[it.ID] = it
|
||||
}
|
||||
idx.byName[strings.ToLower(it.Name)] = it
|
||||
idx.Items = append(idx.Items, *it)
|
||||
}
|
||||
|
||||
// ByID returns the item with the given numeric ID, or nil.
|
||||
func (idx *Index) ByID(id int) *Item {
|
||||
if it, ok := idx.byID[id]; ok {
|
||||
c := *it
|
||||
return &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ByName looks up an item by its canonical name token (case-insensitive).
|
||||
func (idx *Index) ByName(name string) *Item {
|
||||
if it, ok := idx.byName[strings.ToLower(name)]; ok {
|
||||
c := *it
|
||||
return &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search returns items whose name OR description contains the query (case-
|
||||
// insensitive). Empty query returns all items. limit <= 0 returns all matches.
|
||||
func (idx *Index) Search(q string, category string, limit int) []Item {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
cat := strings.ToLower(strings.TrimSpace(category))
|
||||
out := make([]Item, 0, 64)
|
||||
for _, it := range idx.Items {
|
||||
if cat != "" && strings.ToLower(it.Category) != cat {
|
||||
continue
|
||||
}
|
||||
if q != "" {
|
||||
lname := strings.ToLower(it.Name)
|
||||
ldesc := strings.ToLower(it.Description)
|
||||
if !strings.Contains(lname, q) && !strings.Contains(ldesc, q) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, it)
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Categories returns the distinct, sorted-by-frequency categories present in the index.
|
||||
func (idx *Index) Categories() []string {
|
||||
counts := map[string]int{}
|
||||
for _, it := range idx.Items {
|
||||
if it.Category != "" {
|
||||
counts[it.Category]++
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(counts))
|
||||
for k := range counts {
|
||||
out = append(out, k)
|
||||
}
|
||||
// stable order: descending by count, then alpha
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && (counts[out[j]] > counts[out[j-1]] || (counts[out[j]] == counts[out[j-1]] && out[j] < out[j-1])); j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripComment(s string) string {
|
||||
// `#` starts a comment unless inside quotes — items config doesn't use `#`
|
||||
// inside string values in stock content, so a naive strip is safe.
|
||||
if i := strings.Index(s, "#"); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func stripQuotes(s string) string {
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// splitTopLevel splits on the given separator while ignoring separators
|
||||
// inside braces or quotes. Used to break "Id: 12, Name: Foo" into pieces
|
||||
// without accidentally splitting things like `BuffMod: "a, b, c"`.
|
||||
func splitTopLevel(s string, sep byte) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
depth := 0
|
||||
inQuote := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == '"':
|
||||
inQuote = !inQuote
|
||||
cur.WriteByte(c)
|
||||
case !inQuote && (c == '{' || c == '['):
|
||||
depth++
|
||||
cur.WriteByte(c)
|
||||
case !inQuote && (c == '}' || c == ']'):
|
||||
depth--
|
||||
cur.WriteByte(c)
|
||||
case !inQuote && depth == 0 && c == sep:
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package empyrionitems
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadItemsConfig(t *testing.T) {
|
||||
idx, err := LoadFromFile("ItemsConfig.ecf")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromFile: %v", err)
|
||||
}
|
||||
if len(idx.Items) < 30 {
|
||||
t.Fatalf("expected at least 30 items in ItemsConfig.ecf, got %d", len(idx.Items))
|
||||
}
|
||||
t.Logf("ItemsConfig: parsed %d items", len(idx.Items))
|
||||
|
||||
// Spot checks against entries seen in the live ItemsConfig.ecf header sample.
|
||||
cases := []struct {
|
||||
id int
|
||||
name string
|
||||
}{
|
||||
{1, "MeleePlayer"},
|
||||
{12, "Fillertool"},
|
||||
{220, "MetalPieces"},
|
||||
{240, "EnergyCellHydrogen"},
|
||||
{398, "XP"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
it := idx.ByID(tc.id)
|
||||
if it == nil {
|
||||
t.Errorf("id %d not found", tc.id)
|
||||
continue
|
||||
}
|
||||
if it.Name != tc.name {
|
||||
t.Errorf("id %d: name %q, want %q", tc.id, it.Name, tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBlocksConfig(t *testing.T) {
|
||||
if _, err := os.Stat("BlocksConfig.ecf"); os.IsNotExist(err) {
|
||||
t.Skip("BlocksConfig.ecf not present alongside test")
|
||||
}
|
||||
idx, err := LoadFromFile("BlocksConfig.ecf")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromFile: %v", err)
|
||||
}
|
||||
if len(idx.Items) < 1500 {
|
||||
t.Fatalf("expected >1500 entries in BlocksConfig.ecf, got %d", len(idx.Items))
|
||||
}
|
||||
t.Logf("BlocksConfig: parsed %d entries", len(idx.Items))
|
||||
|
||||
// Spot check by name (IDs evolve across versions; names are stable).
|
||||
for _, name := range []string{"Air", "Stone", "Grass"} {
|
||||
if idx.ByName(name) == nil {
|
||||
t.Errorf("expected to find %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Search must return matches for common item families.
|
||||
hits := idx.Search("Constructor", "", 50)
|
||||
if len(hits) < 2 {
|
||||
t.Errorf("search 'Constructor' should hit multiple devices, got %d", len(hits))
|
||||
}
|
||||
for _, h := range hits[:1] {
|
||||
if !strings.Contains(strings.ToLower(h.Name), "constructor") {
|
||||
t.Errorf("unexpected match: %+v", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package empyrionitems
|
||||
|
||||
// Loot group parser for Empyrion's `LootGroups.ecf`.
|
||||
// Format:
|
||||
//
|
||||
// { +LootGroup Name: EscapePodEasy
|
||||
// Count: all
|
||||
// Item_0: EmergencyRations, param1: 1
|
||||
// Item_1: WaterBottle, param1: 5
|
||||
// ...
|
||||
// }
|
||||
//
|
||||
// param1 = count (or "low-high" range), param2 = optional probability.
|
||||
// Item names are canonical Item.Name tokens.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LootEntry struct {
|
||||
Item string `json:"item"`
|
||||
Count string `json:"count,omitempty"`
|
||||
Probability string `json:"probability,omitempty"`
|
||||
}
|
||||
|
||||
type LootGroup struct {
|
||||
Name string `json:"name"`
|
||||
Count string `json:"count,omitempty"`
|
||||
Items []LootEntry `json:"items"`
|
||||
}
|
||||
|
||||
type LootIndex struct {
|
||||
Groups []LootGroup
|
||||
byName map[string]*LootGroup
|
||||
byItem map[string][]string // ingredientLower → group names
|
||||
}
|
||||
|
||||
func LoadLootFromFile(path string) (*LootIndex, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return LoadLootFromReader(f)
|
||||
}
|
||||
|
||||
func LoadLootFromReader(r io.Reader) (*LootIndex, error) {
|
||||
idx := &LootIndex{byName: map[string]*LootGroup{}, byItem: map[string][]string{}}
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
|
||||
var current *LootGroup
|
||||
var depth int
|
||||
|
||||
for sc.Scan() {
|
||||
line := stripComment(sc.Text())
|
||||
trim := strings.TrimSpace(line)
|
||||
if trim == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
if strings.HasPrefix(trim, "{ +LootGroup ") || strings.HasPrefix(trim, "{+LootGroup ") || strings.HasPrefix(trim, "{ LootGroup ") {
|
||||
body := strings.TrimPrefix(trim, "{ +LootGroup ")
|
||||
body = strings.TrimPrefix(body, "{+LootGroup ")
|
||||
body = strings.TrimPrefix(body, "{ LootGroup ")
|
||||
current = &LootGroup{}
|
||||
for _, kv := range splitTopLevel(body, ',') {
|
||||
parts := strings.SplitN(strings.TrimSpace(kv), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(parts[0])
|
||||
v := stripQuotes(strings.TrimSpace(parts[1]))
|
||||
if strings.EqualFold(k, "name") {
|
||||
current.Name = v
|
||||
}
|
||||
}
|
||||
depth = 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
opens := strings.Count(trim, "{")
|
||||
closes := strings.Count(trim, "}")
|
||||
depth += opens - closes
|
||||
if depth <= 0 {
|
||||
if current.Name != "" {
|
||||
cp := *current
|
||||
idx.Groups = append(idx.Groups, cp)
|
||||
gp := &idx.Groups[len(idx.Groups)-1]
|
||||
idx.byName[strings.ToLower(cp.Name)] = gp
|
||||
for _, it := range cp.Items {
|
||||
low := strings.ToLower(it.Item)
|
||||
idx.byItem[low] = append(idx.byItem[low], cp.Name)
|
||||
}
|
||||
}
|
||||
current = nil
|
||||
depth = 0
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(trim, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := stripComment(strings.TrimSpace(parts[1]))
|
||||
|
||||
if strings.HasPrefix(strings.ToLower(key), "item_") {
|
||||
// Item_N: Name, param1: count, param2: probability
|
||||
le := LootEntry{}
|
||||
pieces := splitTopLevel(val, ',')
|
||||
for i, p := range pieces {
|
||||
p = strings.TrimSpace(p)
|
||||
if i == 0 {
|
||||
le.Item = stripQuotes(p)
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(p, ":", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
kk := strings.ToLower(strings.TrimSpace(kv[0]))
|
||||
vv := strings.TrimSpace(kv[1])
|
||||
switch kk {
|
||||
case "param1", "param":
|
||||
le.Count = vv
|
||||
case "param2", "xdata":
|
||||
le.Probability = vv
|
||||
}
|
||||
}
|
||||
if le.Item != "" {
|
||||
current.Items = append(current.Items, le)
|
||||
}
|
||||
} else if strings.EqualFold(key, "Count") {
|
||||
current.Count = strings.TrimSpace(val)
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (idx *LootIndex) ByName(name string) *LootGroup {
|
||||
if g, ok := idx.byName[strings.ToLower(name)]; ok {
|
||||
c := *g
|
||||
return &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx *LootIndex) DroppedBy(itemName string) []string {
|
||||
return append([]string{}, idx.byItem[strings.ToLower(itemName)]...)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package empyrionitems
|
||||
|
||||
// Template (recipe) parser for Empyrion's `Templates.ecf`.
|
||||
// Format snippet:
|
||||
//
|
||||
// { +Template Name: PlasticMaterial
|
||||
// BaseItem: true
|
||||
// CraftTime: 2
|
||||
// Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC"
|
||||
// { Child Inputs
|
||||
// CrushedStone: 1
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// We extract Name + outer props (CraftTime, Target, BaseItem,
|
||||
// OutputCount, etc.) and the Inputs map.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Template struct {
|
||||
Name string `json:"name"`
|
||||
BaseItem bool `json:"baseItem,omitempty"`
|
||||
CraftTime string `json:"craftTime,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
OutputCount string `json:"outputCount,omitempty"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
Inputs map[string]string `json:"inputs"` // ingredientName → count token
|
||||
Extras map[string]string `json:"extras,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateIndex struct {
|
||||
Templates []Template
|
||||
byName map[string]*Template
|
||||
}
|
||||
|
||||
func LoadTemplatesFromFile(path string) (*TemplateIndex, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return LoadTemplatesFromReader(f)
|
||||
}
|
||||
|
||||
func LoadTemplatesFromReader(r io.Reader) (*TemplateIndex, error) {
|
||||
idx := &TemplateIndex{byName: map[string]*Template{}}
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
|
||||
var current *Template
|
||||
var inInputs bool
|
||||
var depth int
|
||||
|
||||
for sc.Scan() {
|
||||
line := stripComment(sc.Text())
|
||||
trim := strings.TrimSpace(line)
|
||||
if trim == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
if strings.HasPrefix(trim, "{ +Template ") || strings.HasPrefix(trim, "{+Template ") || strings.HasPrefix(trim, "{ Template ") {
|
||||
body := strings.TrimPrefix(trim, "{ +Template ")
|
||||
body = strings.TrimPrefix(body, "{+Template ")
|
||||
body = strings.TrimPrefix(body, "{ Template ")
|
||||
body = strings.TrimSpace(body)
|
||||
current = &Template{Inputs: map[string]string{}, Extras: map[string]string{}}
|
||||
for _, kv := range splitTopLevel(body, ',') {
|
||||
parts := strings.SplitN(strings.TrimSpace(kv), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(parts[0])
|
||||
v := stripQuotes(strings.TrimSpace(parts[1]))
|
||||
switch strings.ToLower(k) {
|
||||
case "name":
|
||||
current.Name = v
|
||||
case "ref":
|
||||
current.Ref = v
|
||||
}
|
||||
}
|
||||
depth = 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
opens := strings.Count(trim, "{")
|
||||
closes := strings.Count(trim, "}")
|
||||
depth += opens - closes
|
||||
if depth <= 0 {
|
||||
if current.Name != "" {
|
||||
cp := *current
|
||||
idx.Templates = append(idx.Templates, cp)
|
||||
idx.byName[strings.ToLower(cp.Name)] = &idx.Templates[len(idx.Templates)-1]
|
||||
}
|
||||
current = nil
|
||||
inInputs = false
|
||||
depth = 0
|
||||
continue
|
||||
}
|
||||
|
||||
// Track child blocks like `{ Child Inputs`.
|
||||
if strings.HasPrefix(trim, "{ Child Inputs") || strings.HasPrefix(trim, "{Child Inputs") {
|
||||
inInputs = true
|
||||
continue
|
||||
}
|
||||
if inInputs && strings.HasPrefix(trim, "}") {
|
||||
inInputs = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Property line.
|
||||
parts := strings.SplitN(trim, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := stripQuotes(strings.TrimSpace(parts[1]))
|
||||
val = strings.TrimSpace(stripComment(val))
|
||||
|
||||
if inInputs {
|
||||
current.Inputs[key] = val
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(key) {
|
||||
case "baseitem":
|
||||
current.BaseItem = strings.EqualFold(val, "true")
|
||||
case "crafttime":
|
||||
current.CraftTime = val
|
||||
case "target":
|
||||
current.Target = val
|
||||
case "outputcount":
|
||||
current.OutputCount = val
|
||||
default:
|
||||
current.Extras[key] = val
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (idx *TemplateIndex) ByName(name string) *Template {
|
||||
if t, ok := idx.byName[strings.ToLower(name)]; ok {
|
||||
c := *t
|
||||
return &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsedIn returns the list of templates whose inputs include the named item.
|
||||
func (idx *TemplateIndex) UsedIn(itemName string) []Template {
|
||||
want := strings.ToLower(itemName)
|
||||
out := []Template{}
|
||||
for _, t := range idx.Templates {
|
||||
for k := range t.Inputs {
|
||||
if strings.ToLower(k) == want {
|
||||
out = append(out, t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module github.com/dbledeez/panel/pkg
|
||||
|
||||
go 1.26
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
require github.com/beevik/etree v1.6.0 // indirect
|
||||
@@ -0,0 +1,6 @@
|
||||
github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
|
||||
github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,73 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAllManifestsLoadAndEventRegexesCompile is the repo-wide consistency
|
||||
// gate for module manifests: every modules/<id>/module.yaml must LoadDir
|
||||
// cleanly, and every declared events regex must compile under Go's RE2
|
||||
// engine (AMPTemplates uses .NET (?<n>) syntax — these must have been
|
||||
// converted to (?P<n>)) and carry at least one named capture group the
|
||||
// agent's tracker actually reads (see agent/internal/state/tracker.go
|
||||
// OnLogLine: name/player_name, id-family, msg/detail/reason).
|
||||
func TestAllManifestsLoadAndEventRegexesCompile(t *testing.T) {
|
||||
dir := filepath.Join("..", "..", "modules")
|
||||
manifests, err := LoadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDir(%s): %v", dir, err)
|
||||
}
|
||||
if len(manifests) < 20 {
|
||||
t.Fatalf("expected >=20 manifests, got %d — wrong dir?", len(manifests))
|
||||
}
|
||||
|
||||
// Group names the tracker extracts (tracker.go firstGroup calls).
|
||||
known := map[string]bool{
|
||||
"name": true, "player_name": true,
|
||||
"platform_id": true, "owner": true, "cross_id": true, "id": true, "player_id": true,
|
||||
"msg": true, "detail": true, "reason": true,
|
||||
}
|
||||
validKinds := map[string]bool{"join": true, "leave": true, "chat": true, "death": true, "custom": true}
|
||||
|
||||
for _, m := range manifests {
|
||||
m := m
|
||||
t.Run(m.ID, func(t *testing.T) {
|
||||
for evName, ev := range m.Events {
|
||||
re, err := regexp.Compile(ev.Pattern)
|
||||
if err != nil {
|
||||
t.Errorf("event %q: pattern does not compile: %v", evName, err)
|
||||
continue
|
||||
}
|
||||
if !validKinds[ev.Kind] {
|
||||
t.Errorf("event %q: unknown kind %q", evName, ev.Kind)
|
||||
}
|
||||
// Every event must expose at least one group the tracker
|
||||
// knows how to read, otherwise the event emits empty
|
||||
// PlayerName/PlayerId/Detail and is a silently-useless knob.
|
||||
got := false
|
||||
for _, g := range re.SubexpNames() {
|
||||
if known[g] {
|
||||
got = true
|
||||
}
|
||||
}
|
||||
if !got {
|
||||
t.Errorf("event %q: no tracker-known named group (want one of name/id/msg families); groups=%v", evName, re.SubexpNames())
|
||||
}
|
||||
// join/leave should identify the player by name or id.
|
||||
if ev.Kind == "join" || ev.Kind == "leave" {
|
||||
hasIdent := false
|
||||
for _, g := range re.SubexpNames() {
|
||||
if g == "name" || g == "player_name" || g == "id" || g == "platform_id" || g == "player_id" || g == "owner" || g == "cross_id" {
|
||||
hasIdent = true
|
||||
}
|
||||
}
|
||||
if !hasIdent {
|
||||
t.Errorf("event %q (kind %s): no player-identifying named group", evName, ev.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// LoadFile parses a single module.yaml at path.
|
||||
func LoadFile(path string) (*Manifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
var m Manifest
|
||||
dec := yaml.NewDecoder(bytes.NewReader(data))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate %s: %w", path, err)
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs %s: %w", path, err)
|
||||
}
|
||||
m.Path = abs
|
||||
m.Dir = filepath.Dir(abs)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// LoadDir scans dir for subdirectories containing a module.yaml and loads each.
|
||||
// Subdirectories without module.yaml are skipped silently.
|
||||
func LoadDir(dir string) ([]*Manifest, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read dir %s: %w", dir, err)
|
||||
}
|
||||
var out []*Manifest
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, e.Name(), "module.yaml")
|
||||
info, err := os.Stat(p)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", p, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
continue
|
||||
}
|
||||
m, err := LoadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Validate enforces required fields and basic structural invariants.
|
||||
// It does NOT validate configs against JSON schemas or regex compilability
|
||||
// (callers do that after load, since it's policy-specific).
|
||||
func (m *Manifest) Validate() error {
|
||||
if m.ID == "" {
|
||||
return errors.New("id is required")
|
||||
}
|
||||
if m.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if m.Version == "" {
|
||||
return errors.New("version is required")
|
||||
}
|
||||
if len(m.SupportedModes) == 0 {
|
||||
return errors.New("supported_modes must list at least one mode")
|
||||
}
|
||||
for _, mode := range m.SupportedModes {
|
||||
switch mode {
|
||||
case "docker":
|
||||
case "host":
|
||||
// Declared in the manifest schema but the agent has no host
|
||||
// runtime — accepting it here would advertise a capability
|
||||
// that silently doesn't work. Reject loudly until host mode
|
||||
// is actually implemented.
|
||||
return fmt.Errorf("run mode %q is not implemented (only %q is supported); remove it from supported_modes", mode, "docker")
|
||||
default:
|
||||
return fmt.Errorf("unknown run mode %q (valid: docker)", mode)
|
||||
}
|
||||
}
|
||||
if m.HasMode("docker") && (m.Runtime.Docker == nil || m.Runtime.Docker.Image == "") {
|
||||
return errors.New("supported_modes includes docker but runtime.docker.image is missing")
|
||||
}
|
||||
if m.HasMode("host") && m.Runtime.Host == nil {
|
||||
return errors.New("supported_modes includes host but runtime.host is missing")
|
||||
}
|
||||
if len(m.Ports) == 0 {
|
||||
return errors.New("at least one port must be declared")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, p := range m.Ports {
|
||||
if p.Name == "" {
|
||||
return errors.New("port.name is required")
|
||||
}
|
||||
if seen[p.Name] {
|
||||
return fmt.Errorf("duplicate port name %q", p.Name)
|
||||
}
|
||||
seen[p.Name] = true
|
||||
switch p.Proto {
|
||||
case "tcp", "udp":
|
||||
default:
|
||||
return fmt.Errorf("port %q: proto must be tcp or udp (got %q)", p.Name, p.Proto)
|
||||
}
|
||||
if p.Default == 0 {
|
||||
return fmt.Errorf("port %q: default is required", p.Name)
|
||||
}
|
||||
}
|
||||
if m.RCON != nil {
|
||||
if m.RCON.Adapter == "" {
|
||||
return errors.New("rcon.adapter is required when rcon is set")
|
||||
}
|
||||
if m.RCON.HostPort != "" && m.Port(m.RCON.HostPort) == nil {
|
||||
return fmt.Errorf("rcon.host_port %q does not match any declared port", m.RCON.HostPort)
|
||||
}
|
||||
}
|
||||
if len(m.UpdateProviders) == 0 {
|
||||
return errors.New("at least one update_provider is required")
|
||||
}
|
||||
providerIDs := map[string]bool{}
|
||||
for _, p := range m.UpdateProviders {
|
||||
if p.ID == "" {
|
||||
return errors.New("update_provider.id is required")
|
||||
}
|
||||
if providerIDs[p.ID] {
|
||||
return fmt.Errorf("duplicate update_provider id %q", p.ID)
|
||||
}
|
||||
providerIDs[p.ID] = true
|
||||
switch p.Kind {
|
||||
case "steamcmd":
|
||||
if p.AppID == "" {
|
||||
return fmt.Errorf("update_provider %q: app_id required for steamcmd kind", p.ID)
|
||||
}
|
||||
case "github":
|
||||
if p.Repo == "" {
|
||||
return fmt.Errorf("update_provider %q: repo required for github kind", p.ID)
|
||||
}
|
||||
case "direct":
|
||||
if p.URL == "" {
|
||||
return fmt.Errorf("update_provider %q: url required for direct kind", p.ID)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("update_provider %q: unknown kind %q", p.ID, p.Kind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
// Package module defines the on-disk manifest format (module.yaml) that
|
||||
// declares a game plugin: how to install it, how to run it, what ports it
|
||||
// exposes, what RCON dialect it speaks, and what patterns identify state
|
||||
// and player events in its logs.
|
||||
//
|
||||
// Both Controller and Target load manifests from a local modules/ directory.
|
||||
// The Controller owns authoring/UI form generation; the Target uses the
|
||||
// manifest at runtime to shape container launch and state-tracking pipelines.
|
||||
package module
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Manifest is the root structure parsed from a module.yaml file.
|
||||
type Manifest struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Authors []string `yaml:"authors,omitempty"`
|
||||
SupportedModes []string `yaml:"supported_modes"`
|
||||
Runtime Runtime `yaml:"runtime"`
|
||||
Ports []Port `yaml:"ports"`
|
||||
Resources Resources `yaml:"resources,omitempty"`
|
||||
ConfigFiles []ConfigFile `yaml:"config_files,omitempty"`
|
||||
RCON *RCONConfig `yaml:"rcon,omitempty"`
|
||||
StateSources []StateSource `yaml:"state_sources,omitempty"`
|
||||
Events map[string]Event `yaml:"events,omitempty"`
|
||||
UpdateProviders []UpdateProvider `yaml:"update_providers"`
|
||||
Secrets []Secret `yaml:"secrets,omitempty"`
|
||||
Lifecycle *Lifecycle `yaml:"lifecycle,omitempty"`
|
||||
// AutoUpdateOnCreate — run the first update_provider automatically
|
||||
// after a successful create, so the operator doesn't have to click
|
||||
// Update as a separate step. Default is `nil` which the loader
|
||||
// interprets as true for modules that declare a real provider. Set
|
||||
// explicitly to `false` for modules whose image handles its own
|
||||
// downloading at runtime (e.g. the ark-sa community image).
|
||||
AutoUpdateOnCreate *bool `yaml:"auto_update_on_create,omitempty"`
|
||||
// AutoStartOnCreate — after a successful create (and first-time update,
|
||||
// if one ran), boot the server automatically. Default nil = true for any
|
||||
// module. Opt out with `auto_start_on_create: false` for modules whose
|
||||
// first boot needs operator attention (e.g. filling in secrets first).
|
||||
// Independent of AutoUpdateOnCreate: images like ark-sa/ASA skip the
|
||||
// external updater but still need to auto-start so their in-container
|
||||
// entrypoint can run the bundled SteamCMD + launch the game in one go.
|
||||
AutoStartOnCreate *bool `yaml:"auto_start_on_create,omitempty"`
|
||||
// Backup declares which paths the panel should archive when an
|
||||
// operator clicks Back-up-now. Without this the agent tars the
|
||||
// whole BrowseableRoot — fine for tiny games, catastrophic for ARK
|
||||
// SA where that's a 18 GB SteamCMD install plus 200 MB of saves.
|
||||
// Paths are container-relative (resolved against BrowseableRoot)
|
||||
// and use shell-style globs. Saves-only is the goal.
|
||||
Backup *BackupSpec `yaml:"backup,omitempty"`
|
||||
|
||||
// ConfigValues is the on-disk *declaration* of operator-tunable knobs.
|
||||
// The dashboard hardcodes its config-form schemas separately (see
|
||||
// static/index.html `configSchemas`), so this field is not consumed by
|
||||
// the loader yet — it's accepted only to keep the strict YAML decoder
|
||||
// happy when modules document their tunables in the manifest. Future
|
||||
// work: drive the dashboard form generator off this field directly so
|
||||
// the schema lives in one place.
|
||||
ConfigValues []map[string]any `yaml:"config_values,omitempty"`
|
||||
|
||||
// ReadyPattern is the log regex that marks the server as actually
|
||||
// joinable (world loaded, accepting connections) — CONTROLLER/UI
|
||||
// metadata only; the agent never compiles it. The string is a
|
||||
// JavaScript regex: either a bare source ("Done \\(...\\)! For help")
|
||||
// or slash-delimited with flags ("/StartGame done/i"). It may use JS
|
||||
// constructs Go's regexp lacks (lookahead) — do not compile in Go.
|
||||
// Source of truth moved here from the dashboard's READY_PATTERNS map.
|
||||
ReadyPattern string `yaml:"ready_pattern,omitempty"`
|
||||
// Appearance carries the dashboard's visual identity for this module
|
||||
// (emoji, tile gradient, packaged art, Steam appid, optional display
|
||||
// name/tagline/accent). CONTROLLER/UI metadata only. Source of truth
|
||||
// moved here from the dashboard's moduleAppearance map.
|
||||
Appearance *Appearance `yaml:"appearance,omitempty"`
|
||||
|
||||
// Path is the absolute path to module.yaml, set by the loader.
|
||||
Path string `yaml:"-"`
|
||||
// Dir is the directory containing the manifest; template/schema paths
|
||||
// in this manifest are resolved relative to Dir.
|
||||
Dir string `yaml:"-"`
|
||||
}
|
||||
|
||||
// Runtime declares how the module launches under each supported mode.
|
||||
type Runtime struct {
|
||||
Docker *DockerSpec `yaml:"docker,omitempty"`
|
||||
Host *HostSpec `yaml:"host,omitempty"`
|
||||
}
|
||||
|
||||
// DockerSpec is the Docker-mode launch recipe.
|
||||
type DockerSpec struct {
|
||||
Image string `yaml:"image"`
|
||||
Volumes []Volume `yaml:"volumes,omitempty"`
|
||||
Entrypoint []string `yaml:"entrypoint,omitempty"`
|
||||
Command []string `yaml:"command,omitempty"`
|
||||
Env map[string]string `yaml:"env,omitempty"`
|
||||
User string `yaml:"user,omitempty"`
|
||||
// BrowseableRoot is the container-absolute path the file manager
|
||||
// treats as "root" when operating on this container (e.g. "/data").
|
||||
// If empty, the resolver defaults to the first volume's container
|
||||
// path. If neither is set, the file manager falls back to the
|
||||
// host-side data_path bind-mount root.
|
||||
BrowseableRoot string `yaml:"browseable_root,omitempty"`
|
||||
// BrowseableRoots lets a module expose multiple independent "views"
|
||||
// to the file manager — e.g. 7DTD's /game-saves (worlds, configs)
|
||||
// AND /game (mods, plugins, binaries). Each entry becomes a picker
|
||||
// option in the Files tab. First entry is the default view.
|
||||
// Empty → fall back to BrowseableRoot / first volume.
|
||||
BrowseableRoots []BrowseableRoot `yaml:"browseable_roots,omitempty"`
|
||||
// NetworkMode is the Docker network mode. Empty → default bridge with
|
||||
// published ports. Use "host" for modules that rely on long-lived UDP
|
||||
// sessions to external services (e.g. Windrose's P2P coop gateway, where
|
||||
// Docker Desktop's UDP NAT rebinds break TURN consent checks). In host
|
||||
// mode the Ports block is informational only — the container listens on
|
||||
// the host's interfaces directly; the default bind-mount NAT is skipped.
|
||||
NetworkMode string `yaml:"network_mode,omitempty"`
|
||||
}
|
||||
|
||||
// BrowseableRoot names one container-absolute directory for the file
|
||||
// manager to surface in its roots picker. JSON tags match what the
|
||||
// dashboard's Files-tab picker consumes via GET /api/modules.
|
||||
type BrowseableRoot struct {
|
||||
Name string `yaml:"name" json:"name"` // human label — shown in the UI
|
||||
Path string `yaml:"path" json:"path"` // container-absolute path
|
||||
Hint string `yaml:"hint,omitempty" json:"hint,omitempty"` // optional sub-line shown in the picker
|
||||
}
|
||||
|
||||
// Appearance is UI-only visual metadata for a module. Every field is
|
||||
// optional; the dashboard falls back to its built-in defaults for any
|
||||
// missing field. JSON keys mirror the dashboard's moduleAppearance map
|
||||
// entries (emoji/grad/art/steam) so the payload drops in unchanged.
|
||||
type Appearance struct {
|
||||
// DisplayName overrides Manifest.Name in UI card headers when set.
|
||||
DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"`
|
||||
// Tagline is an optional one-line blurb shown under the name.
|
||||
Tagline string `yaml:"tagline,omitempty" json:"tagline,omitempty"`
|
||||
// Accent is an optional CSS color for module-tinted UI chrome.
|
||||
Accent string `yaml:"accent,omitempty" json:"accent,omitempty"`
|
||||
// Emoji shown when no art is available.
|
||||
Emoji string `yaml:"emoji,omitempty" json:"emoji,omitempty"`
|
||||
// Grad is the CSS gradient behind the tile / while art loads.
|
||||
Grad string `yaml:"grad,omitempty" json:"grad,omitempty"`
|
||||
// Art is the panel-hosted art path, e.g. "/game-art/7dtd.jpg".
|
||||
Art string `yaml:"art,omitempty" json:"art,omitempty"`
|
||||
// Steam is the Steam appid used for CDN header-art fallback.
|
||||
Steam string `yaml:"steam,omitempty" json:"steam,omitempty"`
|
||||
}
|
||||
|
||||
// HostSpec is the host-mode launch recipe.
|
||||
type HostSpec struct {
|
||||
ExecutableLinux string `yaml:"executable_linux,omitempty"`
|
||||
ExecutableWindows string `yaml:"executable_windows,omitempty"`
|
||||
Args []string `yaml:"args,omitempty"`
|
||||
Env map[string]string `yaml:"env,omitempty"`
|
||||
WorkingDir string `yaml:"working_dir,omitempty"`
|
||||
}
|
||||
|
||||
// Volume declares a mount point on the instance's container.
|
||||
//
|
||||
// Two kinds are supported:
|
||||
//
|
||||
// type: bind — Target is the host path (may contain $DATA_PATH).
|
||||
// Useful for configs + logs operators want to read/edit.
|
||||
// type: volume — Name is a Docker named volume (may contain $INSTANCE_ID).
|
||||
// Lives inside the WSL2/container-storage filesystem;
|
||||
// dramatically faster than Windows bind-mounts for
|
||||
// high-I/O data like game installs.
|
||||
//
|
||||
// The agent's docker runtime auto-creates named volumes on first use.
|
||||
type Volume struct {
|
||||
Type string `yaml:"type,omitempty"` // "bind" (default if Target set) or "volume"
|
||||
Target string `yaml:"target,omitempty"` // host path for bind (may contain $DATA_PATH)
|
||||
Name string `yaml:"name,omitempty"` // volume name for named volumes (may contain $INSTANCE_ID)
|
||||
Container string `yaml:"container"`
|
||||
ReadOnly bool `yaml:"read_only,omitempty"`
|
||||
}
|
||||
|
||||
// Port is a declared network port.
|
||||
type Port struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Proto string `yaml:"proto" json:"proto"` // "tcp" or "udp"
|
||||
Default uint32 `yaml:"default" json:"default"` // default port number
|
||||
Required bool `yaml:"required,omitempty" json:"required,omitempty"`
|
||||
Internal bool `yaml:"internal,omitempty" json:"internal,omitempty"` // if true, not exposed outside the Target
|
||||
// Env, when set, names the container env var the agent should
|
||||
// populate with this port's allocated HOST port at create time.
|
||||
// Lets the image's entrypoint translate panel-allocated ports
|
||||
// into game launch args (e.g. ARK's ASA_PORT/QUERY_PORT/RCON_PORT
|
||||
// → ?Port=N ?QueryPort=N ?RCONPort=N). Without this, two ARK
|
||||
// instances all advertise the manifest defaults to Steam and only
|
||||
// one of them is reachable via the server browser.
|
||||
Env string `yaml:"env,omitempty" json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// Resources declares RAM / CPU guidance for the UI and default limits.
|
||||
type Resources struct {
|
||||
MinRAMMB uint32 `yaml:"min_ram_mb,omitempty"`
|
||||
RecommendedRAMMB uint32 `yaml:"recommended_ram_mb,omitempty"`
|
||||
MinCPUShares uint32 `yaml:"min_cpu_shares,omitempty"`
|
||||
}
|
||||
|
||||
// BackupSpec narrows what gets included when a backup runs.
|
||||
//
|
||||
// Paths are interpreted relative to BrowseableRoot (the same path the
|
||||
// File Manager treats as "the instance root"). Both Include and Exclude
|
||||
// support tar-style patterns (e.g. "ShooterGame/Saved/SavedArks").
|
||||
//
|
||||
// Without a BackupSpec, the agent falls back to "tar the entire
|
||||
// BrowseableRoot" — preserves backwards compat for modules that
|
||||
// haven't been profiled yet but is wasteful for any game with bulky
|
||||
// install dirs (ARK SA, Empyrion, Conan). Each module's manifest
|
||||
// should ship one of these as soon as someone has time to verify
|
||||
// which paths actually need saving.
|
||||
//
|
||||
// Notes label is shown in the UI alongside backups, e.g. "Saves +
|
||||
// configs (~250 MB typical)".
|
||||
type BackupSpec struct {
|
||||
// Root is the absolute in-container path to cd into before tarring.
|
||||
// Defaults to the runtime's BrowseableRoot when empty. Set this when
|
||||
// the module's save data lives in a different volume than the file
|
||||
// browser default (7DTD: file browser shows /game by default, but
|
||||
// saves live in /game-saves).
|
||||
Root string `yaml:"root,omitempty"`
|
||||
Include []string `yaml:"include,omitempty"`
|
||||
Exclude []string `yaml:"exclude,omitempty"`
|
||||
Notes string `yaml:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// ConfigFile declares a config file the panel manages for the instance.
|
||||
type ConfigFile struct {
|
||||
Path string `yaml:"path"` // relative to $DATA_PATH
|
||||
Format string `yaml:"format"` // xml, properties, yaml, ini, json, toml, kv
|
||||
Schema string `yaml:"schema,omitempty"` // relative path to JSON-schema file in the module dir
|
||||
Template string `yaml:"template,omitempty"`
|
||||
// TemplateByBranch overrides Template for instances installed from a
|
||||
// specific Steam branch (the normalized branch from resolveCreateBranch,
|
||||
// e.g. "" for public/2.6 default, "latest_experimental" / "v3.0" for
|
||||
// 3.0). Keyed by the normalized branch string; an exact match wins,
|
||||
// otherwise Template (the default) is used. This is how 7DTD selects
|
||||
// serverconfig.v3.xml.tmpl for a 3.0 server while ≤2.6 servers keep the
|
||||
// untouched serverconfig.xml.tmpl. Kept data-driven so adding a future
|
||||
// version is a manifest edit, not a code change. See
|
||||
// panel/memory/7dtd-v3-version-and-sandboxcode-plan.md (Workstream D).
|
||||
TemplateByBranch map[string]string `yaml:"template_by_branch,omitempty"`
|
||||
}
|
||||
|
||||
// TemplateForBranch returns the template path to render for this config file
|
||||
// on the given normalized branch — a branch-specific override if one is
|
||||
// declared, else the default Template. An empty result means "no template"
|
||||
// (the file is operator-managed and Render skips it).
|
||||
func (cf ConfigFile) TemplateForBranch(branch string) string {
|
||||
if cf.TemplateByBranch != nil {
|
||||
if t, ok := cf.TemplateByBranch[branch]; ok {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return cf.Template
|
||||
}
|
||||
|
||||
// RCONConfig declares the instance's RCON adapter.
|
||||
type RCONConfig struct {
|
||||
Adapter string `yaml:"adapter"` // source_rcon, telnet, http, stdin_pipe, custom_wasm
|
||||
HostPort string `yaml:"host_port"` // port name from Ports
|
||||
Auth string `yaml:"auth,omitempty"` // password_prompt, source_rcon_login, bearer, none
|
||||
PasswordSecret string `yaml:"password_secret,omitempty"` // name from Secrets to use as RCON password
|
||||
PasswordLiteral string `yaml:"password_literal,omitempty"` // literal password (useful when the module's env pins one — lower priority than PasswordSecret / config_values)
|
||||
// PasswordFromFile reads the password at dial time from a path *inside
|
||||
// the container*. Lowest priority — used as a fallback when the module's
|
||||
// entrypoint generates its own RCON secret on first boot (e.g. 7DTD's
|
||||
// TelnetPassword, which we auto-populate to force 0.0.0.0 binding and
|
||||
// stash in /game-saves/.panel-telnet-password).
|
||||
PasswordFromFile string `yaml:"password_from_file,omitempty"`
|
||||
// PasswordFromINI extracts the password from a specific key in a
|
||||
// specific section of an INI file inside the container. Useful for
|
||||
// modules whose config file holds the RCON password alongside other
|
||||
// settings (ARK: SA stores it as
|
||||
// [ServerSettings]ServerAdminPassword=... in GameUserSettings.ini).
|
||||
// Read fresh on each dial so changes the operator makes via the Config
|
||||
// tab take effect as soon as the server picks them up on restart.
|
||||
PasswordFromINI *PasswordFromINI `yaml:"password_from_ini,omitempty"`
|
||||
Commands map[string]string `yaml:"commands,omitempty"` // named command templates
|
||||
}
|
||||
|
||||
// PasswordFromINI locates a single key/value in an INI file inside the
|
||||
// container. Section is optional — when empty we match the key anywhere.
|
||||
type PasswordFromINI struct {
|
||||
File string `yaml:"file"`
|
||||
Section string `yaml:"section,omitempty"`
|
||||
Key string `yaml:"key"`
|
||||
}
|
||||
|
||||
// StateSource is a state-feed declaration — either an RCON poll or a log tail.
|
||||
type StateSource struct {
|
||||
Type string `yaml:"type"` // rcon, log_tail, http_probe
|
||||
Command string `yaml:"command,omitempty"`
|
||||
Every Duration `yaml:"every,omitempty"`
|
||||
Parse *ParseConfig `yaml:"parse,omitempty"`
|
||||
Files []string `yaml:"files,omitempty"`
|
||||
URL string `yaml:"url,omitempty"`
|
||||
}
|
||||
|
||||
// ParseConfig describes how to turn a raw response into named fields.
|
||||
type ParseConfig struct {
|
||||
Kind string `yaml:"kind"` // regex, json, kv
|
||||
Pattern string `yaml:"pattern,omitempty"`
|
||||
Fields map[string]string `yaml:"fields,omitempty"` // output_name -> named_group
|
||||
}
|
||||
|
||||
// Event is a log-derived event mapping: regex with named groups, mapped to a kind.
|
||||
type Event struct {
|
||||
Pattern string `yaml:"pattern"`
|
||||
Kind string `yaml:"kind"` // join, leave, chat, death, custom
|
||||
}
|
||||
|
||||
// UpdateProvider declares a source the panel can fetch new versions from.
|
||||
type UpdateProvider struct {
|
||||
ID string `yaml:"id"`
|
||||
Kind string `yaml:"kind"` // steamcmd, github, direct
|
||||
AppID string `yaml:"app_id,omitempty"`
|
||||
Beta string `yaml:"beta,omitempty"`
|
||||
// Platform, when non-empty, forces SteamCMD to download a specific
|
||||
// platform's depots via +@sSteamCmdForcePlatformType. Used for Windows-
|
||||
// only server builds installed on Linux hosts (Empyrion, a few others).
|
||||
// Values: "windows", "linux", "macos".
|
||||
Platform string `yaml:"platform,omitempty"`
|
||||
Repo string `yaml:"repo,omitempty"`
|
||||
AssetRegex string `yaml:"asset_regex,omitempty"`
|
||||
URL string `yaml:"url,omitempty"`
|
||||
// TargetPath is where downloaded bytes land, relative to the
|
||||
// instance's BrowseableRoot (if the container exists) or its
|
||||
// DataPath bind mount. Required for `direct` + `github` kinds.
|
||||
TargetPath string `yaml:"target_path,omitempty"`
|
||||
// InstallPath is the container-absolute install dir for SteamCMD's
|
||||
// +force_install_dir. Defaults to BrowseableRoot if empty.
|
||||
InstallPath string `yaml:"install_path,omitempty"`
|
||||
// Extract tells the `direct` / `github` provider to treat the
|
||||
// downloaded bytes as an archive (zip / tar / tar.gz / tar.bz2 /
|
||||
// tar.xz) and extract its contents INTO TargetPath (a directory)
|
||||
// instead of writing the raw bytes as a single file. Factorio's
|
||||
// headless .tar.xz uses this. When false, the provider still
|
||||
// auto-extracts if the content is a recognized archive AND
|
||||
// TargetPath has no file extension (looks like a directory) —
|
||||
// but a TargetPath like /server.jar or /terraria-server.zip is
|
||||
// always written verbatim (minecraft jars ARE zips; terraria's
|
||||
// entrypoint expects the zip file on disk).
|
||||
Extract bool `yaml:"extract,omitempty"`
|
||||
// SkipValidate omits the `validate` keyword from the `app_update`
|
||||
// command. Conan Exiles (app 443030) and a handful of other apps
|
||||
// fail with "Missing configuration" when validate runs against an
|
||||
// empty install dir; plain `app_update` succeeds. Operators who
|
||||
// need periodic integrity checks can run Update manually later.
|
||||
SkipValidate bool `yaml:"skip_validate,omitempty"`
|
||||
// RequiresSteamLogin means `+login anonymous` won't work — the app
|
||||
// is paid content (DayZ 223350, Arma 3 229470, etc.) and SteamCMD
|
||||
// needs a real Steam account that owns it. When true, the controller
|
||||
// halts the update flow with {steam_login_required: true} if no
|
||||
// cached credential is available, letting the UI pop a login modal.
|
||||
// Once creds are stored, they're injected as `+login <user> <pass>`.
|
||||
RequiresSteamLogin bool `yaml:"requires_steam_login,omitempty"`
|
||||
}
|
||||
|
||||
// Secret declares an operator-supplied or panel-generated credential.
|
||||
type Secret struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Generated bool `yaml:"generated,omitempty"`
|
||||
}
|
||||
|
||||
// Lifecycle declares shell commands (or WASM hooks — future) that run at
|
||||
// specific lifecycle phases. Commands run inside the instance sandbox.
|
||||
type Lifecycle struct {
|
||||
PreInstall []string `yaml:"pre_install,omitempty"`
|
||||
PostInstall []string `yaml:"post_install,omitempty"`
|
||||
PreStart []string `yaml:"pre_start,omitempty"`
|
||||
PostStart []string `yaml:"post_start,omitempty"`
|
||||
PreStop []string `yaml:"pre_stop,omitempty"`
|
||||
PostStop []string `yaml:"post_stop,omitempty"`
|
||||
}
|
||||
|
||||
// Duration is a time.Duration that parses from YAML strings like "30s".
|
||||
type Duration time.Duration
|
||||
|
||||
// UnmarshalYAML parses "30s" / "5m" / "1h" style values into a Duration.
|
||||
func (d *Duration) UnmarshalYAML(node *yaml.Node) error {
|
||||
var s string
|
||||
if err := node.Decode(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == "" {
|
||||
*d = 0
|
||||
return nil
|
||||
}
|
||||
v, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q: %w", s, err)
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Std returns the underlying time.Duration.
|
||||
func (d Duration) Std() time.Duration { return time.Duration(d) }
|
||||
|
||||
// HasMode reports whether the module supports the given runtime mode.
|
||||
func (m *Manifest) HasMode(mode string) bool {
|
||||
for _, x := range m.SupportedModes {
|
||||
if x == mode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Port returns the named port declaration, or nil if not declared.
|
||||
func (m *Manifest) Port(name string) *Port {
|
||||
for i := range m.Ports {
|
||||
if m.Ports[i].Name == name {
|
||||
return &m.Ports[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProvider returns the named update provider, or nil.
|
||||
func (m *Manifest) UpdateProvider(id string) *UpdateProvider {
|
||||
for i := range m.UpdateProviders {
|
||||
if m.UpdateProviders[i].ID == id {
|
||||
return &m.UpdateProviders[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLoad7DTDManifest loads the real 7 Days to Die manifest shipped in
|
||||
// modules/7dtd and asserts the panel-critical fields parsed correctly.
|
||||
// Keeping this coupled to the real file (rather than a testdata copy) means
|
||||
// changes to the shipped manifest must keep the test green.
|
||||
func TestLoad7DTDManifest(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "modules", "7dtd", "module.yaml")
|
||||
m, err := LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile: %v", err)
|
||||
}
|
||||
|
||||
if m.ID != "7dtd" {
|
||||
t.Errorf("id = %q, want 7dtd", m.ID)
|
||||
}
|
||||
if !m.HasMode("docker") {
|
||||
t.Errorf("expected docker mode, got %v", m.SupportedModes)
|
||||
}
|
||||
if m.Runtime.Docker == nil || m.Runtime.Docker.Image == "" {
|
||||
t.Error("expected docker runtime with image")
|
||||
}
|
||||
if m.RCON == nil || m.RCON.Adapter != "telnet" {
|
||||
t.Errorf("rcon adapter = %+v, want telnet", m.RCON)
|
||||
}
|
||||
if p := m.Port("telnet"); p == nil || p.Proto != "tcp" || !p.Internal {
|
||||
t.Errorf("telnet port malformed: %+v", p)
|
||||
}
|
||||
if up := m.UpdateProvider("stable"); up == nil || up.Kind != "steamcmd" || up.AppID != "294420" {
|
||||
t.Errorf("stable update provider malformed: %+v", up)
|
||||
}
|
||||
if len(m.Events) < 3 {
|
||||
t.Errorf("expected at least join/leave/chat events, got %d", len(m.Events))
|
||||
}
|
||||
// state_sources[0] should be rcon poll with a 30s cadence
|
||||
if len(m.StateSources) == 0 {
|
||||
t.Fatal("expected at least one state source")
|
||||
}
|
||||
ss := m.StateSources[0]
|
||||
if ss.Type != "rcon" || ss.Command != "lp" {
|
||||
t.Errorf("first state source = %+v, want rcon lp", ss)
|
||||
}
|
||||
if ss.Every.Std() != 30*time.Second {
|
||||
t.Errorf("state source every = %v, want 30s", ss.Every.Std())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDirSkipsDirWithoutManifest(t *testing.T) {
|
||||
// modules/ has 7dtd + minecraft-java + valheim dirs, but only 7dtd has a
|
||||
// module.yaml right now. Loader should return 1 module.
|
||||
dir := filepath.Join("..", "..", "modules")
|
||||
ms, err := LoadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
if len(ms) < 1 {
|
||||
t.Errorf("want >= 1 modules, got %d", len(ms))
|
||||
}
|
||||
found := false
|
||||
for _, m := range ms {
|
||||
if m.ID == "7dtd" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("7dtd not present in LoadDir result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMissingID(t *testing.T) {
|
||||
m := &Manifest{}
|
||||
if err := m.Validate(); err == nil {
|
||||
t.Error("expected validation error for empty manifest")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Registry is a concurrency-safe in-memory index of loaded manifests.
|
||||
// It's held as a shared component by both Controller and Target.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
modules map[string]*Manifest
|
||||
}
|
||||
|
||||
// NewRegistry returns an empty registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{modules: make(map[string]*Manifest)}
|
||||
}
|
||||
|
||||
// LoadDir scans dir for modules and inserts them. Duplicate IDs are rejected.
|
||||
func (r *Registry) LoadDir(dir string) error {
|
||||
ms, err := LoadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, m := range ms {
|
||||
if _, exists := r.modules[m.ID]; exists {
|
||||
return fmt.Errorf("duplicate module id %q in %s", m.ID, dir)
|
||||
}
|
||||
r.modules[m.ID] = m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Put inserts or replaces a manifest.
|
||||
func (r *Registry) Put(m *Manifest) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.modules[m.ID] = m
|
||||
}
|
||||
|
||||
// Get returns the manifest for id, or (nil, false) if unknown.
|
||||
func (r *Registry) Get(id string) (*Manifest, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
m, ok := r.modules[id]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// List returns all manifests sorted by id.
|
||||
func (r *Registry) List() []*Manifest {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]*Manifest, 0, len(r.modules))
|
||||
for _, m := range r.modules {
|
||||
out = append(out, m)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// Len returns the number of modules currently registered.
|
||||
func (r *Registry) Len() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.modules)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// Render writes out each ConfigFile declared by the manifest into dataPath,
|
||||
// applying `values` (which typically combines user config_values + generated
|
||||
// secrets) via Go text/template. Existing files are overwritten.
|
||||
//
|
||||
// Templates are resolved relative to the manifest's Dir. If a ConfigFile
|
||||
// declares no Template, it is skipped — operators can also manage those
|
||||
// files by direct edit via the (future) SFTP/file manager.
|
||||
//
|
||||
// Render uses each ConfigFile's default Template. Callers that know the
|
||||
// instance's Steam branch should use RenderForBranch so version-specific
|
||||
// templates (7DTD 3.0's serverconfig.v3.xml.tmpl) are selected.
|
||||
func Render(manifest *Manifest, dataPath string, values map[string]string) error {
|
||||
return RenderForBranch(manifest, dataPath, values, "")
|
||||
}
|
||||
|
||||
// RenderForBranch is Render with branch-aware template selection: each
|
||||
// ConfigFile renders cf.TemplateForBranch(branch) instead of cf.Template, so
|
||||
// an instance on a 3.0 branch picks serverconfig.v3.xml.tmpl while ≤2.6
|
||||
// instances (branch "") keep the default template. branch is the normalized
|
||||
// Steam branch (see resolveCreateBranch); "" means the module default.
|
||||
func RenderForBranch(manifest *Manifest, dataPath string, values map[string]string, branch string) error {
|
||||
if manifest == nil {
|
||||
return fmt.Errorf("render: manifest is nil")
|
||||
}
|
||||
if manifest.Dir == "" {
|
||||
return fmt.Errorf("render: manifest.Dir is empty (loader must set it)")
|
||||
}
|
||||
for _, cf := range manifest.ConfigFiles {
|
||||
tmpl := cf.TemplateForBranch(branch)
|
||||
if tmpl == "" {
|
||||
continue
|
||||
}
|
||||
if err := renderOne(manifest.Dir, cf, tmpl, dataPath, values); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderOne(moduleDir string, cf ConfigFile, templateRel string, dataPath string, values map[string]string) error {
|
||||
templatePath := filepath.Join(moduleDir, templateRel)
|
||||
tmplData, err := os.ReadFile(templatePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read template %s: %w", templatePath, err)
|
||||
}
|
||||
tmpl, err := template.New(cf.Path).Option("missingkey=zero").Parse(string(tmplData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse template %s: %w", templateRel, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
ctx := struct {
|
||||
Values map[string]string
|
||||
}{Values: values}
|
||||
if err := tmpl.Execute(&buf, ctx); err != nil {
|
||||
return fmt.Errorf("execute template %s: %w", templateRel, err)
|
||||
}
|
||||
|
||||
outPath := filepath.Join(dataPath, cf.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", filepath.Dir(outPath), err)
|
||||
}
|
||||
if err := os.WriteFile(outPath, buf.Bytes(), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", outPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateSecrets emits a random value for each manifest Secret flagged
|
||||
// Generated. Values are URL-safe base64 of 24 bytes — safe in XML, shell,
|
||||
// URLs, and long enough to resist brute force on a local RCON surface.
|
||||
//
|
||||
// Callers typically merge this with user-supplied config_values before
|
||||
// rendering templates and resolving RCON passwords.
|
||||
func GenerateSecrets(manifest *Manifest) (map[string]string, error) {
|
||||
if manifest == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, s := range manifest.Secrets {
|
||||
if !s.Generated {
|
||||
continue
|
||||
}
|
||||
val, err := randomToken(24)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate secret %q: %w", s.Name, err)
|
||||
}
|
||||
out[s.Name] = val
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// randomToken returns a URL-safe base64 string of n random bytes.
|
||||
func randomToken(nBytes int) (string, error) {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRenderWritesTemplate verifies the Go text/template pipeline:
|
||||
// - template file read from manifest.Dir/<cf.Template>
|
||||
// - values flow via .Values.key
|
||||
// - output written under dataPath/<cf.Path>, with sub-dirs created
|
||||
// - missing keys render as empty (missingkey=zero)
|
||||
func TestRenderWritesTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Simulate a module directory with a template subdir.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "templates"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmplPath := filepath.Join(dir, "templates", "demo.conf.tmpl")
|
||||
tmplBody := "name={{ .Values.name }}\npassword={{ .Values.password }}\nmissing={{ .Values.nope }}END\n"
|
||||
if err := os.WriteFile(tmplPath, []byte(tmplBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := &Manifest{
|
||||
ID: "demo",
|
||||
Name: "Demo",
|
||||
Dir: dir,
|
||||
ConfigFiles: []ConfigFile{
|
||||
{Path: "conf/demo.conf", Format: "kv", Template: "templates/demo.conf.tmpl"},
|
||||
},
|
||||
}
|
||||
|
||||
dataPath := filepath.Join(t.TempDir(), "instance-data")
|
||||
values := map[string]string{
|
||||
"name": "hello",
|
||||
"password": "s3cret",
|
||||
}
|
||||
if err := Render(m, dataPath, values); err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
out, err := os.ReadFile(filepath.Join(dataPath, "conf", "demo.conf"))
|
||||
if err != nil {
|
||||
t.Fatalf("read rendered: %v", err)
|
||||
}
|
||||
got := string(out)
|
||||
if !strings.Contains(got, "name=hello") {
|
||||
t.Errorf("missing 'name=hello' in %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "password=s3cret") {
|
||||
t.Errorf("missing 'password=s3cret' in %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "missing=END") {
|
||||
t.Errorf("missing-key didn't render as empty in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSkipsConfigFilesWithoutTemplate verifies a ConfigFile entry
|
||||
// without a Template is left alone (operator edits it directly, for example
|
||||
// via SFTP/file manager).
|
||||
func TestRenderSkipsConfigFilesWithoutTemplate(t *testing.T) {
|
||||
m := &Manifest{
|
||||
ID: "demo",
|
||||
Name: "Demo",
|
||||
Dir: t.TempDir(),
|
||||
ConfigFiles: []ConfigFile{
|
||||
{Path: "manual.cfg", Format: "kv"}, // no Template
|
||||
},
|
||||
}
|
||||
dataPath := filepath.Join(t.TempDir(), "instance-data")
|
||||
if err := Render(m, dataPath, nil); err != nil {
|
||||
t.Errorf("Render with no-template ConfigFile should not error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dataPath, "manual.cfg")); err == nil {
|
||||
t.Error("should not have written manual.cfg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSecretsUnique(t *testing.T) {
|
||||
m := &Manifest{
|
||||
Secrets: []Secret{
|
||||
{Name: "telnet_password", Generated: true},
|
||||
{Name: "admin_token", Generated: true},
|
||||
{Name: "unused_static", Generated: false},
|
||||
},
|
||||
}
|
||||
secrets, err := GenerateSecrets(m)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSecrets: %v", err)
|
||||
}
|
||||
if len(secrets) != 2 {
|
||||
t.Errorf("got %d secrets, want 2 (only Generated=true)", len(secrets))
|
||||
}
|
||||
if secrets["telnet_password"] == "" || secrets["admin_token"] == "" {
|
||||
t.Errorf("empty secret values: %+v", secrets)
|
||||
}
|
||||
if secrets["telnet_password"] == secrets["admin_token"] {
|
||||
t.Errorf("two secrets collided: %q", secrets["telnet_password"])
|
||||
}
|
||||
if _, ok := secrets["unused_static"]; ok {
|
||||
t.Errorf("non-generated secret should not be in output")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderAgainst7DTDTemplate is an integration smoke test that renders
|
||||
// the shipped 7 Days to Die template with a plausible value set and asserts
|
||||
// the resulting XML contains the expected property values.
|
||||
func TestRenderAgainst7DTDTemplate(t *testing.T) {
|
||||
m, err := LoadFile(filepath.Join("..", "..", "modules", "7dtd", "module.yaml"))
|
||||
if err != nil {
|
||||
t.Skipf("7dtd manifest not loadable: %v", err)
|
||||
}
|
||||
// The template path under the manifest may not exist yet during early
|
||||
// development. Skip cleanly if so.
|
||||
var tmplRef, outRel string
|
||||
for _, cf := range m.ConfigFiles {
|
||||
if cf.Template != "" {
|
||||
tmplRef = filepath.Join(m.Dir, cf.Template)
|
||||
outRel = cf.Path
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmplRef == "" {
|
||||
t.Skip("7dtd manifest has no config_files with templates")
|
||||
}
|
||||
if _, err := os.Stat(tmplRef); err != nil {
|
||||
t.Skipf("7dtd template not present: %v", err)
|
||||
}
|
||||
|
||||
dataPath := t.TempDir()
|
||||
values := map[string]string{
|
||||
"server_name": "Refuge Test",
|
||||
"telnet_password": "test-pw-123",
|
||||
"max_players": "16",
|
||||
"world_seed": "Navezgane",
|
||||
}
|
||||
if err := Render(m, dataPath, values); err != nil {
|
||||
t.Fatalf("Render 7dtd: %v", err)
|
||||
}
|
||||
out, err := os.ReadFile(filepath.Join(dataPath, outRel))
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
got := string(out)
|
||||
for _, needle := range []string{"Refuge Test", "test-pw-123", `value="16"`} {
|
||||
if !strings.Contains(got, needle) {
|
||||
t.Errorf("rendered serverconfig.xml missing %q; got:\n%s", needle, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BackupRef identifies one panel backup tarball for an instance.
|
||||
type BackupRef struct {
|
||||
Path string `json:"path"`
|
||||
ID string `json:"id,omitempty"` // "bkp_<hex>" parsed from the name
|
||||
CreatedAt time.Time `json:"created_at"` // parsed from the filename (UTC)
|
||||
}
|
||||
|
||||
// backupTimeLayout matches the panel backup filename timestamp
|
||||
// (time.Now().UTC().Format) — see agent/internal/dispatch/backup.go.
|
||||
const backupTimeLayout = "20060102-150405"
|
||||
|
||||
// ListPanelBackups reads an instance's backup directory
|
||||
// (<agent --backup-dir>/<instance_id>/) and returns the backups sorted newest
|
||||
// first, parsing the UTC timestamp + bkp id out of each filename:
|
||||
//
|
||||
// 20060102-150405-bkp_<hex>.tar.gz
|
||||
//
|
||||
// Files that don't match the pattern are skipped.
|
||||
func ListPanelBackups(instanceBackupDir string) ([]BackupRef, error) {
|
||||
ents, err := os.ReadDir(instanceBackupDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var refs []BackupRef
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".tar.gz") {
|
||||
continue
|
||||
}
|
||||
ref, ok := parseBackupName(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ref.Path = filepath.Join(instanceBackupDir, name)
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
sort.Slice(refs, func(i, j int) bool { return refs[i].CreatedAt.After(refs[j].CreatedAt) })
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// parseBackupName parses "20060102-150405-bkp_<hex>.tar.gz".
|
||||
func parseBackupName(name string) (BackupRef, bool) {
|
||||
base := strings.TrimSuffix(name, ".tar.gz")
|
||||
// timestamp is the first 15 chars: YYYYMMDD-HHMMSS
|
||||
if len(base) < len(backupTimeLayout) {
|
||||
return BackupRef{}, false
|
||||
}
|
||||
tsPart := base[:len(backupTimeLayout)]
|
||||
ts, err := time.Parse(backupTimeLayout, tsPart)
|
||||
if err != nil {
|
||||
return BackupRef{}, false
|
||||
}
|
||||
ref := BackupRef{CreatedAt: ts.UTC()}
|
||||
if i := strings.Index(base, "bkp_"); i >= 0 {
|
||||
ref.ID = base[i:]
|
||||
}
|
||||
return ref, true
|
||||
}
|
||||
|
||||
// ExtractRegionFromBackup pulls one region file out of a panel backup tarball.
|
||||
// The tar stores paths relative to the backup root, and a single backup may
|
||||
// contain SEVERAL worlds (e.g. ".../Saves/Navezgane/MyGame/Region/r.0.0.7rg"
|
||||
// AND ".../Saves/West Apeeni Mountains/MapRender/Region/r.0.0.7rg"). The caller
|
||||
// MUST therefore pass a world-qualified entry suffix — e.g.
|
||||
// "Saves/Navezgane/MyGame/Region/r.0.0.7rg" — so the correct world's region is
|
||||
// returned; a bare "r.0.0.7rg" would be ambiguous and could heal from the wrong
|
||||
// world. An entry matches when its cleaned path equals entry or ends with
|
||||
// "/"+entry. Returns ErrRegionNotInBackup if no entry matches.
|
||||
func ExtractRegionFromBackup(backupPath, entry string) ([]byte, error) {
|
||||
f, err := os.Open(backupPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
want := strings.TrimPrefix(entry, "/")
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tar: %w", err)
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
||||
continue
|
||||
}
|
||||
clean := strings.TrimPrefix(path.Clean(hdr.Name), "./")
|
||||
if clean == want || strings.HasSuffix(clean, "/"+want) {
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read entry: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrRegionNotInBackup, entry)
|
||||
}
|
||||
|
||||
// SaveRelEntry converts an absolute Region directory and a region filename into
|
||||
// the world-qualified backup entry suffix used by ExtractRegionFromBackup. A
|
||||
// regionDir of ".../Saves/Navezgane/MyGame/Region" + "r.0.0.7rg" yields
|
||||
// "Saves/Navezgane/MyGame/Region/r.0.0.7rg". If regionDir contains no "/Saves/"
|
||||
// segment it falls back to the (ambiguous) "Region/<name>".
|
||||
func SaveRelEntry(regionDir, regionFileName string) string {
|
||||
slashed := filepath.ToSlash(regionDir)
|
||||
if i := strings.Index(slashed, "/Saves/"); i >= 0 {
|
||||
return slashed[i+1:] + "/" + regionFileName // from "Saves/" onward
|
||||
}
|
||||
if strings.HasPrefix(slashed, "Saves/") {
|
||||
return slashed + "/" + regionFileName
|
||||
}
|
||||
return "Region/" + regionFileName
|
||||
}
|
||||
|
||||
// ErrRegionNotInBackup is returned when a backup doesn't contain the region.
|
||||
var ErrRegionNotInBackup = fmt.Errorf("region not found in backup")
|
||||
|
||||
// Candidate is a backup evaluated as a heal source for one region.
|
||||
type Candidate struct {
|
||||
Backup BackupRef `json:"backup"`
|
||||
Found bool `json:"found"` // region present in this backup
|
||||
Report RegionReport `json:"report"` // validation of the extracted region
|
||||
Bytes []byte `json:"-"` // extracted region bytes (best only)
|
||||
Note string `json:"note,omitempty"` // why skipped, if applicable
|
||||
}
|
||||
|
||||
// Clean reports whether this candidate's region image validated healthy.
|
||||
func (c Candidate) Clean() bool { return c.Found && c.Report.Healthy() }
|
||||
|
||||
// FindLastGoodRegion walks backups newest→oldest, extracts the region (using a
|
||||
// world-qualified entry suffix — see SaveRelEntry) from each and validates it,
|
||||
// and returns the newest CLEAN candidate (best.Found && best.Clean()). The full
|
||||
// evaluated list is returned for logging/auditing. Only the chosen best carries
|
||||
// Bytes (others are dropped to save memory). If no clean copy is found,
|
||||
// best.Found is false.
|
||||
//
|
||||
// backups should already be sorted newest-first (ListPanelBackups does this);
|
||||
// FindLastGoodRegion re-sorts defensively. An optional notAfter bound skips
|
||||
// backups created at/after it (use the corruption time to avoid trusting a
|
||||
// snapshot that already captured the damage); pass the zero time to disable.
|
||||
func FindLastGoodRegion(backups []BackupRef, entry string, deep bool, notAfter time.Time) (best Candidate, evaluated []Candidate, err error) {
|
||||
sorted := append([]BackupRef(nil), backups...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].CreatedAt.After(sorted[j].CreatedAt) })
|
||||
|
||||
for _, b := range sorted {
|
||||
c := Candidate{Backup: b}
|
||||
if !notAfter.IsZero() && !b.CreatedAt.Before(notAfter) {
|
||||
c.Note = "skipped: at/after corruption time"
|
||||
evaluated = append(evaluated, c)
|
||||
continue
|
||||
}
|
||||
data, exErr := ExtractRegionFromBackup(b.Path, entry)
|
||||
if exErr != nil {
|
||||
c.Note = exErr.Error()
|
||||
evaluated = append(evaluated, c)
|
||||
continue
|
||||
}
|
||||
c.Found = true
|
||||
c.Report = ValidateRegionBytes(data, deep)
|
||||
if c.Report.Healthy() && !best.Found {
|
||||
c.Bytes = data
|
||||
best = c
|
||||
}
|
||||
evaluated = append(evaluated, c)
|
||||
}
|
||||
return best, evaluated, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package regionmedic detects and surgically repairs corrupted 7 Days to Die
|
||||
// region (".7rg") files using the panel's own backups.
|
||||
//
|
||||
// Background
|
||||
//
|
||||
// 7DTD stores the world as a grid of 512×512-block "region" files, each
|
||||
// holding a 32×32 grid of chunks. When the server process dies mid-save (a
|
||||
// bare `docker stop` SIGKILL with no `saveworld`, an OOM kill, a crash) a
|
||||
// region file can be left with a torn chunk: the chunk's bytes no longer begin
|
||||
// with the expected header. On the next load 7DTD logs "Wrong chunk header!",
|
||||
// writes an `error_backup_<cx>_<cz>` salvage file, and drops the chunk — so a
|
||||
// base straddling the damaged region loses exactly the half that lived in it.
|
||||
//
|
||||
// This package reproduces, as deterministic code, the manual recovery that
|
||||
// works: localize the damaged region(s) from the error_backup files, find the
|
||||
// newest pre-corruption backup whose copy of that region validates clean,
|
||||
// snapshot the corrupt live file, swap the clean copy in (in place, preserving
|
||||
// permissions), and quarantine the error_backup salvage files.
|
||||
//
|
||||
// The .7rg on-disk format (reverse-engineered + verified byte-for-byte against
|
||||
// real region files, see RE notes in the repo history):
|
||||
//
|
||||
// Sector size = 4096 bytes; file size is always a multiple of 4096.
|
||||
// Region = 32×32 = 1024 chunks.
|
||||
// Sector 0 = header: magic "7rg"+0x01 at 0x000; bytes 0x004..0xFFF zero.
|
||||
// Sector 1 = location table at 0x1000: 1024 entries × 4 bytes, each
|
||||
// [3-byte little-endian sector offset][1-byte sector count];
|
||||
// an absent chunk is 0x00000000. Table index = localX + localZ*32
|
||||
// (X varies fastest). Chunk blob lives at sectorOffset*4096.
|
||||
// Chunk blob = [u32 LE blockLength][12 bytes zero][magic "ttc\0"]
|
||||
// [u32 LE const 47][raw DEFLATE stream].
|
||||
// blockLength = 8 + len(deflate); the on-disk blob occupies
|
||||
// 16 + blockLength bytes, rounded up to whole sectors (the
|
||||
// stored sector count may be one larger — allocator slack).
|
||||
// The decompressed payload begins int32 chunkX, int32 0,
|
||||
// int32 chunkZ.
|
||||
//
|
||||
// The "ttc\0" magic at blob offset 0x10 is the exact byte sequence 7DTD checks
|
||||
// when it logs "Wrong chunk header!", so it is both what Validate flags and the
|
||||
// minimal faithful corruption used by the test harness.
|
||||
//
|
||||
// Frozen API surface (do not break callers — the agent dispatch handler, the
|
||||
// region-medic CLI, and the unit tests all depend on these signatures):
|
||||
//
|
||||
// Validation: ValidateRegionBytes, ValidateRegionFile -> RegionReport
|
||||
// Localize: RegionCoord, RegionForChunk, ParseErrorBackup, ParseRegionFileName
|
||||
// Scan: ScanRegionDir -> RegionDirScan / AffectedRegion
|
||||
// Backups: BackupRef, ListPanelBackups, ExtractRegionFromBackup,
|
||||
// FindLastGoodRegion -> Candidate
|
||||
// Heal: PlanHeal -> HealPlan; ApplyHeal -> HealResult
|
||||
//
|
||||
// Everything here is pure (filesystem + archive/tar + compress/flate, stdlib
|
||||
// only). It performs no Docker or network actions; orchestration (graceful
|
||||
// save, stop, start, log-watch) is the caller's job, which keeps the decision
|
||||
// logic fully unit-testable.
|
||||
package regionmedic
|
||||
@@ -0,0 +1,193 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealPlan is a dry-run description of a single-region heal: which live file
|
||||
// would be overwritten, from which backup, and which salvage files quarantined.
|
||||
type HealPlan struct {
|
||||
Region RegionCoord `json:"region"`
|
||||
RegionDir string `json:"region_dir"`
|
||||
RegionFile string `json:"region_file"` // live path to overwrite
|
||||
LiveReport RegionReport `json:"live_report"` // validation of the current live file
|
||||
Source Candidate `json:"source"` // chosen backup (Bytes populated)
|
||||
ErrorBackups []string `json:"error_backups"` // salvage files to quarantine
|
||||
Evaluated []Candidate `json:"evaluated"` // all backups considered (audit trail)
|
||||
}
|
||||
|
||||
// Ready reports whether the plan can be applied: a clean source was found and
|
||||
// the live file path is known.
|
||||
func (p HealPlan) Ready() bool { return p.Source.Found && p.Source.Clean() && p.RegionFile != "" }
|
||||
|
||||
// HealResult records what ApplyHeal did.
|
||||
type HealResult struct {
|
||||
Region RegionCoord `json:"region"`
|
||||
RegionFile string `json:"region_file"`
|
||||
SnapshotPath string `json:"snapshot_path"` // corrupt original saved here
|
||||
SourceBackup string `json:"source_backup"` // backup the clean copy came from
|
||||
BytesWritten int `json:"bytes_written"`
|
||||
QuarantinedFiles []string `json:"quarantined_files"`
|
||||
}
|
||||
|
||||
// PlanHeal builds a heal plan for one region: it validates the current live
|
||||
// region file, lists the error_backup salvage files for that region, and picks
|
||||
// the newest clean backup copy via FindLastGoodRegion.
|
||||
//
|
||||
// - regionDir: the instance's active Save Region/ directory.
|
||||
// - region: which region to heal.
|
||||
// - backups: candidate panel backups (newest-first or any order).
|
||||
// - deep: deep-validate (inflate + coord check) backup candidates.
|
||||
// - notAfter: skip backups at/after this time (corruption time); zero disables.
|
||||
func PlanHeal(regionDir string, region RegionCoord, backups []BackupRef, deep bool, notAfter time.Time) (HealPlan, error) {
|
||||
plan := HealPlan{Region: region, RegionDir: regionDir}
|
||||
plan.RegionFile = filepath.Join(regionDir, region.FileName())
|
||||
if st, err := os.Stat(plan.RegionFile); err != nil || st.IsDir() {
|
||||
// No live file is not fatal for planning, but record it.
|
||||
plan.LiveReport = RegionReport{Path: plan.RegionFile, Err: "live region file missing"}
|
||||
plan.RegionFile = ""
|
||||
} else {
|
||||
plan.LiveReport = ValidateRegionFile(filepath.Join(regionDir, region.FileName()), deep)
|
||||
}
|
||||
|
||||
// Collect error_backup salvage files belonging to this region.
|
||||
if ents, err := os.ReadDir(regionDir); err == nil {
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
cx, cz, ok := ParseErrorBackup(e.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if RegionForChunk(cx, cz) == region {
|
||||
plan.ErrorBackups = append(plan.ErrorBackups, filepath.Join(regionDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry := SaveRelEntry(regionDir, region.FileName())
|
||||
best, evaluated, err := FindLastGoodRegion(backups, entry, deep, notAfter)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
plan.Source = best
|
||||
plan.Evaluated = evaluated
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// ApplyHeal executes a ready plan:
|
||||
// 1. snapshots the current (corrupt) live region file into snapshotDir,
|
||||
// 2. overwrites the live region file in place with the clean source bytes
|
||||
// (truncate-in-place preserves the file's owner/mode/ACLs),
|
||||
// 3. moves the region's error_backup salvage files into quarantineDir.
|
||||
//
|
||||
// snapshotDir and quarantineDir are created if missing. Moves fall back to
|
||||
// copy+remove when rename crosses a filesystem boundary. ApplyHeal refuses a
|
||||
// plan that is not Ready().
|
||||
func ApplyHeal(plan HealPlan, snapshotDir, quarantineDir string) (HealResult, error) {
|
||||
var res HealResult
|
||||
if !plan.Ready() {
|
||||
return res, fmt.Errorf("plan not ready: no clean source or missing live region file")
|
||||
}
|
||||
res.Region = plan.Region
|
||||
res.RegionFile = plan.RegionFile
|
||||
res.SourceBackup = plan.Source.Backup.Path
|
||||
|
||||
if err := os.MkdirAll(snapshotDir, 0o755); err != nil {
|
||||
return res, fmt.Errorf("mkdir snapshot dir: %w", err)
|
||||
}
|
||||
// 1. Snapshot the corrupt original.
|
||||
stamp := plan.LiveReport.timeStamp()
|
||||
res.SnapshotPath = filepath.Join(snapshotDir, plan.Region.FileName()+".corrupt-"+stamp)
|
||||
if err := copyFile(plan.RegionFile, res.SnapshotPath); err != nil {
|
||||
return res, fmt.Errorf("snapshot corrupt region: %w", err)
|
||||
}
|
||||
|
||||
// 2. Overwrite in place (O_TRUNC keeps the inode's owner/mode/ACL).
|
||||
if err := writeInPlace(plan.RegionFile, plan.Source.Bytes); err != nil {
|
||||
return res, fmt.Errorf("write clean region: %w", err)
|
||||
}
|
||||
res.BytesWritten = len(plan.Source.Bytes)
|
||||
|
||||
// 3. Quarantine the salvage files (best-effort per file, but report errors).
|
||||
if len(plan.ErrorBackups) > 0 {
|
||||
if err := os.MkdirAll(quarantineDir, 0o755); err != nil {
|
||||
return res, fmt.Errorf("mkdir quarantine dir: %w", err)
|
||||
}
|
||||
for _, src := range plan.ErrorBackups {
|
||||
dst := filepath.Join(quarantineDir, filepath.Base(src))
|
||||
if err := moveFile(src, dst); err != nil {
|
||||
return res, fmt.Errorf("quarantine %s: %w", filepath.Base(src), err)
|
||||
}
|
||||
res.QuarantinedFiles = append(res.QuarantinedFiles, dst)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// timeStamp returns a filesystem-safe stamp; uses the report path's modtime is
|
||||
// not available here, so callers get a coarse unique-ish suffix. We avoid
|
||||
// time.Now in the pure package only inside tests; here a stamp is fine.
|
||||
func (RegionReport) timeStamp() string { return time.Now().UTC().Format("20060102-150405") }
|
||||
|
||||
// writeInPlace truncates an existing file and writes data, preserving the
|
||||
// file's existing owner/permissions/ACL (no create-new). If the file does not
|
||||
// exist it is created 0o664.
|
||||
func writeInPlace(path string, data []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
f, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o664)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// copyFile copies src to dst (content only), creating dst.
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// moveFile renames src to dst, falling back to copy+remove across filesystems.
|
||||
func moveFile(src, dst string) error {
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(src)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// floorMod returns the mathematical (floored) modulo, always in [0, m).
|
||||
func floorMod(a, m int) int { return ((a % m) + m) % m }
|
||||
|
||||
// floorDiv returns the floored integer division (rounds toward -inf).
|
||||
func floorDiv(a, m int) int {
|
||||
q := a / m
|
||||
if (a%m != 0) && ((a < 0) != (m < 0)) {
|
||||
q--
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// RegionCoord identifies a region file r.<X>.<Z>.7rg in the 32×32-chunk grid.
|
||||
type RegionCoord struct {
|
||||
X int `json:"x"`
|
||||
Z int `json:"z"`
|
||||
}
|
||||
|
||||
// FileName returns the on-disk region filename, e.g. "r.-2.0.7rg".
|
||||
func (rc RegionCoord) FileName() string { return fmt.Sprintf("r.%d.%d.7rg", rc.X, rc.Z) }
|
||||
|
||||
// String returns the canonical region id, e.g. "r.-2.0".
|
||||
func (rc RegionCoord) String() string { return fmt.Sprintf("r.%d.%d", rc.X, rc.Z) }
|
||||
|
||||
// RegionForChunk maps a global chunk coordinate to its region (floored /32).
|
||||
func RegionForChunk(cx, cz int) RegionCoord {
|
||||
return RegionCoord{X: floorDiv(cx, RegionSide), Z: floorDiv(cz, RegionSide)}
|
||||
}
|
||||
|
||||
// ParseRegionFileName parses "r.<X>.<Z>.7rg" -> RegionCoord. ok=false otherwise.
|
||||
// Handles negative coordinates, e.g. "r.-2.0.7rg" -> {X:-2, Z:0}.
|
||||
func ParseRegionFileName(name string) (RegionCoord, bool) {
|
||||
if !strings.HasPrefix(name, "r.") || !strings.HasSuffix(name, ".7rg") {
|
||||
return RegionCoord{}, false
|
||||
}
|
||||
mid := strings.TrimSuffix(strings.TrimPrefix(name, "r."), ".7rg")
|
||||
// mid is "<X>.<Z>" where X and Z may be negative. Split on the dot that
|
||||
// separates them: there is exactly one '.' between two integers.
|
||||
x, z, ok := splitTwoInts(mid)
|
||||
if !ok {
|
||||
return RegionCoord{}, false
|
||||
}
|
||||
return RegionCoord{X: x, Z: z}, true
|
||||
}
|
||||
|
||||
// ParseErrorBackup parses 7DTD chunk-salvage filenames written on a failed load:
|
||||
//
|
||||
// error_backup_<cx>_<cz>.comp.bak
|
||||
// error_backup_<cx>_<cz>.uncomp.bak
|
||||
//
|
||||
// returning the global chunk coordinates. ok=false if name is not such a file.
|
||||
func ParseErrorBackup(name string) (cx, cz int, ok bool) {
|
||||
const pfx = "error_backup_"
|
||||
if !strings.HasPrefix(name, pfx) {
|
||||
return 0, 0, false
|
||||
}
|
||||
rest := name[len(pfx):]
|
||||
// Strip the known suffixes.
|
||||
switch {
|
||||
case strings.HasSuffix(rest, ".comp.bak"):
|
||||
rest = strings.TrimSuffix(rest, ".comp.bak")
|
||||
case strings.HasSuffix(rest, ".uncomp.bak"):
|
||||
rest = strings.TrimSuffix(rest, ".uncomp.bak")
|
||||
default:
|
||||
return 0, 0, false
|
||||
}
|
||||
// rest is "<cx>_<cz>" with possibly-negative ints separated by '_'.
|
||||
i := strings.LastIndex(rest, "_")
|
||||
if i <= 0 || i >= len(rest)-1 {
|
||||
return 0, 0, false
|
||||
}
|
||||
a, err1 := strconv.Atoi(rest[:i])
|
||||
b, err2 := strconv.Atoi(rest[i+1:])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return a, b, true
|
||||
}
|
||||
|
||||
// splitTwoInts splits "<a>.<b>" into two ints where each may be negative.
|
||||
// The separator is the first '.' that is not a leading minus sign position.
|
||||
func splitTwoInts(s string) (int, int, bool) {
|
||||
// Find a '.' that splits into two parseable ints. Scan candidate dots.
|
||||
for i := 1; i < len(s); i++ {
|
||||
if s[i] != '.' {
|
||||
continue
|
||||
}
|
||||
a, err1 := strconv.Atoi(s[:i])
|
||||
b, err2 := strconv.Atoi(s[i+1:])
|
||||
if err1 == nil && err2 == nil {
|
||||
return a, b, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- fixture builders -------------------------------------------------------
|
||||
|
||||
func deflateBytes(b []byte) []byte {
|
||||
var buf bytes.Buffer
|
||||
w, _ := flate.NewWriter(&buf, flate.DefaultCompression)
|
||||
_, _ = w.Write(b)
|
||||
_ = w.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// buildRegion synthesizes a valid .7rg image for the given region coordinate
|
||||
// with the listed chunk indices present. Each chunk's decompressed payload
|
||||
// carries its correct GLOBAL coords so deep validation passes.
|
||||
func buildRegion(region RegionCoord, present []int) []byte {
|
||||
header := make([]byte, SectorSize)
|
||||
copy(header, regionMagic)
|
||||
loctable := make([]byte, SectorSize)
|
||||
var data []byte
|
||||
nextSector := headerSectors // first data sector index = 2
|
||||
|
||||
for _, idx := range present {
|
||||
lx, lz := idx%RegionSide, idx/RegionSide
|
||||
gx := region.X*RegionSide + lx
|
||||
gz := region.Z*RegionSide + lz
|
||||
payload := make([]byte, 16)
|
||||
binary.LittleEndian.PutUint32(payload[0:4], uint32(int32(gx)))
|
||||
binary.LittleEndian.PutUint32(payload[8:12], uint32(int32(gz)))
|
||||
payload[12] = 0xAB // arbitrary body
|
||||
comp := deflateBytes(payload)
|
||||
blockLen := 8 + len(comp)
|
||||
need := (24 + len(comp) + SectorSize - 1) / SectorSize
|
||||
blob := make([]byte, need*SectorSize)
|
||||
binary.LittleEndian.PutUint32(blob[0:4], uint32(blockLen))
|
||||
copy(blob[16:20], chunkMagic)
|
||||
binary.LittleEndian.PutUint32(blob[20:24], uint32(chunkConstVal))
|
||||
copy(blob[24:], comp)
|
||||
|
||||
off := nextSector
|
||||
e := loctable[idx*4 : idx*4+4]
|
||||
e[0] = byte(off & 0xff)
|
||||
e[1] = byte((off >> 8) & 0xff)
|
||||
e[2] = byte((off >> 16) & 0xff)
|
||||
e[3] = byte(need)
|
||||
data = append(data, blob...)
|
||||
nextSector += need
|
||||
}
|
||||
out := append(append(append([]byte{}, header...), loctable...), data...)
|
||||
return out
|
||||
}
|
||||
|
||||
// chunkBlobOffset returns the file byte offset of the "ttc" magic for a present
|
||||
// chunk index — i.e. the exact byte the corruption recipe overwrites.
|
||||
func chunkMagicOffset(data []byte, idx int) int {
|
||||
e := data[locTableOffset+idx*4 : locTableOffset+idx*4+4]
|
||||
off := int(e[0]) | int(e[1])<<8 | int(e[2])<<16
|
||||
return off*SectorSize + 16
|
||||
}
|
||||
|
||||
// --- validation tests -------------------------------------------------------
|
||||
|
||||
func TestValidateGoodRegionOrigin(t *testing.T) {
|
||||
data := buildRegion(RegionCoord{0, 0}, []int{0, 1, 33, 1023})
|
||||
rep := ValidateRegionBytes(data, true)
|
||||
if !rep.Healthy() {
|
||||
t.Fatalf("expected healthy, got err=%q bad=%+v", rep.Err, rep.BadChunks)
|
||||
}
|
||||
if rep.PresentChunks != 4 {
|
||||
t.Fatalf("present=%d want 4", rep.PresentChunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGoodRegionNegative(t *testing.T) {
|
||||
// Region r.-2.0 exercises floored-modulo coord checking for negative globals.
|
||||
data := buildRegion(RegionCoord{-2, 0}, []int{0, 1, 31, 500})
|
||||
rep := ValidateRegionBytes(data, true)
|
||||
if !rep.Healthy() {
|
||||
t.Fatalf("expected healthy negative region, got err=%q bad=%+v", rep.Err, rep.BadChunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongChunkHeaderDetected(t *testing.T) {
|
||||
data := buildRegion(RegionCoord{0, 0}, []int{0, 5})
|
||||
// Faithful corruption: clobber the "ttc" magic of chunk index 5.
|
||||
off := chunkMagicOffset(data, 5)
|
||||
data[off] = 0x00 // 't' -> 0x00
|
||||
rep := ValidateRegionBytes(data, true)
|
||||
if rep.Healthy() {
|
||||
t.Fatal("expected corruption to be detected")
|
||||
}
|
||||
if len(rep.BadChunks) != 1 || rep.BadChunks[0].Index != 5 || rep.BadChunks[0].Reason != ReasonWrongHeader {
|
||||
t.Fatalf("want one wrong-header bad chunk idx5, got %+v", rep.BadChunks)
|
||||
}
|
||||
// The other chunk (0) must still be fine and counted present.
|
||||
if rep.PresentChunks != 2 {
|
||||
t.Fatalf("present=%d want 2", rep.PresentChunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadMagic(t *testing.T) {
|
||||
data := buildRegion(RegionCoord{0, 0}, []int{0})
|
||||
data[1] = 'X'
|
||||
if ValidateRegionBytes(data, false).Err == "" {
|
||||
t.Fatal("expected fatal err on bad magic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonSectorAligned(t *testing.T) {
|
||||
if ValidateRegionBytes(make([]byte, 5000), false).Err == "" {
|
||||
t.Fatal("expected fatal err on non-aligned size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutOfBoundsEntry(t *testing.T) {
|
||||
data := buildRegion(RegionCoord{0, 0}, []int{0})
|
||||
// Point chunk index 1's entry far past EOF.
|
||||
e := data[locTableOffset+1*4 : locTableOffset+1*4+4]
|
||||
e[0], e[1], e[2], e[3] = 0xFF, 0xFF, 0x00, 1 // offset 0xFFFF sectors
|
||||
rep := ValidateRegionBytes(data, false)
|
||||
if rep.Healthy() || len(rep.BadChunks) != 1 || rep.BadChunks[0].Reason != ReasonOutOfBounds {
|
||||
t.Fatalf("want out-of-bounds bad chunk, got err=%q bad=%+v", rep.Err, rep.BadChunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlapDetected(t *testing.T) {
|
||||
data := buildRegion(RegionCoord{0, 0}, []int{0, 1})
|
||||
// Make chunk 1's entry point at chunk 0's sector (overlap).
|
||||
e0 := data[locTableOffset : locTableOffset+4]
|
||||
e1 := data[locTableOffset+4 : locTableOffset+8]
|
||||
copy(e1, e0)
|
||||
rep := ValidateRegionBytes(data, false)
|
||||
foundOverlap := false
|
||||
for _, bc := range rep.BadChunks {
|
||||
if bc.Reason == ReasonOverlap {
|
||||
foundOverlap = true
|
||||
}
|
||||
}
|
||||
if !foundOverlap {
|
||||
t.Fatalf("expected overlap detection, got %+v", rep.BadChunks)
|
||||
}
|
||||
}
|
||||
|
||||
// --- localize tests ---------------------------------------------------------
|
||||
|
||||
func TestParseErrorBackup(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cx, cz int
|
||||
ok bool
|
||||
}{
|
||||
{"error_backup_-28_12.comp.bak", -28, 12, true},
|
||||
{"error_backup_-29_-13.uncomp.bak", -29, -13, true},
|
||||
{"error_backup_5_7.comp.bak", 5, 7, true},
|
||||
{"r.0.0.7rg", 0, 0, false},
|
||||
{"error_backup_5_7.txt", 0, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cx, cz, ok := ParseErrorBackup(c.name)
|
||||
if ok != c.ok || (ok && (cx != c.cx || cz != c.cz)) {
|
||||
t.Errorf("%s -> (%d,%d,%v) want (%d,%d,%v)", c.name, cx, cz, ok, c.cx, c.cz, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRegionFileName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rc RegionCoord
|
||||
ok bool
|
||||
}{
|
||||
{"r.-2.0.7rg", RegionCoord{-2, 0}, true},
|
||||
{"r.10.-5.7rg", RegionCoord{10, -5}, true},
|
||||
{"r.0.0.7rg", RegionCoord{0, 0}, true},
|
||||
{"error_backup_5_7.comp.bak", RegionCoord{}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rc, ok := ParseRegionFileName(c.name)
|
||||
if ok != c.ok || (ok && rc != c.rc) {
|
||||
t.Errorf("%s -> (%v,%v) want (%v,%v)", c.name, rc, ok, c.rc, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegionForChunk(t *testing.T) {
|
||||
cases := []struct {
|
||||
cx, cz int
|
||||
rc RegionCoord
|
||||
}{
|
||||
{0, 0, RegionCoord{0, 0}},
|
||||
{31, 31, RegionCoord{0, 0}},
|
||||
{-1, -1, RegionCoord{-1, -1}},
|
||||
{-32, 0, RegionCoord{-1, 0}},
|
||||
{-33, 12, RegionCoord{-2, 0}},
|
||||
{-64, 0, RegionCoord{-2, 0}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := RegionForChunk(c.cx, c.cz); got != c.rc {
|
||||
t.Errorf("RegionForChunk(%d,%d)=%v want %v", c.cx, c.cz, got, c.rc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- scan test --------------------------------------------------------------
|
||||
|
||||
func TestScanRegionDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 3 salvage files in r.-2.0 (chunks -33..-64 / 0..31), 1 in r.2.1.
|
||||
writeEmpty(t, dir, "error_backup_-33_12.comp.bak")
|
||||
writeEmpty(t, dir, "error_backup_-33_12.uncomp.bak") // same chunk, dedup
|
||||
writeEmpty(t, dir, "error_backup_-40_20.comp.bak")
|
||||
writeEmpty(t, dir, "error_backup_70_40.comp.bak") // region r.2.1
|
||||
// a live region file for r.-2.0
|
||||
os.WriteFile(filepath.Join(dir, "r.-2.0.7rg"), buildRegion(RegionCoord{-2, 0}, []int{0}), 0o644)
|
||||
|
||||
scan, err := ScanRegionDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scan.ErrorBackups != 4 {
|
||||
t.Errorf("error_backups=%d want 4", scan.ErrorBackups)
|
||||
}
|
||||
if len(scan.Affected) != 2 {
|
||||
t.Fatalf("affected=%d want 2", len(scan.Affected))
|
||||
}
|
||||
top := scan.Affected[0]
|
||||
if top.Region != (RegionCoord{-2, 0}) || top.ErrorBackups != 3 {
|
||||
t.Errorf("top affected=%v files=%d want r.-2.0 files=3", top.Region, top.ErrorBackups)
|
||||
}
|
||||
if len(top.Chunks) != 2 {
|
||||
t.Errorf("distinct chunks=%d want 2", len(top.Chunks))
|
||||
}
|
||||
if !top.FilePresent {
|
||||
t.Error("expected r.-2.0 live file present")
|
||||
}
|
||||
}
|
||||
|
||||
// --- backup extract + find-last-good ---------------------------------------
|
||||
|
||||
const navSave = "Saves/Navezgane/MyGame/Region"
|
||||
|
||||
// writeBackupTar writes a tarball whose Navezgane Region/<regionName> entry is
|
||||
// regionBytes. If decoyBytes is non-nil it ALSO writes a second world
|
||||
// (West Apeeni Mountains/MapRender) with the SAME region filename but different
|
||||
// bytes — to prove world-qualified extraction picks the right one.
|
||||
func writeBackupTar(t *testing.T, dir, name, regionName string, regionBytes, decoyBytes []byte) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
f, err := os.Create(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
gw := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gw)
|
||||
add := func(entry string, b []byte) {
|
||||
tw.WriteHeader(&tar.Header{Name: entry, Mode: 0o644, Size: int64(len(b)), Typeflag: tar.TypeReg})
|
||||
tw.Write(b)
|
||||
}
|
||||
if decoyBytes != nil {
|
||||
// Decoy world FIRST in tar order, so a naive bare-name match would grab it.
|
||||
add(".local/share/7DaysToDie/Saves/West Apeeni Mountains/MapRender/Region/"+regionName, decoyBytes)
|
||||
}
|
||||
if regionBytes != nil {
|
||||
add(".local/share/7DaysToDie/"+navSave+"/"+regionName, regionBytes)
|
||||
}
|
||||
add("serverconfig.xml", []byte("xml"))
|
||||
tw.Close()
|
||||
gw.Close()
|
||||
return p
|
||||
}
|
||||
|
||||
func TestExtractRegionFromBackup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
region := buildRegion(RegionCoord{0, 0}, []int{0, 1})
|
||||
decoy := buildRegion(RegionCoord{0, 0}, []int{0, 1, 2, 3}) // different bytes
|
||||
p := writeBackupTar(t, dir, "20260101-120000-bkp_aaaa.tar.gz", "r.0.0.7rg", region, decoy)
|
||||
|
||||
// World-qualified entry must return the Navezgane copy, NOT the decoy.
|
||||
got, err := ExtractRegionFromBackup(p, navSave+"/r.0.0.7rg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, region) {
|
||||
t.Fatal("world-qualified extract returned wrong world's region")
|
||||
}
|
||||
if bytes.Equal(got, decoy) {
|
||||
t.Fatal("extracted the decoy world's region — disambiguation failed")
|
||||
}
|
||||
if _, err := ExtractRegionFromBackup(p, navSave+"/r.9.9.7rg"); err == nil {
|
||||
t.Fatal("expected not-found error for absent region")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRelEntry(t *testing.T) {
|
||||
got := SaveRelEntry("/var/lib/docker/volumes/x/_data/.local/share/7DaysToDie/Saves/Navezgane/MyGame/Region", "r.-2.0.7rg")
|
||||
if got != "Saves/Navezgane/MyGame/Region/r.-2.0.7rg" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindLastGoodRegion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
region := RegionCoord{-2, 0}
|
||||
good := buildRegion(region, []int{0, 1, 2})
|
||||
corrupt := buildRegion(region, []int{0, 1, 2})
|
||||
corrupt[chunkMagicOffset(corrupt, 1)] = 0x00 // break chunk 1
|
||||
|
||||
// oldest clean, middle clean, newest corrupt (each also carries a decoy world)
|
||||
writeBackupTar(t, dir, "20260101-010000-bkp_old.tar.gz", region.FileName(), good, good)
|
||||
writeBackupTar(t, dir, "20260102-010000-bkp_mid.tar.gz", region.FileName(), good, good)
|
||||
writeBackupTar(t, dir, "20260103-010000-bkp_new.tar.gz", region.FileName(), corrupt, good)
|
||||
|
||||
refs, err := ListPanelBackups(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(refs) != 3 {
|
||||
t.Fatalf("refs=%d want 3", len(refs))
|
||||
}
|
||||
// newest first
|
||||
if !refs[0].CreatedAt.After(refs[1].CreatedAt) {
|
||||
t.Fatal("backups not sorted newest-first")
|
||||
}
|
||||
|
||||
best, evaluated, err := FindLastGoodRegion(refs, navSave+"/"+region.FileName(), true, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !best.Found || !best.Clean() {
|
||||
t.Fatalf("expected a clean best, got found=%v clean=%v", best.Found, best.Clean())
|
||||
}
|
||||
// Best must be the middle (newest CLEAN), not the corrupt newest.
|
||||
if best.Backup.ID != "bkp_mid" {
|
||||
t.Fatalf("best=%s want bkp_mid (newest clean)", best.Backup.ID)
|
||||
}
|
||||
if len(evaluated) != 3 {
|
||||
t.Fatalf("evaluated=%d want 3", len(evaluated))
|
||||
}
|
||||
}
|
||||
|
||||
// --- full plan + apply heal -------------------------------------------------
|
||||
|
||||
func TestPlanAndApplyHeal(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
// Realistic region dir so SaveRelEntry yields a world-qualified suffix.
|
||||
regionDir := filepath.Join(base, ".local/share/7DaysToDie", navSave)
|
||||
backupDir := filepath.Join(base, "backups")
|
||||
os.MkdirAll(regionDir, 0o755)
|
||||
os.MkdirAll(backupDir, 0o755)
|
||||
|
||||
region := RegionCoord{-2, 0}
|
||||
clean := buildRegion(region, []int{0, 1, 2, 33})
|
||||
|
||||
// Live (corrupt) region file: break one chunk header.
|
||||
corrupt := append([]byte{}, clean...)
|
||||
corrupt[chunkMagicOffset(corrupt, 2)] = 0x00
|
||||
liveFile := filepath.Join(regionDir, region.FileName())
|
||||
if err := os.WriteFile(liveFile, corrupt, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Salvage files for this region.
|
||||
writeEmpty(t, regionDir, "error_backup_-64_0.comp.bak")
|
||||
writeEmpty(t, regionDir, "error_backup_-64_0.uncomp.bak")
|
||||
|
||||
// A clean backup containing the good region (plus a decoy world that must
|
||||
// NOT be chosen).
|
||||
decoy := buildRegion(RegionCoord{99, 99}, []int{0})
|
||||
writeBackupTar(t, backupDir, "20260101-120000-bkp_clean.tar.gz", region.FileName(), clean, decoy)
|
||||
|
||||
refs, _ := ListPanelBackups(backupDir)
|
||||
plan, err := PlanHeal(regionDir, region, refs, true, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plan.LiveReport.Healthy() {
|
||||
t.Fatal("expected live report to flag corruption")
|
||||
}
|
||||
if !plan.Ready() {
|
||||
t.Fatalf("plan not ready: source found=%v clean=%v file=%q", plan.Source.Found, plan.Source.Clean(), plan.RegionFile)
|
||||
}
|
||||
if len(plan.ErrorBackups) != 2 {
|
||||
t.Fatalf("error backups in plan=%d want 2", len(plan.ErrorBackups))
|
||||
}
|
||||
|
||||
snapDir := filepath.Join(base, "snap")
|
||||
qDir := filepath.Join(base, "quarantine")
|
||||
res, err := ApplyHeal(plan, snapDir, qDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Live file now equals the clean bytes.
|
||||
healed, _ := os.ReadFile(liveFile)
|
||||
if sha256.Sum256(healed) != sha256.Sum256(clean) {
|
||||
t.Fatal("healed live file does not match clean source")
|
||||
}
|
||||
if !ValidateRegionFile(liveFile, true).Healthy() {
|
||||
t.Fatal("healed file failed revalidation")
|
||||
}
|
||||
// Snapshot holds the corrupt original.
|
||||
snap, _ := os.ReadFile(res.SnapshotPath)
|
||||
if sha256.Sum256(snap) != sha256.Sum256(corrupt) {
|
||||
t.Fatal("snapshot does not match the corrupt original")
|
||||
}
|
||||
// Salvage files moved out of the region dir.
|
||||
if _, err := os.Stat(filepath.Join(regionDir, "error_backup_-64_0.comp.bak")); !os.IsNotExist(err) {
|
||||
t.Fatal("error_backup still present in region dir after heal")
|
||||
}
|
||||
if len(res.QuarantinedFiles) != 2 {
|
||||
t.Fatalf("quarantined=%d want 2", len(res.QuarantinedFiles))
|
||||
}
|
||||
}
|
||||
|
||||
func writeEmpty(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte{}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ChunkCoord is a global chunk coordinate.
|
||||
type ChunkCoord struct {
|
||||
X int `json:"x"`
|
||||
Z int `json:"z"`
|
||||
}
|
||||
|
||||
// AffectedRegion is one region with corruption evidence (error_backup salvage
|
||||
// files whose chunks fall inside it), plus whether its live region file exists.
|
||||
type AffectedRegion struct {
|
||||
Region RegionCoord `json:"region"`
|
||||
ErrorBackups int `json:"error_backups"`
|
||||
Chunks []ChunkCoord `json:"chunks,omitempty"`
|
||||
RegionFile string `json:"region_file,omitempty"` // absolute path if present
|
||||
FilePresent bool `json:"file_present"`
|
||||
}
|
||||
|
||||
// RegionDirScan is the result of scanning a 7DTD save Region/ directory.
|
||||
type RegionDirScan struct {
|
||||
Dir string `json:"dir"`
|
||||
ErrorBackups int `json:"error_backups"` // total salvage files
|
||||
ErrorBackupDirs int `json:"error_backup_chunks"` // distinct chunks
|
||||
Affected []AffectedRegion `json:"affected"` // sorted by count desc
|
||||
}
|
||||
|
||||
// ScanRegionDir lists error_backup_* salvage files in dir, maps each to its
|
||||
// region, and clusters them. The returned Affected slice is sorted by
|
||||
// ErrorBackups descending (most-damaged region first). A region appears only if
|
||||
// it has at least one error_backup chunk.
|
||||
func ScanRegionDir(dir string) (RegionDirScan, error) {
|
||||
out := RegionDirScan{Dir: dir}
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// region -> set of distinct chunk coords (dedup .comp/.uncomp pairs)
|
||||
type acc struct {
|
||||
files int
|
||||
chunks map[ChunkCoord]struct{}
|
||||
}
|
||||
clusters := map[RegionCoord]*acc{}
|
||||
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
cx, cz, ok := ParseErrorBackup(e.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out.ErrorBackups++
|
||||
rc := RegionForChunk(cx, cz)
|
||||
a := clusters[rc]
|
||||
if a == nil {
|
||||
a = &acc{chunks: map[ChunkCoord]struct{}{}}
|
||||
clusters[rc] = a
|
||||
}
|
||||
a.files++
|
||||
a.chunks[ChunkCoord{X: cx, Z: cz}] = struct{}{}
|
||||
}
|
||||
|
||||
for rc, a := range clusters {
|
||||
ar := AffectedRegion{Region: rc, ErrorBackups: a.files}
|
||||
for c := range a.chunks {
|
||||
ar.Chunks = append(ar.Chunks, c)
|
||||
}
|
||||
sort.Slice(ar.Chunks, func(i, j int) bool {
|
||||
if ar.Chunks[i].X != ar.Chunks[j].X {
|
||||
return ar.Chunks[i].X < ar.Chunks[j].X
|
||||
}
|
||||
return ar.Chunks[i].Z < ar.Chunks[j].Z
|
||||
})
|
||||
out.ErrorBackupDirs += len(ar.Chunks)
|
||||
rf := filepath.Join(dir, rc.FileName())
|
||||
if st, err := os.Stat(rf); err == nil && !st.IsDir() {
|
||||
ar.RegionFile = rf
|
||||
ar.FilePresent = true
|
||||
}
|
||||
out.Affected = append(out.Affected, ar)
|
||||
}
|
||||
|
||||
sort.Slice(out.Affected, func(i, j int) bool {
|
||||
if out.Affected[i].ErrorBackups != out.Affected[j].ErrorBackups {
|
||||
return out.Affected[i].ErrorBackups > out.Affected[j].ErrorBackups
|
||||
}
|
||||
// stable tiebreak by region id
|
||||
if out.Affected[i].Region.X != out.Affected[j].Region.X {
|
||||
return out.Affected[i].Region.X < out.Affected[j].Region.X
|
||||
}
|
||||
return out.Affected[i].Region.Z < out.Affected[j].Region.Z
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Format constants for the 7DTD ".7rg" region file.
|
||||
const (
|
||||
SectorSize = 4096
|
||||
RegionChunks = 1024 // 32 x 32
|
||||
RegionSide = 32
|
||||
headerSectors = 2 // sector 0 = header, sector 1 = location table
|
||||
locTableOffset = 1 * SectorSize
|
||||
chunkConstVal = 47
|
||||
)
|
||||
|
||||
var (
|
||||
regionMagic = []byte{0x37, 0x72, 0x67, 0x01} // "7rg" + version 1
|
||||
chunkMagic = []byte{0x74, 0x74, 0x63, 0x00} // "ttc\0"
|
||||
)
|
||||
|
||||
// RegionReport is the outcome of validating a single .7rg image.
|
||||
type RegionReport struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
TotalSectors int `json:"total_sectors"`
|
||||
PresentChunks int `json:"present_chunks"`
|
||||
BadChunks []BadChunk `json:"bad_chunks,omitempty"`
|
||||
// Err is set for a fatal, file-level problem (bad magic, non-sector-aligned
|
||||
// size, truncated tables) that makes the image unusable as a region at all.
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// Healthy reports whether the region image is structurally sound: no fatal
|
||||
// error and no individually bad chunk.
|
||||
func (r RegionReport) Healthy() bool { return r.Err == "" && len(r.BadChunks) == 0 }
|
||||
|
||||
// BadChunk describes one structurally invalid chunk within a region.
|
||||
type BadChunk struct {
|
||||
Index int `json:"index"`
|
||||
LocalX int `json:"local_x"`
|
||||
LocalZ int `json:"local_z"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Reasons reported on BadChunk.Reason.
|
||||
const (
|
||||
ReasonWrongHeader = "wrong chunk header" // the "Wrong chunk header!" trigger
|
||||
ReasonBadConst = "bad format constant"
|
||||
ReasonOutOfBounds = "sector out of bounds"
|
||||
ReasonOverlap = "overlapping sectors"
|
||||
ReasonShortRead = "blob truncated"
|
||||
ReasonBadLength = "implausible block length"
|
||||
ReasonDeflate = "deflate error"
|
||||
ReasonCoordMismatch = "embedded coord mismatch"
|
||||
)
|
||||
|
||||
// ValidateRegionFile reads path and validates it. deep=true additionally
|
||||
// raw-inflates every present chunk and checks its embedded coordinates.
|
||||
func ValidateRegionFile(path string, deep bool) RegionReport {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return RegionReport{Path: path, Err: fmt.Sprintf("read: %v", err)}
|
||||
}
|
||||
r := ValidateRegionBytes(data, deep)
|
||||
r.Path = path
|
||||
return r
|
||||
}
|
||||
|
||||
// ValidateRegionBytes validates a .7rg image held in memory.
|
||||
//
|
||||
// Structural checks (always): sector-aligned size; "7rg" magic; empty header
|
||||
// remainder; every present chunk's location entry in-bounds and non-overlapping;
|
||||
// each chunk blob's "ttc\0" magic + format constant + plausible block length.
|
||||
// deep=true also inflates each chunk and verifies the decompressed payload's
|
||||
// chunkX/chunkZ match the table index.
|
||||
func ValidateRegionBytes(data []byte, deep bool) RegionReport {
|
||||
rep := RegionReport{SizeBytes: int64(len(data))}
|
||||
|
||||
if len(data) == 0 || len(data)%SectorSize != 0 {
|
||||
rep.Err = fmt.Sprintf("size %d is not a positive multiple of %d", len(data), SectorSize)
|
||||
return rep
|
||||
}
|
||||
total := len(data) / SectorSize
|
||||
rep.TotalSectors = total
|
||||
if total < headerSectors {
|
||||
rep.Err = "file shorter than header+location table"
|
||||
return rep
|
||||
}
|
||||
if !bytes.Equal(data[0:4], regionMagic) {
|
||||
rep.Err = fmt.Sprintf("bad magic % x (want % x)", data[0:4], regionMagic)
|
||||
return rep
|
||||
}
|
||||
// Header remainder must be empty (cheap corruption tripwire).
|
||||
for _, b := range data[4:SectorSize] {
|
||||
if b != 0 {
|
||||
rep.Err = "non-zero bytes in header block"
|
||||
return rep
|
||||
}
|
||||
}
|
||||
|
||||
used := make([]bool, total)
|
||||
used[0] = true // header
|
||||
used[1] = true // location table
|
||||
|
||||
for idx := 0; idx < RegionChunks; idx++ {
|
||||
e := data[locTableOffset+idx*4 : locTableOffset+idx*4+4]
|
||||
off := int(e[0]) | int(e[1])<<8 | int(e[2])<<16
|
||||
cnt := int(e[3])
|
||||
if off == 0 && cnt == 0 {
|
||||
continue // absent chunk — legitimate
|
||||
}
|
||||
rep.PresentChunks++
|
||||
lx, lz := idx%RegionSide, idx/RegionSide
|
||||
bad := func(reason string) { rep.BadChunks = append(rep.BadChunks, BadChunk{Index: idx, LocalX: lx, LocalZ: lz, Reason: reason}) }
|
||||
|
||||
if off < headerSectors || cnt < 1 || off+cnt > total {
|
||||
bad(ReasonOutOfBounds)
|
||||
continue
|
||||
}
|
||||
overlap := false
|
||||
for s := off; s < off+cnt; s++ {
|
||||
if used[s] {
|
||||
overlap = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if overlap {
|
||||
bad(ReasonOverlap)
|
||||
continue
|
||||
}
|
||||
for s := off; s < off+cnt; s++ {
|
||||
used[s] = true
|
||||
}
|
||||
|
||||
base := off * SectorSize
|
||||
capacity := cnt * SectorSize
|
||||
// Need at least the 24-byte blob header.
|
||||
if capacity < 24 {
|
||||
bad(ReasonShortRead)
|
||||
continue
|
||||
}
|
||||
blockLen := int(binary.LittleEndian.Uint32(data[base : base+4]))
|
||||
// blob occupies 16 + blockLen bytes; must fit the allocated sectors.
|
||||
if blockLen < 8 || 16+blockLen > capacity {
|
||||
bad(ReasonBadLength)
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(data[base+16:base+20], chunkMagic) {
|
||||
bad(ReasonWrongHeader) // <-- the "Wrong chunk header!" failure
|
||||
continue
|
||||
}
|
||||
if binary.LittleEndian.Uint32(data[base+20:base+24]) != chunkConstVal {
|
||||
bad(ReasonBadConst)
|
||||
continue
|
||||
}
|
||||
if deep {
|
||||
// Deep check: confirm the chunk's raw DEFLATE stream actually
|
||||
// inflates. A torn chunk whose "ttc" header happens to survive but
|
||||
// whose compressed body is garbage is caught here. The DEFLATE
|
||||
// stream is exactly blockLen-8 bytes starting at base+24.
|
||||
//
|
||||
// NOTE: we deliberately do NOT compare the decompressed payload's
|
||||
// embedded coordinates. Real pregenerated (RWG-baked) regions store
|
||||
// those in a layout that does not match a naive global-coord
|
||||
// assumption, so a coord comparison false-positives on perfectly
|
||||
// healthy regions — which would make every backup look corrupt and
|
||||
// block healing. The "ttc" magic check above is exactly what 7DTD
|
||||
// itself validates for "Wrong chunk header!", so structural validity
|
||||
// is the trustworthy signal.
|
||||
compLen := blockLen - 8
|
||||
start := base + 24
|
||||
if start+compLen > len(data) {
|
||||
bad(ReasonShortRead)
|
||||
continue
|
||||
}
|
||||
if _, err := io.ReadAll(flate.NewReader(bytes.NewReader(data[start : start+compLen]))); err != nil {
|
||||
bad(ReasonDeflate)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return rep
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Code generated from 7DTD V3.0 SandboxOptions enum (Assembly-CSharp,
|
||||
// buildid 23705258). The integer value of each member IS the 2-letter code in a
|
||||
// SandboxCode (via IndexToAlpha2). Order is authoritative — transcribed 1:1 from
|
||||
// rb-decompile/3.0/src/SandboxOptions/SandboxOptions.cs, INCLUDING the UNUSED1
|
||||
// (116) / UNUSED2 (117) gap. Do not reorder.
|
||||
package sandbox
|
||||
|
||||
// sandboxEnumNames is the SandboxOptions enum in declaration order; the slice
|
||||
// index equals the enum integer value. UNUSED1/UNUSED2 are kept so subsequent
|
||||
// members get their correct integer. "Max" terminates the enum (not an option).
|
||||
var sandboxEnumNames = []string{
|
||||
"RangedDamage", // 0
|
||||
"MeleeDamage", // 1
|
||||
"BlockDamage", // 2
|
||||
"TerrainDamage", // 3
|
||||
"HeadshotMultiplier", // 4
|
||||
"CrouchSpeed", // 5
|
||||
"WalkSpeed", // 6
|
||||
"RunSpeed", // 7
|
||||
"JumpStrength", // 8
|
||||
"StaminaUsage", // 9
|
||||
"StaminaRegen", // 10
|
||||
"PlayerLevelBonusApplied", // 11
|
||||
"JarRefund", // 12
|
||||
"ShowHealthBars", // 13
|
||||
"ShowEnemyDamage", // 14
|
||||
"NewbieCoat", // 15
|
||||
"HeadshotMode", // 16
|
||||
"IncomingDamage", // 17
|
||||
"XPMultiplier", // 18
|
||||
"ShowXP", // 19
|
||||
"EncumbranceModifier", // 20
|
||||
"ItemDegradation", // 21
|
||||
"LoseItemsOnDeathType", // 22
|
||||
"LoseItemsOnDeathCount", // 23
|
||||
"DegradeItemsOnDeath", // 24
|
||||
"DegradeAmountOnDeath", // 25
|
||||
"DeathPenalty", // 26
|
||||
"DropOnDeath", // 27
|
||||
"DropOnQuit", // 28
|
||||
"InfectionRate", // 29
|
||||
"EnemySpawnMode", // 30
|
||||
"EntityDamage", // 31
|
||||
"BlockDamageAI", // 32
|
||||
"BlockDamageAIBM", // 33
|
||||
"ZombieMove", // 34
|
||||
"ZombieMoveNight", // 35
|
||||
"ZombieFeralMove", // 36
|
||||
"ZombieBMMove", // 37
|
||||
"ZombieFeralSense", // 38
|
||||
"AISmellMode", // 39
|
||||
"AllowZombieDigging", // 40
|
||||
"ZombieRageChance", // 41
|
||||
"EntityIncomingDamage", // 42
|
||||
"MaxEnemyTier", // 43
|
||||
"BiomeZombieRespawn", // 44
|
||||
"BiomeAnimalRespawn", // 45
|
||||
"BiomeEnemyDensity", // 46
|
||||
"ZombiesEatAnimals", // 47
|
||||
"BloodMoonFrequency", // 48
|
||||
"BloodMoonRange", // 49
|
||||
"BloodMoonWarning", // 50
|
||||
"BloodMoonEnemyCount", // 51
|
||||
"AirDropFrequency", // 52
|
||||
"AirDropMarker", // 53
|
||||
"AirDropRandomTime", // 54
|
||||
"BiomeProgression", // 55
|
||||
"TemperatureSurvival", // 56
|
||||
"StormFreq", // 57
|
||||
"StormWarning", // 58
|
||||
"HeatMapSensitivity", // 59
|
||||
"GlobalGSModifier", // 60
|
||||
"BiomeGSModifier", // 61
|
||||
"GlobalLSModifier", // 62
|
||||
"BiomeLSModifier", // 63
|
||||
"POITierLSModifier", // 64
|
||||
"GlobalTSModifier", // 65
|
||||
"DayNightLength", // 66
|
||||
"DayLightLength", // 67
|
||||
"AllowMap", // 68
|
||||
"AllowCompass", // 69
|
||||
"AllowScreenMarkers", // 70
|
||||
"ShowLocationInfo", // 71
|
||||
"ShowDayTime", // 72
|
||||
"WorkstationsInTheWild", // 73
|
||||
"MaxTechType", // 74
|
||||
"LootRespawnDays", // 75
|
||||
"LootTimer", // 76
|
||||
"LootMaxTier", // 77
|
||||
"GlobalLootCount", // 78
|
||||
"FoodLootCount", // 79
|
||||
"DrinkLootCount", // 80
|
||||
"MedicalLootCount", // 81
|
||||
"AmmoLootCount", // 82
|
||||
"ResourceLootCount", // 83
|
||||
"ArmorLootCount", // 84
|
||||
"MeleeLootCount", // 85
|
||||
"RangedLootCount", // 86
|
||||
"DukesLootCount", // 87
|
||||
"CraftingMagazinesLootCount", // 88
|
||||
"TreasureMapChance", // 89
|
||||
"LootBagChance", // 90
|
||||
"CropOutput", // 91
|
||||
"SeedDropOutput", // 92
|
||||
"CropGrowthSpeed", // 93
|
||||
"BackpackCrafting", // 94
|
||||
"WorkstationCrafting", // 95
|
||||
"CraftingProgression", // 96
|
||||
"CraftingTime", // 97
|
||||
"CraftingInput", // 98
|
||||
"CraftingOutput", // 99
|
||||
"CraftingMaxTier", // 100
|
||||
"MiningOutput", // 101
|
||||
"HarvestingOutput", // 102
|
||||
"ScrappingOutput", // 103
|
||||
"SmeltingType", // 104
|
||||
"DewCollectorTime", // 105
|
||||
"DewCollectorOutput", // 106
|
||||
"DewCollectorInput", // 107
|
||||
"ApiaryTime", // 108
|
||||
"ApiaryOutput", // 109
|
||||
"ApiaryInput", // 110
|
||||
"UNUSED1", // 111
|
||||
"UNUSED2", // 112
|
||||
"RepairTypes", // 113
|
||||
"MaxDegradationAmount", // 114
|
||||
"PointsPerMagazine", // 115
|
||||
"SkillGainRate", // 116
|
||||
"SkillPointsPerLevel", // 117
|
||||
"QuestsEnabled", // 118
|
||||
"IntroQuestEnabled", // 119
|
||||
"TraderToTraderQuestsEnabled", // 120
|
||||
"StarterSkillPoints", // 121
|
||||
"QuestsPerTier", // 122
|
||||
"QuestProgressionDailyLimit", // 123
|
||||
"BuriedQuestsEnabled", // 124
|
||||
"POIQuestsEnabled", // 125
|
||||
"TraderDialog", // 126
|
||||
"TraderHours", // 127
|
||||
"TradersEnabled", // 128
|
||||
"VendingEnabled", // 129
|
||||
"TraderSellPrices", // 130
|
||||
"TraderBuyPrices", // 131
|
||||
"TraderProtection", // 132
|
||||
"TraderResetInterval", // 133
|
||||
"TraderItemAbundance", // 134
|
||||
"TraderBuyLimit", // 135
|
||||
"TraderMaxTier", // 136
|
||||
"VendingResetInterval", // 137
|
||||
"VendingItemAbundance", // 138
|
||||
"ChallengesEnabled", // 139
|
||||
"IntroChallengesEnabled", // 140
|
||||
"VehicleFuelUsage", // 141
|
||||
"VehicleEntityDamage", // 142
|
||||
"VehicleBlockDamage", // 143
|
||||
"VehicleSelfDamage", // 144
|
||||
"ElectricalOutput", // 145
|
||||
"SillyCelebrate", // 146
|
||||
"SillyBigHeads", // 147
|
||||
"SillyTinyZombies", // 148
|
||||
"SillySounds", // 149
|
||||
"SillyLowGravity", // 150
|
||||
"SillyBlackandWhite", // 151
|
||||
// "Max" (152) — enum terminator, not an option.
|
||||
}
|
||||
|
||||
// enumByName maps a SandboxOptions member name to its integer value, built once
|
||||
// from sandboxEnumNames.
|
||||
var enumByName = func() map[string]int {
|
||||
m := make(map[string]int, len(sandboxEnumNames))
|
||||
for i, n := range sandboxEnumNames {
|
||||
m[n] = i
|
||||
}
|
||||
return m
|
||||
}()
|
||||
@@ -0,0 +1,208 @@
|
||||
// Code generated from 7DTD V3.0 Assembly-CSharp SandboxOptionManager.SetupOptions
|
||||
// + the SandboxOptions enum (buildid 23705258, experimental 2026-06-15).
|
||||
// The Enum field is the integer value of the SandboxOptions enum member — it IS
|
||||
// the 2-letter code in a SandboxCode (IndexToAlpha2((int)enum)). DO NOT renumber.
|
||||
// CrackedFrom: rb-decompile/3.0/src/SandboxOptions/{SandboxOptions.cs,SandboxOptionManager.cs}
|
||||
package sandbox
|
||||
|
||||
// Category is the 8-way grouping the in-game Sandbox Options window uses. The
|
||||
// decompile's CategoryName strings are: General, Entities, World, Resources,
|
||||
// Crafting, Traders, Tasks, Misc. We surface friendlier titles in the UI but
|
||||
// keep the raw key here.
|
||||
type optionType int
|
||||
|
||||
const (
|
||||
optFloat optionType = iota
|
||||
optInt
|
||||
optBool
|
||||
)
|
||||
|
||||
// option mirrors one AddSandboxOption(...) registration. Enum + set are resolved
|
||||
// at init from the authoritative SandboxOptions enum (enum.go) and valueSets
|
||||
// table — never hand-typed here (the Name is the only stable key authored).
|
||||
type option struct {
|
||||
Name string // SandboxOptions enum member name (stable key)
|
||||
Display string // human label from the registration
|
||||
Category string // General|Entities|World|Resources|Crafting|Traders|Tasks|Misc
|
||||
Type optionType // float/int/bool — which value-set kind
|
||||
ValueSet string // key into valueSets
|
||||
// Default is the option's default value (float for optFloat, int for optInt,
|
||||
// bool for optBool). Resolved to a default INDEX in the value-set at init.
|
||||
DefaultFloat float64
|
||||
DefaultInt int
|
||||
DefaultBool bool
|
||||
|
||||
// Resolved at init() — do not set in literals.
|
||||
enum int // SandboxOptions integer value (the 2-char code), from enumByName
|
||||
set valueSet // resolved value-set, from valueSets[ValueSet]
|
||||
}
|
||||
|
||||
// options is the full ordered registration list (insertion order from
|
||||
// SetupOptions). Enum is set explicitly to survive the UNUSED1/UNUSED2 gap.
|
||||
var options = []option{
|
||||
// ---- General / Player ----
|
||||
{Name: "RangedDamage", Display: "Ranged Damage", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "MeleeDamage", Display: "Melee Damage", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "BlockDamage", Display: "Block Damage", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "TerrainDamage", Display: "Terrain Damage", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "HeadshotMultiplier", Display: "Headshot Multiplier", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "IncomingDamage", Display: "Incoming Damage", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "WalkSpeed", Display: "Walk Speed", Category: "General", Type: optFloat, ValueSet: "PlayerSpeedValuesWithNone", DefaultFloat: 1},
|
||||
{Name: "RunSpeed", Display: "Run Speed", Category: "General", Type: optFloat, ValueSet: "PlayerSpeedValues", DefaultFloat: 1},
|
||||
{Name: "CrouchSpeed", Display: "Crouch Speed", Category: "General", Type: optFloat, ValueSet: "PlayerSpeedValues", DefaultFloat: 1},
|
||||
{Name: "JumpStrength", Display: "Jump Height", Category: "General", Type: optFloat, ValueSet: "JumpStrength", DefaultFloat: 1},
|
||||
{Name: "StaminaRegen", Display: "Stamina Regen", Category: "General", Type: optFloat, ValueSet: "StaminaRegen", DefaultFloat: 1},
|
||||
{Name: "StaminaUsage", Display: "Stamina Usage", Category: "General", Type: optFloat, ValueSet: "StaminaUsage", DefaultFloat: 1},
|
||||
{Name: "XPMultiplier", Display: "XP Multiplier", Category: "General", Type: optFloat, ValueSet: "XPGain", DefaultFloat: 1},
|
||||
{Name: "ShowXP", Display: "Show XP", Category: "General", Type: optInt, ValueSet: "ShowXP", DefaultInt: 0},
|
||||
{Name: "PlayerLevelBonusApplied", Display: "Level Health/Stam Bonus", Category: "General", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "SkillGainRate", Display: "Skill Gain Rate", Category: "General", Type: optInt, ValueSet: "SkillGainRate", DefaultInt: 1},
|
||||
{Name: "SkillPointsPerLevel", Display: "Skill Gain Amount", Category: "General", Type: optInt, ValueSet: "PointsPer", DefaultInt: 1},
|
||||
{Name: "DeathPenalty", Display: "Death Penalty", Category: "General", Type: optInt, ValueSet: "DeathPenalty", DefaultInt: 1},
|
||||
{Name: "LoseItemsOnDeathType", Display: "Lose Items Death Type", Category: "General", Type: optInt, ValueSet: "LoseItemsOnDeathType", DefaultInt: 0},
|
||||
{Name: "LoseItemsOnDeathCount", Display: "Lose Items Death Count", Category: "General", Type: optInt, ValueSet: "LoseItemCount", DefaultInt: 1},
|
||||
{Name: "DegradeItemsOnDeath", Display: "Degrade Items On Death", Category: "General", Type: optInt, ValueSet: "DegradeItemsOnDeath", DefaultInt: 0},
|
||||
{Name: "DegradeAmountOnDeath", Display: "Degrade Amount On Death", Category: "General", Type: optFloat, ValueSet: "MaxDegradationAmounts", DefaultFloat: 0.1},
|
||||
{Name: "DropOnDeath", Display: "Drop On Death", Category: "General", Type: optInt, ValueSet: "DropOnDeath", DefaultInt: 1},
|
||||
{Name: "DropOnQuit", Display: "Drop On Quit", Category: "General", Type: optInt, ValueSet: "DropOnQuit", DefaultInt: 0},
|
||||
{Name: "InfectionRate", Display: "Infection Rate", Category: "General", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1},
|
||||
{Name: "NewbieCoat", Display: "Allow Newbie Coat", Category: "General", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "EncumbranceModifier", Display: "Encumbrance Modifier", Category: "General", Type: optFloat, ValueSet: "Encumbrance", DefaultFloat: 1},
|
||||
{Name: "JarRefund", Display: "Jar Refund", Category: "General", Type: optFloat, ValueSet: "JarRefund", DefaultFloat: 0.6},
|
||||
|
||||
// ---- Entities ----
|
||||
{Name: "EnemySpawnMode", Display: "Enemy Spawn", Category: "Entities", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "MaxEnemyTier", Display: "Max Enemy Type", Category: "Entities", Type: optInt, ValueSet: "MaxEnemyType", DefaultInt: 5},
|
||||
{Name: "BiomeEnemyDensity", Display: "Biome Enemy Density", Category: "Entities", Type: optInt, ValueSet: "BiomeEnemyDensity", DefaultInt: 0},
|
||||
{Name: "BiomeZombieRespawn", Display: "Biome Enemy Respawn", Category: "Entities", Type: optInt, ValueSet: "SlowToFast", DefaultInt: 0},
|
||||
{Name: "BiomeAnimalRespawn", Display: "Biome Animal Respawn", Category: "Entities", Type: optInt, ValueSet: "SlowToFast", DefaultInt: 0},
|
||||
{Name: "EntityDamage", Display: "Entity Damage", Category: "Entities", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "EntityIncomingDamage", Display: "Entity Incoming Damage", Category: "Entities", Type: optFloat, ValueSet: "DamageValuesNoNone", DefaultFloat: 1},
|
||||
{Name: "BlockDamageAI", Display: "Entity Block Damage", Category: "Entities", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "BlockDamageAIBM", Display: "Blood Moon Block Damage", Category: "Entities", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "HeadshotMode", Display: "Headshot Mode", Category: "Entities", Type: optInt, ValueSet: "HeadshotMode", DefaultInt: 0},
|
||||
{Name: "ShowHealthBars", Display: "Entity Health Bars", Category: "Entities", Type: optBool, ValueSet: "YesNo", DefaultBool: false},
|
||||
{Name: "ShowEnemyDamage", Display: "Show Entity Damage", Category: "Entities", Type: optBool, ValueSet: "YesNo", DefaultBool: false},
|
||||
{Name: "ZombieMove", Display: "Zombie Day Speed", Category: "Entities", Type: optInt, ValueSet: "ZombieSpeeds", DefaultInt: 0},
|
||||
{Name: "ZombieMoveNight", Display: "Zombie Night Speed", Category: "Entities", Type: optInt, ValueSet: "ZombieSpeeds", DefaultInt: 3},
|
||||
{Name: "ZombieFeralMove", Display: "Zombie Feral Speed", Category: "Entities", Type: optInt, ValueSet: "ZombieSpeeds", DefaultInt: 3},
|
||||
{Name: "ZombieBMMove", Display: "Zombie Blood Moon Speed", Category: "Entities", Type: optInt, ValueSet: "ZombieSpeeds", DefaultInt: 3},
|
||||
{Name: "ZombieFeralSense", Display: "Zombie Feral Sense", Category: "Entities", Type: optInt, ValueSet: "ZombieFeralSense", DefaultInt: 0},
|
||||
{Name: "AISmellMode", Display: "Zombie AI Smell Mode", Category: "Entities", Type: optInt, ValueSet: "AISmellMode", DefaultInt: 3},
|
||||
{Name: "ZombieRageChance", Display: "Zombie Rage Chance", Category: "Entities", Type: optFloat, ValueSet: "ZombieRageChance", DefaultFloat: 0.15},
|
||||
{Name: "AllowZombieDigging", Display: "Allow Zombie Digging", Category: "Entities", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "ZombiesEatAnimals", Display: "Zombies Eat Animals", Category: "Entities", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
|
||||
// ---- World ----
|
||||
{Name: "GlobalGSModifier", Display: "Global GameStage", Category: "World", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1},
|
||||
{Name: "BiomeGSModifier", Display: "Biome GameStage", Category: "World", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1},
|
||||
{Name: "BiomeProgression", Display: "Biome Progression", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "TemperatureSurvival", Display: "Temperature Survival", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "MaxTechType", Display: "Max Tech Type", Category: "World", Type: optInt, ValueSet: "MaxTechType", DefaultInt: 4},
|
||||
{Name: "WorkstationsInTheWild", Display: "Workstations in the Wild", Category: "World", Type: optFloat, ValueSet: "JarRefund", DefaultFloat: 0},
|
||||
{Name: "BloodMoonFrequency", Display: "Blood Moon Frequency", Category: "World", Type: optInt, ValueSet: "BloodMoonFrequency", DefaultInt: 7},
|
||||
{Name: "BloodMoonRange", Display: "Blood Moon Range", Category: "World", Type: optInt, ValueSet: "BloodMoonRange", DefaultInt: 0},
|
||||
{Name: "BloodMoonEnemyCount", Display: "Blood Moon Count", Category: "World", Type: optInt, ValueSet: "BloodMoonCount", DefaultInt: 8},
|
||||
{Name: "BloodMoonWarning", Display: "Blood Moon Warning", Category: "World", Type: optInt, ValueSet: "BloodMoonWarning", DefaultInt: 1}, // enum 50? see note — BloodMoonWarning=49 in enum order
|
||||
{Name: "AirDropFrequency", Display: "Air Drops", Category: "World", Type: optInt, ValueSet: "AirDrops", DefaultInt: 3},
|
||||
{Name: "AirDropRandomTime", Display: "Air Drop Random Time", Category: "World", Type: optInt, ValueSet: "AirDropRandomTime", DefaultInt: 0},
|
||||
{Name: "StormFreq", Display: "Storm Frequency", Category: "World", Type: optFloat, ValueSet: "StormFrequency", DefaultFloat: 1},
|
||||
{Name: "StormWarning", Display: "Storm Warning", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "HeatMapSensitivity", Display: "Heatmap Sensitivity", Category: "World", Type: optFloat, ValueSet: "DisabledLowDefaultHigh", DefaultFloat: 1},
|
||||
{Name: "DayNightLength", Display: "24 Day Cycle", Category: "World", Type: optInt, ValueSet: "DayNightLength", DefaultInt: 60},
|
||||
{Name: "DayLightLength", Display: "Day Light Length", Category: "World", Type: optInt, ValueSet: "DayLightLength", DefaultInt: 18}, // enum 72? see note — DayLightLength=72 actually
|
||||
{Name: "AirDropMarker", Display: "Mark Air Drops", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "AllowMap", Display: "Allow Map", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "AllowCompass", Display: "Allow Compass", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "AllowScreenMarkers", Display: "Allow Screen Markers", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "ShowLocationInfo", Display: "Show Location Info", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, // ShowLocationInfo=71? see note
|
||||
{Name: "ShowDayTime", Display: "Show Day/Time", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
|
||||
// ---- Resources ----
|
||||
{Name: "LootMaxTier", Display: "Loot Max Tier", Category: "Resources", Type: optInt, ValueSet: "ItemTierOptions", DefaultInt: 6},
|
||||
{Name: "GlobalLSModifier", Display: "Global LootStage", Category: "Resources", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1},
|
||||
{Name: "BiomeLSModifier", Display: "Biome LootStage", Category: "Resources", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1}, // BiomeLSModifier=68? see note
|
||||
{Name: "POITierLSModifier", Display: "POI Tier LootStage", Category: "Resources", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1}, // =69? see note
|
||||
{Name: "LootRespawnDays", Display: "Loot Respawn Days", Category: "Resources", Type: optInt, ValueSet: "LootRespawnDays", DefaultInt: 7},
|
||||
{Name: "LootTimer", Display: "Loot Time", Category: "Resources", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1},
|
||||
{Name: "LootBagChance", Display: "Loot Bag Chance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "GlobalLootCount", Display: "Global Loot Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "FoodLootCount", Display: "Food Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "DrinkLootCount", Display: "Drink Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "MedicalLootCount", Display: "Medical Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "AmmoLootCount", Display: "Ammo Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, // AmmoLootCount=81? see note
|
||||
{Name: "ResourceLootCount", Display: "Resource Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "ArmorLootCount", Display: "Armor Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "MeleeLootCount", Display: "Melee Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "RangedLootCount", Display: "Ranged Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "DukesLootCount", Display: "Dukes Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "CraftingMagazinesLootCount", Display: "Magazines Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "TreasureMapChance", Display: "Treasure Map Chance", Category: "Resources", Type: optFloat, ValueSet: "PlayerSpeedValues", DefaultFloat: 1},
|
||||
{Name: "MiningOutput", Display: "Mining Output", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "CropOutput", Display: "Crop Output", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "SeedDropOutput", Display: "Seed Drop Output", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "HarvestingOutput", Display: "Harvesting Output", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1},
|
||||
{Name: "CropGrowthSpeed", Display: "Crop Growth", Category: "Resources", Type: optFloat, ValueSet: "CropGrowthSpeed", DefaultFloat: 1},
|
||||
|
||||
// ---- Crafting ----
|
||||
{Name: "CraftingProgression", Display: "Crafting Progression", Category: "Crafting", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "CraftingMaxTier", Display: "Crafting Max Tier", Category: "Crafting", Type: optInt, ValueSet: "ItemTierOptions", DefaultInt: 6},
|
||||
{Name: "PointsPerMagazine", Display: "Magazine Points", Category: "Crafting", Type: optInt, ValueSet: "PointsPer", DefaultInt: 1},
|
||||
{Name: "BackpackCrafting", Display: "Backpack Crafting", Category: "Crafting", Type: optInt, ValueSet: "BackpackCrafting", DefaultInt: 1},
|
||||
{Name: "WorkstationCrafting", Display: "Workstation Crafting", Category: "Crafting", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, // WorkstationCrafting=100? see note
|
||||
{Name: "SmeltingType", Display: "Smelter Type", Category: "Crafting", Type: optBool, ValueSet: "SmeltingType", DefaultBool: false},
|
||||
{Name: "CraftingTime", Display: "Crafting Time", Category: "Crafting", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1},
|
||||
{Name: "CraftingInput", Display: "Crafting Input", Category: "Crafting", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1},
|
||||
{Name: "CraftingOutput", Display: "Crafting Output", Category: "Crafting", Type: optFloat, ValueSet: "StaminaRegen", DefaultFloat: 1}, // CraftingOutput=104? see note
|
||||
{Name: "ScrappingOutput", Display: "Scrapping Output", Category: "Crafting", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1},
|
||||
{Name: "DewCollectorTime", Display: "Dew Collector Time", Category: "Crafting", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1},
|
||||
{Name: "DewCollectorOutput", Display: "Dew Collector Output", Category: "Crafting", Type: optFloat, ValueSet: "CollectorOutput", DefaultFloat: 1},
|
||||
{Name: "DewCollectorInput", Display: "Dew Collector Input", Category: "Crafting", Type: optFloat, ValueSet: "DewCollectorInput", DefaultFloat: 1},
|
||||
{Name: "ApiaryTime", Display: "Apiary Time", Category: "Crafting", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1},
|
||||
{Name: "ApiaryOutput", Display: "Apiary Output", Category: "Crafting", Type: optFloat, ValueSet: "CollectorOutput", DefaultFloat: 1},
|
||||
{Name: "ApiaryInput", Display: "Apiary Input", Category: "Crafting", Type: optFloat, ValueSet: "ApiaryInput", DefaultFloat: 1},
|
||||
{Name: "ItemDegradation", Display: "Item Degradation", Category: "Crafting", Type: optFloat, ValueSet: "DisabledLowDefaultHigh", DefaultFloat: 1},
|
||||
{Name: "RepairTypes", Display: "Item Repair Types", Category: "Crafting", Type: optInt, ValueSet: "RepairTypes", DefaultInt: 3},
|
||||
{Name: "MaxDegradationAmount", Display: "Max Degrade Amount", Category: "Crafting", Type: optFloat, ValueSet: "MaxDegradationAmounts", DefaultFloat: 0},
|
||||
|
||||
// ---- Traders ----
|
||||
{Name: "TradersEnabled", Display: "Trading Enabled", Category: "Traders", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "VendingEnabled", Display: "Vending Machines Enabled", Category: "Traders", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "TraderHours", Display: "Trader Hours", Category: "Traders", Type: optInt, ValueSet: "TraderHourPresets", DefaultInt: 0},
|
||||
{Name: "TraderProtection", Display: "Trader Protection", Category: "Traders", Type: optInt, ValueSet: "TraderArea", DefaultInt: 0},
|
||||
{Name: "TraderDialog", Display: "Trading Dialog", Category: "Traders", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "GlobalTSModifier", Display: "Global TraderStage", Category: "Traders", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1}, // GlobalTSModifier=70? see note
|
||||
{Name: "TraderMaxTier", Display: "Trader Max Tier", Category: "Traders", Type: optInt, ValueSet: "ItemTierOptions", DefaultInt: 6},
|
||||
{Name: "TraderItemAbundance", Display: "Trader Item Abundance", Category: "Traders", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1},
|
||||
{Name: "VendingItemAbundance", Display: "Vending Item Abundance", Category: "Traders", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1},
|
||||
{Name: "TraderResetInterval", Display: "Trader Reset Interval", Category: "Traders", Type: optInt, ValueSet: "TraderResetInterval", DefaultInt: -1},
|
||||
{Name: "VendingResetInterval", Display: "Vending Reset Interval", Category: "Traders", Type: optInt, ValueSet: "TraderResetInterval", DefaultInt: -1},
|
||||
{Name: "TraderSellPrices", Display: "Traders Sell Price", Category: "Traders", Type: optFloat, ValueSet: "BarterValues", DefaultFloat: 1},
|
||||
{Name: "TraderBuyPrices", Display: "Traders Buy Price", Category: "Traders", Type: optFloat, ValueSet: "BarterValues", DefaultFloat: 1},
|
||||
{Name: "TraderBuyLimit", Display: "Trader Buy Limit", Category: "Traders", Type: optInt, ValueSet: "StarterSkillPoints", DefaultInt: 3},
|
||||
|
||||
// ---- Tasks ----
|
||||
{Name: "ChallengesEnabled", Display: "Challenges Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "IntroChallengesEnabled", Display: "Intro Challenges Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "QuestsEnabled", Display: "Quests Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "IntroQuestEnabled", Display: "Intro Quest Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "TraderToTraderQuestsEnabled", Display: "Trader to Trader Quests", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "BuriedQuestsEnabled", Display: "Buried Quests Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "POIQuestsEnabled", Display: "POI Quests Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true},
|
||||
{Name: "QuestsPerTier", Display: "Quests per Tier", Category: "Tasks", Type: optInt, ValueSet: "QuestPerTier", DefaultInt: 10},
|
||||
{Name: "QuestProgressionDailyLimit", Display: "Quests per Day", Category: "Tasks", Type: optInt, ValueSet: "QuestPerDay", DefaultInt: 4},
|
||||
{Name: "StarterSkillPoints", Display: "Starter Skill Points", Category: "Tasks", Type: optInt, ValueSet: "StarterSkillPoints", DefaultInt: 4},
|
||||
|
||||
// ---- Misc ----
|
||||
{Name: "VehicleFuelUsage", Display: "Vehicle Fuel Usage", Category: "Misc", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "VehicleEntityDamage", Display: "Vehicle Entity Damage", Category: "Misc", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "VehicleBlockDamage", Display: "Vehicle Block Damage", Category: "Misc", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "VehicleSelfDamage", Display: "Vehicle Self Damage", Category: "Misc", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1},
|
||||
{Name: "ElectricalOutput", Display: "Electrical Output", Category: "Misc", Type: optFloat, ValueSet: "StaminaRegen", DefaultFloat: 1},
|
||||
{Name: "SillyCelebrate", Display: "Celebrate Kills", Category: "Misc", Type: optInt, ValueSet: "Celebrate", DefaultInt: 0},
|
||||
{Name: "SillyBigHeads", Display: "Big Heads", Category: "Misc", Type: optBool, ValueSet: "YesNo", DefaultBool: false},
|
||||
{Name: "SillyTinyZombies", Display: "Tiny Zombies", Category: "Misc", Type: optBool, ValueSet: "YesNo", DefaultBool: false},
|
||||
{Name: "SillyLowGravity", Display: "Gravity", Category: "Misc", Type: optFloat, ValueSet: "Gravity", DefaultFloat: 1},
|
||||
{Name: "SillySounds", Display: "Silly Sounds", Category: "Misc", Type: optBool, ValueSet: "YesNo", DefaultBool: false},
|
||||
{Name: "SillyBlackandWhite", Display: "Black and White", Category: "Misc", Type: optBool, ValueSet: "YesNo", DefaultBool: false},
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// Package sandbox handles the 7 Days to Die V3.0 "SandboxCode" — the single
|
||||
// encoded serverconfig.xml property that carries all 150 in-game Sandbox Options
|
||||
// introduced in V3.0 "Dead Hot Summer".
|
||||
//
|
||||
// V3.0 collapses ~29 legacy gameplay serverconfig properties (GameDifficulty,
|
||||
// BloodMoonFrequency, LootAbundance, …) into one deterministic, OFFLINE-encoded
|
||||
// string. The encode/decode algorithm ships in the game binary; this package is
|
||||
// a faithful Go port of it, cracked from the decompiled 3.0 Assembly-CSharp
|
||||
// (SandboxOptions.SandboxOptionManager + SandboxOptionPreset, buildid 23705258).
|
||||
//
|
||||
// ## The encoding (verified against the game's own default code)
|
||||
//
|
||||
// code[0] = version char ('A', SandboxOptionManager.currentVersion)
|
||||
// then 3-char triples, one per option that differs from its default:
|
||||
// triple[0..1] = the SandboxOptions enum int, base-26: c0*26 + c1 (A=0)
|
||||
// triple[2] = the selected value INDEX in that option's value-set (A=0)
|
||||
//
|
||||
// Decode resets every option to its default, then applies each triple. Encode is
|
||||
// the inverse: emit a triple for every option whose chosen index ≠ default index
|
||||
// (options omitted from the code decode to default — identical result, shorter
|
||||
// code, matching how the game emits difficulty presets like AAAJABJACJADJARFBNC).
|
||||
//
|
||||
// Round-trip + the game's example codes are checked in sandbox_test.go.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// codeVersion is SandboxOptionManager.currentVersion. A code must start with it.
|
||||
const codeVersion = 'A'
|
||||
|
||||
// ErrCodecUnavailable is retained for API compatibility with the day-one
|
||||
// passthrough scaffold. The real codec no longer returns it.
|
||||
var ErrCodecUnavailable = errors.New("sandbox: per-option SandboxCode codec unavailable")
|
||||
|
||||
// optByEnum / optByName index the options table for O(1) lookup. defaultIdx
|
||||
// holds each option's default value's index in its value-set.
|
||||
var (
|
||||
optByEnum = map[int]*option{}
|
||||
optByName = map[string]*option{}
|
||||
defaultIdx = map[string]int{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
for i := range options {
|
||||
o := &options[i]
|
||||
e, ok := enumByName[o.Name]
|
||||
if !ok {
|
||||
panic("sandbox: option " + o.Name + " has no SandboxOptions enum entry")
|
||||
}
|
||||
o.enum = e
|
||||
vs, ok := valueSets[o.ValueSet]
|
||||
if !ok {
|
||||
panic("sandbox: option " + o.Name + " references unknown value-set " + o.ValueSet)
|
||||
}
|
||||
di, ok := indexOfDefault(*o, vs)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("sandbox: option %s default not present in value-set %s", o.Name, o.ValueSet))
|
||||
}
|
||||
o.set = vs
|
||||
defaultIdx[o.Name] = di
|
||||
optByEnum[e] = o
|
||||
optByName[o.Name] = o
|
||||
}
|
||||
}
|
||||
|
||||
// indexOfDefault finds the index of an option's default value in its value-set.
|
||||
func indexOfDefault(o option, vs valueSet) (int, bool) {
|
||||
switch o.Type {
|
||||
case optFloat:
|
||||
for i, f := range vs.Floats {
|
||||
if floatEq(f, o.DefaultFloat) {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
case optInt:
|
||||
for i, n := range vs.Ints {
|
||||
if n == o.DefaultInt {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
case optBool:
|
||||
for i, b := range vs.Bools {
|
||||
if b == o.DefaultBool {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func floatEq(a, b float64) bool { return math.Abs(a-b) < 1e-6 }
|
||||
|
||||
// Codec converts between a SandboxCode string and a per-option value map.
|
||||
// The map is keyed by the SandboxOptions member name (e.g. "BloodMoonFrequency")
|
||||
// with string values (the human value: "7", "1.5", "true"). Encode emits a code
|
||||
// for the options whose value differs from default; Decode returns the FULL set
|
||||
// of 150 options at their effective values (defaults for any omitted from code).
|
||||
type Codec interface {
|
||||
Version() string
|
||||
Encode(options map[string]string) (string, error)
|
||||
Decode(code string) (map[string]string, error)
|
||||
}
|
||||
|
||||
// V3Codec is the real 3.0 SandboxCode codec.
|
||||
type V3Codec struct{}
|
||||
|
||||
func (V3Codec) Version() string { return string(codeVersion) }
|
||||
|
||||
// Encode builds a SandboxCode from a name→value map. Unknown keys are ignored;
|
||||
// a value that doesn't match any entry in its option's value-set is an error
|
||||
// (prevents silently writing an out-of-range setting). Options absent from the
|
||||
// map, or equal to their default, are omitted from the code (decode to default).
|
||||
// Triples are emitted in ascending enum order for a stable, diffable code.
|
||||
func (V3Codec) Encode(vals map[string]string) (string, error) {
|
||||
type triple struct {
|
||||
enum, idx int
|
||||
}
|
||||
var triples []triple
|
||||
for name, raw := range vals {
|
||||
o, ok := optByName[name]
|
||||
if !ok {
|
||||
continue // unknown option key — ignore (forward-compat)
|
||||
}
|
||||
idx, err := o.indexOfValue(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sandbox: option %s: %w", name, err)
|
||||
}
|
||||
if idx == defaultIdx[name] {
|
||||
continue // equals default — omit (decodes to same value)
|
||||
}
|
||||
triples = append(triples, triple{o.enum, idx})
|
||||
}
|
||||
sort.Slice(triples, func(i, j int) bool { return triples[i].enum < triples[j].enum })
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteByte(codeVersion)
|
||||
for _, t := range triples {
|
||||
s, err := indexToAlpha2(t.enum)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c, err := indexToAlpha(t.idx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteString(s)
|
||||
b.WriteByte(c)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// Decode parses a SandboxCode into the FULL effective option set (all 150 at
|
||||
// their resolved values: code-specified or default). An empty code returns all
|
||||
// defaults. A code with a wrong version char, bad length, or unknown enum is an
|
||||
// error (unknown enums could mean a version drift we shouldn't silently accept).
|
||||
func (V3Codec) Decode(code string) (map[string]string, error) {
|
||||
out := DefaultsMap()
|
||||
if code == "" {
|
||||
return out, nil
|
||||
}
|
||||
if rune(code[0]) != codeVersion {
|
||||
return nil, fmt.Errorf("sandbox: code version %q unsupported (want %q)", string(code[0]), string(codeVersion))
|
||||
}
|
||||
body := code[1:]
|
||||
if len(body)%3 != 0 {
|
||||
return nil, fmt.Errorf("sandbox: code body length %d is not a multiple of 3", len(body))
|
||||
}
|
||||
for i := 0; i < len(body); i += 3 {
|
||||
enum, err := alpha2ToIndex(body[i : i+2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx, err := alphaToIndex(body[i+2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o, ok := optByEnum[enum]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("sandbox: code references unknown option enum %d (game version drift?)", enum)
|
||||
}
|
||||
if !o.set.IsValidIndex(idx) {
|
||||
return nil, fmt.Errorf("sandbox: option %s index %d out of range (0..%d)", o.Name, idx, o.set.Len()-1)
|
||||
}
|
||||
out[o.Name] = o.valueAtIndex(idx)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// indexOfValue resolves a human value string to its index in the option's set.
|
||||
func (o option) indexOfValue(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
switch o.Type {
|
||||
case optFloat:
|
||||
f, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("expected a number, got %q", raw)
|
||||
}
|
||||
for i, v := range o.set.Floats {
|
||||
if floatEq(v, f) {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("value %s not an allowed option (set %s)", raw, o.ValueSet)
|
||||
case optInt:
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("expected an integer, got %q", raw)
|
||||
}
|
||||
for i, v := range o.set.Ints {
|
||||
if v == n {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("value %d not an allowed option (set %s)", n, o.ValueSet)
|
||||
case optBool:
|
||||
b, err := parseBool(raw)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for i, v := range o.set.Bools {
|
||||
if v == b {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("value %v not an allowed option (set %s)", b, o.ValueSet)
|
||||
}
|
||||
return 0, fmt.Errorf("option %s has unknown type", o.Name)
|
||||
}
|
||||
|
||||
// valueAtIndex returns the human value string for an index in the option's set.
|
||||
func (o option) valueAtIndex(i int) string {
|
||||
switch o.Type {
|
||||
case optFloat:
|
||||
return strconv.FormatFloat(o.set.Floats[i], 'f', -1, 64)
|
||||
case optInt:
|
||||
return strconv.Itoa(o.set.Ints[i])
|
||||
case optBool:
|
||||
return strconv.FormatBool(o.set.Bools[i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseBool(s string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "true", "1", "yes", "on":
|
||||
return true, nil
|
||||
case "false", "0", "no", "off":
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("expected a boolean, got %q", s)
|
||||
}
|
||||
|
||||
// ---- alpha <-> index helpers (ports of SandboxOptionManager.*) ----
|
||||
|
||||
func indexToAlpha(index int) (byte, error) {
|
||||
if index < 0 || index > 26 {
|
||||
return 0, fmt.Errorf("sandbox: value index %d out of code range", index)
|
||||
}
|
||||
return byte(65 + index%26), nil
|
||||
}
|
||||
|
||||
func indexToAlpha2(index int) (string, error) {
|
||||
if index < 0 || index >= 676 {
|
||||
return "", fmt.Errorf("sandbox: enum index %d out of code range", index)
|
||||
}
|
||||
return string([]byte{byte(65 + index/26), byte(65 + index%26)}), nil
|
||||
}
|
||||
|
||||
func alphaToIndex(value byte) (int, error) {
|
||||
v := upper(value)
|
||||
if v < 'A' || v > 'Z' {
|
||||
return 0, errors.New("sandbox: code value char must be A–Z")
|
||||
}
|
||||
return int(v - 65), nil
|
||||
}
|
||||
|
||||
func alpha2ToIndex(value string) (int, error) {
|
||||
if len(value) != 2 {
|
||||
return 0, errors.New("sandbox: code option key must be exactly 2 letters")
|
||||
}
|
||||
a, b := upper(value[0]), upper(value[1])
|
||||
if a < 'A' || a > 'Z' || b < 'A' || b > 'Z' {
|
||||
return 0, errors.New("sandbox: code option key must contain only A–Z")
|
||||
}
|
||||
return int(a-65)*26 + int(b-65), nil
|
||||
}
|
||||
|
||||
func upper(b byte) byte {
|
||||
if b >= 'a' && b <= 'z' {
|
||||
return b - 32
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// DefaultsMap returns every option at its default value (name → value string).
|
||||
func DefaultsMap() map[string]string {
|
||||
out := make(map[string]string, len(options))
|
||||
for i := range options {
|
||||
o := &options[i]
|
||||
out[o.Name] = o.valueAtIndex(defaultIdx[o.Name])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Active is the codec the panel uses. Now the real V3 codec.
|
||||
var Active Codec = V3Codec{}
|
||||
|
||||
// Preset is an official (or operator-saved) named SandboxCode.
|
||||
type Preset struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// Presets are the V3.0 official presets surfaced in the UI. The default
|
||||
// difficulty (Adventurer) code is shipped from the game's serverconfig comment;
|
||||
// the named-preset codes are captured from the in-game Sandbox Options window
|
||||
// (see scripts/capture-preset-codes). An empty Code == the game's built-in
|
||||
// default sandbox.
|
||||
var Presets = []Preset{
|
||||
{ID: "default", Name: "Default (Adventurer)", Description: "The game's built-in default sandbox.", Code: "AAAJABJACJADJARFBNC"},
|
||||
{ID: "undead_matinee", Name: "Undead Matinee", Description: "Official V3.0 preset.", Code: ""},
|
||||
{ID: "madmoles_mayhem", Name: "Madmole's Mayhem", Description: "Official V3.0 preset.", Code: ""},
|
||||
{ID: "almost_creative", Name: "Almost Creative Mode", Description: "Official V3.0 preset.", Code: ""},
|
||||
{ID: "bite_club", Name: "Bite Club", Description: "Official V3.0 preset.", Code: ""},
|
||||
{ID: "legacy_survival", Name: "Legacy Survival", Description: "Official V3.0 preset.", Code: ""},
|
||||
}
|
||||
|
||||
// PresetByID returns the named preset, or ok=false if unknown.
|
||||
func PresetByID(id string) (Preset, bool) {
|
||||
id = strings.TrimSpace(strings.ToLower(id))
|
||||
for _, p := range Presets {
|
||||
if strings.ToLower(p.ID) == id {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return Preset{}, false
|
||||
}
|
||||
|
||||
// Validate does a cheap structural sanity check on a SandboxCode before it's
|
||||
// written into serverconfig.xml. An empty code is valid (== default sandbox).
|
||||
// A non-empty code must decode cleanly (right version, length, known enums,
|
||||
// in-range indices) and contain no XML-attribute-breaking characters.
|
||||
func Validate(code string) error {
|
||||
if code == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.ContainsAny(code, `"<>&`) {
|
||||
return errors.New("sandbox: code contains characters not valid in an XML attribute value")
|
||||
}
|
||||
if _, err := (V3Codec{}).Decode(code); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestInitConsistency: every option resolves an enum + a default index, and no
|
||||
// two options share an enum (the 2-char code must be unique per option).
|
||||
func TestInitConsistency(t *testing.T) {
|
||||
if len(options) != 150 {
|
||||
t.Fatalf("expected 150 sandbox options, got %d", len(options))
|
||||
}
|
||||
seen := map[int]string{}
|
||||
for i := range options {
|
||||
o := &options[i]
|
||||
if o.enum == 0 && o.Name != "RangedDamage" {
|
||||
t.Errorf("%s: enum unresolved (0)", o.Name)
|
||||
}
|
||||
if prev, ok := seen[o.enum]; ok {
|
||||
t.Errorf("enum %d shared by %s and %s", o.enum, prev, o.Name)
|
||||
}
|
||||
seen[o.enum] = o.Name
|
||||
if _, ok := defaultIdx[o.Name]; !ok {
|
||||
t.Errorf("%s: no default index", o.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameDefaultCode decodes the game's own default difficulty (Adventurer)
|
||||
// code from serverconfig.xml and checks the documented meaning, then re-encodes
|
||||
// and confirms it round-trips byte-for-byte.
|
||||
func TestGameDefaultCode(t *testing.T) {
|
||||
const code = "AAAJABJACJADJARFBNC" // shipped in 3.0 serverconfig.xml comment
|
||||
c := V3Codec{}
|
||||
vals, err := c.Decode(code)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// Triples: AA J / AB J / AC J / AD J / AR F / BN C
|
||||
// AA(0)=RangedDamage idx J=9 -> DamageValues[9]=1.5
|
||||
// AB(1)=MeleeDamage idx 9 -> 1.5
|
||||
// AC(2)=BlockDamage idx 9 -> 1.5
|
||||
// AD(3)=TerrainDamage idx 9 -> 1.5
|
||||
// AR(17)=IncomingDamage idx F=5 -> DamageValues[5]=0.75
|
||||
// BN(39)=AISmellMode idx C=2 -> AISmellMode set Ints[2]=2
|
||||
checks := map[string]string{
|
||||
"RangedDamage": "1.5",
|
||||
"MeleeDamage": "1.5",
|
||||
"BlockDamage": "1.5",
|
||||
"TerrainDamage": "1.5",
|
||||
"IncomingDamage": "0.75",
|
||||
"AISmellMode": "2",
|
||||
}
|
||||
for name, want := range checks {
|
||||
if got := vals[name]; got != want {
|
||||
t.Errorf("%s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
// Re-encode: only the 6 non-default options should appear, in enum order →
|
||||
// must reproduce the original code exactly.
|
||||
round, err := c.Encode(vals)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
if round != code {
|
||||
t.Errorf("round-trip mismatch:\n have %q\n want %q", round, code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyAndDefaultsEncodeEmpty: the all-defaults map encodes to just the
|
||||
// version char (no triples), and an empty code decodes to all defaults.
|
||||
func TestEmptyAndDefaultsEncodeEmpty(t *testing.T) {
|
||||
c := V3Codec{}
|
||||
def := DefaultsMap()
|
||||
code, err := c.Encode(def)
|
||||
if err != nil {
|
||||
t.Fatalf("encode defaults: %v", err)
|
||||
}
|
||||
if code != "A" {
|
||||
t.Errorf("defaults should encode to bare version char %q, got %q", "A", code)
|
||||
}
|
||||
vals, err := c.Decode("")
|
||||
if err != nil {
|
||||
t.Fatalf("decode empty: %v", err)
|
||||
}
|
||||
if len(vals) != 150 {
|
||||
t.Errorf("empty decode should yield 150 defaults, got %d", len(vals))
|
||||
}
|
||||
for name, v := range vals {
|
||||
if def[name] != v {
|
||||
t.Errorf("%s: empty-decode %q != defaults %q", name, v, def[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoundTripNonDefault sets a spread of options across categories/types and
|
||||
// confirms encode→decode preserves them.
|
||||
func TestRoundTripNonDefault(t *testing.T) {
|
||||
c := V3Codec{}
|
||||
in := DefaultsMap()
|
||||
in["BloodMoonFrequency"] = "14" // int set, non-default
|
||||
in["XPMultiplier"] = "2" // float set
|
||||
in["AllowZombieDigging"] = "false"
|
||||
in["TraderResetInterval"] = "7"
|
||||
in["SillyBigHeads"] = "true"
|
||||
code, err := c.Encode(in)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
out, err := c.Decode(code)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
for _, k := range []string{"BloodMoonFrequency", "XPMultiplier", "AllowZombieDigging", "TraderResetInterval", "SillyBigHeads"} {
|
||||
if out[k] != in[k] {
|
||||
t.Errorf("%s: round-trip %q != %q", k, out[k], in[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlphaHelpers spot-checks the index<->alpha conversions against the C#.
|
||||
func TestAlphaHelpers(t *testing.T) {
|
||||
if v, _ := alpha2ToIndex("BN"); v != 39 {
|
||||
t.Errorf("BN -> %d, want 39", v)
|
||||
}
|
||||
if v, _ := alpha2ToIndex("AA"); v != 0 {
|
||||
t.Errorf("AA -> %d, want 0", v)
|
||||
}
|
||||
if s, _ := indexToAlpha2(39); s != "BN" {
|
||||
t.Errorf("39 -> %q, want BN", s)
|
||||
}
|
||||
if v, _ := alphaToIndex('J'); v != 9 {
|
||||
t.Errorf("J -> %d, want 9", v)
|
||||
}
|
||||
if b, _ := indexToAlpha(9); b != 'J' {
|
||||
t.Errorf("9 -> %c, want J", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate rejects malformed codes and accepts good ones.
|
||||
func TestValidate(t *testing.T) {
|
||||
if err := Validate(""); err != nil {
|
||||
t.Errorf("empty should be valid: %v", err)
|
||||
}
|
||||
if err := Validate("AAAJABJACJADJARFBNC"); err != nil {
|
||||
t.Errorf("game default should be valid: %v", err)
|
||||
}
|
||||
if err := Validate("ZZZ"); err == nil {
|
||||
t.Error("wrong version char should fail")
|
||||
}
|
||||
if err := Validate("AAB"); err == nil {
|
||||
t.Error("non-multiple-of-3 body should fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// SchemaOption is one sandbox option as the UI needs it: identity, type, the
|
||||
// selectable values (with display labels), and the default value.
|
||||
type SchemaOption struct {
|
||||
Name string `json:"name"` // SandboxOptions enum member (stable key + config_values suffix)
|
||||
Display string `json:"display"` // human label
|
||||
Category string `json:"category"` // raw category key (General/Entities/...)
|
||||
Type string `json:"type"` // "float" | "int" | "bool"
|
||||
Values []string `json:"values"` // selectable values in index order (the code stores the index)
|
||||
Labels []string `json:"labels"` // optional UI labels parallel to Values (may be empty)
|
||||
Default string `json:"default"` // default value (one of Values)
|
||||
}
|
||||
|
||||
// SchemaCategory groups options under a UI-friendly title.
|
||||
type SchemaCategory struct {
|
||||
Key string `json:"key"` // raw category key
|
||||
Title string `json:"title"` // UI title
|
||||
Icon string `json:"icon"` // emoji for the section
|
||||
Options []SchemaOption `json:"options"` // in registration order
|
||||
}
|
||||
|
||||
// categoryMeta maps the decompile's raw category keys to UI titles + icons,
|
||||
// in display order.
|
||||
var categoryMeta = []struct{ Key, Title, Icon string }{
|
||||
{"General", "Player", "🧍"},
|
||||
{"Entities", "Entities & Zombies", "🧟"},
|
||||
{"World", "World & Blood Moon", "🌍"},
|
||||
{"Resources", "Loot & Resources", "💰"},
|
||||
{"Crafting", "Crafting", "🔨"},
|
||||
{"Traders", "Traders", "🪙"},
|
||||
{"Tasks", "Quests & Tasks", "📋"},
|
||||
{"Misc", "Miscellaneous", "🎛️"},
|
||||
}
|
||||
|
||||
// Schema returns the full 150-option sandbox schema grouped into the 8 UI
|
||||
// categories, in display order. The UI renders per-option controls from this and
|
||||
// sends back a name→value map for Encode.
|
||||
func Schema() []SchemaCategory {
|
||||
byCat := map[string][]SchemaOption{}
|
||||
for i := range options {
|
||||
o := &options[i]
|
||||
so := SchemaOption{
|
||||
Name: o.Name,
|
||||
Display: o.Display,
|
||||
Category: o.Category,
|
||||
Default: o.valueAtIndex(defaultIdx[o.Name]),
|
||||
}
|
||||
switch o.Type {
|
||||
case optFloat:
|
||||
so.Type = "float"
|
||||
for _, f := range o.set.Floats {
|
||||
so.Values = append(so.Values, strconv.FormatFloat(f, 'f', -1, 64))
|
||||
}
|
||||
case optInt:
|
||||
so.Type = "int"
|
||||
for _, n := range o.set.Ints {
|
||||
so.Values = append(so.Values, strconv.Itoa(n))
|
||||
}
|
||||
case optBool:
|
||||
so.Type = "bool"
|
||||
for _, b := range o.set.Bools {
|
||||
so.Values = append(so.Values, strconv.FormatBool(b))
|
||||
}
|
||||
}
|
||||
so.Labels = append(so.Labels, o.set.Display...)
|
||||
byCat[o.Category] = append(byCat[o.Category], so)
|
||||
}
|
||||
var out []SchemaCategory
|
||||
for _, cm := range categoryMeta {
|
||||
out = append(out, SchemaCategory{Key: cm.Key, Title: cm.Title, Icon: cm.Icon, Options: byCat[cm.Key]})
|
||||
}
|
||||
// Any category not in categoryMeta (forward-compat) appended at the end.
|
||||
known := map[string]bool{}
|
||||
for _, cm := range categoryMeta {
|
||||
known[cm.Key] = true
|
||||
}
|
||||
var extra []string
|
||||
for k := range byCat {
|
||||
if !known[k] {
|
||||
extra = append(extra, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(extra)
|
||||
for _, k := range extra {
|
||||
out = append(out, SchemaCategory{Key: k, Title: k, Icon: "⚙️", Options: byCat[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package sandbox
|
||||
import "testing"
|
||||
func TestSchemaShape(t *testing.T){
|
||||
s := Schema()
|
||||
if len(s) < 8 { t.Fatalf("want >=8 categories, got %d", len(s)) }
|
||||
n := 0
|
||||
for _, c := range s { n += len(c.Options); for _, o := range c.Options {
|
||||
if len(o.Values)==0 { t.Errorf("%s has no values", o.Name) }
|
||||
found:=false; for _,v:=range o.Values { if v==o.Default {found=true} }
|
||||
if !found { t.Errorf("%s default %q not in values %v", o.Name, o.Default, o.Values) }
|
||||
}}
|
||||
if n != 150 { t.Fatalf("want 150 options across categories, got %d", n) }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Code generated from 7DTD V3.0 Assembly-CSharp SandboxOptionManager.SetupOptions
|
||||
// (buildid 23705258, experimental 2026-06-15). DO NOT hand-edit casually — these
|
||||
// tables ARE the SandboxCode encoding. A wrong value silently corrupts a
|
||||
// server's sandbox settings. Re-derive from the decompiled SetupOptions if the
|
||||
// encoding changes in a future 3.0 build (see CrackedFrom below).
|
||||
//
|
||||
// CrackedFrom: rb-decompile/3.0/src/SandboxOptions/SandboxOptionManager.cs
|
||||
package sandbox
|
||||
|
||||
// valueKind distinguishes how a value-set's entries map to a serverconfig-style
|
||||
// scalar. The SandboxCode itself only stores the INDEX into the set; these are
|
||||
// for the panel UI / display + for emitting a human value.
|
||||
type valueKind int
|
||||
|
||||
const (
|
||||
kindFloat valueKind = iota // FloatValues
|
||||
kindInt // IntValues
|
||||
kindBool // BoolValues
|
||||
)
|
||||
|
||||
// valueSet mirrors a SandboxOptionValueSet{Float,Int,Bool}. Exactly one of
|
||||
// Floats/Ints/Bools is populated (per Kind). The index stored in a SandboxCode
|
||||
// is a position into that slice. Display* are the localization-key labels (kept
|
||||
// for the UI; not needed for encode/decode).
|
||||
type valueSet struct {
|
||||
Kind valueKind
|
||||
Floats []float64
|
||||
Ints []int
|
||||
Bools []bool
|
||||
Display []string // optional UI labels, parallel-ish to values (may be shorter)
|
||||
}
|
||||
|
||||
// Len returns the number of selectable indices in the set.
|
||||
func (v valueSet) Len() int {
|
||||
switch v.Kind {
|
||||
case kindFloat:
|
||||
return len(v.Floats)
|
||||
case kindInt:
|
||||
return len(v.Ints)
|
||||
case kindBool:
|
||||
return len(v.Bools)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsValidIndex mirrors SandboxOptionValueSet.IsValidIndex.
|
||||
func (v valueSet) IsValidIndex(i int) bool { return i >= 0 && i < v.Len() }
|
||||
|
||||
// valueSets is the ValueSets dictionary from SetupOptions(). Keyed by the same
|
||||
// string the game uses so the option table can reference them by name.
|
||||
var valueSets = map[string]valueSet{
|
||||
"DamageValues": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.35, 0.5, 0.65, 0.75, 0.85, 1, 1.25, 1.5, 2, 2.5, 3}, Display: []string{"none"}},
|
||||
"DamageValuesNoNone": {Kind: kindFloat, Floats: []float64{0.25, 0.35, 0.5, 0.65, 0.75, 0.85, 1, 1.25, 1.5, 2, 2.5, 3}},
|
||||
"PlayerSpeedValues": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 2, 3}, Display: []string{"none"}},
|
||||
"PlayerSpeedValuesWithNone": {Kind: kindFloat, Floats: []float64{0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 2, 3}},
|
||||
"SpeedValues": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3}, Display: []string{"none"}},
|
||||
"StaminaUsage": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2}, Display: []string{"none"}},
|
||||
"LootAbundanceValues": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.35, 0.5, 0.65, 0.75, 0.85, 1, 1.25, 1.5, 2, 3, 4, 5}, Display: []string{"none"}},
|
||||
"ZombieRageChance": {Kind: kindFloat, Floats: []float64{0, 0.15, 0.3, 0.35, 0.4, 0.5, 0.6, 0.75, 0.9, 1}, Display: []string{"none"}},
|
||||
"ZombieSpeeds": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4}, Display: []string{"goZMWalk", "goZMJog", "goZMRun", "goZMSprint", "goZMNightmare"}},
|
||||
"AISmellMode": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5}, Display: []string{"none", "goZMWalk", "goZMJog", "goZMRun", "goZMSprint", "goZMNightmare"}},
|
||||
"JumpStrength": {Kind: kindFloat, Floats: []float64{0, 0.5, 1, 1.25, 1.5, 2, 3}, Display: []string{"none"}},
|
||||
"StaminaRegen": {Kind: kindFloat, Floats: []float64{0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3}},
|
||||
"XPGain": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 5}, Display: []string{"none"}},
|
||||
"JarRefund": {Kind: kindFloat, Floats: []float64{0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1}, Display: []string{"none"}},
|
||||
"BarterValues": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 4}, Display: []string{"none"}},
|
||||
"DisabledLowDefaultHigh": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 1, 1.5, 2}, Display: []string{"goDisabled", "goVeryLow", "goLow", "goDefault", "goHigh", "goVeryHigh"}},
|
||||
"LowDefaultHigh": {Kind: kindFloat, Floats: []float64{0.25, 0.5, 1, 1.5, 2}, Display: []string{"goVeryLow", "goLow", "goDefault", "goHigh", "goVeryHigh"}},
|
||||
"Encumbrance": {Kind: kindFloat, Floats: []float64{10, 1.35, 1, 0.7, 0.35, 0}, Display: []string{"goDisabled", "goLow", "goDefault", "goHigh", "goVeryHigh", "xuiOptionsVideoTexQualityFull"}},
|
||||
"SkillGainRate": {Kind: kindInt, Ints: []int{1, 2, 3, 4, 5}},
|
||||
"PointsPer": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6, 7}, Display: []string{"none"}},
|
||||
"StarterSkillPoints": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, Display: []string{"none"}},
|
||||
"BloodMoonFrequency": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 20, 30}, Display: []string{"goDisabled", "goDay"}},
|
||||
"BloodMoonRange": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 7, 10, 14, 20}, Display: []string{"goDays", "goDay"}},
|
||||
"BloodMoonWarning": {Kind: kindInt, Ints: []int{0, 1, 2}, Display: []string{"goDisabled", "goMorning", "goEvening"}},
|
||||
"BloodMoonCount": {Kind: kindInt, Ints: []int{4, 6, 8, 10, 12, 16, 24, 32, 64}},
|
||||
"AirDrops": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6}, Display: []string{"goDisabled", "goAirDropValue"}},
|
||||
"AirDropRandomTime": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6}, Display: []string{"none", "goMorning", "goMidDayOnly", "goEvening", "goNightOnly", "goAllDay", "goAnyValue"}},
|
||||
"StormFrequency": {Kind: kindFloat, Floats: []float64{0, 0.5, 1, 1.5, 2, 3, 4, 5}, Display: []string{"none"}},
|
||||
"QuestPerTier": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, Display: []string{"none"}},
|
||||
"QuestPerDay": {Kind: kindInt, Ints: []int{-1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, Display: []string{"goUnlimited"}},
|
||||
"TraderArea": {Kind: kindInt, Ints: []int{0, 1, 2}, Display: []string{"xuiYes", "goClaimable", "goNotClaimable"}},
|
||||
"TraderResetInterval": {Kind: kindInt, Ints: []int{-1, 1, 2, 3, 4, 5, 6, 7, 14}, Display: []string{"xuiDefault", "goDay"}},
|
||||
"ItemTierOptions": {Kind: kindInt, Ints: []int{1, 2, 3, 4, 5, 6}},
|
||||
"DewCollectorInput": {Kind: kindFloat, Floats: []float64{0, 1, 2, 3}, Display: []string{"none"}},
|
||||
"ApiaryInput": {Kind: kindFloat, Floats: []float64{0, 0.2, 0.4, 0.6, 0.8, 1, 1.5, 2, 3}, Display: []string{"none"}},
|
||||
"CollectorOutput": {Kind: kindFloat, Floats: []float64{1, 2, 3, 4, 5}},
|
||||
"BackpackCrafting": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"xuiNo", "xuiYes", "goLimited", "goWorkbenchOnly"}},
|
||||
"DeathPenalty": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"none", "goXPOnly", "goInjured", "goPermaDeath"}},
|
||||
"DropOnDeath": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4}, Display: []string{"none", "lblAll", "goToolbelt", "goBackpack", "goDeleteAll"}},
|
||||
"DropOnQuit": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"none", "lblAll", "goToolbelt", "goBackpack"}},
|
||||
"LoseItemsOnDeathType": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5}, Display: []string{"none", "lblAll", "goToolbelt", "goBackpack", "goEquipment", "goCarriedOnly"}},
|
||||
"DegradeItemsOnDeath": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"none", "xuiDurability", "xuiMaxDurability", "xuiBoth"}},
|
||||
"TraderHourPresets": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6}, Display: []string{"xuiDefault", "goMorning", "goMidDayOnly", "goEvening", "goNightOnly", "goOnlyClosedOnBM", "goAlwaysOpen"}},
|
||||
"YesNo": {Kind: kindBool, Bools: []bool{false, true}, Display: []string{"xuiNo", "xuiYes"}},
|
||||
"Celebrate": {Kind: kindInt, Ints: []int{0, 1, 2}, Display: []string{"xuiNo", "xuiYes", "goHeadshotOnly"}},
|
||||
"ShowXP": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"lblAll", "goBarOnly", "goNotificationsOnly", "none"}},
|
||||
"HeadshotMode": {Kind: kindInt, Ints: []int{0, 1, 2}, Display: []string{"none", "goHeadshotOnly", "goHeadshotFinisher"}},
|
||||
"MaxEnemyType": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5}, Display: []string{"goNormals", "goStrongs", "goSpecials", "goFerals", "goRadiated", "goElites"}},
|
||||
"MaxTechType": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4}, Display: []string{"none", "goTech0", "goTech1", "goTech2", "goTech3"}},
|
||||
"LoseItemCount": {Kind: kindInt, Ints: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, Display: []string{"1-3", "1-5", "1-10", "1-20", "3-5", "5-7", "5-10", "7-10", "10-15", "15-20"}},
|
||||
"DayNightLength": {Kind: kindInt, Ints: []int{10, 20, 30, 40, 50, 60, 90, 120}},
|
||||
"DayLightLength": {Kind: kindInt, Ints: []int{0, 4, 6, 8, 10, 12, 14, 16, 18, 20, 24}, Display: []string{"goAlwaysNight", "4", "6", "8", "10", "12", "14", "16", "18", "20", "goAlwaysDay"}},
|
||||
"LootRespawnDays": {Kind: kindInt, Ints: []int{-1, 5, 7, 10, 15, 20, 30, 40, 50}, Display: []string{"goDisabled"}},
|
||||
"MaxChunkAge": {Kind: kindInt, Ints: []int{-1, 1, 3, 5, 7, 10, 20, 30, 40, 50, 75, 100}, Display: []string{"goDisabled"}},
|
||||
"Gravity": {Kind: kindFloat, Floats: []float64{0.5, 0.6, 0.7, 0.8, 0.9, 1}},
|
||||
"SlowToFast": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5}, Display: []string{"xuiDefault", "goVerySlow", "goSlow", "goNormal", "goFast", "goVeryFast"}},
|
||||
"BiomeEnemyDensity": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5}, Display: []string{"xuiDefault", "goVeryLow", "goLow", "goMedium", "goHigh", "goVeryHigh"}},
|
||||
"SmeltingType": {Kind: kindBool, Bools: []bool{false, true}, Display: []string{"goSmelter", "lblContextActionRecipes"}},
|
||||
"RepairTypes": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"none", "goRepairOnly", "goCombineOnly", "xuiBoth"}},
|
||||
"MaxDegradationAmounts": {Kind: kindFloat, Floats: []float64{0, 0.05, 0.1, 0.15, 0.2, 0.25}, Display: []string{"none"}},
|
||||
"CropGrowthSpeed": {Kind: kindFloat, Floats: []float64{1000, 0, 0.2, 0.5, 0.75, 1, 1.5, 2, 3}, Display: []string{"none", "xuiInstant"}},
|
||||
"ZombieFeralSense": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"goDisabled", "goZMDay", "goZMNight", "goZMAll"}},
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package steamvdf
|
||||
|
||||
import "testing"
|
||||
|
||||
// Realistic ACF as steamcmd writes it for a 7DTD dedicated server pinned
|
||||
// to a frozen beta branch.
|
||||
const acf7dtd = `"AppState"
|
||||
{
|
||||
"appid" "294420"
|
||||
"Universe" "1"
|
||||
"name" "7 Days to Die Dedicated Server"
|
||||
"StateFlags" "4"
|
||||
"installdir" "7 Days to Die Dedicated Server"
|
||||
"LastUpdated" "1750912345"
|
||||
"SizeOnDisk" "14680064000"
|
||||
"StagingSize" "0"
|
||||
"buildid" "14503775"
|
||||
"LastOwner" "76561190000000000"
|
||||
"UpdateResult" "0"
|
||||
"BytesToDownload" "0"
|
||||
"BytesDownloaded" "0"
|
||||
"AutoUpdateBehavior" "0"
|
||||
"AllowOtherDownloadsWhileRunning" "0"
|
||||
"ScheduledAutoUpdate" "0"
|
||||
"InstalledDepots"
|
||||
{
|
||||
"294422"
|
||||
{
|
||||
"manifest" "1118115119020227469"
|
||||
"size" "14680064000"
|
||||
}
|
||||
}
|
||||
"UserConfig"
|
||||
{
|
||||
"betakey" "v2.6"
|
||||
}
|
||||
"MountedConfig"
|
||||
{
|
||||
"betakey" "v2.6"
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const acfPublic = `"AppState"
|
||||
{
|
||||
"appid" "2430930"
|
||||
"name" "Palworld Dedicated Server"
|
||||
"buildid" "20112233"
|
||||
"UserConfig"
|
||||
{
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func TestParseAppManifestBetaBranch(t *testing.T) {
|
||||
m, err := ParseAppManifest(acf7dtd)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAppManifest: %v", err)
|
||||
}
|
||||
if m.AppID != "294420" || m.BuildID != "14503775" || m.BetaKey != "v2.6" {
|
||||
t.Fatalf("got %+v", m)
|
||||
}
|
||||
if m.Name != "7 Days to Die Dedicated Server" {
|
||||
t.Fatalf("name: %q", m.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAppManifestPublic(t *testing.T) {
|
||||
m, err := ParseAppManifest(acfPublic)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAppManifest: %v", err)
|
||||
}
|
||||
if m.BuildID != "20112233" || m.BetaKey != "" {
|
||||
t.Fatalf("got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAppManifestNoBuildID(t *testing.T) {
|
||||
if _, err := ParseAppManifest(`"AppState" { "appid" "1" }`); err == nil {
|
||||
t.Fatal("expected error for manifest without buildid")
|
||||
}
|
||||
}
|
||||
|
||||
// app_info_print output as steamcmd emits it: banner noise, the numeric
|
||||
// appid root key, escaped quotes, nested branches with pwdrequired etc.
|
||||
const appInfo = `Redirecting stderr to '/root/Steam/logs/stderr.txt'
|
||||
Loading Steam API...OK
|
||||
Connecting anonymously to Steam Public...OK
|
||||
Waiting for client config...OK
|
||||
Waiting for user info...OK
|
||||
AppID : 294420, change number : 27581234/0, last change : Sat Jun 28 12:00:00 2026
|
||||
"294420"
|
||||
{
|
||||
"common"
|
||||
{
|
||||
"name" "7 Days to Die Dedicated Server"
|
||||
"type" "Tool"
|
||||
}
|
||||
"config"
|
||||
{
|
||||
"installdir" "7 Days to Die Dedicated Server"
|
||||
}
|
||||
"depots"
|
||||
{
|
||||
"294422"
|
||||
{
|
||||
"name" "Linux Depot"
|
||||
"config"
|
||||
{
|
||||
"oslist" "linux"
|
||||
}
|
||||
}
|
||||
"branches"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"buildid" "19216811"
|
||||
"timeupdated" "1751168000"
|
||||
}
|
||||
"latest_experimental"
|
||||
{
|
||||
"buildid" "19333444"
|
||||
"description" "Latest \"experimental\" build"
|
||||
"timeupdated" "1752168000"
|
||||
}
|
||||
"v2.6"
|
||||
{
|
||||
"buildid" "14503775"
|
||||
"description" "2.6 Stable"
|
||||
"timeupdated" "1750168000"
|
||||
}
|
||||
"private_qa"
|
||||
{
|
||||
"buildid" "19999999"
|
||||
"pwdrequired" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func TestParseAppInfoBranches(t *testing.T) {
|
||||
b, err := ParseAppInfoBranches(appInfo, "294420")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAppInfoBranches: %v", err)
|
||||
}
|
||||
want := map[string]string{
|
||||
"public": "19216811",
|
||||
"latest_experimental": "19333444",
|
||||
"v2.6": "14503775",
|
||||
"private_qa": "19999999",
|
||||
}
|
||||
for k, v := range want {
|
||||
if b[k] != v {
|
||||
t.Fatalf("branch %s: got %q want %q (all: %v)", k, b[k], v, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAppInfoBranchesUnknownRootKey(t *testing.T) {
|
||||
// Caller passes the wrong appid; fallback finds the object anyway.
|
||||
b, err := ParseAppInfoBranches(appInfo, "999999")
|
||||
if err != nil {
|
||||
t.Fatalf("fallback failed: %v", err)
|
||||
}
|
||||
if b["public"] != "19216811" {
|
||||
t.Fatalf("got %v", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAppInfoNoBranches(t *testing.T) {
|
||||
if _, err := ParseAppInfoBranches(`"1" { "common" { "name" "x" } }`, "1"); err == nil {
|
||||
t.Fatal("expected error when branches missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToleratesCommentsAndUnquoted(t *testing.T) {
|
||||
kv, err := Parse("// comment\nAppState { key value // trailing\n nested { a b } }")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if kv.Get("AppState", "key") != "value" || kv.Get("appstate", "nested", "a") != "b" {
|
||||
t.Fatalf("got %v", kv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUnterminated(t *testing.T) {
|
||||
if _, err := Parse(`"A" { "k" "v"`); err == nil {
|
||||
t.Fatal("expected unterminated-object error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Package version holds the single build-time version string shared by
|
||||
// every panel binary (controller, agent, panelctl).
|
||||
//
|
||||
// It defaults to "dev" and is injected at build time via:
|
||||
//
|
||||
// go build -ldflags "-X github.com/dbledeez/panel/pkg/version.Version=v1.2.3"
|
||||
//
|
||||
// The Makefile at the repo root and the install scripts (install.sh /
|
||||
// install.ps1) wire this automatically from `git describe` or $PANEL_VERSION.
|
||||
package version
|
||||
|
||||
// Version is the panel build version. Overridden by -ldflags -X; "dev"
|
||||
// in ad-hoc `go build` / `go run` builds.
|
||||
var Version = "dev"
|
||||
|
||||
// UserAgent returns the canonical User-Agent header value for outbound
|
||||
// HTTP requests made by panel binaries.
|
||||
func UserAgent() string {
|
||||
return "panel/" + Version + " (+https://github.com/dbledeez/panel)"
|
||||
}
|
||||
Reference in New Issue
Block a user