Cenosis Docs Extension API
v4.0 · npc-extend · ABI major 4

Extension API

The npc-extend crate is a first-class Rust API for replacing core engine decisions. Extensions are compiled into the engine build and shipped to hosts transparently over the stable C ABI — the host never sees the extension; it is baked into the cdylib.

This is the only extension model

There is no C#/JS plugin model and no no-code tuning knobs — the configuration knobs are packs (data) and the typed RuntimeConfig flags. Host-language hooks (v3.6) let C#/C++/JS teams override one decision at a time without recompiling Rust — see Host-Language Hooks below.

Quick Start

src/my_extension.rs
use std::sync::Arc;
use npc_extend::*;
use npc_common::stability::StabilityTier;

#[derive(Debug)]
struct MyEconomyExtension { manifest: ExtensionManifest }

impl MyEconomyExtension {
    fn new() -> Self {
        Self { manifest: ExtensionManifest {
            id:        "my.economy".into(),
            version:   semver::Version::new(1, 0, 0),
            provides:  vec![Capability::new("economy.policy")],
            uses:      vec![],
            stability: StabilityTier::Experimental,
        }}
    }
}

impl Extension for MyEconomyExtension {
    fn manifest(&self) -> &ExtensionManifest { &self.manifest }

    // Override the engine's economy decision
    fn economy_policy(&self) -> Option<Arc<dyn npc_common::EconomyPolicy>> {
        Some(self.policy.clone())
    }
}

// Register before the first tick
runtime.register_extension(Box::new(MyEconomyExtension::new()))?;

Tier 1 — Core Seams

11 curated extension points covering 7 policy seams and 4 service seams. With no extension registered, every accessor is None and the engine is byte-identical to v3.1. Last registered provider wins.

CapabilityTraitStability
economy.policyEconomyPolicyStable
lifecycle.policyLifeCyclePolicyStable
skill.policySkillPolicyStable
affect.policyAffectPolicyStable
governance.policyGovernancePolicyStable
justice.policyJusticePolicyStable
macro.policyMacroPolicyStable
goal.selectorGoalSelectorStable
embedderEmbedderExperimental
memory.storeMemoryStoreExperimental
cloud.dispatcherCloudDispatcherExperimental

Capability Registry

Extensions never reference each other's crates. They declare capabilities and the engine brokers typed calls between them:

use npc_extend::resolve::CapabilityRegistry;

let mut registry = CapabilityRegistry::resolve(&manifests)?;
let provider = registry.broker("my.ext", &version, "economy.policy")?;
// If optional capability is absent → returns None (no error)

Resolution algorithm: Backtracking constraint solver + Kahn's topological sort. Deterministic: lexical tie-breaking.

Determinism Contract

Every extension decision must be recorded before applied. The DecisionRecorder handles this:

let mut recorder = DecisionRecorder::new();
recorder.record(RecordedDecision {
    extension_id:      "my.ext".into(),
    extension_version: semver::Version::new(1, 0, 0),
    decision_key:      "economy.clear".into(),
    value:             serde_json::json!({ "cleared": true }),
});

Three rules, no exceptions

1. Record before apply. 2. Replay is a pure function of recorded inputs — never re-invokes extension code. 3. An unrecorded decision in replay mode is a fatal ExtensionReplayMiss error naming the extension id + version.

Tier 2 — Deep Extensions (v3.3+)

Tier-2 lets an extension add a whole new tick-participating subsystem — a disease model, a weather economy, a new LOD tier. Code that runs inside the tick loop, appends to the WAL, persists in the bundle, and stays bit-deterministic across replay.

Advanced stability

Tier-2 contracts are Advanced — not stable across ABI majors. Within an ABI major they grow additively (new default-implemented methods). The deep conformance verifier, not a stability freeze, is the safety mechanism.

use npc_extend::deep::*;

pub trait DeepExtension: Extension {
    fn tick_phase(&self) -> TickPhase;
    fn on_tick(
        &mut self,
        world:   &dyn DeepWorldView,
        effects: &mut dyn DeepEffectSink,
    ) -> ExtensionResult<()>;
    fn export_bundle_state(&self) -> Option<serde_json::Value> { None }
    fn import_bundle_state(&mut self, state: &serde_json::Value) -> ExtensionResult<()> { Ok(()) }
    fn bundle_schema_version(&self) -> u32 { 1 }
    fn migrate_bundle_state(&self, from: u32, state: &serde_json::Value) -> ExtensionResult<serde_json::Value> { Ok(state.clone()) }
}

Tick Phases

PhaseRuns whenExample use
StatisticalSimAfter SS need decay + schedule, before event recordingPassive-agent statistics
FullSimAfter FS utility selection, before tactical cognitionActive-agent processing
MacroAfter district aggregation, before macro-event dispatchSettlement-level simulation
PostTickAfter all tier ticks, before surfaced-event drainCross-tier reconciliation

Deep Determinism Rules

  1. Effects through the sink. Every mutation must go through DeepEffectSink::emit(). Direct writes to projections break replay.
  2. Read-only world view. The extension receives a DeepWorldView snapshot — it never holds a mutable reference to engine state.
  3. Engine-seeded RNG. The extension does not own an RNG. The engine provides a deterministic source keyed to extension id + tick.
  4. Bundle state is versioned. bundle_schema_version() must be bumped on any breaking change. migrate_bundle_state must be a pure function.

Host-Language Hooks (v3.6)

Let a team working in C#, C++, or JS replace a single core decision without recompiling Rust. A hook is a host-side callback registered over the C ABI or WASM. The engine invokes it inline at the seam's decision point and falls back to the engine default on any failure.

C ABI Hook

// 1. Define your callback
int32_t my_price_hook(
    const char* seam, const char* request,
    char* out, size_t max, void* user_data)
{
    uint32_t multiplier = *(uint32_t*)user_data;
    // Return >0 = bytes written, 0 = decline, <0 = error
    return snprintf(out, max, "%u", base_price * multiplier) + 1;
}

// 2. Register before ticking
uint32_t mult = 3;
npc_engine_register_decision_hook(engine, "economy.policy", my_price_hook, &mult);

// 3. Unregister to restore the engine default
npc_engine_register_decision_hook(engine, "economy.policy", NULL, NULL);

WASM / JS Hook

engine.register_decision_hook("economy.policy", (seam, requestJson) => {
    const req = JSON.parse(requestJson);
    return String(req.base_price * 3);
});
engine.unregister_decision_hook("economy.policy");

Fault Handling

FaultOutcome
Hook panics (caught by catch_unwind)Default + warning + fallback counter
Malformed / non-JSON responseDefault + warning + counter
Host error return (< 0)Default + warning + counter
Host declines (0)Default (no counter increment)
10th consecutive budget overrunCircuit opens — all calls fall back to default

Re-entrancy Guard

A hook runs with the runtime lock held. It must not call back into a runtime-locking FFI export (npc_engine_tick, npc_engine_request_player_dialog, etc.). In debug builds a re-entrant-lock guard returns -97. In release, re-entering causes a deadlock.

Conformance Verifier

Before shipping, run the verifier to prove your extension is deterministic:

use npc_extend::verify::ConformanceVerifier;

let verifier = ConformanceVerifier::new(vec![ext.manifest().clone()])?;
let result = verifier.verify_extension(&ext, "economy.clear", Some("economy.policy"), |recorder| {
    let value = my_decision();
    recorder.record(/* decision */);
    value
});
assert!(result.passed);

The verifier checks three things:

  1. Record/replay determinism — two independent invocations must agree.
  2. Recording completeness — the decision must be recorded before applied.
  3. Decisions within scope — the capability must be in the extension's provides list.

Deep Conformance Verifier (Tier 2)

DeepConformanceVerifier runs four additional checks: deep_determinism, deep_wal_bypass, deep_bundle_roundtrip, and deep_full_tick_replay.

What Extensions Cannot Do

  • Cannot add C ABI symbols — extensions compile into the cdylib; the C ABI is unchanged.
  • Cannot bypass determinism — all decisions must go through the DecisionRecorder. The conformance verifier catches violations.
  • Cannot reference other extensions' crates — inter-extension communication goes through the capability broker.
  • Cannot put code in packs — packs remain validated JSON data. The v0.4 security line does not move.