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.rsuse 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.
| Capability | Trait | Stability |
|---|---|---|
economy.policy | EconomyPolicy | Stable |
lifecycle.policy | LifeCyclePolicy | Stable |
skill.policy | SkillPolicy | Stable |
affect.policy | AffectPolicy | Stable |
governance.policy | GovernancePolicy | Stable |
justice.policy | JusticePolicy | Stable |
macro.policy | MacroPolicy | Stable |
goal.selector | GoalSelector | Stable |
embedder | Embedder | Experimental |
memory.store | MemoryStore | Experimental |
cloud.dispatcher | CloudDispatcher | Experimental |
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
| Phase | Runs when | Example use |
|---|---|---|
StatisticalSim | After SS need decay + schedule, before event recording | Passive-agent statistics |
FullSim | After FS utility selection, before tactical cognition | Active-agent processing |
Macro | After district aggregation, before macro-event dispatch | Settlement-level simulation |
PostTick | After all tier ticks, before surfaced-event drain | Cross-tier reconciliation |
Deep Determinism Rules
- Effects through the sink. Every mutation must go through
DeepEffectSink::emit(). Direct writes to projections break replay. - Read-only world view. The extension receives a
DeepWorldViewsnapshot — it never holds a mutable reference to engine state. - Engine-seeded RNG. The extension does not own an RNG. The engine provides a deterministic source keyed to extension id + tick.
- Bundle state is versioned.
bundle_schema_version()must be bumped on any breaking change.migrate_bundle_statemust 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
| Fault | Outcome |
|---|---|
Hook panics (caught by catch_unwind) | Default + warning + fallback counter |
| Malformed / non-JSON response | Default + warning + counter |
Host error return (< 0) | Default + warning + counter |
Host declines (0) | Default (no counter increment) |
| 10th consecutive budget overrun | Circuit 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:
- Record/replay determinism — two independent invocations must agree.
- Recording completeness — the decision must be recorded before applied.
- Decisions within scope — the capability must be in the extension's
provideslist.
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.