Pawn lifecycle API 1.0 candidate#
The v1 compatibility contract defines the frozen surface and version policy.
This adapter realizes a provider's abstract population as new native pawns, tracks outcomes, and retains actual individuals across saves. It accepts only members it generated. It does not ingest existing colonists, unload maps, or claim transparent virtualization of arbitrary modded pawns.
Version 0.4 adds opt-in queued transitions: admission without native effects, one member attempt per unpaused update, saved round-robin progress, and explicit queued resume after faults. The synchronous calls below retain their existing behavior.
Version 0.5 adds explicit cancellation and settlement for unfinished plans, including durable preparation, observed deaths/transfers, unused claims and interrupted commits.
Ownership and population#
SimLODWorldComponent owns the abstract archive and a native journal. Each native member receives a save-scoped monotonic member ID independent of Pawn.ThingID. Member snapshots expose both IDs and the actual Pawn reference when present. The snapshot's fields are immutable; the Pawn remains a mutable native object.
| Member state | Population / save owner |
|---|---|
| Cancelled | Generation provably never started; settlement released the unused claim. The member ID remains a tombstone and is never reused. |
| Reserved | Population was deducted by the provider; generation/spawning is pending. A captured staged Pawn lives in the component's deep ThingOwner<Pawn>. |
| Materialized | Population remains outside the aggregate. A spawned Pawn belongs to its native map. |
| Retained | Population remains outside the aggregate. The exact Pawn is deep-saved in the component's holder, with identity permanently pinned. |
| Aggregated | Only an explicitly disposable developer fixture can reach this state. Its native body is destroyed and removed from WorldPawns; the provider adds an anonymous unit. |
| Dead | The native corpse or verified WorldPawns ownership preserves the dead Pawn. The journal retains a tombstone, without a live Pawn reference. |
| Transferred | Faction change or colony capture/slavery ends membership. The external native owner keeps the Pawn; the journal keeps a tombstone. |
Retained Pawns receive no automatic Pawn ticks: the holder does not actively simulate health, needs, biological aging, pregnancy, or jobs. Chronological age and other properties derived from global native time can still advance. This qualifies the earlier shorthand “frozen.” The holder preserves the native save graph; it does not simulate an off-map life. Rematerialize spawns the same object. A seed is only an initial generation input and is never a replacement for a captured individual's identity.
The herd laboratory's invariant at completed operations is: anonymous count + living materialized members + retained members + deaths + transfers = original population. The controlled test first prepares healthy generated animals, then injects its outcomes. Its 31-member scenario ends with 23 anonymous animals, one retained injured animal, six dead animals, and one transferred animal. Ordinary example generation can include native old wounds; those additional individuals are retained too.
Provider contract#
Subclass SimLOD.Materialization.PawnSimulationProvider, which extends the ordinary provider API:
| Callback | Contract |
|---|---|
PlanMaterialization(state, count) |
Return a PawnMaterializationPlan containing the complete reserved payload and exactly count PawnSpawnSpec recipes. Deduct those members from abstract simulation. |
PlanReconciliation(state, outcomes) |
Return the complete final payload for the specified member outcomes. Do not count retained/materialized/dead/transferred individuals as anonymous live population. |
PlanMaterialization(state, count, context) |
Time-aware overload. context.EffectiveTick is the native game tick at the original request. Its default forwards to the legacy callback. |
PlanReconciliation(state, outcomes, context) |
Time-aware overload, also forwarding to the legacy callback by default. Each outcome includes PreviousState, distinguishing a retained loss from a deployed loss. |
PlanRematerialization(state, members, context) |
Return the reserved payload for retained members becoming deployed. Default returns the unchanged payload for existing providers. |
AllowsIntraStepTransitions |
False by default: the entity's logical tick must equal the current game tick. Opt in only when population changes between fixed steps have explicitly defined semantics. |
AllowsNativeSettlement / PlanNativeSettlement |
Explicitly opt in and compute a pure payload from observed dispositions; see the settlement contract for accounting state and time. |
AllowsQueuedTransitions |
False by default: queued APIs require explicit opt-in to accounting across partial native work and multiple game updates. |
CanAggregateFixture(pawn) |
Extra permission for destructive developer-fixture aggregation, false by default. This callback never overrides the framework's preservation checks. |
Planning and validation callbacks must be pure, deterministic, bounded, and must not call back into the world facade. Do not mutate native objects from these callbacks. The API checks payload structure and provider validation; only the provider can enforce its own conservation law. The example keeps all Muffalo and population semantics in its separate assembly. Its Advance is the identity function, so transitions between steps are unambiguous; no ecology has been implemented.
New providers can override the context overloads directly; legacy overrides continue to work. The expedition provider accounts its old offline population through EffectiveTick, then changes representation counts. Its fixed steps account only time after its saved cursor, preventing mid-step transitions or replay from charging supplies twice. Rematerialization validates and reserves its planned payload before spawning. Recovery uses the original saved plan and time; it does not invoke planning again. Context does not make intra-step transitions safe automatically: the provider must define that accounting convention explicitly.
Each recipe supplies a PawnKindDef, faction, biological age, and optional disposableFixture flag (false by default). The bounded adapter accepts ordinary one-cell Pawn classes and 1–32 recipes per request. Generation forces a new Pawn, disables generated relations and pregnancy, and scopes Verse.Rand using a saved member-specific seed. This does not promise identical initial generation across game builds or mod sets.
Calls and replay#
var sim = Find.World.GetComponent<SimLODWorldComponent>();
EntitySnapshot state = sim.Get(entityId);
NativeOperationReceipt result = sim.Materialize(
entityId, state.Revision, "encounter-17/open", map, destinationCells);
var members = sim.GetPawnMembers(entityId);
result = sim.Reconcile(entityId, sim.Get(entityId).Revision,
"encounter-17/return", returningMemberIds);
The map must be currently loaded. Destinations must be distinct clear standable cells, with no pawn or building. Selected member IDs must belong to the entity. Reconciliation also observes deaths and ownership changes among active members; other living members remain materialized unless requested. Carried/off-map returnees are refused rather than taken from another holder. A dead Pawn must have a verified external save owner before its reference is released. Changed ownership of a retained Pawn requires an explicit external native transfer first; this prototype does not supply that transfer API.
Request keys are 1–160 printable ASCII characters and unique per entity. Save a key in the caller's own encounter/task state. Repeating the same key and request returns its stored receipt even with a stale revision, without repeating native effects. Reordering a return selection is equivalent; reordering spawn destinations changes the request. Reusing a key for different arguments is rejected. Replay does not automatically recover a fault.
Rematerialize(entity, revision, key, retainedIds, map, cells) returns retained individuals without creating new ones; its provider callback can update representation accounting. PinNativeMember(entity, memberId, reason) permanently opts a native individual out of aggregation. GetNativeOperations(entity) exposes saved request keys, status, effective tick, applied-item count, affected members, and diagnostic reasons. Member snapshots include the observed native save owner.
Failure and explicit recovery#
An operation takes a revision-checked entity lock and removes its scheduled queue entry. Materialization commits the reserved payload before generation. Reconciliation retains its previous payload until all native outcomes are applied. Native effects are synchronous and sequential, not rollback transactions. On failure the record becomes TransitionFaulted, retains its journal and stops advancing. Ordinary Retry cannot release this lock.
During a partial reconciliation, the old aggregate/member snapshot is provisional: some native effects may already have happened and are recorded in the pending operation. Do not report completed conservation totals or start another encounter until the operation completes. The journal records completed fixture disposal explicitly, so a missing reference alone is never interpreted as successful destruction.
After correcting a recoverable condition, call:
NativeOperationReceipt result = sim.RecoverNativeOperation(entityId, savedRequestKey);
Recovery first checks provider/schema/model, the saved entity lock, loaded destination and clear cells, native identity/liveness/faction, and current save owners. It rechecks already applied items before further work and before committing the final payload. Changed applied outcomes cannot silently commit an obsolete provider plan. A blocked preflight throws without changing provider status, payload, member bindings, or the saved operation. A later exception during actual native work can still record a new fault.
Recovery reuses a captured generated Pawn, recognizes an already spawned Pawn, and does not discard an already disposed fixture twice. Success commits the final payload, member states and completed receipt, then schedules the original deadline. The scheduler catches up overdue steps. New operations require an entity that is not overdue; recovery can finish an existing overdue lock. Restoring the original provider schema/model is required; pending payloads are never migrated underneath their native journal.
InspectNativeOperation(entity, key) returns NativeRecoveryDiagnostics: the operation receipt, specific blockers, and per-item intended effect, applied/disposed progress, ThingID and observed owner. CanAttemptRecovery means this unfinished operation can currently be recovered; ordinary queued Pending work already has a queue turn and does not need recovery. Native work can still fail later. The developer action Inspect native ownership and recovery opens an entity window with these details, pin reasons, pending operations, and five recent completed receipts. Retry recovery or Resume queued work calls the same guarded API and is disabled while blocked. The window refreshes on request, so it adds no continuous world scan. It cannot force ownership changes, replace lost maps, or override an ambiguous generator failure.
If native generation throws without returning a recoverable Pawn, SimLOD refuses to generate another: unknown native side effects might otherwise create a duplicate. Arbitrary exceptions inside native destruction, despawning, or third-party callbacks can similarly require manual investigation or restoring a backup. Tests cover captured generation, completed spawn, fixture disposal, interrupted item application, changed applied ownership, provider version mismatch, an uncaptured generation attempt, and a lost destination reference. The latter fixture removes only the saved operation's map handle; it does not validate deleting an actual map. Synchronous reentrant saves from arbitrary mod callbacks, process termination during save writes, and transactional rollback of arbitrary native effects are outside the contract.
Deliberate aggregation boundary#
Normal recipes default to pinned native identity. Destructive aggregation requires all of: recipe opt-in, developer mode, no identity pin, an ordinary spawned healthy wild animal with no meaningful name, injuries/hediffs, direct relations, quest tags, inventory, equipment or apparel, and provider permission. These conservative checks do not detect every private reference a mod could hold. The laboratory provider therefore permits aggregation only with Core and the known SimLOD example/test packages active; unknown mods or DLC cause eligible returnees to be retained instead. This is a disposable laboratory policy, not a production compatibility guarantee.
No broad Harmony hooks protect or ingest arbitrary game objects. Important returned individuals retain their complete native object. Preservation can avoid reconstruction loss while still retaining Pawn memory cost.
Save/version boundary#
Abstract archive version 2 adds transition keys and failure reasons; version 1 upgrades with empty locks. RNG version remains 1. The public assembly identity remains 0.1.0.0; framework package/file version is 0.5.0. Native journal version 3 saves queued execution mode, exact next-turn order and prepared settlement history; it reads versions 1 and 2 without automatically queuing old synchronous operations. Optional effectiveTick metadata defaults to -1 (unknown) in older receipts. Accounting cursors live in provider payloads. Legacy provider callbacks are exercised by the rebuilt herd package; loading a previously compiled third-party binary has not been certified. The API is still experimental.
The native journal uses framework-defined sealed Scribe types, Pawn references, and a single deep holder. Removing the provider preserves these records and vanilla Pawn graphs across another save. This promise does not extend to removing the mod that defines a Pawn/Thing/Hediff subclass or required Def, removing SimLOD itself, or downgrading a native journal to an older reader. The native journal is not an opaque forward-compatible archive; unsupported/malformed journals disable native operations. Operation receipts and tombstones currently accumulate; no compaction or large-scale native performance claim is made.
Current-operation polling (0.6)#
Persist the operation key in the consumer's save root, then call GetNativeOperation(entity, requestKey) for its immutable receipt. The lookup uses the saved journal's index and throws KeyNotFoundException for an unknown pair. Main-thread and non-reentrancy requirements still apply. GetNativeOperations(entity) remains available for full-history inspection, but allocates all matching receipts and should not be the per-frame progress polling path. See the separately compiled Visitor Relay for an encounter integration.