Cobnuts colony sim · delve

Modding API — versioning & migration procedure#

The Cobnuts modding API (the GDScript ModApi surface, reached from GDScript as the Mods autoload,

  • the Lua on_<key>/api_version() surface) is semver'd. The single source of truth is

groundwork_sim::scripting::API_VERSION, queryable at runtime from both surfaces (Mods.api_version() / Lua api_version()). The generated contract is [docs/modding-api.md](./modding-api.md); a headless test fails if it drifts from the live surface.

Bump rules#

  • Additive change → MINOR bump (e.g. 1.0.01.1.0). Non-breaking; existing mods keep working.

Examples: adding a new event to the taxonomy (scripting/api.rs), adding a new ModApi query, adding a new optional argument. No migration note required. Regenerate docs/modding-api.md (the drift test enforces it).

  • Breaking change → MAJOR bump (e.g. 1.4.02.0.0). Examples: renaming or removing an event

or query, changing an argument's name/type/order, changing an event's firing semantics. Requires a dated migration note appended to this file stating what changed and how a mod adapts.

Totality guard#

Adding a simulation event Vec to Messages (crates/groundwork-sim/src/sim/mod.rs) without a matching scripting::api::events() entry fails the taxonomy-totality test. The documented surface can never silently fall behind the simulation.

Parallel-branch collision guard#

API_VERSION is a scalar that two parallel feature branches can bump to the same value as an additive minor step from a shared base — git sees identical literals and merges them with no conflict, silently collapsing two features into one version. The headless guard api_version_matches_newest_migrations_entry (crates/groundwork-sim/tests/content/scripting.rs, feature 245) defends against this: it ties API_VERSION to the newest ## Worked example (applied in X.Y.Z) entry below and fails on a missing/stale entry or a duplicate version heading. So: every bump records a worked example here with a unique version, and feature tests must not pin the absolute API_VERSION literal (the guard owns that invariant).

Building def retired: core:rec_tablecore:cobnuts (2026-08-14, GitLab #4874, SAVE_VERSION 41 → 42)#

Breaking: a shipped building def is deleted, not renamed. Cobnuts is the recreation epic's playable proof — play resolved as a skill contest — and it shipped gated behind core:construction, two techs deep. The owner decision makes cobnuts the default joy building, buildable on turn 1, and retires the core:rec_table rung it used to sit under rather than keeping two parallel joy buildings. A researched chessboard is the intended future occupant of that rung.

SurfaceWasNowAlias?
Building def idcore:rec_tablecore:cobnutsno — the def is deleted
core:cobnuts gateresearch_prerequisite: Some("core:construction")(absent — ungated)n/a
core:toolmaking.unlocks["core:rec_table"](absent — empty)n/a
core:tutorial scenario spawncore:rec_table at (13, 9)core:cobnuts at (13, 9)n/a

How to adapt. A mod referencing core:rec_table — in unlocks:, a scenario spawn, a room-role requires_building, or a sprite override — must say core:cobnuts or drop the reference. A dangling id is rejected at load, so this fails loudly rather than silently. Note the two are not drop-in equivalents: the rec table was joy_quality_permille: Some(2000) and cost 2 boulders; cobnuts is baseline 1000, costs 2 wood, and carries a recreation_contest plus use_capacity: 8 with authored use_offsets. A mod that wanted the 2× joy rung must author its own def.

SAVE_VERSION bumps 41 → 42 — required, not cosmetic, on the #4665 precedent above. restore rejects only on version mismatch, so a v41 save holding a core:rec_table Structure would load successfully with that entity's building DefId resolving to nothing. Every consumer probes with is_some_and/and_then rather than unwrapping, so it never panics — it becomes a silent ghost: un-repairable, un-deconstructable, unrendered, occupying its tiles forever. The bump converts that quiet corruption into a clean version rejection.

Retained deliberately: assets/mods/core/textures/rec_table.png and its ASSET_LICENSES.md row. stone_sculpture.png and grand_sculpture.png are documented derivatives of it, so the file remains the provenance root for shipping art even though no def names it.

Why no API_VERSION bump: following the Manhunter → Maddened and Grievance → ClassConsciousness precedents — the modding surface has no external consumers pre-launch and resets to 1.0.0 at release, and pre-launch the content surface carries no compatibility guarantee (constitution, Content as a Data Contract). Deliberately not a ## Worked example heading, so api_version_matches_newest_migrations_entry leaves API_VERSION where it is.

Chronicle history voice becomes subject-keyed: chronicle_templates.ron (2026-08-14, GitLab #1893, no version change)#

Additive content, no surface change. #1567 gave the chronicle a history voice via an optional ChronicleRuleDef.chronicle_template, keyed by LogEvent alone. #1893 adds a new def type, ChronicleTemplateDef, authored in assets/mods/<mod>/defs/chronicle_templates.ron, so a specific subject can carry its own history line the way the feed's log_template already can:

(id: "core:chronicle_research_masonry", event: ResearchDone, subject: Some("core:masonry"),
    template: "The colony learned to cut and set stone, …"),

Resolution is (event, subject) with the feed's own precedence: an exact subject match wins, then a subject-less entry for the event, then #1567's per-rule chronicle_template, then the feed wording. Authoring nothing leaves every chronicle line byte-identical — #1567's fallback guarantee is intact.

Why no bump. Like chronicle_rules, the new category registers as bespoke, so no generic Mods.def(...) accessor is generated and the queryable event/query/argument surface is unchanged. It is authorable content, not modding surface — the same reasoning recorded for #1567. No SAVE_VERSION bump either: templates are content and are never saved, and the state_hash goldens run on Defs::default() (which loads no RON), so they are unmoved.

What a modder must know. Both the placeholder set and the subject are validated at load: a placeholder outside the event's known set, or a subject that resolves to no loaded def, is a hard InvalidChronicleTemplate error rather than a silently dead line.

targets_flora is authored: flora tile designations mint markers (2026-08-14, GitLab #5118, no version change)#

Additive content change, no schema change. #5117 added targets_flora and left it unauthored; #5118 authors it on the three flora verbs and lands the TileWork::Flora work site behind it:

(id: "core:harvest",      name: "Harvest",      target: Plant, targets_flora: true, …),
(id: "core:chop",         name: "Cut",          target: Plant, targets_flora: true, …),
(id: "core:clear_blight", name: "Clear Blight", target: Plant, targets_flora: true, …),

target: Plant is unchanged and still selects the tree entity carrier — the epic's blocking-tree carve-out depends on it. core:mine / core:mine_ramp must not gain targets_flora: surface() returns one WorkSurface and digs_rock wins, so a both-flagged kind would silently lose its flora arm.

What a modder can now do

A kind authoring targets_flora: true is aimable at a mask-flora tile. Designating one mints a FloraWorkDesignation { kind } marker entity — one per outstanding order, not per plant — which is itself the order: it carries no WorkDesignations, exactly as the rock marker does not. A marker is retired by a residency-gated sweep when its mask cell stops existing, or, for a blight-curing kind, when the tile's blight entry goes. Grazing a cell to zero growth is not a removal and does not retire an order.

No job ships in #5118. A designated flora tile mints and retires its marker correctly, and no colonist acts on it; the harvest, chop and clear-blight jobs are separate children. A kind authoring targets_flora today therefore produces a standing order that nothing yet works.

Projection change

None. targets_flora was already projected to def("work_kinds", id) by #5117; #5118 only authors it. No API_VERSION bump (nothing new is projected), and no SAVE_VERSION bump — the marker registers an additive optional component key, and a world with no flora markers emits no new saved bytes, so the state_hash golden is unmoved.

WorkTarget is an entity-carrier selector, not a tile gate (2026-08-13, GitLab #5117, no version change)#

Additive, and the ruling matters more than the diff. Epic #4996 moves non-blocking vegetation into the flora mask, where a plant has no Plant entity. work_kinds.ron authors target: Plant for core:harvest, core:chop and core:clear_blight, and every WorkTarget match is a component probe against an Entity — so a tile-borne plant fails all of them. #5117 settles what that variant means before #5118 mints the first flora marker.

The decision

target: names the entity carrier a work kind attaches its WorkDesignations to. It does not decide whether the kind can be aimed at a tile. Tile-borne work is opted into by a kind flag paired with a band-aware tile predicate — digs_rock yesterday, targets_flora now.

WorkTarget::Plant therefore broadens to "a plant, wherever it lives", with no variant added and no match arm changed. Existing mods keep working unchanged; no authored RON moves.

Why this and not a new variant

Three options were weighed. (a) broaden Plant. (b) add a variant and narrow Plant to entities. (c) make the marker a work-pool holder while WorkDesignations stays on entities.

(b) is wrong on the merits, not merely expensive. core:harvest/core:chop/core:clear_blight must keep working on tree Plant entities — the epic's blocking-tree carve-out keeps oak, pine and apple as entities. A tile-only variant breaks trees; keeping both needs two kinds per verb, re-exposing to the player the storage split the mask exists to hide. It would also silently change what an existing mod's override of those three kinds means.

(c) is refuted, and the reason usually given for it is wrong. It is not that a marker cannot hold a component — MineTileDesignation is spawned as (MineTileDesignation, TilePos) and holds one fine. The real refutation: the rock marker deliberately carries no WorkDesignations, and designated_targets, has_designated_target, marked_for_kind and carrier_matches are all blind to it. The marker is the order. Splitting want-state onto a marker while WorkDesignations stays on entities gives flora two competing order records with no reconciler — one marker per outstanding order, violated by construction.

The precedent this rests on, which was already shipped

core:mine and core:mine_ramp both author target: Deposit and mint tile markers anyway. apply_work_designations' tile arm has never consulted def.target. The two axes were already independent; #5117 names that fact rather than introducing it.

What a mod author writes

(id: "mymod:forage", name: "Forage", target: Plant, targets_flora: true, menu_glyph: Some("🌾")),

target: Plant still selects the entity carrier (so the kind also works on tree entities); targets_flora: true opts the kind into the mask-flora tile arm. The two are set independently, and digs_rock / targets_flora are mutually exclusive in effect — WorkKindDef::surface() is the single place tile reach is derived, and it returns one WorkSurface.

targets_flora shipped inert in #5117: it was unauthored in shipped content and TileWork::Flora had no tile-site implementation, so no marker was minted and no colonist got a new job. #5118 landed the site and authored the flag — see the entry above.

Projection change

def("work_kinds", id) gains one additive bool field, targets_flora, beside digs_rock. Nothing is renamed, retyped or removed; target still projects the same four strings.

Why no API_VERSION bump

By the bump rules above, a new projected field is additive → MINOR. It is deliberately recorded without one (owner decision), following the #4581 and Grievance → ClassConsciousness precedents: the modding API has no external consumers pre-launch and resets to 1.0.0 at release, so version churn now is pointless, and pre-launch the content surface carries no compatibility guarantee (constitution, Content as a Data Contract). The sibling flag digs_rock was added to this same projection by #3627 under the same reasoning; treating targets_flora differently would be the anomaly. This note is intentionally not a ## Worked example (applied in X.Y.Z) heading, so the api_version_matches_newest_migrations_entry guard leaves API_VERSION where it is. No SAVE_VERSION bump appliesWorkKindDef is content, not saved state, and no golden moves.

Coined vocabulary retired: ManhunterMaddened, inserterloader_arm (2026-08-06, GitLab #4665, no version change)#

Breaking, on three modder-facing surfaces at once. #4493 audited shipped display names against their sources and renamed the prose ("Manhunter pack"→"Maddened pack", "Inserter"→"Loader Arm"), but deliberately left the tier below — enum variants, schema field names, ids — as "addresses, not shipped prose". #4665 is the decision on that tier: these are vocabulary a modder types, so they teach another work's coined terms through the content contract itself.

SurfaceWasNowAlias?
IncidentDef.kind tokenkind: Manhunterkind: Maddenedyes#[serde(alias = "Manhunter")]
Projected incident-kind key (Mods.def("incidents", …))"manhunter""maddened"no
BuildingDef fieldinserter: Some((…))loader_arm: Some((…))yes#[serde(alias = "inserter")]
Building def idcore:insertercore:loader_armno
Save section keys"inserter", "inserter_cycle""loader_arm", "loader_arm_cycle"n/a

How to adapt. Authored RON keeps loading unchanged on the two aliased surfaces — you do not have to touch kind: or inserter: today, though new content should use the new spellings. Two things do break:

  1. A script mod comparing an incident kind against "manhunter" silently stops matching. There is

no alias tier for a projected string (it has exactly one spelling), so update the comparison to "maddened". This is the one deliberate break.

  1. A mod referencing core:inserter — in unlocks:, a recipe, a sprite override — must say

core:loader_arm. A dangling id is rejected at load, so this fails loudly rather than silently.

loader_arm, not loader, because "the loader" already means the content loader throughout this codebase; a bare loader: field would read as the wrong thing. The name matches the display name the building has shipped under since #4493.

SAVE_VERSION bumps 35 → 36 — required, not cosmetic. Renaming a component save section key is invisible to the loader (save_registry: unknown keys "are simply never read here (ignored)"), so a v35 save would load with every loader-arm component silently dropped and its core:inserter structures resolving to no def. The bump converts that quiet reset into a clean version rejection — the same reasoning as #4666's attribute-id migration.

Why no API_VERSION bump: following the Grievance → ClassConsciousness and #3618 precedents below — the modding surface has no external consumers pre-launch and resets to 1.0.0 at release, and pre-launch the content surface carries no compatibility guarantee (constitution, Content as a Data Contract). Deliberately not a ## Worked example heading, so api_version_matches_newest_migrations_entry leaves API_VERSION where it is. The projected key lives in docs/modding-api.md's def-field surface, which that file does not enumerate, so it needed no regeneration.

Ids deliberately kept: core:manhunter_pack and core:opening_manhunter. Unlike the enum variant, an incident id reaches saves through PendingThreat.incident, and neither is player-visible (the arc's display name is already "Maddened pack"). Renaming them is a separate call with a separate cost; see #4665 for the ids left on the table.

Derived-stat ids drop the source model's "Basic" coinage (2026-08, GitLab #4560, no version change)#

Three shipped derived-stat ids are renamed to the game's own vocabulary (the display names — Quickness, Stride, Carrying Capacity — were already generic and are unchanged):

  • core:basic_speed_quarterscore:speed_quarters (the _quarters scale suffix stays — see #3546)
  • core:basic_movecore:stride
  • core:basic_liftcore:lift

Core's own references moved with them: derived_stats.ron, movement.ron's move_stat, pawns.ron's carry_capacity_stat, and character_templates.ron's derived_stat_entries.

How to adapt: a mod naming the old ids — an advantage's derived_modifiers shift, a move_stat / carry_capacity_stat, a template derived_stat_entries row, or an override row for one of the three defs — spells them the new way. Where the reference is load-validated (template entries, tuning stats) the old spelling now fails at load with a dangling-reference error; where it is a bare query (derived_stat by id, the projected sheet rows) it resolves to the neutral 0 / matches nothing. ⚠ The second kind is the quiet break — a mod comparing a projected sheet row's id against "core:basic_move" still parses and runs, and silently never matches again. If your mod reads a derived stat by id, grep it now.

Why no version bump: following the damage-type and Grievance → ClassConsciousness precedents below — pre-launch, the content surface carries no compatibility guarantee, and the modding surface resets to 1.0.0 at release. Deliberately not a ## Worked example heading, so api_version_matches_newest_migrations_entry leaves API_VERSION where it is. No SAVE_VERSION bump: derived-stat defs are content, never serialized; the only saved state keying these ids (DerivedLevels) could not name them before #4560 made them buyable in the same MR.

Character templates: derived_stat_entries (2026-08, GitLab #4254, no version change)#

Purely additive RON field, but structurally loud. CharacterTemplateDef gains an optional fifth list, derived_stat_entries: [(stat, levels)] — the generator's purchase of a derived-stat level above (or below) its formula, priced at the stat's buyable.cost_per_point and stored in the DerivedLevels component. Omitted means an empty list, so every existing template keeps parsing and drawing exactly as before.

Two things a modder must know:

  • Each entry is one RNG draw per generated colonist, unconditionally (the step is not gated by

the advantages toggle). Adding or removing an entry re-phases the character-generation stream for every colonist in every existing save — the draw contract in character_templates.ron's header. Retuning an entry's band values/weights does not.

  • The named stat must be authored buyable (derived_stats.ron); an entry naming a

formula-only stat, a dangling id, or an empty band is rejected at load.

Active defenses: three new optional gear fields and a tuning def (2026-07, GitLab #3555, no version change)#

Purely additive — nothing renames, nothing changes meaning, and no existing content file needs editing. Recorded here because the fields are inert, and a modder authoring them should know why they appear to do nothing.

  • New content category defense_tuning (assets/mods/core/defs/defense_tuning.ron), the

singleton core:defense_tuning. Carries skill_base, skill_divisor, dodge_stat, unseen_penalty, and an encumbrance_dodge_penalty ladder.

  • New optional WeaponDef field parry: `Some((skill: "core:combat", modifier: -1,

unbalanced: true))`, or omitted. Omitted means the weapon cannot parry, which is a stronger statement than parrying at no bonus — the shipped bow and sling author it explicitly.

  • New optional ApparelDef fields block_skill and defense_bonus. block_skill present is

what makes a piece a shield; defense_bonus adds to every active defense, not only blocks.

⚠ Two things that will surprise a reader of the other tuning files.

First, 0 does not mean "off" in defense_tuning.ron, unlike dice_tuning.ron and combat_tuning.ron. A skill_divisor of 0 is a division by zero and is rejected at load; a skill_base of 0 is a different economy rather than an absent one. This feature's neutral posture is that nothing calls the defense code at all, which a zero-call-sites guard enforces.

Second, a shield is identified by block_skill, never by its equip slot. The two shipped shields sit in different slots (core:hide_shield in the default body slot, core:bronze_shield in core:offhand), so a mod testing the slot will find one and miss the other.

Nothing reads any of it yet — #3556 (the combat exchange) is the item that resolves a blow against a defender — so a mod can author these now and see no effect, by design.

Why no version bump: additive under the bump rules above, and following the Grievance → ClassConsciousness and #3618 precedents below. Deliberately not a ## Worked example heading, so api_version_matches_newest_migrations_entry leaves API_VERSION where it is. docs/modding-api.md does not enumerate def fields, so it needed no regeneration.

Damage types become ids: "sharp""core:cutting" (2026-07, GitLab #3554, no version change)#

DamageType was a closed three-variant enum (Sharp/Blunt/Heat). It is now a content def addressed by id, so a mod can add a damage type with its own wounding multiplier — the thing a closed enum made impossible.

  • Content field damage_type on a weapon: Sharp"core:cutting" (a quoted id, not a bare token).
  • Content field mitigation on apparel: the pair key changes the same way —

[(Sharp, 40)][("core:cutting", 40)].

  • Projected def fields (Lua/GDScript defs view) keep their names and types — both were already

strings — but the value changes from a lowercased variant name to a def id.

How to adapt: map the three old variants onto the shipped types. Bluntcore:crushing; Heatcore:burning; Sharpcore:cutting or core:impaling, whichever the weapon actually is (an edge or a point). Core's own content took core:impaling for the spears and core:cutting for the sword and axe. Also available: core:small_piercing, core:piercing, core:large_piercing. A mod defining its own type authors one row in damage_types.ron and references it by id.

⚠ This is the quiet kind of break. A mod comparing weapon.damage_type == "sharp" still parses, still loads, still runs — and silently never matches again. Nothing in the engine can warn about it, because the field name and its type are unchanged; only the value moved. If your mod reads a damage type, grep it now.

Why no version bump: following the Grievance → ClassConsciousness and #3618 precedents below — the modding surface has no external consumers pre-launch and resets to 1.0.0 at release, and pre-launch the content surface carries no compatibility guarantee (constitution, Content as a Data Contract). Deliberately not a ## Worked example heading, so api_version_matches_newest_migrations_entry leaves API_VERSION where it is. docs/modding-api.md does not enumerate def fields, so it needed no regeneration.

Two new optional weapon fields ship alongside, both inert: damage_mode (which strength→dice ladder the weapon's damage is rolled from) and damage_add (a flat modifier on that roll). Apparel gains dr (flat damage resistance per type) beside the unchanged mitigation. Nothing reads any of the three yet — #3556 is the item that resolves damage through them — so a mod can author them now and see no effect, by design.

Permille: rest_quality/joy_quality*_permille (2026-07, GitLab #3618, no version change)#

BuildingDef's bed and joy quality multipliers were Option<f32> (Some(2.0) = twice as fast). They are now Option<u32> in permille and renamed to match:

  • Content field rest_qualityrest_quality_permille; joy_qualityjoy_quality_permille.
  • Projected def fields (Lua/GDScript defs view) rename identically, and their type changes from

float to integer.

How to adapt: multiply the authored value by 1000 — rest_quality: Some(2.0) becomes rest_quality_permille: Some(2000), Some(1.3) becomes Some(1300). A mod reading the projection divides by 1000 to recover the old multiplier. The baseline (formerly 1.0) is 1000; a building is still a bed/joy building iff the field is present, unchanged. Load-time validation still rejects a non-positive value (now Some(0) rather than a non-finite float).

Why: the constitution requires authoritative state to be integer-only (Deterministic Simulation). These two values are divided into the authored integer NeedDef::satisfy_ticks at three call sites to produce an authoritative tick budgetceil(satisfy_ticks / quality) — so a float here put float arithmetic directly on the authoritative path. Permille is the existing codebase idiom (politics.rs).

Behaviour is preserved. The integer form is ceil(base * 1000 / permille), which agrees with the old float ceil(base / quality) on every shipped value (2.0/1.5/1.3/1.0) and across a swept 30,000 (base, quality) pairs — including the non-representable 1.3, whose quotient lands mid-interval. No golden moved and SAVE_VERSION is unchanged: these are content-def fields, never serialized into a save (the tick budget is recomputed from content every tick and compared against the saved integer progress).

By the bump rules above this is a breaking content/projection change, which would normally be a MAJOR bump. It is recorded without an API_VERSION bump, following the Grievance → ClassConsciousness precedent below: the modding surface has no external consumers pre-launch and will reset to 1.0.0 at release, and pre-launch the content surface carries no compatibility guarantee (constitution, Content as a Data Contract). Deliberately not a ## Worked example heading, so api_version_matches_newest_migrations_entry leaves API_VERSION at 4.11.0. docs/modding-api.md does not enumerate def fields, so it needed no regeneration.

Worked example (applied in 9.3.0)#

Colonists can now come to blows (GitLab #2931, epic::family-tree) adds one event to the taxonomy. Additive — no migration required; existing mods are unaffected.

SurfaceAdded
Event keysocial_fight
GDScript signalsocial_fight
Lua callbackon_social_fight
Argumentsaggressor: str, victim: str

When it fires. A pair of colonists whose mutual opinion has sunk below the authored resentment gate can escalate a chat argument into a bounded, non-lethal scuffle. The event is directional: aggressor is the colonist who started it (derived from trait propensity, falling back to standing — never from entity order), and victim is the one who took the bruise. This is the first narrated event the chat system emits; feature 51's contract declared that system presentation-free, and #2931 exists to close exactly that gap.

What a mod can rely on. The fight never kills, never knocks anyone down, and never permanently scars, and the guarantee holds for modded content because it is enforced at load rather than by the shipped numbers happening to be small. Content validation rejects a social-fight injury that bleeds or is permanent, and a severity that would register as a major wound against a baseline colonist, and a severity that would incapacitate once multiplied by chat_max_partners_per_round. That last pair matters more than it looks: the death spiral reads accumulated severity, not blood loss, so "it does not bleed" is not sufficient for non-lethality, and wounds from several fights in one round land in the same list and are summed. A downed colonist is never a participant.

Tuning it without code. The whole feature is the social_fight block on a chat_outcomes row in opinion_tuning.ron (fight_opinion_max, fight_chance_pct, injury, injury_severity, aggressor_thought, victim_thought, opinion_delta). fight_opinion_max must be negative — it is a resentment gate — and opinion_delta must not be positive.

Two ways to switch it off, both free. Delete the block, or set fight_chance_pct: 0. Either way the event stops firing with no code change and no RNG draw spent, so a save stays bit-identical to one built without the feature. (The zero case is short-circuited ahead of the roll precisely so it is a real disable knob and not a silent desync.)

Worked example (applied in 9.2.0)#

A mod can author layered pawn appearance (GitLab issues #830 / #831, epic::colonist-identity) exposes the paper-doll surface: a pawn is composited from an ordered stack of full-canvas layers instead of a single sprite sheet.

Two new def categories join Mods.def(category, id) / the Lua def(...) accessor:

  • appearance_layers — one authored drawing set. Fields: id, band (one of body,

clothing, head, hair, weapon — the fixed bottom-to-top composite order), south (always present; the only required art path), takes_skin_tint (bool), plus north, east, west and sprite_license omitted when unauthored. A missing direction resolves through the fallback chain the sim uses: west falls back to a mirrored east, and any absent direction falls back to south.

  • skin_tones — a curated tone. Fields: id, name, and the tint as three integer channels

r_permille, g_permille, b_permille. These are multipliers applied to the authored body art, not absolute colours — core:skin_pale is (1000, 1000, 1000), meaning "the art exactly as drawn", and every other tone darkens from there. 1000 is the ceiling, not the midpoint: the render side is a modulate, which multiplies and clamps to LDR, so a channel above 1000 does not lighten — it saturates to white and collapses into every other tone that also saturates. Content validation rejects any channel above 1000 for this reason. Permille integers rather than floats on the #3618 terms, so the content surface carries no float and the no_new_float_enters_the_authoritative_content_surface guard needs no allow-list entry for this feature. The division into a renderable colour happens past the sim boundary, in the shell. Three scalar keys rather than a triple because the projection vocabulary has no list-of-numbers variant, and widening Field for one def would have been a breaking change to both mappers.

Six additive def fields become authorable. PawnDef gains the four roll pools — body_layers, head_layers, hair_layers, skin_tones — each projected as a StrList omitted when empty, following the portraits convention (#3928), so a pawn authoring none projects byte-unchanged. ItemDef gains appearance_layer, the layer an equipped item draws, projected and omitted when None. SlotDef gains layer_band (which band that slot composites into); it is authorable but not projected, because slots has never been an exposed Mods.def(...) category and exposing it is out of scope here. A mod reads an item's band through the appearance_layers entry its appearance_layer names.

Per the rules above this is purely additive — two new categories, six defaulted def fields, no existing key's name/type/order changed — so it ships as a minor bump (9.1.09.2.0) with a regenerated docs/modding-api.md and this note. How to adapt: nothing is required of an existing mod. A pawn def that authors no layer pools keeps rendering from its sprite sheet exactly as before; the layered path activates only when a def authors at least one pool.

No SAVE_VERSION bump: the per-pawn Appearance component rides the additive component registry, and the choices it stores are plain indices into the authored pools. The world goldens did move (SEED_ORACLE_GOLDEN, WORD_SEED_GOLDEN), because rolling a colonist's appearance draws from the worldgen RNG stream; both were re-baselined together per docs/save-format.md. The persistence goldens are unmoved — scenario::init_demo hand-spawns its colonist and never rolls appearance.

Worked example (applied in 9.1.0)#

A mod can hook animal training and the handler bond (GitLab issue #2375, epic::right-click-orders) adds two new events to the taxonomy:

  • animal_trained (Lua on_animal_trained; GDScript signal animal_trained) with args

name: str (the handler, empty when unnamed), def_id: str (the species), level: int (the obedience level just reached), x: int, y: int. Fires when a completed training session's roll succeeds.

  • animal_bonded (Lua on_animal_bonded; GDScript signal animal_bonded) with args

name: str, def_id: str, x: int, y: int. Fires when an animal bonds with the colonist training it — at most once per animal, since a bond is never replaced or re-rolled.

The same feature also projects four new AnimalView fields for the animal inspector: training_level, trainable, is_trainable_now, and bonded_to. is_trainable_now is composed sim-side (tamed ∧ species trainable ∧ below the species ceiling ∧ core:husbandry researched) for the same reason as the shipped is_tameable_now: the research term is a colony fact, and a consumer that ANDed the per-animal fields itself would offer a Train verb the sim then refuses. bonded_to reads None for a stale bond (the handler died or left), matching how the sim treats it everywhere — the panel never names a handler who is gone.

Three additive AnimalDef fields become authorable: trainable (default false), train_difficulty (default 30), and trainable_max_level (default 3). A species omitting all three is untrainable, so every pre-#2375 animal def and third-party mod loads unchanged. HusbandryTuning likewise gains six dials, each #[serde(default)] — note this is the first defaulted field on that record, and it had to be, since the shipped husbandry_tuning.ron spells every field and would otherwise fail to load.

Per the rules above this is purely additive (two events appended at the end of the taxonomy; no existing event's key/args/order changes; new def fields all defaulted), so it ships as a minor bump (9.0.09.1.0) with a regenerated docs/modding-api.md and this note. How to adapt: nothing is required of an existing mod.

No SAVE_VERSION bump: both event payloads are transient (drained per tick into the log, never serialized), and the new AnimalTraining component rides the additive component registry — absent from every animal in a colony that never trains, so it emits zero bytes there and the persistence goldens are unmoved.

Worked example (applied in 9.0.0)#

The sleep need is called core:sleep, and a bed's quality field with it (GitLab issue #4887). Two breaking renames land together because they are the same vocabulary problem.

1. The need id: core:restcore:sleep#

The def contradicted itself. Its satisfier was Sleep, one of its own fields was sleep_sufficient_ticks, the component tracking its daily tally is SleepDebt, and its executor is advance_sleep — only the id said "rest". "Rest" is also the vaguer word: colonists need sleep; resting is what a chair gives you. The confusion was not hypothetical — it caused a real misreading during #4609 planning, where the two sleep quantities in the def read as two separate bars.

-- before
local rest = need_by_id(colonist, "core:rest")
-- after
local sleep = need_by_id(colonist, "core:sleep")

Nothing about the need's shape changed: same fields, same thresholds, same authored values. Only the id moved.

2. The bed field: rest_quality_permillesleep_quality_permille#

// before
(id: "core:bed", rest_quality_permille: Some(2000), ...)
// after
(id: "core:bed", sleep_quality_permille: Some(2000), ...)

This one is a content-format break for any mod that authors a bed, and it is load-bearing beyond its name: a building is a bed iff it declares this field (sim::bed::is_bed is exactly that check — there is no separate flag). So a mod that renames the field keeps its bed; a mod that misses the rename does not have a bed any more, and its furniture silently stops being sleepable. The value and units are unchanged (permille, 1000 = baseline).

The projected defs view renames identically, so a mod reading another mod's bed quality changes the key it asks for.

How to adapt#

  1. Replace core:rest with core:sleep everywhere — queries, comparisons, any need-id table.
  2. Rename rest_quality_permille to sleep_quality_permille in every bed/furniture def you author,

and in any code that reads it off the projected defs view.

  1. Nothing else moves. sleep_sufficient_ticks, SleepDebt, advance_sleep, satisfier: Sleep and

joy_quality_permille were already right and are untouched.

Failure modes, in order of how quietly they bite#

  • Missing the bed-field rename is the dangerous one. Your building simply stops being a bed. No

error, no validation failure — the capability is the field's presence, so absence is a legal state meaning "not a bed". Colonists will walk past it.

  • Missing the need-id rename is loud by comparison: a query for core:rest returns nothing, so the

mod reads an absent need and (if it guards its zeroes, per 8.0.0's note) does nothing.

SAVE_VERSION bumped 38 → 39#

The need id is save state — a Needs entry is keyed by DefId — so a v38 save is rejected rather than loading with an inert core:rest entry beside a freshly back-filled core:sleep. See docs/save-compatibility-policy.md for the worked example. The bed field is not save state (a Structure stores its building id, not the def's fields).

Worked example (applied in 8.0.0)#

Needs are stored as a reserve, not a debt (GitLab issue #4886). Mods.colonist(id)'s needs entries change shape and the numbers change meaning:

before:  { id, urgency, critical_ticks }              urgency 0 = satisfied, rising = worse
after:   { id, reserve, capacity, action_threshold, critical_ticks }
                                                      reserve = capacity = satisfied, falling = worse

What changed. urgency is gone. reserve is how much of the need is LEFT: capacity when just satisfied, 0 when consequences begin. capacity (the old critical_level) is the percent basis for a need bar. action_threshold is now stated on the reserve scale — the level at or below which the colonist acts — so a consumer can band reserve against it directly instead of re-deriving a complement. critical_ticks is unchanged: it still counts consecutive ticks at the consequence point.

Why it is breaking rather than additive. The rename alone would be — a mod reading urgency gets nil/an absent key and fails loudly, which is the good case. The dangerous half is the inversion: urgency and reserve share a type and a plausible range, so a mod (or an LLM-assisted patch) that renames the key without inverting the comparison keeps running and reads every well-fed colonist as starving. That silent-wrong-answer risk is what makes this a major bump rather than a rename note.

How to adapt. Invert every comparison as you rename:

-- before
if need.urgency >= need.critical_level then alarm() end
if need.urgency == 0 then satisfied() end
local pct = need.urgency * 100 / need.critical_level     -- "how bad"

-- after
if need.reserve == 0 then alarm() end
if need.reserve == need.capacity then satisfied() end
local pct = need.reserve * 100 / need.capacity           -- "how much left"

Guard your zeroes. The degraded-default flips with the polarity. A need whose def does not resolve projects capacity: 0, and under a reserve reserve == 0 alone reads as maximally deprived — so a bare if need.reserve == 0 badges every colonist carrying a need your mod cannot see. Gate on capacity > 0 first, exactly as the shipped is_cold / roster-badge / overlay code now does. The same applies to any division: capacity == 0 must mean "nothing to report", not "empty".

Content dials are unchanged. needs.ron keeps action_threshold and critical_level with their authored values and their authored meanings (action_threshold < critical_level still holds), so a mod that only authors needs needs no edit at all. Only the projected/queried surface moved.

SAVE_VERSION bumped 37 → 38 — the stored field is renamed and inverted, so a v37 save is rejected rather than silently misread. Migration is none, which is conforming in the project's Development phase (docs/save-compatibility-policy.md).

Worked example (applied in 7.2.0)#

A mod can hook a pawn falling (GitLab issue #4479, epic::z-axis) adds one new event to the taxonomy: pawn_fell (Lua hook on_pawn_fell; GDScript signal pawn_fell) with args x: int, y: int, z: int and levels: int. It fires when a pawn resting on nothing drops to the level below and is hurt by the landing.

x/y/z are the landing tile, not the tile it fell from — click-to-jump should take the player to where the pawn now is, which matters most under the z-level camera, where a fallen pawn otherwise leaves the visible world with no notification. levels is how far it dropped.

The distinction worth reading: falling is an invariant, not a collapse consequence (#3566). A pawn falls whenever its tile is passable and unsupported, so pawn_fell is not a sibling of cave_in that only fires after one — shipped worldgen authors core:open_air above the surface on core:frontier, which drops a pawn with no collapse anywhere. Do not assume an on_cave_in fired first.

Per the rules above this is a purely additive change (one new event appended at the end of the taxonomy; no existing event's key/args/order changes), so it ships as a minor bump (7.1.07.2.0) with a regenerated docs/modding-api.md and this note. How to adapt: nothing is required of an existing mod — a mod that wants to react to a fall defines on_pawn_fell(x, y, z, levels) (Lua) or connects the pawn_fell signal (GDScript).

It fires only for colonists, and only on an armed world. Animals fall on exactly the same rules and are wounded identically, but they raise no event: on shipped terrain wildlife stepping off a ledge is routine, and one alert per falling animal is noise rather than signal. The support system's data gate also still applies — a world authoring no support.ron never runs the pass at all (shipped core does author one since #3568, so this does fire by default).

No SAVE_VERSION bump: the event payload is transient (drained per tick into the log), never serialized, and the fall itself writes only pre-existing components.

Worked example (applied in 7.1.0)#

The mood breakdown reconciles under stacked thoughts (GitLab issue #1192, follow-up to #1169) added one field to each entry of the mood.thoughts array in the Mods.colonist(id) snapshot: stacked_offset (int, signed). Purely additive ⇒ a MINOR bump (7.0.07.1.0), regenerated docs, and no migration note — mood_offset and every other pre-existing key are unchanged (byte-identical), so no existing reader breaks.

Why. mood_offset has always been, and remains, the per-instance value the ThoughtDef authors — 6.1.0 published it that way, and the life-fraction formula that entry documents depends on pairing it with a per-instance duration_ticks. But the sim does not sum per-instance offsets: mood::recompute_level sums mood_offset × stack. So a mod (or the in-game inspector, which had the same defect) that added up the itemized thought offsets got a number that disagreed with mood.level as soon as any thought stacked — a stack of three witnessed-deaths reported -15 against a -45 reality. 2.7.0 closed that remainder for single-instance thoughts; this closes it for the rest.

Redefining mood_offset to mean the total would have been a MAJOR break and would have destroyed the 6.1.0 formula, so the total arrives as a new sibling key. Both meanings are now published explicitly rather than one being inferred.

What's new for mods:

  • …["thoughts"][i]["stacked_offset"] (int, signed) — this thought's total contribution to net

mood: mood_offset * stack. Equal to mood_offset when stack == 1. 0 when the def is unresolved — the sim skips such a thought entirely when summing, so it contributes nothing no matter how deep its stack.

To reconcile a breakdown, sum stacked_offset (never mood_offset) across thoughts and add the trait_contributions offsets.

GDScript — new (≥ 7.1.0):

var c = Mods.colonist(id)
var total := 0
for t in c["mood"]["thoughts"]:
    total += t["stacked_offset"]   # NOT mood_offset — that one is per instance
for tc in c["mood"]["trait_contributions"]:
    total += tc["mood_offset"]
# total == c["mood"]["level"], except at the clamp (below)

The one exception: the bounded window. Since #2473 recompute_level clamps its net sum to [-100, 100]. The invariant is therefore precise rather than absolute: the itemized items sum to the unclamped total, and mood.level is that total clamped to the window. A colonist miserable enough to bottom out will show items summing past -100 against a level of exactly -100. There is no "clamp" entry, in the dict or in the inspector — the clamp is a property of the sum, not a contributor to it.

(The inspector row now renders the stacked total too — • Witnessed death: -30 ×2 where the def authors -15 — but that is internal presentation plumbing, not a queryable surface, so it adds no further bump: the same carve-out the 6.1.0 and 2.6.0 entries record.)

Worked example (applied in 7.0.0)#

Skill xp counts toward a flat rate, not a rising ladder (GitLab #3560, epic::character-sheet #3544) — a MAJOR bump, because an existing key changed meaning rather than a new one appearing.

  • What changed: Mods.colonist(id)["skills"][i]["xp"] still carries a remainder, but **what it is

a remainder of has changed**, so any consumer computing a progress fraction is now dividing by the wrong number. A new sibling key xp_per_point (int) carries the correct denominator.

Before #3560, experience walked a rising per-skill ladder — the next point cost 100_000 × (points_invested + 1), so the denominator grew as the colonist improved and a mod had to reconstruct it from the (unpublished) invested count. Experience was also spent immediately into the skill that earned it.

After #3560, experience banks against a flat, authored rate (WorkTuning.xp_per_point, shipped at 10000), and the point it mints goes to the colonist's spendable pool rather than into that skill. The denominator no longer depends on the skill, the colonist, or how far along they are — but it is content, and a mod may retune it.

This is a MAJOR bump rather than a minor one because the existing xp key's meaning moved. A reader that keeps working will keep producing plausible, wrong percentages rather than failing — which is exactly the case the bump rules reserve MAJOR for.

  • How to adapt: divide by the published xp_per_point, and guard the zero case (accrual can be

authored off, and 0 means "no denominator", the duration_ticks convention from 6.1.0):

``gdscript for s in Mods.colonist(id)["skills"]: var denom: int = s["xp_per_point"] if denom > 0: # 0 ⇒ accrual authored off, no progress to show print("%s: %d%% toward the next character point" % [s["name"], s["xp"] * 100 / denom]) ``

If your mod reconstructed the old ladder itself (base * (points + 1)), delete that arithmetic — the function it mirrored (skill::xp_to_next) has been removed, deliberately, so that no consumer keeps dividing by a curve the sim no longer has.

The points, level, name and id keys are untouched. Note that points no longer rises on its own from working: a colonist's invested points now change only when a point is spent into that skill, automatically by their passions or by the player.

Worked example (applied in 6.1.0)#

A thought's duration reaches the mod surface (GitLab issue #1169, follow-up to #430) added two fields to each entry of the mood.thoughts array in the Mods.colonist(id) snapshot: duration_ticks (int) and stack (int, >= 1). Purely additive ⇒ a MINOR bump (6.0.06.1.0), regenerated docs, and no migration note — the pre-existing id / name / mood_offset / remaining_ticks keys are unchanged (byte-identical), so no existing reader breaks.

Why both, and why now. 2.6.0 shipped remaining_ticks "to surface the same value the in-game colonist inspector renders". That numerator is not usable on its own: without the def's total a mod can no more compute how far through its life a thought is than the player could — which was exactly #1169's complaint about the inspector. stack is required for the same computation to be correct, not merely convenient: a stacked thought shares ONE timer across its instances and drains them one duration_ticks window at a time, so the naive ratio jumps back to 1.0 at every window boundary.

What's new for mods:

  • …["thoughts"][i]["duration_ticks"] (int) — the def's authored full duration. **0 when the def

is unresolved** (removed/renamed by a mod); treat 0 as "no denominator" rather than dividing.

  • …["thoughts"][i]["stack"] (int, >= 1) — the live instance count.

The correct fraction is (remaining_ticks + (stack - 1) * duration_ticks) / (stack * duration_ticks), which decreases monotonically across a whole stack drain.

GDScript — new (≥ 6.1.0):

for t in Mods.colonist(id)["mood"]["thoughts"]:
    var total: int = t["duration_ticks"] * t["stack"]
    if total > 0:  # 0 ⇒ unresolved def, no denominator
        var left: int = t["remaining_ticks"] + (t["stack"] - 1) * t["duration_ticks"]
        print("%s: %d%% of its life left" % [t["name"], left * 100 / total])

(The render-side ThoughtView widening and the inspector's drain_meter / thought_duration_suffix formatters are internal presentation plumbing, not a queryable surface, so they add no further bump — the same carve-out the 2.6.0 entry records.)

Worked example (applied in 5.1.0)#

A colonist's skill investment reaches the mod surface, and the character sheet reaches the Rust projection (GitLab issue #3552, epic::character-sheet). Purely additive ⇒ a MINOR bump (5.0.05.1.0): every field a mod could read before still exists, unchanged, and no meaning shifts.

What a script gains: the colonist dict's skills rows gain points, which has been on SkillView since #3573 but never reached Godot — so a mod could read a colonist's standing but not their investment.

What is Rust-only for now: the rest of the sheet below is a projection (groundwork_sim::scripting::character_view) consumed by the inspector. It is not emitted as dict keys — a script cannot yet read a colonist's attributes, derived stats, priced advantages or point totals. Projecting them is #3857, which is additive and needs no further bump. Documented here rather than left implicit, following the BuildingDef.placeholder_colour precedent at the top of this file.

ColonistView gains sheet (CharacterSheetView), carrying what #3551 made a colonist out of:

  • attributes — every loaded attribute, id-sorted, with level, default_level and

cost_per_point. Rows come from the defs, not the character's record, so a character with no Attributes reads the authored defaults rather than an empty list.

  • derived_stats — every loaded derived stat at its computed value, already including the gated

advantage shift. Scale lives in the id's suffix: core:basic_speed_quarters is quarters (a shipped 20 reads as 5.00), _pct is percent. Format on the suffix, never on a hardcoded id. (As authored in 5.1.0. The id is now core:speed_quarters — see the #4560 rename note near the top of this file. The suffix rule is unchanged; this line is left as written so the historical record stays accurate.)

  • advantages — the per-row twin of traits/trait_names, adding each entry's signed cost.

The sign is the advantage/disadvantage distinction; there is no is_disadvantage flag.

  • pointsattributes/advantages/skills/derived_stats/total character points spent.

Legitimately negative for a character whose disadvantages outweigh their purchases.

Two things worth knowing before you build on this:

  • There is no unspent. The generator discards leftover budget, so it is not saved state, and

budget − total is not a stand-in: advantage grants, the cohort skill-tier floor and XP earned in play all add points off-budget, so that subtraction goes negative for anyone who has worked a shift. Ask for what was spent, not what is left. (As authored in 5.1.0 — the first sentence is now superseded; the reasoning is not. #3560 since shipped a spendable pool: UnspentPoints (crates/groundwork-sim/src/sim/unspent_points.rs), which is saved state, and CharacterPointsView now also carries unspent, earmarked, earmarked_skills and auto_spend. The budget − total argument above stands unchanged, and is exactly why the pool is a stored component read rather than a subtraction — see #3852. Two things to know if you build on it: unspent is the gross pool, earmarked points included, so free-spendable is unspent − earmarked; and like the rest of the sheet these fields are the Rust projection only — a script still cannot read them, per the note above. Projecting them is #3857.)

  • sheet.advantages is empty while the advantages layer is off — matching traits, and

matching what the sim itself reads. attributes, derived_stats and the skill rows are substrate and always populated. A layer-off points.total therefore counts purchases whose refunds are hidden; the shipped inspector narrows its label to say so.

traits and trait_names are unchanged and not deprecated — same names, same shape, same gating. New consumers should prefer sheet.advantages, which carries the cost.

Worked example (applied in 5.0.0)#

A colonist's skill level becomes their derived standing (GitLab issue #3573, epic::character-sheet). This is a breaking change to a projected field's meaning — the field keeps its name and type, so nothing fails to compile and nothing fails to load; a mod comparing it against a threshold simply starts getting different answers. That is precisely why it takes a MAJOR bump (4.11.05.0.0) rather than the quieter treatment a pre-launch content change can take: the compiler cannot warn about it and neither can the loader.

SkillView.level and ProfessionView.level now carry the effective level — the controlling attribute, plus the skill's authored difficulty, plus what the colonist's invested character points buy — derived on every read rather than stored. SkillView gains points so the investment underneath is still reachable.

The practical trap: skill.level == 0 was "untrained" and is now false for every colonist. Standings start at the skill's untrained floor (5 for an Average-difficulty skill at the shipped attribute defaults, 6 for an Easy one), so every threshold authored against the old 0-based scale is too low. Ask skill.points == 0 instead.

See the 4.11.0 → 5.0.0 entry under Migration notes below for the full adaptation table and the related professions.ron re-anchoring.

Worked example (applied in 4.11.0)#

A mod can hook a cave-in (GitLab issue #3103, epic::z-axis Z8) adds one new event to the taxonomy: cave_in (Lua hook on_cave_in; GDScript signal cave_in) with args x: int, y: int and z: int — the collapsing tile, for click-to-jump. It fires when a cell loses its structural support and comes down, damaging any structure standing there and wounding any colonist caught under it.

Note this is the first event carrying a z argument, which is why it is worth reading rather than skimming: the z-axis epic is making tiles genuinely volumetric, and events about tiles will increasingly need to say which level. Existing events are unchanged — none gained a z.

Per the rules above this is a purely additive change (one new event appended at the end of the taxonomy; no existing event's key/args/order changes), so it ships as a minor bump (4.10.04.11.0) with a regenerated docs/modding-api.md and this note. How to adapt: nothing is required of an existing mod — a mod that wants to react to a collapse defines on_cave_in(x, y, z) (Lua) or connects the cave_in signal (GDScript).

It never fired on the shipped game when 4.11.0 shipped. The support system's single data gate is an authored SupportDef singleton, and shipped core authored no support.ron — so a mod that wanted cave-ins had to author the tuning (see docs/modding-api.md and the support def stem) as well as hook the event.

#3568 changed that: shipped core now authors support.ron, so cave_in fires by default. Hooking the event is enough; authoring tuning is only needed to retune it. Note the event is raised only where a collapse had a consequence — a structure damaged or a colonist wounded — so a cell that came down on nothing is not reported. This is a behaviour change for a mod that assumed the event was dormant, but not an API-surface change: the key, arguments and ordering are untouched, so no version bump.

No SAVE_VERSION bump: the event payload is transient (drained per tick into the log/chronicle), never serialized, and a collapse writes only the pre-existing Integrity component and Broken marker.

Worked example (applied in 4.10.0)#

A mod can hook the expedition broken-link beat (GitLab issue #3035, epic::chunked-world; a #2907 follow-up) adds one new event to the taxonomy: expedition_link_broken (Lua hook on_expedition_link_broken; GDScript signal expedition_link_broken) with args name: str (the party pawn's name), x: int and y: int (its last-known tile, for click-to-jump). It fires colony-scope, edge-triggered once, when a dispatched expedition party's link home first goes intact→broken (the party is cut off on a disjoint resident island), and re-arms if the link is restored.

Per the rules above this is a purely additive change (one new event appended at the end of the taxonomy; no existing event's key/args/order changes), so it ships as a minor bump (4.9.04.10.0) with a regenerated docs/modding-api.md and this note. How to adapt: nothing is required of an existing mod — a mod that wants to react to a cut-off party defines on_expedition_link_broken(name, x, y) (Lua) or connects the expedition_link_broken signal (GDScript).

No SAVE_VERSION bump: the event payload is transient (drained per tick into the log/chronicle), never serialized; the presence-gated LinkBroken latch and Reinforcing (#3034) markers are additive save sections, so nothing new forces a version-gated migration.

Worked example (applied in 4.9.0)#

A mod can read a colonist's break speed (GitLab issue #2789, epic::mood; a #487 follow-up) fills two break-speed fields into the mood sub-dict of colonist(id) that the native inspector already read but the modding dict omitted: break_speed_pct (int, the combined mean-time-between-break factor — 100 neutral, lower breaks sooner) and break_speed_contributions (an array of { id, name, mtb_mean_pct } dicts, one per trait whose break-speed factor is non-neutral — the per-trait breakdown, the same shape as the existing trait_contributions). Empty/neutral for an untraited colonist.

Per the rules above this is a purely additive change (two new read-only keys; no existing key's value or shape changes), so it ships as a minor bump (4.8.04.9.0) with a regenerated docs/modding-api.md and this note. How to adapt: nothing is required of an existing mod — a mod that wants to reason about why a colonist breaks reads colonist(id)["mood"]["break_speed_pct"] and iterates ["break_speed_contributions"].

No SAVE_VERSION bump: the values are a read-only projection of the colonist's traits (computed by mood::combined_mtb_pct), never stored — nothing new reaches the save or the state_hash.

Worked example (applied in 4.8.0)#

A themed-site boss can be slain (GitLab issue #1546, epic::themed-sites) adds one new narrated event kind, boss_slain, to the event taxonomy. It carries a single name arg (the boss's generated proper name) and a location (the death tile, so click-to-jump works). Script surfaces gain an on_boss_slain Lua callback and a boss_slain Godot signal automatically — the EVENTS taxonomy is derived from the single canonical table, so this is one appended registry row, not a hand-edit of each surface.

Per the rules above this is a purely additive change (a new event kind appended last; no existing event's args or ordering change), so it ships as a minor bump (4.7.04.8.0) with a regenerated docs/modding-api.md and this note. How to adapt: nothing is required of an existing mod — a mod that wants the new beat adds an on_boss_slain(args) handler reading args.name / args.location.

No SAVE_VERSION bump: the event is a transient narrated signal; the only authoritative state a boss death writes is the existing SiteFlags.dead_guardians/cleared bitset (additive, #1545), not a new save field.

Worked example (applied in 4.7.0)#

The plant yield surface becomes a verb-keyed table (GitLab issue #2458, epic::flora-dynamics) replaces the plants projection's scalar produces / yield_min / yield_max fields with a single yields key: a list of "<work_kind>=<item>x(<min>-<max>)" rows, one per verb that produces a product (e.g. "core:harvest=core:apple x(3-3)" and "core:chop=core:wood x(2-3)" for an apple tree). This mirrors the RON schema change beneath it (the retired produces/yield_range/fruit/fruit_yield fields on PlantDef became an N-product yields table), so a plant can now yield any number of products keyed by verb — the query surface follows.

Per the rules above this is a breaking projection change (three keys removed, one added), but during pre-launch the format-version field carries no compatibility guarantee (see the constitution's Content-as-a-Data-Contract note), so it ships as a minor bump (4.6.04.7.0 — 4.7.0, not 4.4.0, because #2120/#2046/#2163 already claimed 4.4.0/4.5.0/4.6.0 on main before this cluster merged) with a regenerated docs/modding-api.md and this note. How to adapt: a mod reading plant["produces"] / plant["yield_min"] / plant["yield_max"] reads plant["yields"] and parses the row for the verb it cares about (harvest for crops/bushes, chop for trees).

No SAVE_VERSION bump: PlantDef is content, never serialized into a save — only the per-entity Plant/FruitState runtime components are. The verb-keyed table changes what content authors write and what a mod reads, not the save payload.

Rename: Grievance → ClassConsciousness (2026-07, GitLab #2479, no version change)#

The class-position accumulator Grievance was renamed ClassConsciousness to name it for what it is (a class-position readout that drives the resistance arc), not a second mood system. The modder-facing surface renames accordingly:

  • Event keys grievance_onset / grievance_milestoneclass_consciousness_onset /

class_consciousness_milestone (Lua callbacks on_class_consciousness_onset / on_class_consciousness_milestone).

  • The aligned_group_formed argument mean_grievancemean_class_consciousness.
  • strike_won / strike_held_out are unchanged except their documented noun ("class consciousness is

relieved / ratchets up"); no argument name/type/order or firing condition changed.

How to adapt: rename those callbacks/arguments in your mod; no behavior changed.

By the bump rules above this is a breaking event/argument rename, which would normally be a MAJOR bump. It is deliberately recorded without an API_VERSION bump (owner decision): the modding API has no external consumers pre-launch and will reset to 1.0.0 at release, so version churn now is pointless — and pre-launch the surface carries no compatibility guarantee (constitution, Content as a Data Contract). This note is intentionally not a ## Worked example (applied in X.Y.Z) heading, so the api_version_matches_newest_migrations_entry guard leaves API_VERSION at 4.7.0. The generated docs/modding-api.md was regenerated. (A SAVE_VERSION bump did apply — the save section key "grievance""class_consciousness" — but that is the save contract, tracked in sim/save.rs, not this API ledger.)

Worked example (applied in 4.6.0)#

Two new war events (GitLab issue #2163, Legends L5 wars & eras) add war_declared and war_ended to the canonical sim→shell event taxonomy, appended at the end of the table (append-only order, so no existing key's index shifts). Each exposes the two belligerent civ def ids as a / b (Str) — a mod reads them via the Lua on_war_declared / on_war_ended hooks and the GDScript war_declared / war_ended signals, and may re-narrate them. They replace L5's borrowed treaty_changed render tag on the Legends war member event with a first-class kind; their world-history (Legends) significance is on by default while the colony chronicle deliberately excludes them (an event may be in Legends without the chronicle — the #2121 two-axis significance).

Purely additive — two new events, nothing renamed or removed — so per the rules above this is a MINOR bump (4.5.04.6.0), a regenerated docs/modding-api.md, and this note.

Golden movement: adding the taxonomy rows moves the event-taxonomy / docs/modding-api.md golden (expected, re-baselined here). The war events themselves are emitted by the civ↔civ war producer (#2162); a war-free world fires neither, so no unrelated determinism golden moves.

Worked example (applied in 4.5.0)#

The weather modding surface (GitLab issue #2046, epic::weather, reads #839) adds the two missing bridges over the already-shipped weather system. Static: weathers becomes a def("weathers", id)-queryable content category — a mod reads a weather type's authored tuning (duration_ticks, the duration_min/duration_max 2047 range, the transitions (target-id, weight) pairs, the min_temp/max_temp eligibility gate, and the #840 effect percentages growth_pct / regrowth_pct / soil_recovery_pct / fire_spread_pct / fire_burnout_pct / move_slow_pct / work_slow_pct / chill_per_tick). WeatherDef carries no name, so the projection omits it (the build_menu_categories / refusal_reasons name-less precedent); an unknown id reads nil with no error (closed surface). Available on both the Lua and GDScript def readers (shared DefsView). Live: the GDScript Mods.colony_stats() query gains weather (the current weather type id, "" when idle) and weather_remaining (ticks left in the spell, 0 when idle) — a read-only projection of the #839 Sim.weather (via the #2045 ColonyStats DTO fields). This live half is GDScript-only: the Lua host holds no live Sim handle (the #1771 rival-intel live-vs-static split).

Both changes are purely additive — a new def category and a new live query, nothing renamed or removed — so per the rules above this is a MINOR bump (4.4.04.5.0), a regenerated docs/modding-api.md, and this note.

No SAVE_VERSION bump and no golden movement: these are read-only projections of already-authoritative content (WeatherDef, frozen at load) and already-projected live state (Sim.weather). Nothing new is serialized and no golden determinism checksum moves — the scripting surface is versioned separately from the save format.

Worked example (applied in 4.4.0)#

A new civ_founded event (GitLab issue #2120, Legends L4 civ/world-history) adds one event to the taxonomy: civ_founded, emitted once per rival civilization when it is founded at world start. It carries a single argument — civ: str (the civ def's display id) — so a mod can react to a rival being founded (Mods.connect("civ_founded", …) in GDScript, function on_civ_founded(civ) in Lua), the same shape as the existing colony_founded beat one level up. Adding an event is additive ⇒ a MINOR bump (4.3.04.4.0), a regenerated docs/modding-api.md, and this note. Nothing is renamed or removed — a mod that never connects to civ_founded is unaffected.

How a mod uses it

  • GDScript: Mods.connect("civ_founded", func(civ): print("Founded: ", civ)).
  • Lua: function on_civ_founded(civ) log("Founded: " .. civ) end.

The event's internal civ_id (the durable runtime id) and site (the owning overworld site) are not on the modding surface — they are sim-internal routing for the Legends world-history record and never reach a mod. No SAVE_VERSION bump: the founding beat is a transient tick message, recorded into the existing skip-if-empty historical_record header (itself additive), so nothing new is serialized and a civ-free world is byte-identical.

Worked example (applied in 4.3.0)#

The mood dict exposes the state the inspector already reads (GitLab issue #2643, the mood legibility spine #2477) grows the Mods.colonist(id)["mood"] query surface by five readable keysband (the happiness-band word), enabled (is the mood design layer running?), break_name, break_severity, break_pressure_rising — plus work_drag_pct (#2683, the #2471 output loss). These are a straight projection of fields the native MoodView already carried and the built-in inspector already rendered; the modding dict merely stopped omitting them. Nothing is renamed or removed — a mod that never mentions the new keys reads exactly as before. Per the rules above that is additive ⇒ a MINOR bump (4.2.04.3.0), a regenerated docs/modding-api.md, and this note.

The sharpest of the five is enabled: before it, a mod on a colony with the mood layer switched off read mood.level == 0 and could not tell a genuinely content colonist from one whose mood is not running at all — the exact distinction enabled draws (and which the inspector already honored). The break trio (break_name/break_severity/break_pressure_rising) is present as data even when the colonist is not broken (the name/severity keys are simply absent, mirroring the projection's Option), so has("break_name") is a mod's "is this colonist broken?" check.

No SAVE_VERSION bump: these are read-only projections of already-authoritative state — nothing new is serialized. The bounded-scale change beneath them (#2473) also needs no bump (the Mood.level field keeps its i32 shape and is a cached value recomputed each tick).

Worked example (applied in 4.2.0)#

A ground patch is marked, not built (GitLab issue #2550) adds one field to BuildingDefinstant (bool, #[serde(default)]). BuildingDef is projected field-by-field by Mods.def("buildings", …), so the queryable surface grows one readable key. Nothing is renamed or removed: a mod that never mentions instant reads exactly as before, and every existing building def loads byte-identically. Per the rules above that is additive ⇒ a MINOR bump (4.1.04.2.0), a regenerated docs/modding-api.md, and this note.

instant: true means the building is placed and removed with no construction at all — no blueprint, no hauling, no colonist, no work ticks at either end. A sleeping spot is a patch of dirt the player marks; nothing is made, so no one should have to make it. It does not mean "no rules": an instant placement still passes every gate a built one does (bounds, research/age, occupancy). The shipped worked examples are core:sleeping_spot and core:gathering_spot, authored through the identical data path a mod uses — ( id: "mymod:prayer_mat", instant: true, build_cost: [], joy_quality: Some(1.2), … ) is a complete marked-patch-of-ground def. (As authored in 4.2.0. joy_quality: Some(1.2) is now joy_quality_permille: Some(1200) — see the permille note near the top of this file, #3618. The rest of the example is unchanged; this line is left as it was written, with the correction noted, rather than rewritten, so the historical record stays accurate.)

Why this one is projected when placeholder_colour (#2551) deliberately is not — see the held-back list at the end of this file. That field is a cosmetic value nothing queries; its absence misleads nobody. instant is different in kind: build_work_ticks and deconstruct_work_ticks are already projected, and instant changes what they mean (they go unread entirely). A mod reading build_work_ticks: 10 off core:sleeping_spot and planning around "10 ticks to build" would be actively lied to by this contract. Projecting it is what keeps the published surface honest, so it is required rather than optional.

Load validation rejects the incoherent combinations rather than letting them reach the sim: instant cannot pair with a build_cost or material_categories (this one is a security guard, not pedantryinstant + a cost is a free-materials duplicator: place for free, deconstruct, keep materials the build never consumed, repeat, which is exactly the turn-1 exploit #2550 fixes, and a mod could otherwise re-author it in one line), with quality_matters (quality comes from the builder's skill, and there is no builder), or with a belt/loader_arm role (a logistics machine is built, not marked).

Note the separate, unversioned rule shipped alongside it: a building that cost nothing now leaves no debris when destroyed, by any cause. That is keyed on build_cost, not on instant — pipeline and economics are different questions, which is why core:research_bench (not instant, but free) is fixed by it too. No API surface changes for that half.

No SAVE_VERSION bump: a Def is reloaded from content on load and never serialized, and an instant structure saves as an ordinary Structure { material: None, quality: 0 } — the pre-2513/2514 byte shape.

Worked example (applied in 4.1.0)#

Fruiting trees (GitLab issue #2493, #1951 stage 2/3) adds two fields to PlantDeffruit (Option<DefId>) and fruit_yield (Option<(u32, u32)>) — both #[serde(default)]. PlantDef is projected field-by-field by Mods.def("plants", …), so the queryable surface grows two readable keys. Nothing is renamed or removed: a mod that never mentions fruit reads exactly as before, and every existing plant def loads byte-identically. Per the rules above that is additive ⇒ a MINOR bump (4.0.04.1.0), a regenerated docs/modding-api.md, and the dated migration note below.

The pair is the whole fruiting-tree contract, authored purely in data (no engine flag): blocks_movement already means "this is a tree" (⇒ choppable), and fruit.is_some() now means "it also bears" (⇒ harvestable while standing). A mod authors its own fruiting species through these same fields — assets/mods/core/defs/plants.ron's core:apple_tree is the shipped worked example, and it goes through the identical data path.

Load validation rejects the malformed combinations rather than letting them reach the sim: fruit must resolve to a real item, must sit on a blocks_movement: true plant, and requires regrowth_ticks (the fruit's regrow cadence — without one the tree is instantly re-ripe, an infinite food tap); fruit_yield must be a non-inverted, non-zero range. No SAVE_VERSION bump: ripeness lives in an append-only FruitState component that is absent until a tree is actually picked, so pre-feature saves are unchanged.

Worked example (applied in 4.0.0)#

Building materials (GitLab issue #2513) renamed a building def's materials key to build_cost on the queryable surface (Mods.def("buildings", …) projects every BuildingDef field), so a mod reading b.materials breaks. Per the rules above that is breaking ⇒ a MAJOR bump to 4.0.0, a regenerated docs/modding-api.md, and the dated migration note below. (It lands above #1401's additive 3.1.0, which merged first: a MAJOR subsumes a pending MINOR, so the two do not stack — 3.0.0 → 3.1.0 → 4.0.0.)

The same MR is additive in two other ways, which on their own would have been a MINOR bump: two new def categories (materials, material_categories) are readable via the category-generic accessors, and BuildingDef gains material_categories + material_slots. A MAJOR bump subsumes them.

Note the rename is a correction, not a churn: the field never held "what the building is made of" — it held what the blueprint demands. That misnomer was survivable while a building's material was welded into its id (core:wood_wall vs core:wall); #2513 collapses those per-material defs into one def built from a MaterialDef, so the name had to start meaning what it says.

Worked example (applied in 3.1.0)#

Sibling exhaustion tuning fields on the needs projection (GitLab issue #1401, epic epic::sleep #2443) adds three additive fields to the Mods.def("needs", …) accessor: sleep_sufficient_ticks (the Sleep ticks a colonist must accrue across a day to wake rested, #540), work_exhaustion_ticks (the ordinary-work tick budget before becoming Exhausted, #541), and work_exhaustion_floor_ticks (the minimum effective budget after the sleep-debt reduction, #541). All three are non-optional u32 with named defaults, so they are always present on every projected need — the same shape as #644's work_exhausted_output_pct, whose set they complete. No accessor field was removed, renamed, or retyped and no event signature changed — a mod that ignores the new fields reads a need exactly as before. Per the rules above that is additive ⇒ a MINOR bump (3.0.03.1.0), a regenerated docs/modding-api.md, and no migration note.

Why the fields exist: #644 exposed work_exhausted_output_pct — the output drag applied while a colonist is Exhausted — but none of the three dials that decide when a colonist becomes Exhausted in the first place. A mod reading the projection could see how hard exhaustion bites but not what triggers it, so it could not present, rebalance, or reason about the rule it is subject to. The exhaustion tuning set on NeedDef is now fully projected.

Shipped alongside (same MR, issues #1380/#1398): these dials are now load-time validated (validate_need_dials), rejecting sleep_sufficient_ticks == 0 (which would make every colonist trivially rested) and work_exhausted_output_pct > 100 (which would make an exhausted colonist work faster). That tightens what a mod may author but does not change the accessor surface, so it does not affect this bump.

No new saved state and no projection of derived state ⇒ no SAVE_VERSION bump and the golden state_hash is unmoved. It lands at 3.1.0 as the next free minor above #1951's 3.0.0.

Worked example (applied in 3.0.0)#

Harvest unification (GitLab issue #1951) retired the core:forage work kind in favour of one gathering verb, core:harvest, and removed the Wild variant from WorkRequirement. Both are visible on the def surface (Mods.def("work_kinds", …) projects every WorkKindDef, including its requirement string), so a mod that reads the core:forage id or matches the "Wild" requirement string breaks. Per the rules above that is breaking ⇒ a MAJOR bump to 3.0.0, a regenerated docs/modding-api.md, and the dated migration note below.

Note the retired id is the work kind only. core:forage also names a PlayActionDef and core:foraging a tech — three ids in three namespaces. #1951 touched only the work kind; the research vocabulary is a separate axis and is unchanged (core plants now author play_action explicitly to credit it).

Worked example (applied in 2.53.0)#

Sim-authoritative batch acting-count (GitLab issue #2419, feature 2419) added one event to the taxonomy (groundwork-events-derive/src/events.rs): batch_resolved (batch: int, claimed: int — a right-click prioritize fan-out is fully resolved, and claimed reports the exact acting-pawn count). It is one entry in the feature-179 canonical event table, appended after order_claimed, so the Lua on_batch_resolved callback and the GDScript batch_resolved signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump, regenerated docs/modding-api.md, and no migration note — no existing event or query changed. It lands at 2.53.0 because 2.52.0 was already taken by feature 1373's data-driven WorkKindDef fields on main (the same "next free minor" rule as the 1.15.0 example below).

Worked example (applied in 2.52.0)#

Data-driven WorkKindDef: removal bridge + work-menu presentation (GitLab issues #1373, #1372, #1370, subsuming #1425/#1426; epic epic::work-designation, feature 1373) adds four additive fields to the Mods.def("work_kinds", <id>) accessor: bridges_removal (bool — does designating a Structure for this kind also queue it for teardown, the #815 RemovalDesignation bridge; false unless authored, and only core:deconstruct sets it in shipped content), plus the three Work-menu presentation fields menu_glyph, menu_label, and menu_order (all omitted when unset, the size_tiles omit-when-absent precedent). No accessor field was removed, renamed, or retyped and no event signature changed — a mod that ignores the new fields reads a work kind exactly as before. Per the rules above that is additive ⇒ a MINOR bump (2.51.02.52.0), regenerated docs/modding-api.md, and no migration note.

Why the fields exist: #1114 made the work-kind vocabulary data-driven, but three seams still hardcoded the five core:* ids, so a mod kind could not work end-to-end without an engine change. bridges_removal retires the hardcoded core:deconstruct compare in apply_work_designations / clear_work_designations (#1373); the menu_* fields let the shell build the Work menu from the projected vocabulary instead of a hardcoded five-entry list, so a mod kind is visible and armable (#1370). The third seam — the shell's advisory eligibility preview — needed no new def field: it now reads a per-kind designatability map the render snapshot projects from the sim's authoritative carrier_matches for every loaded kind (#1372, folding the #1425 mod-kind false-red and the #1426 Deconstruct/Mine/Salvage carrier-parity). That map is derived snapshot state, not a mod-facing accessor, so it is not part of this bump.

The new content fields are additive #[serde(default)] RON, so pre-1373 content/mods load unchanged; the core kinds author the glyphs/labels/order the shell previously hardcoded, so the menu renders identically. No new saved state ⇒ no SAVE_VERSION bump (stays 16) and the golden state_hash is unmoved. It lands at 2.52.0 as the next free minor above #1461's 2.51.0.

Worked example (applied in 2.51.0)#

Surface animal lifespan to the mod def surface (GitLab issue #1461, epic epic::animal-ai, feature 1456) adds one additive field to the Mods.def("animals", <id>) accessor: lifespan_years (the species' authored lifespan in years; omitted when 0 = immortal, the size_tiles omit-when-absent precedent). It also surfaces the new juvenile_sprite life-stage sprite path when authored. No accessor field was removed, renamed, or retyped and no event signature changed — a mod that ignores lifespan_years reads the animal exactly as before. Per the rules above that is additive ⇒ a MINOR bump (2.50.02.51.0), regenerated docs/modding-api.md, and no migration note. The new content fields (maturity_years, juvenile_sprite, senescence) are additive #[serde(default)] RON, so pre-1456 content/mods load unchanged; the age-driven behavior (age-gated breeding, product-yield senescence) adds no new saved per-animal state, so there is no SAVE_VERSION bump (stays 16). It lands at 2.51.0 as the next free minor above #2107's 2.50.0.

Worked example (applied in 2.50.0)#

Auto-pause on a bleeding/downed colonist (GitLab issue #2107, epic epic::foundations, feature 2107) adds two new events to the sim→shell taxonomy: colonist_started_bleeding and colonist_downed, each carrying (x, y) (the colonist's tile at onset). They fire the frame a player colonist first starts bleeding from an untended wound, and the frame a colonist is first downed by injury (edge-triggered once per onset; colonist-only). They feed the #266/#267 auto-pause framework's two new on_bleeding / on_downed categories so the game gets the player's attention on the moment — completing the #2097 AlertKind::Bleeding on-ramp. As taxonomy events they auto-propagate to the Lua on_colonist_started_bleeding / on_colonist_downed callbacks and the Godot colonist_started_bleeding / colonist_downed signals, and are rendered in docs/modding-api.md. Two new events were added (none removed, renamed, or retyped; no existing event signature changed), appended at the end of the frozen event order. Per the rules above that is additive ⇒ a MINOR bump (2.49.02.50.0), regenerated docs, and no migration note. The events are transient one-tick Messages queues (never serialized), so there is no SAVE_VERSION bump and the deterministic state_hash / golden runs are unmoved. It lands at 2.50.0 as the next free minor above #1572's 2.49.0 (rebased forward past #1572's post-fire-recovery 2.49.0 when it merged first — the parallel-branch collision guard).

Worked example (applied in 2.49.0)#

Post-fire recovery: data-authored terrain succession (GitLab issue #1572, epic epic::flora-dynamics, feature 1572, F3) adds two new optional queryable fields on the terrain (TerrainDef) category: recovery_stages: Option<Vec<(DefId, u32)>> (the ordered intermediate (terrain, duration_ticks) a scorched tile heals through) and recovery_ticks: u32 (the initial scorched-stage duration, default 0). Both are rendered for modders in docs/modding-api.md (every def field is emitted), so the queryable surface grew. Absent (both) ⇒ the terrain is not a scorch state and is byte-identical to pre-1572. Present (authored on core:scorched): when a fire burns out on a fuel-bearing tile the ground becomes core:scorched, then heals scorched → recovery_stages… → the tile's original terrain as a pure function of the burn tick (zero SimRng drawn). Each stage terrain is validated at load to resolve to a loaded passable terrain with a non-zero duration (DanglingSuccessionStage / InvalidSuccessionStage). No field was removed, renamed, or retyped; no event signature changed; no new event was added to the taxonomy. Per the rules above that is additive ⇒ a MINOR bump (2.48.02.49.0), regenerated docs, and no migration note. Recovery state rides an additive skip-if-empty save header (empty when nothing is recovering), so there is no SAVE_VERSION bump. It lands at 2.49.0 as the next free minor above #840's 2.48.0 (rebased forward past #840's weather-warmth 2.48.0 when it merged first — the parallel-branch collision guard).

Worked example (applied in 2.48.0)#

Weather gameplay effects: passive Satisfier::Shelter + core:warmth need (GitLab issue #840, epic epic::weather, feature 840) adds one new variant to the Satisfier enum — Shelter — rendered for modders as the satisfier string "shelter" (via scripting/defs_view.rs::satisfier_name, which every NeedDef.satisfier projects through), so the queryable modding surface grew. It backs the new core:warmth need: a passive environmental need whose urgency the weather::advance_warmth system raises for a colonist outdoors under a cold weather and lowers when sheltered — the colonist takes no job to satisfy it (the assign.rs dispatch arm is a no-op fall-through). No existing satisfier value was removed, renamed, or retyped; no event signature changed; no event was added to the taxonomy. Per the rules above that is additive ⇒ a MINOR bump (2.47.02.48.0), regenerated docs, and no migration note — an existing mod that never reads the satisfier string is unaffected, and one that does sees only a new value. The seven new WeatherDef effect fields are content (not projected to the Mods.def(...)/DefsView weather surface) and add no further bump; core:warmth rides the existing additive Needs save path, so there is no SAVE_VERSION bump. It lands at 2.48.0 as the next free minor above #1571's 2.47.0 (rebased forward past #1571's tree-reseeding 2.47.0 when it merged first — the parallel-branch collision guard).

Worked example (applied in 2.47.0)#

Tree reseeding & forest succession (GitLab issue #1571, epic epic::flora-dynamics, feature 1571, F2) adds three new optional queryable fields: reseed_ppm: Option<u32> on the plants (PlantDef) category, and forest_density_cap_pct: Option<u32> + reseed_cadence_ticks: Option<u32> on the biomes (BiomeDef) category. Each is rendered for modders in docs/modding-api.md (every def field is emitted), so the queryable surface grew. Absent (all three) ⇒ forests are static exactly as pre-1571 (no spread, byte-identical). Present: a tree with reseed_ppm reseeds new saplings onto empty growable tiles adjacent to a mature tree of the same species, on the biome's reseed_cadence_ticks epoch cadence, capped at forest_density_cap_pct percent of a chunk's growable tiles. Placement is draw-free (splitmix64 over (world_seed, tile, epoch)), so zero SimRng is drawn. No field was removed, renamed, or retyped; no event signature changed; no new event was added to the taxonomy. Per the rules above that is additive ⇒ a MINOR bump (2.46.02.47.0), regenerated docs, and no migration note. Reseeded trees are ordinary Plant entities (derived def reads), so there is no SAVE_VERSION bump. It lands at 2.47.0 as the next free minor above #1570's 2.46.0 (rebase forward and take the next minor if another branch claims 2.47.0 first — the parallel-branch collision guard).

Worked example (applied in 2.46.0)#

Wild forage regrowth: authorable PlantDef.regrowth_ticks (GitLab issue #1570, epic epic::flora-dynamics, feature 1570) adds one new optional field to the plants (PlantDef) def category: regrowth_ticks: Option<u32>. It is rendered for modders in docs/modding-api.md (every PlantDef field is emitted), so the queryable surface grew. Absent ⇒ a wild forageable regrows at maturation speed (today's behavior, byte-identical); Some(n), n ≥ 1 (load-validated), is a rate — the plant reaches its maturation growth_total in n ticks, independent of first-maturation. No field was removed, renamed, or retyped; no event signature changed; no new event was added to the taxonomy. Per the rules above that is additive ⇒ a MINOR bump (2.45.02.46.0), regenerated docs, and no migration note. The per-plant regrowth phase lives on Plant.last_growth_tick (an additive #[serde(default)] component field), so there is no SAVE_VERSION bump. It lands at 2.46.0 as the next free minor above #1592's 2.45.0 (rebased forward past #1771/#1593/#1924/#1520/#1922/#1592 as each merged first — the parallel-branch collision guard).

Worked example (applied in 2.45.0)#

Revolt — the uprising ceiling (GitLab issue #1592, epic epic::class-resistance, feature 1592, child C5) added three events to the taxonomy (groundwork-events-derive/src/events.rs): revolt_began, revolt_suppressed, and revolt_seized — the ceiling of the class-resistance ladder, where an aligned aggrieved group turns hostile through the threat surface and contests ownership. revolt_began carries size + x/y (the group turned hostile); revolt_suppressed carries fallen + x/y (the colony's defense won, the dead seed the next uprising); revolt_seized carries owner (Str) + x/y (the rebels held an owned production building past the authored duration and its ownership transferred to the uprising's leader or the commons). Each surfaces as the Lua on_<key> callback and the GDScript <key> signal, derived by rule from the feature-179 canonical event table (the taxonomy-totality test covers them). Per the rules above that is additive ⇒ a MINOR bump (2.44.02.45.0), regenerated docs, and no migration note — no existing event or query changed. (The new GrievanceTuningDef.revolt_*/seizure_hold_ticks/martyr_* threshold fields and the RaidObjective::Seize variant are authorable content / internal sim types, not part of the queryable Mods.def(...) surface, so they add no further bump.) It lands at 2.45.0 as the next free minor above #1922's 2.44.0 (the parallel-branch collision guard above — #1592 originally targeted 2.40.0, rebased forward past #1771 then #1593 then #1924 then #1520 then #1922 as each merged first).

Worked example (applied in 2.44.0)#

Begging (GitLab issue #1922, epic epic::class-resistance, feature 1922) added one event to the taxonomy (groundwork-events-derive/src/events.rs): colonist_begged — the soft, pre-theft middle rung of the priced-out colonist's repertoire, emitted when an aggrieved, priced-out colonist begs a wealthier one for food (no item changes hands). It carries name (the beggar) plus x/y (their tile) and is narrated via the core:colonist_begged log template. It surfaces as the Lua on_colonist_begged callback and the GDScript colonist_begged signal, both derived by rule from the feature-179 canonical event table (the taxonomy-totality test covers them). Per the rules above that is additive ⇒ a MINOR bump (2.43.02.44.0), regenerated docs, and no migration note — no existing event or query changed. (The new GrievanceTuningDef.beg_threshold field, the core:beg event_weights opinion key, the core:begged_relief ThoughtDef, and the core:colonist_begged LogTemplateDef are authorable content, not part of the queryable Mods.def(...) surface, so they add no further bump.) It lands at 2.44.0 as the next free minor above #1520's 2.43.0 (the parallel-branch collision guard — #1922 originally targeted 2.40.0, rebased forward past #1771/#1593/#1924/#1520 as each merged first).

Worked example (applied in 2.43.0)#

Marriage event (GitLab issue #1520, epic::legends, feature 1520 / Legends L3) added one event to the taxonomy (groundwork-events-derive/src/events.rs): marriage — the marriage-upgrade beat. Marriage previously produced no narrated event (only a private core:married mood), so this is the first time a wedding enters the event log / chronicle / historical record; it is the producer of the symmetric core:married relation edge in the Legends graph (L3's per-figure relational overlay). It carries two string arguments — a and b, the spouses' names — and is narrated via the core:chronicle_marriage rule ("{a} and {b} were wed."). It surfaces as the Lua on_marriage callback and the GDScript marriage signal, both derived by rule from the feature-179 canonical event table (the taxonomy-totality test covers them). Per the rules above that is additive ⇒ a MINOR bump (2.42.02.43.0), regenerated docs, and no migration note — no existing event or query changed. (The new RelationDef content category and the ChronicleRuleDef.relations field are authorable content, not part of the queryable Mods.def(...) surface, so they add no further bump; the historical record's relational roles are a sim/save structure, not a scripting-surface change.) It lands at 2.43.0 as the next free minor above #1924's 2.42.0 (rebased forward past #1771's rival-intel 2.40.0, #1593's wage-lever 2.41.0, and #1924's emigration 2.42.0 — each merged first, the parallel-branch collision guard).

Worked example (applied in 2.42.0)#

Emigration departure narration (GitLab issue #1924, epic epic::class-resistance, feature 1924) added one event to the taxonomy (groundwork-events-derive/src/events.rs): colonist_departed — the Hirschman exit response to the class divide, emitted once (edge-triggered per colonist) when a sustainedly aggrieved, near- starving, priced-out colonist who is not held by a living partner or child leaves the colony rather than starve. It carries a single name string argument (the departing colonist) and is narrated via the core:colonist_departed log template. It surfaces as the Lua on_colonist_departed callback and the GDScript colonist_departed signal, both derived by rule from the feature-179 canonical event table (the taxonomy- totality test covers them). Per the rules above that is additive ⇒ a MINOR bump (2.41.02.42.0), regenerated docs, and no migration note — no existing event or query changed. (The new GrievanceTuningDef.departure_* threshold fields and the core:colonist_departed LogTemplateDef are authorable content, not part of the queryable Mods.def(...) surface, so they add no further bump.) It lands at 2.42.0 as the next free minor above #1593's 2.41.0 (which merged ahead of this branch — the parallel-branch collision guard; #1924 originally targeted 2.40.0, rebased forward past #1771 and #1593 as each merged first).

Worked example (applied in 2.41.0)#

Owner's wage lever narration (GitLab issue #1593, epic epic::class-resistance, child C6, feature 1593) added one event to the taxonomy (groundwork-events-derive/src/events.rs): wage_fraction_set — emitted when the player, as the colony's owner, commands the runtime wage fraction and the effective wage actually changes. It carries a single permille integer argument (the new worker share of routed surplus) and is narrated via the core:wage_fraction_set log template. It surfaces as the Lua on_wage_fraction_set callback and the GDScript wage_fraction_set signal, both derived by rule from the feature-179 canonical event table (the taxonomy-totality test covers them). Per the rules above that is additive ⇒ a MINOR bump (2.40.02.41.0), regenerated docs, and no migration note — no existing event or query changed. (The new runtime Sim.wage_fraction_override state and the core:wage_fraction_set LogTemplateDef are engine state / authorable content, not part of the queryable Mods.def(...) surface, so they add no further bump.) It lands at 2.41.0 as the next free minor above #1771's 2.40.0 (the parallel-branch collision guard — #1593 originally targeted 2.40.0, rebased forward past #1771 as it merged first).

Worked example (applied in 2.40.0)#

Rival intel: modding-API query + HUD panel (GitLab issue #1771, epic epic::rival-inequality, feature 1771) widened the query surface two ways, both additive. (1) Two new GDScript live queries — Mods.rivals() (all rival civilizations' live intel) and Mods.rival(id) (one rival by id) — each returning { id, name, age, exploitation, unrest, revolt_locked, opinion, treaty, distance }, read from the per-tick RenderSnapshot (panels.rival_intel), the same projection the CenterStage RivalIntelPanel renders. These follow the GDScript-only live colonist-query pattern (Mods.colonists()/colonist(id)); the Lua host holds no live Sim handle, so live rival queries stay GDScript-only. (2) A new civs category on the frozen def(...) surface: def_ids("civs") and def("civs", id) now project each authored CivDef (name, personality aggression/mercantilism/scholarship, starting age, exploitation lean, direction lean) to both GDScript and Lua, so a Lua mod can read a rival's authored static posture. Per the rules above that is additive — no existing event or query changed, only new queries and a new def category — ⇒ a MINOR bump (2.39.02.40.0), regenerated docs, and no migration note. No new event is added to the taxonomy; the feature reads and projects only pre-existing authoritative state (no sim/save change). It lands at 2.40.0 as the next free minor above #1925's 2.39.0.

Worked example (applied in 2.39.0)#

Scarcity-contrast narration (GitLab issue #1925, epic epic::class-resistance, feature 1925) added one event to the taxonomy (groundwork-events-derive/src/events.rs): scarcity_contrast — the inequality-thesis flagship structural-contrast line, emitted once (edge-triggered on the condition's onset) when a data-authored number of colonists went hungry (priced out of subsistence) while a data-authored quantity of edible surplus was owned by, hence withheld from, the hungry ("the granary held plenty"). It carries a single count integer argument (how many went hungry) and is narrated via the core:scarcity_contrast log template. It surfaces as the Lua on_scarcity_contrast callback and the GDScript scarcity_contrast signal, both derived by rule from the feature-179 canonical event table (the taxonomy-totality test covers them). Per the rules above that is additive ⇒ a MINOR bump (2.38.02.39.0), regenerated docs, and no migration note — no existing event or query changed. (The new GrievanceTuningDef.scarcity_contrast_* threshold fields and the core:scarcity_contrast LogTemplateDef / chronicle_template are authorable content, not part of the queryable Mods.def(...) surface, so they add no further bump.) It lands at 2.39.0 as the next free minor above #1774's 2.38.0 (the parallel-branch collision guard above — #1925 originally targeted 2.35.0, rebased forward past #1596/#1862/#1591/#1774 as each merged first).

Worked example (applied in 2.38.0)#

Rival unrest onset narration (GitLab issue #1774, epic epic::rival-inequality, feature 1774) added one event to the taxonomy: civ_unrest_rising — fired the first time a rival civilization's internal unrest crosses the tuning's narration_threshold on the upward edge (its people grow restless under sustained exploitation), the story beat before the civ_revolt boil-over and the rival-side mirror of the player's grievance_onset. It carries a single civ string argument (the affected civilization's CivDef id) and is narrated via the core:civ_unrest_rising log template. It surfaces as the Lua on_civ_unrest_rising callback and in the Mods event taxonomy. Per the rules above that is additive ⇒ a MINOR bump (2.37.02.38.0), regenerated docs, and no migration note — no existing event or query changed. (The new narration_threshold field on the existing CivUnrestTuningDef content def is authorable content, not part of the queryable Mods.def(...) surface, so it adds no further bump.) It lands at 2.38.0 as the next free minor above #1591's 2.37.0 (the parallel-branch collision guard above — #1774 originally targeted 2.35.0, rebased forward past #1596/#1862/#1591 as each merged first).

Worked example (applied in 2.37.0)#

Strike — collective stoppage (GitLab issue #1591, feature 1591; epic #1587 class-resistance child C4) added four narrated events to the taxonomy: strike_demands_issued (demands, size, x, y), strike_began (size, x, y), strike_won (demand, size, x, y), and strike_held_out (size, x, y) — each re-exposed as an on_strike_* Lua callback / gdext signal. Per the rules above that is additive (new events, no existing event/arg changed) ⇒ a MINOR bump (2.36.02.37.0), regenerated docs, and no migration note. It lands at 2.37.0 as the next free minor above #1862's 2.36.0, which merged first (the parallel-branch collision guard above — #1591 originally targeted 2.31.0, rebased forward past #1594/#1543/#423/#1770/#1596/#1862 as each merged first). The events fire only under an armed, unequal colony (an aligned group must cross the strike threshold), so a default/equal colony never emits them.

Worked example (applied in 2.36.0)#

Solidarity narration (GitLab issue #1862, epic::class-resistance) added one event to the sim→shell event taxonomy: aligned_group_formed. It fires once, edge-triggered, when an aligned aggrieved group (a proto-union) first forms — the largest bloc's size first crosses the core:grievance_tuning solidarity_narration_min_size this slow-cadence step ("the workers are uniting", the beat above the colony-seethe grievance_milestone). It carries size (the bloc's member count) and mean_grievance (its integer mean grievance), both Int. Generated surfaces mirror every other event: the Lua callback on_aligned_group_formed(size, mean_grievance), the Godot signal aligned_group_formed(size, mean_grievance), the Messages.aligned_group_formed queue, and a regenerated docs/modding-api.md taxonomy row. The event is appended last in the frozen event set (crates/groundwork-events-derive/src/events.rs + FROZEN_KEYS), so no existing event key, order, or argument changes. Per the rules above that is additive ⇒ a MINOR bump, regenerated docs, and no migration note — no existing event or argument changed. Default-inert (solidarity_narration_min_size: 0 ⇒ never fires; the shipped RON arms it), so an equal colony emits nothing and the goldens stay byte-identical; no SAVE_VERSION bump. It lands at 2.36.0 as the next free minor above #1596's 2.35.0 (the parallel-branch collision guard — #1862 originally targeted 2.34.0, rebased forward past #1770/#1596 as each merged first).

Worked example (applied in 2.35.0)#

Founding ownership moment (GitLab issue #1596, Milestone 14) added one event to the taxonomy (groundwork-events-derive/src/events.rs): founding_ownership (structure_name: str, owner_name: str — the colony grants founding ownership of a surplus-producing structure to a colonist, emitted only on a grant). It is one entry in the feature-179 canonical event table, so the Lua on_founding_ownership callback and the GDScript founding_ownership signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (2.34.02.35.0), regenerated docs, and no migration note — no existing event/query changed. (The new BuildingDef.surplus_producing flag and the ScenarioDef.starting_ownership_regime content field are not part of the queryable Mods.def(...) event surface, so they add no further bump.) It lands at 2.35.0 as the next free minor above #1770's 2.34.0 (the parallel-branch collision guard above — #1596 originally targeted 2.31.0, rebased forward past #1594/#1543/#423/#1770 as each merged first).

Worked example (applied in 2.34.0)#

Civ unrest revolt (GitLab issue #1770, epic epic::rival-inequality 3/5, feature 1770) added one event to the taxonomy: civ_revolt — fired when a rival civilization's sustained exploitation drives its internal unrest across the revolt threshold (its research is zeroed and its age briefly locked). It carries a single civ string argument (the revolting civilization's CivDef id) and is narrated via the core:civ_revolt log template. It surfaces as the Lua on_civ_revolt callback and in the Mods event taxonomy, the rival-side mirror of the player's grievance events. Per the rules above that is additive ⇒ a MINOR bump (2.33.02.34.0), regenerated docs, and no migration note — no existing event or query changed. (The new CivUnrestTuningDef content def and the core:civ_revolt LogTemplateDef are authorable content, not part of the queryable Mods.def(...) surface, so they add no further bump.) It lands at 2.34.0 as the next free minor above #423's 2.33.0 (the parallel-branch collision guard — #1770 originally targeted 2.31.0, rebased forward past #1594/#1543/#423 as each merged first).

Worked example (applied in 2.33.0)#

Per-growth-stage plant sprites (GitLab issue #423, epic::art-audio) added one optional field to the queryable plant def surface: stage_sprites (a PackedStringArray of per-growth-stage sprite paths, index = the plant's growth stage; absent ⇒ the renderer falls back to the single sprite). It is projected by project_plant (scripting/defs_view.rs) like sprite/size_tiles — present verbatim (an ordered string list) when authored and non-empty, omitted otherwise — so Mods.def("plants", id) and the Lua def accessor read it by rule. Per the rules above that is additive ⇒ a MINOR bump, regenerated docs, and no migration note — no existing field or query changed. (The sim-side PlantDef.stage_sprites content field and the shell's sim::farm::stage_sprite selector are presentation-only and not saved; only the projected field bumps the API.) It lands at 2.33.0 as the next free minor above #1543's 2.32.0, which merged first (the parallel-branch collision guard above — #423 originally targeted 2.31.0, rebased forward past #1594/#1543 as each merged first).

Worked example (applied in 2.32.0)#

Themed-site placement (GitLab issue #1543, S1) added one readable def category to the generic def surface (scripting/defs_view.rs): sites. A SiteDef is a themed worldgen location — a named, biome-gated, rarity-weighted tile-stamp footprint placed deterministically by the draw-free sim::site oracle. It is queryable via the existing generic surface — Lua def_ids("sites") / def("sites", id) and GDScript Mods.def_ids("sites") / Mods.def("sites", id) — projecting { id, name, theme, rarity_weight, biomes, footprint_size, guardians?, loot_table? } (biomes a string list; footprint_size the stamp-tile count; the guardians/loot_table stubs present only when set). No new API method is introduced — only the "sites" category is added to the projection, so every prior category id/field and the def/def_ids closed-surface behavior are unchanged. Per-world site placement (positions) is seed-derived, not a def, and is not exposed. Per the rules above that is additive ⇒ a MINOR bump, regenerated docs, and no migration note — no existing event, query, or argument changed. It lands at 2.32.0 as the next free minor above #1594's 2.31.0 (the parallel-branch collision guard above — #1543 originally targeted 2.29.0, rebased forward past #1566/#1530/#1594 as each merged first).

Worked example (applied in 2.31.0)#

Estate inheritance (GitLab issue #1594, Milestone 14 — Internal Economy & Class) added one event to the taxonomy (groundwork-events-derive/src/events.rs): estate_inherited (heir: str, relation: str, item: str — a dead owner's estate, owned property and/or a banked claim, passes to a living heir; heir is the heir's name, relation the tie to the deceased (partner|child|sibling), item a representative inherited asset). It is one entry in the feature-179 canonical event table, so the Lua on_estate_inherited(heir, relation, item) callback and the GDScript estate_inherited(heir, relation, item) signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (2.30.02.31.0), regenerated docs, and no migration note — no existing event, query, or argument changed. The feature extends the shipped feature-163 estate/bequest system (adding the Claim transfer, a living-sibling nearest-kin tier, and this narrated event); it bumps no SAVE_VERSION (the Bequest/EstateInherited are within-tick signals; Claim's stored shape is unchanged).

Worked example (applied in 2.30.0)#

Per-tile stockpile capacity (GitLab issue #1530, feature 1530) added one optional field to the queryable item def surface: per_tile_capacity (int, the max count of that item a single stockpile cell holds; absent ⇒ unbounded). It is projected by project_item (scripting/defs_view.rs) like weight/ loot_priority (present verbatim when authored, omitted when absent), so Mods.def(...) and the Lua def accessor read it by rule. Per the rules above that is additive ⇒ a MINOR bump (2.29.02.30.0), regenerated docs, and no migration note — no existing field/query changed. (The sim-side store rule and the ItemDef.per_tile_capacity content field are separate; only the projected field bumps the API.) It lands at 2.30.0 as the next free minor above #1566's 2.29.0, which merged first (the parallel-branch collision guard above — #1530 originally targeted 2.29.0, rebased onto #1566).

Worked example (applied in 2.29.0)#

Chronicle heartbeat (GitLab issue #1566, epic epic::legends, parent #1555 slice C2) added one event to the taxonomy (groundwork-events-derive/src/events.rs): year_turned (year: int, population: int — an in-game year turns, the colony's yearly chronicle heartbeat; year is the year reached (1 = surviving the founding year), population the living-colonist count when the turn is chronicled; emitted once per crossed year). It is one entry in the feature-179 canonical event table, so the Lua on_year_turned(year, population) callback and the GDScript year_turned(year, population) signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (2.28.02.29.0), regenerated docs, and no migration note — no existing event, query, or argument changed. The distinct first-year line is an internal extra_events: narration variant (FirstYearSurvived, selected on year == 1, like animal_preyed_upon/animal_starved) — it has no events-table row, so it is not a separately queryable/callback event and adds no further surface. The new persisted last_year_chronicled marker is save state, not part of the queryable Mods.def(...) surface, so it adds no further bump.

Chronicle voice (GitLab issue #1567, epic epic::legends, parent #1555 slice C3) rides this same 2.29.0 with no further bump: it adds an optional ChronicleRuleDef.chronicle_template: Option<String> RON field — authorable content that re-voices a chronicle entry with the event's own existing placeholders (validated at load against the event's known set), omitted ⇒ the feed wording byte-identically. Like the sibling ChronicleRuleDef.first_only field, it is content, not part of the queryable Mods.def(...) / event surface, so it adds no event, query, or argument and needs no version change. The in-world date prefix on the chronicle panel ("Year N, Season — …") is presentation-only (a RenderSnapshot projection), not modding surface.

Worked example (applied in 2.28.0)#

Chronicle firsts (GitLab issue #1565, epic epic::legends, parent #1555 slice C1) added one event to the taxonomy (groundwork-events-derive/src/events.rs): colony_founded (count: int — the colony is founded at world start with count starting settlers, emitted exactly once). It is one entry in the feature-179 canonical event table, so the Lua on_colony_founded callback and the GDScript colony_founded signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump, and no migration note — no existing event, query, or argument changed. (The sibling ChronicleRuleDef.first_only RON field is authorable content, not part of the queryable Mods.def(...) surface, so it adds no further bump.) It lands at 2.28.0 as the next free minor above #829's 2.27.0, which merged first (the parallel-branch collision guard above — #1565 originally targeted 2.27.0, rebased onto #829).

Worked example (applied in 2.27.0)#

Generic "threat cleared / you survived" (GitLab issue #829) added one event to the taxonomy (groundwork-events-derive/src/events.rs): threat_cleared (count: int, x: int, y: int — the last living on-map hostile is gone and the colony survives; count is how many hostiles were repelled, x/y the last cleared hostile's tile). It is the generic win side of colony_wiped, independent of a raid party, so a manhunter pack driven off is acknowledged (raids keep their richer raid_succeeded/ raid_failed; threat_cleared is suppressed on a tick a raid resolves, and on a colony wipe). It is one entry in the feature-179 canonical event table, so the Lua on_threat_cleared(count, x, y) callback and the GDScript threat_cleared(count, x, y) signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (2.26.02.27.0), regenerated docs, and no migration note — no existing event/query changed. The new core:threat_cleared LogTemplateDef is authorable content, not part of the queryable Mods.def(...) surface, so it adds no further bump. No objective-tracker change: surviving a manhunter already satisfies the demo's RaidSurvived objective — a manhunter arrives as an incident, and the shell-side objective tracker keys "a raid was seen" off any Incident-category log line — so #829 deliberately touches no ObjectiveCondition.

Worked example (applied in 2.26.0)#

Drafted-order abandonment (GitLab issue #1695) added one event to the taxonomy (groundwork-events-derive/src/events.rs): order_abandoned (name: str, x: int, y: int — a mental break destroyed a drafted colonist's live manual move order; the colonist is auto-undrafted). It is one entry in the feature-179 canonical event table, so the Lua on_order_abandoned callback and the GDScript order_abandoned signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (2.25.02.26.0), regenerated docs, and no migration note — no existing event/query changed. (The sibling immediate notice reuses the existing command_refused event with a new core:mental_break_abandoned reason token — content, not a taxonomy change — so it adds no further bump. It lands at 2.26.0 as the next free minor above #1690's 2.25.0, which merged first — the parallel-branch collision guard above; #1695 originally targeted 2.24.0, then 2.25.0.)

Worked example (applied in 2.25.0)#

Grievance-milestone narration (GitLab issue #1690, epic epic::class-resistance, deferred colony-scope sibling of #1668) added a new taxonomy event grievance_milestone, fired once when the count of aggrieved colonists (each at/above the #1668 narration_threshold) first crosses the data-authored core:grievance_tuning milestone_threshold — the colony-wide tipping point ("the colony seethes"), narrated to the event log / chronicle and dispatched to Lua as on_grievance_milestone(count) (count = how many colonists now resent the divide). Colony-scope (not located), edge-triggered (once per crossing) and rate-limited per kind. This is additive (a new event; no existing event, query, or argument was renamed, removed, or re-semanticized) ⇒ a MINOR bump and no migration action for existing mods: a mod that never defines on_grievance_milestone is unaffected. The new GrievanceTuningDef.milestone_threshold RON field and the core:grievance_milestone LogTemplateDef are authorable content, not part of the queryable Mods.def(...) surface, so they add no further bump. (It lands at 2.25.0 as the next free minor above #1671's 2.24.0 — #1671 and #1589 both merged first, claiming 2.24.0/2.23.0; this MR rebased onto them per the feature-245 collision guard, #1364.)

Worked example (applied in 2.24.0)#

Colonist class in the modding snapshot (GitLab issue #1671, epic epic::class-resistance, the deferred half of #1624) added two keys to the Mods.colonist(id) snapshot: class — the colonist's derived social-class stable id (e.g. "core:elite", the durable value mods branch on, matching the id-keyed convention of the other snapshot entries) — and class_name — the class display name (the ClassDef.name, falling back to the raw id when the def is missing — canon/mod safety). #1624 surfaced the class in the inspector but deliberately kept it off the modding surface; this closes that gap. The keys are present only for a colonist that has a derived Class (both Some together); a class-less colonist's snapshot omits both keys entirely, exactly as before. In GDScript:

var c := Mods.colonist(id)
if c.has("class"):
    print("%s is a %s (%s)" % [c["name"], c["class_name"], c["class"]])   # new (≥ 2.24.0)

and in Lua local c = Mods.colonist(id); if c.class then ... end. This is additive (two new keys; no existing key was renamed, removed, or re-typed, and a class-less colonist's dict is byte-identical to today) ⇒ a MINOR bump and no migration action for existing mods: a mod that never reads class is unaffected. The class is derived-not-saved and the snapshot is never serialized, so SAVE_VERSION is unchanged (no save migration). (It lands at 2.24.0 as the next free minor above #1589's 2.23.0, which merged first — the parallel-branch collision guard above; #1671 originally targeted 2.23.0.)

Worked example (applied in 2.23.0)#

Individual resistance (GitLab issue #1589, epic epic::class-resistance child C2) added one event to the taxonomy (groundwork-events-derive/src/events.rs): theft (good: str, x: int, y: int — an aggrieved, priced-out colonist stole food from another without paying). It is one entry in the feature-179 canonical event table, so the Lua on_theft callback and the GDScript theft signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (2.22.02.23.0), regenerated docs, and no migration note — no existing event/query changed. (The sibling work-refusal half of #1589 reuses the existing command_refused event with a new core:aggrieved reason token — content, not a taxonomy change — so it adds no further bump. The new GrievanceTuningDef refusal_threshold/theft_threshold fields and the core:theft opinion weight are RON tuning, not part of the queryable Mods.def(...) surface, so they add no further bump. It lands at 2.23.0 as the next free minor above #1668's 2.22.0.)

Worked example (applied in 2.22.0)#

Grievance-onset narration (GitLab issue #1668, epic epic::class-resistance, child of #1582 legibility) added a new taxonomy event grievance_onset, fired once when a colonist's grievance (#1588) first crosses the data-authored core:grievance_tuning narration threshold — the onset of class consciousness, narrated to the event log / chronicle and dispatched to Lua as on_grievance_onset(name, x, y, level) (name the colonist, x/y their tile, level the grievance level at the crossing). Edge-triggered (once per onset) and rate-limited per kind. This is additive (a new event; no existing event, query, or argument was renamed, removed, or re-semanticized) ⇒ a MINOR bump and no migration action for existing mods: a mod that never defines on_grievance_onset is unaffected. The new GrievanceTuningDef.narration_threshold RON field and the core:grievance_onset LogTemplateDef are authorable content, not part of the queryable Mods.def(...) surface, so they add no further bump. (It lands at 2.22.0 as the next free minor above #1014's 2.21.0 — #1014 merged first, claiming 2.21.0 for threat_averted.)

Worked example (applied in 2.21.0)#

Telegraphed threat averted (GitLab issue #1014, epic epic::demo-framing) added a new taxonomy event threat_averted, fired once when a telegraphed threat (an incident with warning_ticks) reaches its arrival tick but its force cannot be placed anywhere on the map (a fully-walled / degenerate map — every tile impassable). Before this, such a warned threat consumed its PendingThreat and silently no-showed, leaving a dangling HUD countdown; now it surfaces the outcome. The event carries incident (the incident id, a string), size (the frozen force size, an integer), and origin_civ (the attributing civ id, a string, empty when unattributed) — a strict subset of the existing threat_incoming args (no ticks_remaining/fire_tick, since it resolves now). The instant (non-telegraphed) path is unchanged: an un-announced incident that cannot place its force still drops silently. Adding a new event to the taxonomy is additive ⇒ a MINOR bump, and no migration note is required (a mod that never handles threat_averted is unaffected). No SAVE_VERSION bump — the event is transient (never saved). (It lands at 2.21.0 as the next free minor above #166's 2.20.0.)

What's new for mods: a new script/signal hook for an averted telegraphed threat, on the same event surface as threat_incoming — read it to clear a countdown banner with a message instead of a dangling warning.

Lua — new (≥ 2.21.0):

function on_threat_averted(incident, size, origin_civ)
  log(("the telegraphed %s (size %d) was averted"):format(incident, size))
end

Worked example (applied in 2.20.0)#

Wealth stratification & class (GitLab issue #166, Milestone 14 capstone) added a new read-only projection, scripting::class_view::project_classes(&Sim) -> ClassView, exposing each colonist's derived social class (ColonistClassView { entity_bits, class_id, class_name }, id-sorted). Class is derived (never saved) from wealth + claim + profession, so the projection is a pure read — no mutation, no RNG, no save surface. This is additive (a new projection; no event, query, or argument changed or removed) ⇒ a MINOR bump and no migration note: a mod that never reads the class projection is unaffected. The new ClassDef / ClassTuningDef RON content types are not part of the queryable Mods.def(...) surface this feature, so they add no further bump. (It lands at 2.20.0 as the next free minor above #448's 2.19.0.)

Worked example (applied in 2.19.0)#

Skill passions (GitLab issue #448, epic epic::premier-hud, the deferred passions half of #017) added two optional authorable fields to SkillDef: passion_minor_pct and passion_major_pct (percent chances 0–100 that a colonist rolls a minor / major passion for that skill at creation). Both are #[serde(default)] (default 0), so this is an additive change — existing core and third-party skill content parses unchanged, and an un-authored skill simply never attracts a passion. A mod tunes which skills tend to attract passion by setting these on its SkillDefs; the passion → experience multiplier curve (none 100% / minor 150% / major 200%) is a global simulation constant, not moddable content. No event, query, or argument was changed or removed, so no mod needs to adapt.

Worked example (applied in 2.18.0)#

Animal aging & natural death (GitLab issue #1344, epic epic::animal-ai, follow-up to #1309) added a new taxonomy event animal_died_of_age, fired once when an animal dies of old age — the third non-hunt death source, after #1309's predation and starvation. It carries the same args as the existing animal_killed event: def_id (the species id, a string) plus the death tile x, y (integers). Old age is narrated distinctly ("The old deer dies of age.") while hunt/predation deaths keep firing animal_killed and tamed-livestock deaths fire #1339's livestock_died. Adding a new event to the taxonomy is additive ⇒ a MINOR bump, and no migration note is required to keep existing mods working (a mod that never handles animal_died_of_age is unaffected).

(The 2.17.02.18.0 step is because #630's build-menu categories claimed 2.16.0, #1339's livestock_died claimed 2.15.0, and #1346's herd/pack projection claimed 2.17.0 on main while this feature was in review; the same additive-minor collision the parallel-branch guard defends against, so on rebase this feature advances to the next free minor.)

What's new for mods: a new script/signal hook for old-age deaths, on the same event surface as animal_killed.

Lua — new (≥ 2.18.0):

function on_animal_died_of_age(def_id, x, y)
  log(("the old %s died at %d,%d"):format(def_id, x, y))
end

GDScript — new (≥ 2.18.0): connect the animal_died_of_age(def_id: String, x: int, y: int) signal on the Mods autoload.

Worked example (applied in 2.17.0)#

Herd & pack coordination (GitLab issue #1346, epic epic::animal-ai, follow-up to #1309) added four optional fields to the Mods.def("animals", id) projection: herds (bool), pack (bool), cohesion_radius (int), and pack_radius (int). The animals category was already queryable; these surface a species' new group-coordination config so a mod script can read whether a species moves as a herd (same-species members cluster and flee together) or hunts as a pack (predators converge on a shared prey target), plus the cohesion/pack tuning distances. The values are authored on AnimalDef in defs/animals.ron and default to herds:false / pack:false / cohesion_radius:4 / pack_radius:8. Adding optional fields to an already-exposed category is additive ⇒ a MINOR bump (2.16.02.17.0), regenerated docs, and no migration note is required to keep existing mods working (a mod that never reads these fields is unaffected).

(The 2.16.02.17.0 step is because #1339's livestock_died event, #644's work-exhaustion drag, and #630's build-menu categories claimed 2.15.0/2.14.0/2.16.0 on main while this feature was in review — the same additive-minor collision the parallel-branch guard defends against; on rebase this feature advances to the next free minor.)

Note: the save format did not change (SAVE_VERSION stays 14). Group coordination is derived per tick (the herd neighborhood and the shared pack target are recomputed each tick, never persisted), so no new saved state is introduced and the animal-free golden state hash is unmoved.

What's new for mods: Mods.def("animals", id) exposes herds, pack (bools, always present) and cohesion_radius, pack_radius (numbers, always present); author them on any species to make it herd or pack.

GDScript — new (≥ 2.17.0):

var wolf := Mods.def("animals", "core:wolf")
if bool(wolf.get("pack", false)):
    print("wolves hunt in packs within ", int(wolf.get("pack_radius", 8)), " tiles")

Worked example (applied in 2.16.0)#

Data-driven build-menu categories (GitLab issue #630, epic epic::premier-hud; a #534 follow-up, sibling to #560) added a new content category build_menu_categories (a BuildMenuCategoryDef registry), so the labeled sections the shell's grouped build menu renders (Shelter / Furniture / Power / Production / Logistics / Storage / Defense / Commerce / Other) are now authored data a mod can extend rather than a closed engine enum. A BuildMenuCategoryDef has an id, a label (the section header), a glyph (the non-color cue), and a sort_order (ascending section position; core:other last). A mod adds a new menu section by declaring a category here and referencing it by id from a building's category — no engine change.

Adding a def category is additive ⇒ a MINOR bump (2.15.02.16.0), regenerated docs, and no migration note is required to keep existing mods working (a mod that never touches build_menu_categories is unaffected).

(The 2.15.02.16.0 step is because #644's work-exhaustion drag and #1339's livestock_died event claimed 2.14.0/2.15.0 on main while this feature was in review — the same additive-minor collision the parallel-branch guard defends against; on rebase this feature advances to the next free minor.)

Note on the buildings projection: the existing Mods.def("buildings", id) category field now projects the referenced category id ("core:power") instead of the pre-#630 bare enum-variant name ("Power"). The field name and type (a string) are unchanged and the value round-trips through the new build_menu_categories set, so — exactly like #1114's WorkDesignation.kind reshape — this is treated as an additive projection change, not a break. state_hash / SAVE_VERSION are unchanged (the category is content read at load, never persisted).

What's new for mods: Mods.def("build_menu_categories", id) exposes label, glyph, and sort_order; Mods.def_ids("build_menu_categories") lists the categories. Author defs/build_menu_categories.ron in your mod to add one, and reference it from a building's category.

GDScript — new (≥ 2.16.0):

for id in Mods.def_ids("build_menu_categories"):
    var c := Mods.def("build_menu_categories", id)
    print(id, " → ", c.get("label", ""), " ", c.get("glyph", ""), " @", c.get("sort_order", 0))
# A mod file defs/build_menu_categories.ron:
#   [ (id: "mymod:aquatics", label: "Aquatics", glyph: "🌊", sort_order: 45) ]
# and a building defs/buildings.ron:
#   [ (id: "mymod:fish_trap", name: "Fish Trap", category: Some("mymod:aquatics"), …) ]

Worked example (applied in 2.15.0)#

Tamed-livestock death event (GitLab issue #1339, epic epic::animal-ai, follow-up to #1309) added a new event livestock_died to the taxonomy. Feature #1339 extends the wild-animal hunger metabolism to tamed livestock — a tamed animal you fail to feed grows hungry and starves — and narrates that death distinctly from a hunt kill: a tamed animal's death emits livestock_died (a livestock death), whereas a wild animal killed by a hunter still emits animal_killed. The new event carries def_id: str, x: int, y: int (the species id + death tile), matching the animal_killed / animal_born shape. Adding a taxonomy event is additive ⇒ a MINOR bump (2.14.02.15.0), regenerated docs, and no migration note is required to keep existing mods working (a mod that never handles livestock_died is unaffected). The save format is unchanged (no SAVE_VERSION bump — the hunger state already persisted additively since #1309; #1339 only widens which animals carry it).

(The 2.14.02.15.0 step is because #644's work-exhaustion drag claimed 2.14.0 on main while this feature was in review — the same additive-minor collision the parallel-branch guard defends against; on rebase this feature advances to the next free minor.)

What's new for mods: define function on_livestock_died(def_id, x, y) (Lua) or connect the livestock_died signal (GDScript) to react when a tamed animal dies.

GDScript — new (≥ 2.15.0):

func _ready() -> void:
    Mods.connect("livestock_died", _on_livestock_died)

func _on_livestock_died(def_id: String, x: int, y: int) -> void:
    print("Lost livestock: ", def_id, " at ", x, ",", y)

Worked example (applied in 2.14.0)#

Work-exhaustion output drag (GitLab issue #644, follow-up to #541) added the optional work_exhausted_output_pct field to the Mods.def("needs", id) projection. The needs category was already queryable; this adds one integer field: the absolute percent of normal work output an Exhausted colonist produces (100 = no drag; 85 = the core default; lower = a harsher slowdown). The value is read while advancing work orders, so exhaustion (from continuous work #541 or sleep debt #540, which share the Exhausted marker) now lightly slows output instead of being mood-only. Adding an optional field to an already-exposed category is additive ⇒ a MINOR bump (2.13.02.14.0), regenerated docs, and no migration note is required to keep existing mods working (a mod that never reads work_exhausted_output_pct is unaffected).

What's new for mods: Mods.def("needs", id) exposes work_exhausted_output_pct (a number, always present); author it on any need in defs/needs.ron to retune or disable (100) the exhausted drag.

GDScript — new (≥ 2.14.0):

var rest := Mods.def("needs", "core:rest")
var drag := int(rest.get("work_exhausted_output_pct", 100))
print("exhausted colonists work at ", drag, "% of normal")

Worked example (applied in 2.13.0)#

Data-driven work kinds (GitLab issue #1114, epic epic::work-designation) added a new content category work_kinds (a WorkKindDef registry), so the vocabulary of player work designations (Forage / Chop / Deconstruct / Mine / Salvage) is now authored data a mod can extend rather than a closed engine enum. A WorkKindDef has an id, a name, a target carrier tag (Plant | Structure | Deposit | Item), and an optional requirement refinement (None | Wild | LooseItem). A mod registers a new work kind by adding an entry that reuses an existing target — no engine change. Adding a def category is additive ⇒ a MINOR bump (2.12.02.13.0), regenerated docs, and no migration note is required to keep existing mods working (a mod that never touches work_kinds is unaffected).

Note: the save format did change (SAVE_VERSION 12 → 13) because the saved WorkDesignation.kind field was reshaped from a bare enum variant to an id string; that is a save-migration matter (pre-13 saves are rejected — see save.rs), not a modding-API break. The Mods.def(...) projection is purely additive.

What's new for mods: Mods.def("work_kinds", id) exposes name, target, and requirement (all strings); Mods.def_ids("work_kinds") lists the registered kinds. Author defs/work_kinds.ron in your mod to add one.

GDScript — new (≥ 2.13.0):

for id in Mods.def_ids("work_kinds"):
    var wk := Mods.def("work_kinds", id)
    print(id, " → ", wk.get("target", ""), "/", wk.get("requirement", "None"))
# A mod file defs/work_kinds.ron:
#   [ (id: "mymod:harvest_all", name: "Harvest All", target: Plant) ]

Worked example (applied in 2.12.0)#

Expose loot_priority on the item def projection (GitLab issue #1262, follow-up to !495/#1220) added the optional loot_priority field to the Mods.def("items", id) projection. The items category was already queryable and already exposed trade_value, but loot_priority (feature 046 — the "grab this first" ranking that colonists read for loose-item haul/salvage and raiders read for theft) was never projected, so mod scripts could not read it. This feature authors loot_priority on the core gear/valuable defs (weapons, apparel, high-value manufactured goods, ranked below core:silver's 50) and projects the field so mods can reason about the same signal the core uses. loot_priority is an integer (>= 1) meaningful only on a tradeable item; it is content that a simulation reads (unlike the presentation-only size_tiles), but exposing it to the query surface is still additive to the mod API. Adding an optional field to an already-exposed category is additive ⇒ a MINOR bump (2.11.02.12.0), regenerated docs, and no migration note is required to keep existing mods working (a mod that never read loot_priority is unaffected; reading it on a def that omits it returns nil). state_hash / SAVE_VERSION are unchanged (the field is content read by the existing haul/raid ranking; nothing new is serialized).

What's new for mods: Mods.def("items", id) exposes loot_priority (a number) when the def declares one; higher means grabbed/stolen first, absent means ranked by trade_value.

GDScript — new (≥ 2.12.0):

var armor := Mods.def("items", "core:leather_armor")
print(armor.get("loot_priority", 0))   # 42 for the authored def, 0/absent otherwise

Worked example (applied in 2.11.0)#

Pawn carry capacity (GitLab issue #660, follow-up to #655) added two optional fields to the Mods.def(...) projection: weight on item defs and carry_capacity on pawn defs. weight is an item's integer carry weight (nil/absent → weightless); carry_capacity is a pawn type's maximum summed held weight (nil/absent → unlimited). Both are authoritative simulation data (the cap is enforced when a pawn takes an item into its inventory), but adding an optional projected field is additive ⇒ a MINOR bump (2.10.02.11.0), regenerated docs, and no migration note is required to keep existing mods working (a mod that never reads weight/carry_capacity is unaffected; reading either on a def that omits it returns nil). state_hash / SAVE_VERSION are unchanged — both fields are content, and used capacity is recomputed, so nothing new is serialized into a save.

What's new for mods: Mods.def("items", id) exposes weight (a number) and Mods.def("pawns", id) exposes carry_capacity (a number) when the def declares them.

GDScript — new (≥ 2.11.0):

var wood := Mods.def("items", "core:wood")
print(wood.get("weight", 0))              # carry weight, or 0 when weightless
var settler := Mods.def("pawns", "core:settler")
print(settler.get("carry_capacity", 0))   # carry limit, or 0 when unlimited

Worked example (applied in 2.10.0)#

Broaden size_tiles to item / plant / structure defs (GitLab issue #1284, follow-up to !487) added the same optional size_tiles field to the Mods.def(...) projection of item, plant, and building defs — the three categories were already queryable through the accessor, so this feature only adds the one field to each, mirroring the pawn/animal accessor shipped in 2.9.0. size_tiles is the def's logical on-screen size in tiles: nil/absent → the shell's per-category default (Cat::Item 0.5, Cat::Plant 0.7, Cat::Structure 1.0 × a tile); a positive value renders the sprite at value × tile so a boulder reads bigger than a berry and an oak bigger than a sapling. It is presentation metadata read only by the shell — no simulation system reads it (state_hash / SAVE_VERSION unchanged). Adding an optional field to an already-exposed category is additive ⇒ a MINOR bump (2.9.02.10.0), regenerated docs, and no migration note is required to keep existing mods working (a mod that never read size_tiles is unaffected; reading it on a def that omits it returns nil). A non-positive size_tiles is now rejected at content/mod load (the shared validation rule this feature folds in).

What's new for mods: Mods.def("items", id), Mods.def("plants", id), and Mods.def("buildings", id) expose size_tiles (a number) when the def declares it.

GDScript — new (≥ 2.10.0):

var oak := Mods.def("plants", "core:oak")
print(oak.get("size_tiles", 0.7))   # declared logical size in tiles, or the plant default

Worked example (applied in 2.9.0)#

Art-dimension-aware pawn sprite scale (GitLab issue #1233, follow-up to !469) added one optional field size_tiles to the Mods.def(...) projection of pawn and animal defs, and exposed a new animals category to Mods.def(category, id) (previously animal defs were not queryable through the accessor). size_tiles is the creature's logical on-screen size in tiles: nil/absent → the shell's per-category default (settler-equivalent, ~1.25× a tile); a positive value renders the sprite at value × tile so a large animal reads bigger than a settler. It is presentation metadata read only by the shell — no simulation system reads it (state_hash / SAVE_VERSION unchanged). Adding an optional field and a new readable category is additive ⇒ a MINOR bump (2.8.02.9.0), regenerated docs, and no migration note is required to keep existing mods working (a mod that never read animals or size_tiles is unaffected; reading size_tiles on a def that omits it returns nil).

What's new for mods: Mods.def("animals", id) now resolves (with the full AnimalDef field set), and both Mods.def("pawns", id) and Mods.def("animals", id) expose size_tiles (a number) when the def declares it.

GDScript — new (≥ 2.9.0):

var deer := Mods.def("animals", "core:deer")
print(deer.get("size_tiles", 1.25))   # declared logical size in tiles, or the default

Worked example (applied in 2.8.0)#

Pawn inventory container (GitLab issue #655) added one array to the Mods.colonist(id) snapshot: inventory — the colonist's held items (items physically carried, distinct from equipped gear and from the owned-property belongings). Each entry is { def (string), quality (int), id (string) }, where id is the held item's stable Entity id (stringified u64, matching the colonist-id convention) so a HUD row can address one specific held item (e.g. drop/forbid). The array is sorted by id (deterministic) and is empty for a colonist holding nothing. All pre-existing colonist keys are unchanged (byte-identical), so no existing reader breaks. Per the rules above that is additive ⇒ a MINOR bump (2.7.02.8.0), regenerated docs, and no migration note. (No new event, Def type, or Mods.def(...) field; the sim-side Inventory/HeldItem components persist additively with no SAVE_VERSION bump.)

What's new for mods: Mods.colonist(id)["inventory"] — an array of { "def", "quality", "id" }, one per held item.

GDScript — new (≥ 2.8.0):

for it in Mods.colonist(id)["inventory"]:
    print("%s (q%d) id=%s" % [it["def"], it["quality"], it["id"]])

Worked example (applied in 2.7.0)#

Itemize trait baseline mood contributions (GitLab issue #431, follow-up to feature 046) added one array to the mood sub-dict of the Mods.colonist(id) snapshot: trait_contributions — a sibling of thoughts, one entry { id, name, mood_offset (int, signed) } per colonist trait whose baseline mood offset is non-zero. It surfaces the other half of the mood breakdown the in-game inspector now renders (e.g. Cheerful: +8), so the shown thought offsets + trait offsets reconcile to mood.level. The pre-existing level / low_ticks / thoughts keys of mood are unchanged (byte-identical), so no existing reader breaks. Per the rules above that is additive ⇒ a MINOR bump (2.6.02.7.0), regenerated docs, and no migration note — no existing field changed. (No new event, Def type, or Mods.def(...) field is added — the render-side TraitContributionView projection and the inspector-panel row are internal presentation plumbing, not a queryable surface, so they add no further bump.)

What's new for mods: Mods.colonist(id)["mood"]["trait_contributions"] — an array of { "id", "name", "mood_offset" }, one per mood-affecting trait. A trait with a zero (or absent) mood offset is omitted (no +0 entry).

GDScript — new (≥ 2.7.0):

for tc in Mods.colonist(id)["mood"]["trait_contributions"]:
    print("%s: %+d" % [tc["name"], tc["mood_offset"]])  # e.g. "Cheerful: +8"

Worked example (applied in 2.6.0)#

Per-thought remaining duration (GitLab issue #430, follow-up to feature 046) added one field to each entry of the mood.thoughts array in the Mods.colonist(id) snapshot: remaining_ticks (int) — the live countdown, in ticks, until that thought expires. It surfaces the same value the in-game colonist inspector now renders as (Nh left). The pre-existing id / name / mood_offset keys of each thought are unchanged (byte-identical), so no existing reader breaks. Per the rules above that is additive ⇒ a MINOR bump (2.5.02.6.0), regenerated docs, and no migration note — no existing field changed. (No new event, Def type, or Mods.def(...) field is added — the render-side ThoughtView widening, the ColonyStats.ticks_per_hour snapshot basis, and the thought_duration_suffix formatter are internal presentation plumbing, not a queryable surface, so they add no further bump.)

What's new for mods: Mods.colonist(id)["mood"]["thoughts"][i]["remaining_ticks"] (int) — ticks left before thought i expires. Always ≥ 1 for a listed thought (a thought at 0 is dropped by the sim).

GDScript — new (≥ 2.6.0):

for t in Mods.colonist(id)["mood"]["thoughts"]:
    var ticks_left := t["remaining_ticks"]  # ticks until this thought fades
    print("%s: %+d (%d ticks left)" % [t["name"], t["mood_offset"], ticks_left])

Worked example (applied in 2.5.0)#

Surface crafted gear quality (GitLab issue #1074, follow-up to feature 217) added two fields to the equipped sub-dictionary of the Mods.colonist(id) snapshot: weapon_quality (int, 0 = baseline/un-stamped) and apparel_quality (PackedInt32Array, index-aligned with the existing apparel id list). Both surface feature 217's per-instance crafted quality so mods/tooling can read it the way the in-game colonist inspector now does. The pre-existing equipped.weapon / equipped.apparel keys are unchanged (byte-identical), so no existing reader breaks. Per the rules above that is additive ⇒ a MINOR bump (2.4.02.5.0), regenerated docs, and no migration note — no existing field changed. (No new event, Def type, or Mods.def(...) field is added — the render-model GearView widening and the new quality_line formatter are internal presentation plumbing, not a queryable surface, so they add no further bump.)

What's new for mods: Mods.colonist(id)["equipped"]["weapon_quality"] (int) and ["apparel_quality"] (PackedInt32Array); apparel_quality[i] is the quality of apparel[i]. 0 means baseline (an ordinary, un-stamped piece).

GDScript — new (≥ 2.5.0):

var eq = Mods.colonist(id)["equipped"]
for i in eq["apparel"].size():
    var q := eq["apparel_quality"][i]  # 0 = baseline; >100 = masterwork
    print("%s: quality %d" % [eq["apparel"][i], q])

Worked example (applied in 2.4.0)#

Diplomacy & relations (GitLab issue #153, feature 153) added five events to the taxonomy (groundwork-events-derive/src/events.rs): civ_relation_changed (civ: str, score: int, delta: int — the colony's relation score with a rival civ moved), envoy_arrived (civ: str, demand: str — an envoy arrives with a demand), demand_accepted / demand_refused (civ: str, demand: str — the colony's vote resolved the demand), and treaty_changed (civ: str, treaty: str — the treaty with a civ transitioned to war/peace/a trade pact). Each is one entry in the feature-179 canonical event table, so the matching Lua on_* callbacks and GDScript signals derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (2.3.02.4.0), regenerated docs, and no migration note — no existing event or query changed. (The new RelationsTuning def / relations_tuning content category, the IssueEffect::Diplomatic arm, the IncidentKind::Envoy + IncidentDef.demand_issue RON fields, and the LogEvent::{CivRelationChanged,EnvoyArrived,DemandAccepted,DemandRefused,TreatyChanged} narrated colony-log events are not part of the queryable Mods.def(...) surface, so they add no further bump.) It lands at 2.4.0 because 2.3.0 was taken by feature 970's attack_landed target_name argument on main.

What's new for mods: five Lua callbacks — on_civ_relation_changed(civ, score, delta), on_envoy_arrived(civ, demand), on_demand_accepted(civ, demand), on_demand_refused(civ, demand), on_treaty_changed(civ, treaty) — and the matching GDScript signals. Diplomacy tuning lives in relations_tuning.ron (core:relations: drift_step + per-interaction event_weights); demands are IssueDefs using IssueEffect::Diplomatic { relation_delta, set_treaty }; envoys are IncidentDefs with kind: Envoy + demand_issue — all data, no code change.

Lua — new (≥ 2.4.0):

function on_envoy_arrived(civ, demand)
  -- react to an incoming demand (civ = the demanding civ, demand = "tribute"/"ransom")
end

function on_treaty_changed(civ, treaty)
  -- react to a treaty transition (treaty = "war"/"peace"/"a trade pact")
end

Worked example (applied in 2.3.0)#

Richer combat-hit narration (GitLab issue #970, follow-up to feature 49) added one trailing argument to the existing attack_landed event in the feature-179 canonical event table (groundwork-events-derive/src/events.rs): target_name: str — the struck target's resolved display name (fallback "a foe" for an unnamed target such as a hunted wild animal), joining the existing magnitude: int. Adding an argument to an existing event is additive ⇒ a MINOR bump (2.2.02.3.0), regenerated docs, and no migration note: the event key, its firing semantics, and the magnitude argument are all unchanged, so existing on_attack_landed / attack_landed-signal mods keep working and simply gain the extra parameter. (The new {target}/{force} narration placeholders and the NarrationTuning.force_bands RON field are colony-log/content surface, not part of the queryable Mods.def(...) surface, so they add no further bump.)

What's new for mods: the Lua on_attack_landed callback and the GDScript attack_landed signal now carry target_name after magnitude.

Worked example (applied in 2.2.0)#

Threat telegraph (GitLab issue #821, feature 821) added one event to the taxonomy (groundwork-events-derive/src/events.rs): threat_incoming (incident: str, size: int, ticks_remaining: int, origin_civ: str — a warned incident has been telegraphed and will spawn after a warning window). It is one entry in the feature-179 canonical event table, so the Lua on_threat_incoming callback and the GDScript threat_incoming signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (2.1.02.2.0), regenerated docs, and no migration note — no existing event or query changed. (The new IncidentDef.warning_ticks RON field and the LogEvent::ThreatIncoming narrated colony-log event are not part of the queryable Mods.def(...) surface, so they add no further bump. It lands at 2.2.0 because 2.1.0 was taken by feature 187's animal_tamed/animal_born on main.)

Worked example (applied in 2.1.0)#

Taming & livestock events narrated (GitLab issue #187, narration follow-up to feature 110) added two events to the taxonomy (groundwork-events-derive/src/events.rs): animal_tamed (name: str, def_id: str, x: int, y: int) when a colonist tames a wild animal, and animal_born (def_id: str, x: int, y: int) when a tamed pair's livestock offspring is born. Adding events is additive ⇒ a MINOR bump (2.0.02.1.0), regenerated docs, and this note. No existing event, signal, or callback changed, so nothing breaks for existing mods.

What's new for mods: two Lua callbacks — on_animal_tamed(name, def_id, x, y) and on_animal_born(def_id, x, y) — and the matching GDScript signals animal_tamed / animal_born. The event-log wording is a log_templates.ron template over the registry default (AnimalTamed {handler}/{animal}, AnimalBorn {animal}), and chronicle inclusion is a chronicle_rules.ron ChronicleRuleDef (both ship significant: false; flip either to record the milestone in the persistent chronicle) — no code change.

Lua — new (≥ 2.1.0):

function on_animal_tamed(name, def_id, x, y)
  -- react to a successful tame (name = the handler, def_id = the species)
end

function on_animal_born(def_id, x, y)
  -- react to a livestock birth (def_id = the species)
end

Worked example (applied in 2.0.0)#

Prioritize refusal converged into the command-refusal taxonomy (GitLab issue #908) removed one event from the taxonomy (groundwork-events-derive/src/events.rs): prioritize_refused (reason: str, x: int, y: int, added in 1.17.0 by #858). A refused player prioritize order is now carried by the existing command_refused event with command == "prioritize" (the unified refusal event #480 added in 1.18.0), so there is one sim refusal event and one shell notice path instead of two. Removing an event is a breaking change ⇒ a MAJOR bump (1.18.02.0.0), regenerated docs, and this migration note.

What changed for mods: the Lua on_prioritize_refused(reason, x, y) callback and the GDScript prioritize_refused signal no longer exist. Handle the unified command_refused event instead and branch on the command argument.

Lua — before (≤ 1.18.0):

function on_prioritize_refused(reason, x, y)  -- removed in 2.0.0
  -- react to a refused prioritize
end

Lua — after (≥ 2.0.0):

function on_command_refused(command, reason, x, y)
  if command == "prioritize" then
    -- same reaction as before
  end
end

GDScript signal consumers rename the prioritize_refused connection to command_refused and branch on the command argument identically. (The retired LogEvent::PrioritizeRefused narrated colony-log event and its core:prioritize_refused template were not part of the queryable Mods.def(...) surface, so they add no further bump; a community override of the removed core:prioritize_refused template id simply becomes inert.)

Worked example (applied in 1.1.0)#

The broad world/entity query expansion (GitLab issue #31, feature 036) added queries to ModApi (colonist_ids(), colonist(id), colonists(), plus the full field set on def(...) and a broadened category set; Lua gained def/def_ids over a frozen content view). Per the rules above that is additive ⇒ a MINOR bump (1.0.01.1.0), regenerated docs, and no migration note — the surface grew without breaking. (Live colonist queries are GDScript-only this version; the Lua live- query bridge is deferred to a future feature — documented in modding-api.md, not a breaking change.)

Worked example (applied in 1.2.0)#

Generalized research gating (GitLab issue #95, feature 106) added research_prerequisite to RecipeDef, so the def("recipes", id) projection (GDScript + Lua) gains that key on gated recipes (omitted when ungated — the existing Option convention shared with buildings / weapons / apparel). Per the rules above that is additive ⇒ a MINOR bump (1.1.01.2.0), regenerated docs, and no migration note — no event/query changed.

Worked example (applied in 1.4.0)#

Asset license metadata (GitLab issue #170, feature 170) added sprite_license (an SPDX id) to the four visual defs — BuildingDef, ItemDef, PlantDef, PawnDef — next to each one's existing sprite, so the def("buildings"|"items"|"plants"|"pawns", id) projection (GDScript + Lua) gains that key on defs that declare it (omitted when absent — the existing Option convention shared with sprite). The id is validated at load against the allowed set (CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0, OFL-1.1, or any LicenseRef-* custom reference). Per the rules above that is additive ⇒ a MINOR bump (1.3.01.4.0), regenerated docs, and no migration note — no event/query changed. (The 1.2.01.3.0 bump was feature 113; this entry follows it.)

Worked example (applied in 1.6.0)#

Births & family (GitLab issue #148, feature 148) added two events to the taxonomy (scripting/api.rs): birth (child: str, parent: str — a colonist is born to a colony couple) and partnership_formed (a: str, b: str — two colonists become partners). Both ride the existing for_each_event! registry, so the Lua on_birth/on_partnership_formed callbacks and the GDScript birth/partnership_formed signals derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (1.5.01.6.0), regenerated docs, and no migration note — no existing event/query changed. (The 1.4.01.5.0 bump was feature 045's capture/rescue/ransom events; this entry follows it.)

Worked example (applied in 1.7.0)#

Wanderer/visitor join mechanic (GitLab issue #137, feature 137) added one event to the taxonomy (scripting/api.rs): visitor_joined (name: str — a wanderer/visitor joins the colony as a colonist). It rides the existing for_each_event! registry, so the Lua on_visitor_joined callback and the GDScript visitor_joined signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (1.6.01.7.0), regenerated docs, and no migration note — no existing event/query changed.

Worked example (applied in 1.8.0)#

Hunting (GitLab issue #109, feature 109) added one event to the taxonomy: animal_killed (def_id: str, x: int, y: int — a wild animal is killed by a hunter). It is one entry in the feature-179 canonical event table (groundwork-events-derive/src/events.rs), so the Lua on_animal_killed callback and the GDScript animal_killed signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (1.7.01.8.0), regenerated docs, and no migration note — no existing event/query changed.

Worked example (applied in 1.9.0)#

Machine breakdown + maintenance (GitLab issue #133, feature 133) added two events to the taxonomy: machine_broke_down (def_id: str, x: int, y: int — an autonomous machine wears out and breaks down) and power_outage (x: int, y: int, supply: int, demand: int — a power network drops below demand on the brownout rising edge). Each is one entry in the feature-179 canonical event table (groundwork-events-derive/src/events.rs), so the Lua on_<key> callbacks and the GDScript signals derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (1.8.01.9.0), regenerated docs, and no migration note — no existing event/query changed.

Worked example (applied in 1.10.0)#

Machine modules (GitLab issue #131, feature 131) added a new def category, modules, to the Mods.def(...) query surface (the ModuleDef fields — slot_cost, speed_pct, efficiency_pct, productivity_pct, gates), reachable identically from GDScript and Lua. Adding a queryable category is additive ⇒ a MINOR bump (1.9.01.10.0), regenerated docs, and no migration note — no existing query or event changed. (It lands at 1.10.0 rather than 1.9.0 because 1.9.0 was already taken by feature 133's two events on main.)

Worked example (applied in 1.11.0)#

Research directions (GitLab issue #160, feature 160) added a new def category, directions, to the Mods.def(...) / def_ids(...) query surface (the DirectionDef fields — id, name, tiers as (tier name, threshold) pairs, and opposed direction ids), reachable identically from GDScript and Lua; and it widened the existing research category projection with one additive field, directions (a tech's (direction id, weight) tags). Adding a queryable category plus an additive field on an existing category is additive ⇒ a MINOR bump (1.10.01.11.0), regenerated docs, and no migration note — no existing query or event changed name, type, or semantics.

Worked example (applied in 1.12.0)#

Mechanical policy consequences (GitLab issue #157, feature 157) added a new def category, policies, to the Mods.def(...) query surface (the PolicyDef fields — hunger_rate_pct, work_output_pct, recreation_threshold_pct, trade_openness_pct, mood_thought, gates), reachable identically from GDScript and Lua. It also added the IssueEffect::Policy content kind, but that is internal content, not a query/event change. Adding a queryable category is additive ⇒ a MINOR bump (1.11.01.12.0), regenerated docs, and no migration note — no existing query or event changed.

Worked example (applied in 1.13.0)#

Internal market — colonist-to-colonist trade (GitLab issue #164, feature 164) added one event to the taxonomy: trade_completed (good: str, price: int — a colonist buys a good from another at a market stall). It is one entry in the feature-179 canonical event table (groundwork-events-derive/src/events.rs), so the Lua on_trade_completed callback and the GDScript trade_completed signal derive by rule and the taxonomy-totality test covers them. The feature also added the market_stall building-role flag and the market_tuning / communal_economy content, but those are internal content, not query/event surface. Adding an event is additive ⇒ a MINOR bump (1.12.01.13.0), regenerated docs, and no migration note — no existing query or event changed name, type, or semantics.

Worked example (applied in 1.14.0)#

Government forms unlocked by research direction (GitLab issue #161, feature 161) added one new event, government_form_changed (form: str), fired when the colony adopts a government form. Adding an event to the taxonomy is additive ⇒ a MINOR bump (1.13.01.14.0), regenerated docs, and no migration note — no existing query or event changed.

Worked example (applied in 1.18.0)#

Command-refusal reason (GitLab issue #480) added one event to the taxonomy (groundwork-events-derive/src/events.rs): command_refused (command: str, reason: str, x: int, y: int — a player context-menu command other than Prioritize — move / attack / draft / assign-to-bed / ransom — was refused). It is one entry in the feature-179 canonical event table, so the Lua on_command_refused callback and the GDScript command_refused signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (1.17.01.18.0), regenerated docs, and no migration note — no existing event or query changed. (The new LogEvent::CommandRefused narrated colony-log event is not part of the queryable Mods.def(...) surface, so it adds no further bump. Generalizes #858's prioritize_refused to the remaining context-menu commands.)

Worked example (applied in 6.0.0)#

Per-instance body parts (GitLab issue #3574, epic::character-sheet) changed the value of the body_part key on each entry of the health.wounds array returned by the colonist query.

  • Before: a bare lowercase part kind"leg", "arm", "head", "torso", "foot", or ""

for an unlocated wound. The set was closed: it came from an exhaustive match on an engine enum.

  • After: the part's id"core:left_leg", "core:right_arm", … — still "" when the wound

is unlocated. The set is open: a mod can author a body part and see its own id here.

Changing an existing key's value format is breaking by the rules above ⇒ a MAJOR bump (5.1.06.0.0) and this note.

How a mod adapts. A script comparing against the old bare names must compare against ids, or strip the namespace:

-- before
if wound.body_part == "leg" then ... end

-- after: name the limb you mean
if wound.body_part == "core:left_leg" then ... end
-- or, to keep the old coarse behaviour, ask the kind
local part = Mods.def("body_parts", wound.body_part)
if part and part.kind == "core:leg" then ... end

The second form is the better one going forward: "leg" used to mean both legs because the engine could not tell them apart, and asking the kind is how you say that now — deliberately, rather than by accident.

Why this is worth a major bump even though nothing in the generated contract moved. The wound dictionary's keys are not enumerated in [docs/modding-api.md](./modding-api.md), so the drift test does not see this change and no test would have failed. That makes it quieter than a renamed event, not smaller: a mod reading wound.body_part == "leg" keeps loading, keeps running, and silently stops matching. A note is the only thing that catches it.

Worked example (applied in 1.17.0)#

Prioritize-refusal reason (GitLab issue #858) added one event to the taxonomy (groundwork-events-derive/src/events.rs): prioritize_refused (reason: str, x: int, y: int — a player prioritize order was refused because the colonist can't take orders). It is one entry in the feature-179 canonical event table, so the Lua on_prioritize_refused callback and the GDScript prioritize_refused signal derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (1.16.01.17.0), regenerated docs, and no migration note — no existing event or query changed. (The new LogEvent::PrioritizeRefused narrated colony-log event is not part of the queryable Mods.def(...) surface, so it adds no further bump.)

Worked example (applied in 1.16.0)#

Civ-grounded incidents (GitLab issue #152, feature 152) added a third argument, origin_civ: str, to the existing incident_started event (groundwork-events-derive/src/events.rs): the CivDef id of the rival civ a raid was sourced from, or the empty string for a non-civ incident / civ-free world. A new optional trailing argument on an existing event is additive by the rules above ⇒ a MINOR bump (1.15.01.16.0), regenerated docs, and no migration note — existing on_incident_started callbacks that read only incident/size keep working unchanged; the new arg is simply available to those that want it. (The new CivGearDef RON content type is not part of the queryable Mods.def(...) surface this feature, so it adds no further bump.)

Worked example (applied in 1.15.0)#

Civilization entities (GitLab issue #151, feature 151) added two events to the taxonomy (groundwork-events-derive/src/events.rs): civ_age_advanced (civ: str, age: str — a rival civilization advances to a new age) and civ_direction_tier (civ: str, direction: str, tier: int — a rival civilization reaches a new research-direction tier). Each is one entry in the feature-179 canonical event table, so the Lua on_civ_age_advanced/on_civ_direction_tier callbacks and the GDScript civ_age_advanced/civ_direction_tier signals derive by rule and the taxonomy-totality test covers them. Per the rules above that is additive ⇒ a MINOR bump (1.14.01.15.0), regenerated docs, and no migration note — no existing event/query changed. (The new CivDef/CivTuning RON content types are not part of the queryable Mods.def(...) surface this feature, so they add no further bump. It lands at 1.15.0 because 1.13.0/1.14.0 were already taken by feature 164's trade_completed and feature 161's government_form_changed on main.)

Migration notes#

on_age_advanced and the ages def category are REMOVED (2026-08-05, #4583)#

The Ages system is gone. Research prerequisites are the sole content gate.

This is the final child of epic::remove-ages (#4585) and completes the break #4581 began on the rival-civ side. Where #4581 renamed an event, this one removes one — a strictly larger break, because removal shifts every later key in the frozen taxonomy rather than moving it in place.

What changed

  • The event age_advanced is removed (Lua hook on_age_advanced; GDScript signal likewise).

There is no replacement: nothing advances, because there is no ladder. A colony's progress is now read entirely from research_completed and the research queries.

  • def("ages", id) is removed — the whole ages category is gone from the queryable surface,

and assets/mods/core/defs/ages.ron is deleted. Mods.def("ages", …) now returns nothing.

  • def("research", id) drops its age field. It was a required AgeDef id on every project;

a tech's position is now given by its prerequisites alone.

  • The narration template core:age_advanced and the chronicle rule core:chronicle_age are removed

with their event, along with the AgeAdvanced sound and floating-text effect rows.

How to adapt

  • Delete any on_age_advanced callback. If you used it as a coarse "the colony has progressed" beat,

on_research_completed is the honest replacement — it fires on the actual unit of progress.

  • Replace def("ages", …) lookups and any age read on a research def. If you were displaying an

era name, there is no longer one to display; if you were gating on an era, gate on the relevant research_prerequisite instead, which is what the base game now does everywhere.

  • Content authoring: remove the age: field from every record in your techs.ron — it is no

longer part of ResearchProjectDef and the loader will reject an unknown field. Delete your ages.ron if you shipped one.

Why no API_VERSION bump

By the bump rules above, removing a documented event and a whole def category is unambiguously a MAJOR break. It is deliberately recorded without one (owner decision), following the #4581 and Grievance → ClassConsciousness precedents: the modding API has no external consumers pre-launch and resets to 1.0.0 at release, so version churn now is pointless, and pre-launch the content surface carries no compatibility guarantee (constitution, Content as a Data Contract). This note is intentionally not a ## Worked example (applied in X.Y.Z) heading, so the api_version_matches_newest_migrations_entry guard leaves API_VERSION where it is. The generated docs/modding-api.md was regenerated. A SAVE_VERSION bump did apply — current_age is required saved state inside a required header (see sim/save.rs, the 33 → 34 block).

on_civ_age_advancedon_civ_tier_advanced, and CivDef.starting_agestarting_tier (2026-08-04, #4581)#

Rival civs no longer climb the shared AgeDef ladder; they climb their own research tier.

What changed

  • The event civ_age_advanced is renamed civ_tier_advanced (Lua hook on_civ_age_advanced

on_civ_tier_advanced; GDScript signal likewise). Its second argument changes name and type: age: str (an ages.ron def id such as "core:age_bronze") → tier: int (an unbounded integer, 0 at seed). The civ: str argument is unchanged, as is the event's position in the frozen taxonomy.

  • The def("civs", id) projection replaces starting_age: str with starting_tier: int.
  • Mods.rivals() / Mods.rival(id) rows replace the age key (a display string like

"Stone Age") with tier (a derived label like "Tier 3").

  • The narration template for the event changes from "The {civ} entered the {age}." to

"The {civ} reached research tier {tier}.", and its template vars from {civ}, {age} to {civ}, {tier}. A per-age template override is no longer possible — the event has no content subject now, because an integer tier is not a def to key an override on.

  • CivGearDef.age (an AgeDef id, matched exactly) becomes min_band: u32, matched <=.

The ContentError::DanglingCivGearAge load error is removed: an integer cannot dangle.

How to adapt

  • Rename the callback and read the second argument as a number:

function on_civ_tier_advanced(civ, tier). If you formatted the old age id for display, format the integer instead — there is no id to look up, and no ages lookup is needed (or available for rival civs).

  • Replace any starting_age read with starting_tier, and any rivals-row age read with tier.
  • In civ_gear.ron, replace age: "core:age_stone" with min_band: 0, age: "core:age_bronze"

with min_band: 1, and so on by the age's old order. Note the semantics change: rows now match every civ at or above the band, so a civ that climbs past your highest row keeps that row's gear instead of falling back to bare-handed pawn defaults.

Why no API_VERSION bump

By the bump rules above this is a breaking event/argument rename and an argument-type change, which would normally be a MAJOR bump. It is deliberately recorded without one (owner decision), following the Grievance → ClassConsciousness precedent below: the modding API has no external consumers pre-launch and resets to 1.0.0 at release, so version churn now is pointless, and pre-launch the content surface carries no compatibility guarantee (constitution, Content as a Data Contract). This note is intentionally not a ## Worked example (applied in X.Y.Z) heading, so the api_version_matches_newest_migrations_entry guard leaves API_VERSION where it is. The generated docs/modding-api.md was regenerated. A SAVE_VERSION bump did apply — Civ.age is required saved state and changes type (see sim/save.rs).

4.11.0 → 5.0.0 (2026-07-26)#

A colonist's skill level now means their DERIVED STANDING, not a stored number (#3573).

What changed

  • SkillView.level and ProfessionView.level now carry the colonist's effective level — their

controlling attribute, plus the skill's authored difficulty, plus what their invested character points buy. Nothing stores a level any more; it is derived on every read.

  • SkillView gains points: the character points actually invested in that skill. This is the

persisted, authoritative value.

  • No field was renamed or removed.

Do I need to change anything?

Yes, if your mod compares level against a number. The scale moved. Under the old model an untrained colonist read 0 and a career expert read into the high teens or twenties. Under the new one, standings start at the skill's untrained floor — 5 for an Average-difficulty skill at the shipped attribute defaults, 6 for an Easy one — and a realistic career tops out in the mid-to-high teens.

Concretely: if skill.level == 0 no longer means "untrained". Any threshold you authored against the old scale is too low.

#3875 update — level == 0 is reachable again, and it means something different. The untrained floor is now authored per-skill in skills.ron rather than fixed in the engine, and a skill whose defaults list is empty has no route at all: a colonist with no points in it reads 0, and job::craft will not let them reach a masterwork or destroy their inputs with it. No core skill ships that way, so level == 0 is still false for every colonist in the base game — but a mod may author an unimprovisable skill (surgery being the canonical case), and against such a mod the old == 0 test coincidentally works again. Do not rely on that: it distinguishes no route from untrained, which is not what the old code meant.

you were askingnow ask
"is this colonist untrained?"skill.points == 0
"can they attempt this at all?"skill.level > 0 (#3875 — 0 means no route)
"how much have they studied?"skill.points
"how good are they, all in?"skill.level (unchanged field, new scale)

Why the meaning changed rather than a new field being added

There is exactly one answer to "how good is this colonist at X", and it is the derived one. Leaving level on the stored number would have left the modding API reporting a different figure from the colonist inspector, the work grid, and the simulation's own decisions — a divergence far more expensive than a version bump. points is added so the investment is still reachable.

Related content changes in the same release

professions.ron's min_level tiers were re-anchored onto the new scale. If your mod ships its own professions.ron, re-anchor its tiers too — a tier at 0 or 3 is now below the untrained floor, so it can never be the highest matching tier and its title becomes unreachable.

4.0.0 → 4.1.0 (2026-07-16)#

Fruiting trees (#2493): a plant def can declare fruit it yields while STANDING, distinct from the wood it yields when felled.

What changed

  • Mods.def("plants", …) gains fruit (a def id string, or absent) and fruit_yield (an

Array[min, max], or absent). Both are optional and default to absent.

  • Nothing renamed, nothing removed. A mod that ignores these reads exactly as it did on 4.0.0.

Do I need to change anything?

No. This is additive. Every existing plant def loads byte-identically, and existing mods are unaffected — the bump is recorded so a mod can detect the capability (api_version() >= 4.1.0).

Authoring a fruiting tree (the new capability)

(
    id: "mymod:cherry_tree",
    name: "Cherry Tree",
    growth_ticks: 120, stages: 3,
    produces: "core:wood", yield_range: (2, 3),   // felling yield — unchanged meaning
    sow_work_ticks: 1, harvest_work_ticks: 12,
    blocks_movement: true,                        // ⇒ it is a TREE (choppable)
    fruit: Some("mymod:cherry"),                  // ⇒ it also BEARS (harvestable while standing)
    fruit_yield: Some((3, 3)),                    // fixed range ⇒ draws no RNG
    regrowth_ticks: Some(43200),                  // REQUIRED with `fruit`: the regrow cadence
)

Harvesting picks the fruit and leaves the tree standing; chopping fells it for produces. The fruit item must carry nutrition to be edible.

Load errors to expect (each names the offending plant)

  • fruit item … does not resolve — the fruit id must name a real item.
  • fruit requires blocks_movement: true (a fruiting tree must be a tree).
  • fruit requires regrowth_ticks … — without a cadence the tree is instantly re-ripe (an infinite tap).
  • fruit_yield min (…) must be <= max (…) / fruit_yield max must be >= 1.

3.0.0 → 4.0.0 (2026-07-15)#

Building materials (#2513): a building def's materials key is renamed build_cost, and two new def categories are queryable.

What changed

  • Mods.def("buildings", …) returns build_cost where it returned materials. The shape is

unchanged — still Array[{ "key", "value" }].

  • Two new categories are readable via Mods.def_ids / Mods.def: materials and

material_categories.

  • A building def gains material_categories (PackedStringArray) and material_slots (int).

Why

The field never held "what the building is made of" — it held what the blueprint demands, a cost list. That misnomer was harmless while a building's material was welded into its id (core:wood_wall vs core:wall). #2513 collapses those per-material defs into one def built from a MaterialDef, so the name had to mean what it says: build_cost is the fixed part of the recipe, and the material is chosen per placement. Renaming it in Rust but not here would have left the misleading name at the one boundary where a mod author cannot see past it.

How a mod adapts

  • Read b.build_cost instead of b.materials. Nothing else about that field moved.
  • A material-built building (non-empty material_categories) has an empty build_cost: its real

cost is material_slots × the chosen material's units_per_slot of that material's item. If your mod totals a build cost, add that term or you will report a wall as free.

  • The shipped ids core:wood_wall, core:adobe_wall, core:palisade and core:wood_floor are

gone (use core:wall / core:floor + a material), and core:wood_door is now core:door.

2.53.0 → 3.0.0 (2026-07-15)#

Harvest unification (#1951): the core:forage work kind and WorkRequirement::Wild are gone.

What changed

  • The work kind core:forage no longer exists. One kind, core:harvest, now covers wild

bushes, farm crops and fruiting trees alike. core:chop is unchanged.

  • WorkKindDef.requirement no longer has a "Wild" value; the surviving values are "None" and

"LooseItem". core:harvest declares no requirement — it must match every plant.

Why

The Wild refinement existed to keep Forage and Chop disjoint. That axis cannot express a fruiting tree, which is deliberately both choppable (fell it for wood) and harvestable (pick its fruit while it stands). Chop-vs-harvest eligibility now keys on the content signal instead — whether the plant blocks its tile — which is the axis the affordance actually turns on.

How a mod adapts

  • Replace "core:forage" with "core:harvest" in DesignateWork commands and any work_kinds lookup.
  • Drop requirement: Wild from a mod-authored WorkKindDef; if your kind should only target wild

flora, filter consumer-side (the flora::WildFlora marker still exists and still means "worldgen-placed wild flora").

  • Nothing else moves: the core:forage play action and the core:foraging tech are untouched,

so a mod crediting foraging research keeps working unchanged.

Deliberately unprojected def fields#

Not every BuildingDef field reaches the queryable surface — project_building is hand-written, so a new field appears to mods only when someone projects it. Recording the ones held back on purpose, so a later author does not "fix" the omission without meaning to:

  • BuildingDef.placeholder_colour (#2551) — the (r, g, b) an art-less def renders as. **Not

projected ⇒ no API bump** (it stayed 4.1.0). It follows the standing line: no colour is on the mod surface today (TerrainDef.color / BiomeDef.color are not projected either — terrain and biome are not projected categories at all), and projecting the first one is a scope decision no feature has needed yet. Shape is not the obstacle — Field::IntPairs already carries tuples (AnimalDef.product_yield) — so this stays cheap to add if a mod ever wants it.

A mod can already see that a def has no art: sprite / sprite_license are projected via opt_str and omitted when None (the size_tiles omit-when-absent precedent). The colour would add only which colour, which nothing has asked for.

Note the field is still authored through the ordinary mod data path: a mod's own building may declare placeholder_colour in its RON and it renders, unprojected. Not being queryable is about reading other defs, not about authoring your own.

  • CharacterTemplateDef.starting_unspent / .starting_auto_spend (#3885) — an authored stipend of

unspent character points, and whether the character spends it automatically. Not projected ⇒ no API bump. CharacterTemplateDef is not a projected category at all, so this follows the same line as derived_stat_entries (#4254) before it.

Both are optional with serde defaults — 0 and true — so every existing template keeps parsing unchanged, and both are flat authored constants that consume zero RNG draws. Adding or retuning them therefore cannot re-phase generation; it moves the character-generation state-hash pins without moving the draw count, which is the draw contract working rather than a problem.

Because starting_auto_spend defaults to true rather than bool::default(), CharacterTemplateDef carries a hand-written impl Default whose field calls the same default_starting_auto_spend fn the serde attribute names — the BuildingDef/PlantDef convention the #3623 guard enforces. A derived Default would hand every ..Default::default() a manual-control template, which is non-vacant and attaches a pool to characters that authored none.

Two things a template author must know:

  1. A vacant pool is never attached. starting_unspent: 0 with starting_auto_spend: true (the

defaults) attaches no component at all, so it costs nothing in the save. The suppression tests UnspentPoints::is_vacant(), not starting_unspent > 0 — which is why starting_unspent: 0 with starting_auto_spend: false does attach: "spawn under manual control" is a real authored decision, and it persists.

  1. On auto-spend, the stipend does not sit in hand. The unspent_points::auto_spend system moves

free points into the character's major passion on the next tick and then drops the emptied pool. Author starting_auto_spend: false if the points are meant to remain visible for a player to assign.

This is deliberately not the generator's leftover budget. generate_character still discards its residue: leftover is an artifact of a greedy single-pass walk order, whereas starting_unspent is a number an author wrote down.

<!-- Append breaking-change notes here, newest first, as:

x.y.z → X.0.0 (YYYY-MM-DD)#

  • What changed:
  • How to adapt:

-->

← Modding