4cf3471398
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
364 lines
11 KiB
Go
364 lines
11 KiB
Go
// Package sandbox handles the 7 Days to Die V3.0 "SandboxCode" — the single
|
||
// encoded serverconfig.xml property that carries all 150 in-game Sandbox Options
|
||
// introduced in V3.0 "Dead Hot Summer".
|
||
//
|
||
// V3.0 collapses ~29 legacy gameplay serverconfig properties (GameDifficulty,
|
||
// BloodMoonFrequency, LootAbundance, …) into one deterministic, OFFLINE-encoded
|
||
// string. The encode/decode algorithm ships in the game binary; this package is
|
||
// a faithful Go port of it, cracked from the decompiled 3.0 Assembly-CSharp
|
||
// (SandboxOptions.SandboxOptionManager + SandboxOptionPreset, buildid 23705258).
|
||
//
|
||
// ## The encoding (verified against the game's own default code)
|
||
//
|
||
// code[0] = version char ('A', SandboxOptionManager.currentVersion)
|
||
// then 3-char triples, one per option that differs from its default:
|
||
// triple[0..1] = the SandboxOptions enum int, base-26: c0*26 + c1 (A=0)
|
||
// triple[2] = the selected value INDEX in that option's value-set (A=0)
|
||
//
|
||
// Decode resets every option to its default, then applies each triple. Encode is
|
||
// the inverse: emit a triple for every option whose chosen index ≠ default index
|
||
// (options omitted from the code decode to default — identical result, shorter
|
||
// code, matching how the game emits difficulty presets like AAAJABJACJADJARFBNC).
|
||
//
|
||
// Round-trip + the game's example codes are checked in sandbox_test.go.
|
||
package sandbox
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// codeVersion is SandboxOptionManager.currentVersion. A code must start with it.
|
||
const codeVersion = 'A'
|
||
|
||
// ErrCodecUnavailable is retained for API compatibility with the day-one
|
||
// passthrough scaffold. The real codec no longer returns it.
|
||
var ErrCodecUnavailable = errors.New("sandbox: per-option SandboxCode codec unavailable")
|
||
|
||
// optByEnum / optByName index the options table for O(1) lookup. defaultIdx
|
||
// holds each option's default value's index in its value-set.
|
||
var (
|
||
optByEnum = map[int]*option{}
|
||
optByName = map[string]*option{}
|
||
defaultIdx = map[string]int{}
|
||
)
|
||
|
||
func init() {
|
||
for i := range options {
|
||
o := &options[i]
|
||
e, ok := enumByName[o.Name]
|
||
if !ok {
|
||
panic("sandbox: option " + o.Name + " has no SandboxOptions enum entry")
|
||
}
|
||
o.enum = e
|
||
vs, ok := valueSets[o.ValueSet]
|
||
if !ok {
|
||
panic("sandbox: option " + o.Name + " references unknown value-set " + o.ValueSet)
|
||
}
|
||
di, ok := indexOfDefault(*o, vs)
|
||
if !ok {
|
||
panic(fmt.Sprintf("sandbox: option %s default not present in value-set %s", o.Name, o.ValueSet))
|
||
}
|
||
o.set = vs
|
||
defaultIdx[o.Name] = di
|
||
optByEnum[e] = o
|
||
optByName[o.Name] = o
|
||
}
|
||
}
|
||
|
||
// indexOfDefault finds the index of an option's default value in its value-set.
|
||
func indexOfDefault(o option, vs valueSet) (int, bool) {
|
||
switch o.Type {
|
||
case optFloat:
|
||
for i, f := range vs.Floats {
|
||
if floatEq(f, o.DefaultFloat) {
|
||
return i, true
|
||
}
|
||
}
|
||
case optInt:
|
||
for i, n := range vs.Ints {
|
||
if n == o.DefaultInt {
|
||
return i, true
|
||
}
|
||
}
|
||
case optBool:
|
||
for i, b := range vs.Bools {
|
||
if b == o.DefaultBool {
|
||
return i, true
|
||
}
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func floatEq(a, b float64) bool { return math.Abs(a-b) < 1e-6 }
|
||
|
||
// Codec converts between a SandboxCode string and a per-option value map.
|
||
// The map is keyed by the SandboxOptions member name (e.g. "BloodMoonFrequency")
|
||
// with string values (the human value: "7", "1.5", "true"). Encode emits a code
|
||
// for the options whose value differs from default; Decode returns the FULL set
|
||
// of 150 options at their effective values (defaults for any omitted from code).
|
||
type Codec interface {
|
||
Version() string
|
||
Encode(options map[string]string) (string, error)
|
||
Decode(code string) (map[string]string, error)
|
||
}
|
||
|
||
// V3Codec is the real 3.0 SandboxCode codec.
|
||
type V3Codec struct{}
|
||
|
||
func (V3Codec) Version() string { return string(codeVersion) }
|
||
|
||
// Encode builds a SandboxCode from a name→value map. Unknown keys are ignored;
|
||
// a value that doesn't match any entry in its option's value-set is an error
|
||
// (prevents silently writing an out-of-range setting). Options absent from the
|
||
// map, or equal to their default, are omitted from the code (decode to default).
|
||
// Triples are emitted in ascending enum order for a stable, diffable code.
|
||
func (V3Codec) Encode(vals map[string]string) (string, error) {
|
||
type triple struct {
|
||
enum, idx int
|
||
}
|
||
var triples []triple
|
||
for name, raw := range vals {
|
||
o, ok := optByName[name]
|
||
if !ok {
|
||
continue // unknown option key — ignore (forward-compat)
|
||
}
|
||
idx, err := o.indexOfValue(raw)
|
||
if err != nil {
|
||
return "", fmt.Errorf("sandbox: option %s: %w", name, err)
|
||
}
|
||
if idx == defaultIdx[name] {
|
||
continue // equals default — omit (decodes to same value)
|
||
}
|
||
triples = append(triples, triple{o.enum, idx})
|
||
}
|
||
sort.Slice(triples, func(i, j int) bool { return triples[i].enum < triples[j].enum })
|
||
|
||
var b strings.Builder
|
||
b.WriteByte(codeVersion)
|
||
for _, t := range triples {
|
||
s, err := indexToAlpha2(t.enum)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
c, err := indexToAlpha(t.idx)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
b.WriteString(s)
|
||
b.WriteByte(c)
|
||
}
|
||
return b.String(), nil
|
||
}
|
||
|
||
// Decode parses a SandboxCode into the FULL effective option set (all 150 at
|
||
// their resolved values: code-specified or default). An empty code returns all
|
||
// defaults. A code with a wrong version char, bad length, or unknown enum is an
|
||
// error (unknown enums could mean a version drift we shouldn't silently accept).
|
||
func (V3Codec) Decode(code string) (map[string]string, error) {
|
||
out := DefaultsMap()
|
||
if code == "" {
|
||
return out, nil
|
||
}
|
||
if rune(code[0]) != codeVersion {
|
||
return nil, fmt.Errorf("sandbox: code version %q unsupported (want %q)", string(code[0]), string(codeVersion))
|
||
}
|
||
body := code[1:]
|
||
if len(body)%3 != 0 {
|
||
return nil, fmt.Errorf("sandbox: code body length %d is not a multiple of 3", len(body))
|
||
}
|
||
for i := 0; i < len(body); i += 3 {
|
||
enum, err := alpha2ToIndex(body[i : i+2])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
idx, err := alphaToIndex(body[i+2])
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
o, ok := optByEnum[enum]
|
||
if !ok {
|
||
return nil, fmt.Errorf("sandbox: code references unknown option enum %d (game version drift?)", enum)
|
||
}
|
||
if !o.set.IsValidIndex(idx) {
|
||
return nil, fmt.Errorf("sandbox: option %s index %d out of range (0..%d)", o.Name, idx, o.set.Len()-1)
|
||
}
|
||
out[o.Name] = o.valueAtIndex(idx)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// indexOfValue resolves a human value string to its index in the option's set.
|
||
func (o option) indexOfValue(raw string) (int, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
switch o.Type {
|
||
case optFloat:
|
||
f, err := strconv.ParseFloat(raw, 64)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("expected a number, got %q", raw)
|
||
}
|
||
for i, v := range o.set.Floats {
|
||
if floatEq(v, f) {
|
||
return i, nil
|
||
}
|
||
}
|
||
return 0, fmt.Errorf("value %s not an allowed option (set %s)", raw, o.ValueSet)
|
||
case optInt:
|
||
n, err := strconv.Atoi(raw)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("expected an integer, got %q", raw)
|
||
}
|
||
for i, v := range o.set.Ints {
|
||
if v == n {
|
||
return i, nil
|
||
}
|
||
}
|
||
return 0, fmt.Errorf("value %d not an allowed option (set %s)", n, o.ValueSet)
|
||
case optBool:
|
||
b, err := parseBool(raw)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
for i, v := range o.set.Bools {
|
||
if v == b {
|
||
return i, nil
|
||
}
|
||
}
|
||
return 0, fmt.Errorf("value %v not an allowed option (set %s)", b, o.ValueSet)
|
||
}
|
||
return 0, fmt.Errorf("option %s has unknown type", o.Name)
|
||
}
|
||
|
||
// valueAtIndex returns the human value string for an index in the option's set.
|
||
func (o option) valueAtIndex(i int) string {
|
||
switch o.Type {
|
||
case optFloat:
|
||
return strconv.FormatFloat(o.set.Floats[i], 'f', -1, 64)
|
||
case optInt:
|
||
return strconv.Itoa(o.set.Ints[i])
|
||
case optBool:
|
||
return strconv.FormatBool(o.set.Bools[i])
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func parseBool(s string) (bool, error) {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "true", "1", "yes", "on":
|
||
return true, nil
|
||
case "false", "0", "no", "off":
|
||
return false, nil
|
||
}
|
||
return false, fmt.Errorf("expected a boolean, got %q", s)
|
||
}
|
||
|
||
// ---- alpha <-> index helpers (ports of SandboxOptionManager.*) ----
|
||
|
||
func indexToAlpha(index int) (byte, error) {
|
||
if index < 0 || index > 26 {
|
||
return 0, fmt.Errorf("sandbox: value index %d out of code range", index)
|
||
}
|
||
return byte(65 + index%26), nil
|
||
}
|
||
|
||
func indexToAlpha2(index int) (string, error) {
|
||
if index < 0 || index >= 676 {
|
||
return "", fmt.Errorf("sandbox: enum index %d out of code range", index)
|
||
}
|
||
return string([]byte{byte(65 + index/26), byte(65 + index%26)}), nil
|
||
}
|
||
|
||
func alphaToIndex(value byte) (int, error) {
|
||
v := upper(value)
|
||
if v < 'A' || v > 'Z' {
|
||
return 0, errors.New("sandbox: code value char must be A–Z")
|
||
}
|
||
return int(v - 65), nil
|
||
}
|
||
|
||
func alpha2ToIndex(value string) (int, error) {
|
||
if len(value) != 2 {
|
||
return 0, errors.New("sandbox: code option key must be exactly 2 letters")
|
||
}
|
||
a, b := upper(value[0]), upper(value[1])
|
||
if a < 'A' || a > 'Z' || b < 'A' || b > 'Z' {
|
||
return 0, errors.New("sandbox: code option key must contain only A–Z")
|
||
}
|
||
return int(a-65)*26 + int(b-65), nil
|
||
}
|
||
|
||
func upper(b byte) byte {
|
||
if b >= 'a' && b <= 'z' {
|
||
return b - 32
|
||
}
|
||
return b
|
||
}
|
||
|
||
// DefaultsMap returns every option at its default value (name → value string).
|
||
func DefaultsMap() map[string]string {
|
||
out := make(map[string]string, len(options))
|
||
for i := range options {
|
||
o := &options[i]
|
||
out[o.Name] = o.valueAtIndex(defaultIdx[o.Name])
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Active is the codec the panel uses. Now the real V3 codec.
|
||
var Active Codec = V3Codec{}
|
||
|
||
// Preset is an official (or operator-saved) named SandboxCode.
|
||
type Preset struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
Code string `json:"code"`
|
||
}
|
||
|
||
// Presets are the V3.0 official presets surfaced in the UI. The default
|
||
// difficulty (Adventurer) code is shipped from the game's serverconfig comment;
|
||
// the named-preset codes are captured from the in-game Sandbox Options window
|
||
// (see scripts/capture-preset-codes). An empty Code == the game's built-in
|
||
// default sandbox.
|
||
var Presets = []Preset{
|
||
{ID: "default", Name: "Default (Adventurer)", Description: "The game's built-in default sandbox.", Code: "AAAJABJACJADJARFBNC"},
|
||
{ID: "undead_matinee", Name: "Undead Matinee", Description: "Official V3.0 preset.", Code: ""},
|
||
{ID: "madmoles_mayhem", Name: "Madmole's Mayhem", Description: "Official V3.0 preset.", Code: ""},
|
||
{ID: "almost_creative", Name: "Almost Creative Mode", Description: "Official V3.0 preset.", Code: ""},
|
||
{ID: "bite_club", Name: "Bite Club", Description: "Official V3.0 preset.", Code: ""},
|
||
{ID: "legacy_survival", Name: "Legacy Survival", Description: "Official V3.0 preset.", Code: ""},
|
||
}
|
||
|
||
// PresetByID returns the named preset, or ok=false if unknown.
|
||
func PresetByID(id string) (Preset, bool) {
|
||
id = strings.TrimSpace(strings.ToLower(id))
|
||
for _, p := range Presets {
|
||
if strings.ToLower(p.ID) == id {
|
||
return p, true
|
||
}
|
||
}
|
||
return Preset{}, false
|
||
}
|
||
|
||
// Validate does a cheap structural sanity check on a SandboxCode before it's
|
||
// written into serverconfig.xml. An empty code is valid (== default sandbox).
|
||
// A non-empty code must decode cleanly (right version, length, known enums,
|
||
// in-range indices) and contain no XML-attribute-breaking characters.
|
||
func Validate(code string) error {
|
||
if code == "" {
|
||
return nil
|
||
}
|
||
if strings.ContainsAny(code, `"<>&`) {
|
||
return errors.New("sandbox: code contains characters not valid in an XML attribute value")
|
||
}
|
||
if _, err := (V3Codec{}).Decode(code); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|