0a941f3ba6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
491 lines
15 KiB
Go
491 lines
15 KiB
Go
// 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
|
|
}
|