// 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 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 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 `` 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-) 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 entries from snippet's . 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 }