📄 Declarative Mod Patch Format¶
This page describes the v0.1 format for representing Pioneers of Pagonia GameDatabase mods as small, reviewable patches.
The goal is not to replace the XML documentation. The goal is to make future mods easier to read, validate, compare, and combine.
Which mod pattern does this format target? This page describes the declarative mod format used to build Pattern A (patched canonical pak) and the XML side of Pattern B (side-by-side overlay pak). For a comparison of all three Pioneers of Pagonia mod patterns — patching shipped paks, side-by-side overlays, and user maps from the editor — see Mod Distribution Patterns.
Core Idea¶
A mod should describe intent:
Find this known entity and field.
Confirm the old value is still what the mod expects.
Apply this small change.
Warn if another mod or game update changed the same target first.
In short:
This is inspired by patch-based modding workflows such as Anno modding, where mods describe targeted XML operations instead of shipping full replacement database files. For Pagonia, the format should be stricter and more beginner-friendly by default.
Useful Anno references for comparison:
- Anno Modloader Modinfo Reference
- Anno Modloader Loading Order
- Anno Modding Guide: Structure Of A Mod
The most important lessons to carry over are metadata, dependencies, optional dependencies, load order, incompatibilities, and a clear folder structure. The main improvement for Pagonia should be stricter target validation with expected old values.
Why Patch Descriptions?¶
Directly editing full XML files is hard to share safely:
- it is difficult to see what changed
- it is easy to accidentally include unrelated game data
- multiple mods can overwrite each other
- game updates can silently invalidate edits
- beginners often change GUIDs or structure when they only meant to change one amount
A patch description keeps the mod small:
operation: replaceValue
target:
file: core/gdb/buildings.gd.xml
entityGuid: c732cb26-7487-4a7b-b1ba-b65e094f9bac
entityName: Sawmill
component: AspectBuildup
path: Costs/Item[Content/Resource='c22b4997-5563-44ab-8aa0-04a7b2c826be']/Content/Amount
expectedOldValue: "4"
value: "3"
The exact syntax will evolve, but the principle should stay: every patch declares its target and its expectation.
v0.1 schemas for this format live in schemas/mod-patches.
Suggested Mod Layout¶
A future mod package could look like this:
mod.yaml describes the mod:
patchFormatVersion: 0.1
id: pagonia-land.example.cheaper-sawmill
name: Cheaper Sawmill
version: 0.1.0
author: Pagonia Land
gameDatabaseVersion: "1.4.0-12032+195221"
description: Lowers one Sawmill construction cost for local testing.
requiredPackages:
- core
optionalPackages: []
requiresNewGame: false
safeToRemove: unknown
multiplayerSafe: unknown
campaignSafe: unknown
loadAfter: []
loadBefore: []
incompatibleWith: []
patches:
- patches/buildings.yaml
Patch files describe operations:
operations:
- id: cheaper-sawmill-softwood
operation: replaceValue
risk: low
reason: Lower early Sawmill construction cost for testing.
target:
file: core/gdb/buildings.gd.xml
entityGuid: c732cb26-7487-4a7b-b1ba-b65e094f9bac
entityName: Sawmill
component: AspectBuildup
path: Costs/Item[Content/Resource='c22b4997-5563-44ab-8aa0-04a7b2c826be']/Content/Amount
expectedOldValue: "4"
value: "3"
Required Metadata¶
| Field | Purpose |
|---|---|
patchFormatVersion |
Version of this patch metadata format, so future tools can handle format changes |
id |
Stable unique mod id, preferably reverse-domain style |
name |
Human-readable mod name |
version |
Mod version |
author |
Author or team |
gameDatabaseVersion |
Exact program/database version the mod was authored against, such as 1.4.0-12032+195221 |
description |
Short description |
requiredPackages |
Packages that must be present, such as core, dlc1, decorations1, or tools |
optionalPackages |
Packages that enable optional patch sets when present |
requiresNewGame |
Whether the mod should only be used with a new save |
safeToRemove |
Whether the mod can likely be removed from an existing save |
multiplayerSafe |
Whether the mod is expected to work in multiplayer |
campaignSafe |
Whether the mod is expected to work in campaign mode |
loadAfter / loadBefore |
Optional ordering hints |
incompatibleWith |
Known incompatible mod ids |
patches |
Patch files included in the mod |
Game Database Version¶
Use the exact program version that belongs to the extracted GameDatabase XML set:
Prefer this exact version over descriptive names such as "Meadowsong release". Human labels can still be used in notes, but validators and patch files need stable, unambiguous identifiers.
This keeps mod metadata short and readable. A separate technical checksum can still be added later by tooling if the community needs stricter verification.
Package And DLC Dependencies¶
Pagonia data is split into package folders:
Patch metadata should declare which packages are required:
If a mod has optional DLC support, put that support in a separate patch set:
requiredPackages:
- core
optionalPackages:
- dlc1
patchSets:
- id: base
optional: false
requiresPackages:
- core
patches:
- patches/core.yaml
- id: dlc1-support
optional: true
requiresPackages:
- dlc1
patches:
- patches/dlc1.yaml
The validator should skip optional patch sets when their package is missing. It should block required patch sets when their package is missing.
This is especially important because dlc1 can add new resources, buildings, recipes, objectives, map data, and cross-package references. A core-only player should not accidentally apply DLC-targeted patches.
At deploy time, "missing" splits in two. Because Envision ships every pak (
core,decorations1,dlc1,tools) to every player, "the pak is present on disk" does not mean "the player owns the expansion".pagonia-managerseparates Present (on disk) from Owned (declared) from Effective (active in solo play), so it can give your players an honest message instead of a silent no-op: a required package that's genuinely absent errors, while a present-but-not-owned one only warns and still deploys (the bytes a co-op participant needs to match an owning host). As a mod author you only declarerequiredPackages/optionalPackageshere; the ownership handling is the manager's. See Present vs Owned vs Effective and the manager's expansion gate.
Save And Mode Safety¶
Safety metadata should be explicit even when the answer is unknown:
Suggested values:
| Field | Values |
|---|---|
requiresNewGame |
true, false, unknown |
safeToRemove |
true, false, unknown |
multiplayerSafe |
true, false, unknown |
campaignSafe |
true, false, unknown |
Examples:
- changing one display icon may be
requiresNewGame: false - adding a new entity may be
requiresNewGame: unknown - changing objectives or tech tree data should usually be
campaignSafe: unknownuntil tested - removing entities should usually be
safeToRemove: falseorunknown
Target Model¶
Targets should be entity-oriented first and XPath-like second.
Good target information:
| Field | Why It Helps |
|---|---|
file |
Limits the search scope and improves error messages |
entityGuid |
Stable identity of the target entity |
entityName |
Human-readable safety check |
component |
Limits the patch to one component |
path |
Identifies the field or list item inside the component |
The entityGuid should be the primary identity. The entityName is a helpful cross-check and error message, but names can be duplicated or changed.
The schema's target is permissive — its anyOf accepts entityName alone because addEntity uses entityName as the new entity's group name — but every operation that edits an existing entity requires entityGuid. entityName alone cannot locate an entity; such a target fails planning with a clear "requires target.entityGuid" diagnostic rather than resolving. Always set entityGuid as the primary identity (see Mod Conflicts → Recommended first rule set); entityName stays a human-readable cross-check that warns on a name mismatch.
Patch Operations¶
Start with a small operation set. More operations can be added later after real modding tests.
| Operation | Meaning | Beginner Risk |
|---|---|---|
replaceValue |
Replace the text value of an existing field | Low |
multiplyValue |
Scale an existing numeric value by a factor | Low |
addValue |
Add a delta to an existing numeric value | Low |
replaceAttribute |
Replace an existing XML attribute value | Medium |
addListItem |
Add one item to an existing list | Medium |
mergeComponent |
Add missing fields to an existing component | Medium |
removeListItem |
Remove one matching item from a list | High |
replaceNode |
Replace a whole XML node | High |
addEntity |
Add a new entity with a new GUID (⚠ advanced — container contract not yet finalised; see note below) | High |
removeEntity |
Remove an entity | Very high |
The Beginner Risk column above is descriptive guidance. It is not the
operation's risk: metadata field — that field takes the machine-readable
values low | medium | high | very-high (note the hyphen in very-high).
addEntity is advanced and not yet fully specified for general use: the
container contract (where a brand-new entity is inserted, and what target
should carry when there is no existing entity to point at) is still being
finalised, and there is no worked example yet. Until then, prefer the existing
in-place operations.
For first public experiments, prefer replaceValue only.
The xml: field (for addListItem, mergeComponent, replaceNode, addEntity)¶
The operations that insert or replace whole nodes carry the new content in an
xml: field — a raw XML fragment. The patcher normalises whitespace inside
xml: blocks before insertion, so your indentation there is for readability
only; it does not have to match the surrounding file. (The same normalisation
applies to expectedOldXml, which is why a value-level cross-check tolerates
reformatting.)
addListItem appends the fragment as a new item under the path list:
- id: add-fast-cleanse-ability-to-sanctuary
operation: addListItem
risk: medium
reason: Surface the custom Fast Cleanse ability in the Sanctuary spell bar.
target:
file: dlc1/gdb/buildings.gd.xml
entityGuid: 482eddcc-ae28-49b0-8e97-807ab1a69db6
entityName: Sanctuary
component: AspectShrine
path: Abilities
xml: |
<Item>
<Content>
<Ability>e1a91b00-0001-4000-8000-000000000001</Ability>
</Content>
</Item>
replaceNode swaps the single node selected by target.path for the xml:
fragment; mergeComponent merges the fragment's child elements into the
component named by target.component. In every case the fragment is a plain
XML element with no namespace declarations — match the shape the file already
uses for that list or node.
Arithmetic operations (multiplyValue, addValue)¶
replaceValue sets a value to a fixed number. The arithmetic operations instead
compute the new value relative to the existing one, which is what a literal
substitution cannot express — "all costs ×1.5" or "+2 to a build cost":
multiplyValuewritesexpectedOldValue × factor;addValuewritesexpectedOldValue + delta(a negativedeltasubtracts).
- id: scale-sawmill-softwood-cost
operation: multiplyValue
risk: low
reason: Scale the Sawmill softwood cost with the shared cost multiplier.
target:
file: core/gdb/buildings.gd.xml
entityGuid: c732cb26-7487-4a7b-b1ba-b65e094f9bac
entityName: Sawmill
component: AspectBuildup
path: Costs/Item[Content/Resource='c22b4997-5563-44ab-8aa0-04a7b2c826be']/Content/Amount
expectedOldValue: "4"
factor: "{{ tweaks.cost-multiplier }}" # literal or a tweak placeholder
rounding: round # round | floor | ceil (default round)
clampMin: "1" # optional bounds on the result
Key points:
- The math runs at plan time from the declared
expectedOldValueand the operand — the existing-value cross-check (the same drift guardreplaceValueuses) still has to match the file, so the result never depends on another mod's earlier write.expectedOldValueis therefore required. - The operand (
factor/delta) may be a literal or a{{ tweaks.<id> }}placeholder, so one shared multiplier tweak referenced by many ops is one knob that scales every target relative to its own vanilla value. - Game-database values are integers, so the result is rounded (
round/floor/ceil, defaultround) and optionally clamped toclampMin/clampMaxafter rounding. A clamp that fires is reported in the plan. - The target must be a single leaf value (same rule as
replaceValue), and two ops writing the same target still conflict — amultiplyValueand areplaceValueon one field collide just like tworeplaceValues.
Binary Pak Entries¶
XML patches live in patches/*.yaml and target the game-database XML files. Non-XML pak entries — icons, textures, meshes, prefabs, materials, audio events, etc. — are handled separately by an entries: block in mod.yaml plus a sibling entries/ folder that ships the raw bytes.
entries:
replace:
- path: core/gui/icons/buildings/icon_sawmill.image
source: entries/icon_sawmill.image
add:
- path: core/gdb/my-new-rule.bin
source: entries/my-new-rule.bin
delete:
- core/sounds/obsolete-cue.audio
The three lists are all optional; a real mod uses whichever it needs.
| Operation | What it does | Path constraint |
|---|---|---|
entries.replace |
Substitute an existing pak entry with the bytes from source. The entry keeps its original compressed flag. |
path must match an entry name in the base pak you'll patch against. |
entries.add |
Append a new pak entry. Compression flag is chosen by extension (.xml/.yaml/.yml/.txt/.json → gzip; everything else stored verbatim). |
path must NOT already be in the base pak. |
entries.delete |
Omit a pak entry from the resulting pak. | path must match an entry name in the base pak. |
source is a path relative to the mod folder; it must not be rooted and must not contain ... path is a pak entry name; the first segment is always a pak namespace (core, decorations1, dlc1, tools) and .. traversal is rejected at the schema level.
Conflicts surface in the plan as entryConflict diagnostics. Two mods touching the same path is an error UNLESS every touching operation is a delete (two mods removing the same entry agree). The same path cannot appear twice across replace/add/delete within a single mod either.
Apply produces, in addition to the patched XML files:
- one file under
sandbox/out/<pak>/<path>for everyreplaceandaddoperation - one line in
sandbox/out/.entries-deleted.txtperdelete
scripts/sandbox-pack.ps1 -BasePak <base.pak> consumes that layout: every file under sandbox/out/ is forwarded to pagonia-paker patch as a positional argument (it auto-classifies into Replace vs Add by comparing against the base pak's entry list), and every line in .entries-deleted.txt becomes a --delete <path> flag. The pure-asset side and the XML side both land in the same .pak file from one sandbox-pack invocation.
When entries.add ships a new *.gd.xml under a module namespace already in the base pak, pagonia-paker patch also updates that module's <m>/<m>.gd.bin index automatically so the engine actually loads the new file. No extra mod-side declaration needed; the auto-register surfaces as a pakPatchGdbinUpdated diagnostic and a GdbinUpdates entry in the patch report. Pass --no-gdbin-register to pagonia-paker patch to opt out.
See sandbox/examples/add-extra-asset/ for a worked template that ships only entry operations and no XML patches.
Standalone Overlay Paks (pak: block)¶
The entries: workflow above produces a patched copy of an existing base pak — Pattern A in docs/mod-distribution.md. When the mod is meant to ship as a standalone overlay pak (Pattern B) instead — a brand-new module that lives alongside core.pak and overlays its files at load time — declare a pak: block in mod.yaml:
pak:
name: pagonia-overlay-example # required — becomes the module folder + .gd.bin filename
summary: One-line description shown in mod browsers
author: Pagonia Land
image: pagonia-overlay-example/images/preview.image
dependencies: # optional — defaults to ["core"] when omitted
- core
When pak: is present, the patcher's apply step writes the module skeleton the engine needs to recognise the folder as a loadable module:
| File | Always written? | Contents |
|---|---|---|
<name>/manifest.json |
yes | { Name, Summary, Author, Image, Dependencies } mirroring shipped paks. |
<name>/memory.bin |
yes | 28 zero bytes (shipped paks carry per-module allocation stats here; a fresh scaffold uses zeros and trusts the engine to repopulate). |
<name>/files.json |
when *.gd.xml and/or a localization/ folder is present under <name>/ |
Pointer table. A GameDatabase key (["<name>/<name>.gd.bin"]) is added when the module ships *.gd.xml; a Localization key (["<name>/localization"], the folder) is added only when it actually ships compiled loca_<lang>.bin files. An asset-only overlay with neither (System.pak-style) skips this file. (The 1.4.0 editor instead writes the Localization key unconditionally whenever it emits files.json — pointing at a localization/ folder even when none is shipped; we deliberately don't write that dangling pointer. See Mod Distribution → files.json.) |
<name>/<name>.gd.bin |
only when *.gd.xml is present under <name>/ |
Index listing every *.gd.xml the module ships, in ordinal order. byte[3] tracks entries.Count - 1 per the .gd.bin format. |
The scaffold runs AFTER patches and entry operations are applied, so any new *.gd.xml or localization/loca_<lang>.bin from entries.add is picked up automatically — no need to list it twice.
pak.name must be a single path segment (no slashes, no ..); validation surfaces scaffoldNameInvalid if the value contains separators. A missing or empty pak.name produces scaffoldNameMissing and fails the apply.
See sandbox/examples/standalone-overlay/ for a worked example. After sandbox-apply.ps1, run sandbox-pack.ps1 without -BasePak to bundle the result into an engine-loadable .pak.
Tweaks (tweaks: block)¶
New to tweaks? The Mod Tweaks guide maps the whole feature end-to-end (author → curator → player). This section is the authoring reference it points to.
A mod author can mark specific values as user-adjustable — a multiplier, a threshold, an on/off flag — so a player picks a value in the mod manager without editing the mod files. The tweaks: block in mod.yaml declares those parameters; it is the contract a manager reads to build its adjustment UI and the set of names patch operations may later reference.
tweaks:
- id: softwood-cost # required — ^[a-z0-9][a-z0-9-]*$, max 40 chars; stable, see below
type: integer # required — number | integer | boolean | enum
label: Softwood trunk cost # required — short human-readable name (max 80 chars)
description: How many softwood trunks the Sawmill costs to build. # optional (max 500)
default: 3 # required — must match `type`
min: 1 # number / integer only (optional)
max: 8 # number / integer only (optional)
step: 1 # number / integer only (optional)
- id: free-upkeep
type: boolean
label: Remove upkeep
default: false
- id: difficulty
type: enum
label: Difficulty preset
default: standard
values: # enum only — required, the allowed { value, label } options
- { value: mild, label: Mild }
- { value: standard, label: Standard }
- { value: hardcore, label: Hardcore }
aliases: # optional — legacy ids this tweak used to carry
- difficulty-level
Field rules enforced by validate-mod (and, where a single field can express
it, by schema-validate / IDE JSON-Schema validation):
| Field | Applies to | Rule |
|---|---|---|
id |
all | Unique within the mod, including across aliases. Pattern ^[a-z0-9][a-z0-9-]*$, max 40 chars. |
type |
all | One of number, integer, boolean, enum. |
label |
all | Required, max 80 chars. |
default |
all | Required, and its literal must match type (a number for number/integer, true/false for boolean, one of values for enum). The literal↔type match itself is enforced by schema-validate / IDE JSON-Schema; validate-mod adds the semantic checks on top — default within min..max, and default ∈ values. |
min / max / step |
number, integer | Optional bounds. The default must fall inside min..max; validate-mod warns when min exceeds max, when step is given but not greater than zero, or — for an integer tweak — when min / max / step is not a whole number. |
values |
enum | Required array of { value, label } options. |
aliases |
all | Optional legacy ids, each satisfying the id pattern. |
A few of these are cross-field rules — most notably "default must fall
inside min..max" and "enum default is one of the values". A plain JSON
Schema cannot express a literal-vs-sibling-bounds check, so your IDE's
schema validation alone will not catch an out-of-range default. validate-mod
does. Run it before publishing; don't rely on the IDE squiggles for these.
Stable ids matter. A mod manager stores a user's chosen values keyed by tweak id. If you rename a tweak between versions, the stored choice would silently fall back to the default. To rename safely, keep the new id and list the old one under aliases: — the manager maps the stored value forward. (This is the lesson the iMYA tweak feature learned the hard way.)
Tag a mod that exposes tweaks with the tweakable tag so catalog browsers can filter for mods they can adjust.
Using tweaks in patch operations¶
A patch operation references a tweak with a {{ tweaks.<id> }} placeholder in any of its value-carrying fields — value, factor, delta, clampMin, clampMax, expectedOldValue, xml, expectedOldXml (the arithmetic operands and clamp bounds included, so one shared tweak can drive a multiplier and its floor alike). rounding is the exception: it's a fixed enum (round/floor/ceil), not a placeholder field. The patcher substitutes the chosen (or default) value at plan time, so the plan report shows the concrete values and apply just consumes the resolved plan. Two forms exist:
operations:
# Literal substitution — the tweak's value replaces the placeholder verbatim.
- id: sawmill-softwood-cost
operation: replaceValue
target: { file: core/gdb/buildings.gd.xml, entityGuid: "…", entityName: Sawmill, component: AspectBuildup, path: "Costs/Item/Content/Amount" }
expectedOldValue: "4"
value: "{{ tweaks.softwood-cost }}"
# Boolean ternary — picks one of two literal strings based on a boolean tweak.
- id: sawmill-upkeep-mode
operation: replaceValue
target: { … }
expectedOldValue: Normal
value: "{{ tweaks.free-upkeep ? 'NoUpkeep' : 'Normal' }}"
That is the entire grammar — literal substitution plus one boolean ternary. There is deliberately no arithmetic, no nested expressions, and no eval surface (it keeps the patcher AOT-clean and the contract small). If a tweak needs to scale a vanilla value (vanilla × factor), that would arrive as a dedicated patch op, not as expression syntax.
Resolution rules:
- A placeholder that names a tweak the mod didn't declare fails planning with
tweakUndeclared. Malformed placeholder syntax fails withtweakSyntaxError. - Each substitution emits an info
tweakValueResolveddiagnostic naming the value's origin (default,collection,external, orlockfile). - The ternary treats the tweak value
true(case-insensitive) as the first branch and anything else as the second.
A CLI consumer (or the manager) supplies overrides; absent overrides resolve to the declared defaults. From the CLI:
An externally-supplied number outside a tweak's declared min..max produces a tweakValueOutOfRange warning but is still applied — the bound is the author's recommendation, not a hard gate. The plan report records every tweak's effective value under a per-mod resolvedTweaks array (tweakId, sourceValue, resolvedValue, origin).
Expected Old Values¶
Every destructive or changing operation should include an expectation:
This gives a future validator three important checks:
- The target still exists.
- The game database still has the value the mod was written against.
- No previous mod already changed the value unexpectedly.
If the old value does not match, the patch should not be applied automatically.
Safer List Items¶
List patches select a list item with a [predicate] step. Only match-by-value
predicates of the form [child/path='value'] are supported. Positional indexes
like [1] are not supported — the resolver does not interpret them, so a path
using one fails to resolve (targetPathMissing). They are also fragile by nature:
list order is not guaranteed stable across game versions.
Not supported (positional index — will not resolve):
Better:
Best for human-readable patches:
path: Costs/Item[Content/Resource='c22b4997-5563-44ab-8aa0-04a7b2c826be']/Content/Amount
resourceName: Softwood Trunk
resourceGuid: c22b4997-5563-44ab-8aa0-04a7b2c826be
The validator can resolve the name through generated/entities.json and still apply by GUID.
Validation Flow¶
The Pagonia Land Patcher already implements this flow:
1. Read base GameDatabase snapshot.
2. Read all enabled mods.
3. Validate mod metadata.
4. Check required packages and optional patch sets.
5. Compare gameDatabaseVersion.
6. Resolve every patch target.
7. Check expected old values.
8. Build a combined write plan.
9. Detect conflicts.
10. Show a dry-run report.
11. Apply only if there are no blocking errors.
12. Run database validation again.
No patch should be applied silently.
Dry-Run Output¶
A good dry-run report might say:
Mod: Cheaper Sawmill 0.1.0
Operations: 1
Safe: 1
Warnings: 0
Conflicts: 0
Will change:
game-gdb/core/gdb/buildings.gd.xml
Sawmill / AspectBuildup / Costs / Softwood Trunk / Amount
4 -> 3
For a conflict:
Conflict:
Two mods write the same target:
Sawmill / AspectBuildup / Costs / Softwood Trunk / Amount
Mod A expects 4 and writes 3.
Mod B expects 4 and writes 2.
Result:
Manual decision required.
Relationship To This Repository¶
This repository can support patch-style mods by providing:
- entity and reference indexes
- generated catalogs for human lookup
- validation scripts
- worked examples
- snapshot and diff workflows
- v0.1 patch schemas
- the Pagonia Land Patcher as the reference validator
The first goal is documentation and shared vocabulary. Tooling can come after the format has survived feedback from real modding experiments.
For the larger tool direction, see Patcher And Mod Manager Architecture.