panel public release

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:26:41 -07:00
commit 295eb22826
2165 changed files with 301492 additions and 0 deletions
+490
View File
@@ -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
}
+170
View File
@@ -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
+389
View File
@@ -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
}
+72
View File
@@ -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)
}
}
}
+159
View File
@@ -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)]...)
}
+170
View File
@@ -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
}
+7
View File
@@ -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
+6
View File
@@ -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=
+73
View File
@@ -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)
}
}
}
})
}
}
+165
View File
@@ -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
}
+436
View File
@@ -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
}
+81
View File
@@ -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")
}
}
+70
View File
@@ -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)
}
+112
View File
@@ -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
}
+153
View File
@@ -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)
}
}
}
+191
View File
@@ -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
}
+56
View File
@@ -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
+193
View File
@@ -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)
}
+103
View File
@@ -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
}
+433
View File
@@ -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)
}
}
+101
View File
@@ -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
}
+189
View File
@@ -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
}
+175
View File
@@ -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
}()
+208
View File
@@ -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},
}
+363
View File
@@ -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 AZ")
}
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 AZ")
}
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
}
+154
View File
@@ -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")
}
}
+94
View File
@@ -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
}
+13
View File
@@ -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) }
}
+114
View File
@@ -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"}},
}
+307
View File
@@ -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
}
+191
View File
@@ -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")
}
}
+20
View File
@@ -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)"
}