Files
panel/pkg/sandbox/sandbox.go
T
dbledeez 4ccccc6fe2 panel v0.9.2 — public release
Self-hostable game server control panel: Go controller + agent, 26 game
modules, embedded web UI. One-line install via install.sh / install.ps1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:43:35 -07:00

364 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}