Guide for mod authors · Damian Sirbu · 2026-04-09
xlibs is an engine abstraction layer for STALKER Anomaly. It wraps X-Ray engine APIs in safe Lua functions: squad operations, smart terrain queries, entity resolution, an event bus, MCM config, PDA messaging, logging, and profiling.
AlifePlus is a reactive framework built on xlibs. Engine callbacks produce causes (a squad entered a smart terrain, an NPC died, a stash was looted). Two pipelines handle the flow: an Event Pipeline that filters engine noise into meaningful cause events, and a Dispatch Pipeline that routes causes to registered consequences (a nearby faction investigates, a revenge squad is dispatched). Protection, rate limiting, ownership, and lifecycle are handled by the framework. Mods register a predicate and a handler. The codebase is split into core (ap_core_* - pipeline, lifecycle, protection) and ext (ap_ext_* - domain logic). Core never imports ext.
Both are published under the PolyForm Perimeter License. Addons, integrations, and modpacks are encouraged with visible credit.
| Allowed | Not Allowed |
|---|---|
| Calling xlibs/AP functions from your mod Addons and integrations that depend on them Modpacks (encouraged) Reading source to learn, extend, debug Using AI tools to learn the public API |
Reproducing the implementation as a standalone replacement Reverse engineering internals to reproduce design Automated extraction for reproduction |
Required: Visible credit -- "xlibs by Damian Sirbu" and/or "AlifePlus by Damian Sirbu".
xlibs wraps X-Ray engine APIs based on reading the engine source (X-Ray Monolith C++), the Anomaly scripting layer, modded executable extensions, and established patterns from reputable community mods. The API surface was validated through tracing and load testing the Anomaly engine using Grafana k6 and a custom benchmarking framework. Key modules:
| Module | What it does | Example |
|---|---|---|
xbus | Event bus (pub/sub) | xbus.subscribe("cause:massacre", fn, "my_handler") |
xsquad | Find, script, release, protect squads | xsquad.find_squads(pos, {factions=t, max_distance=500}) |
xsmart | Smart terrain queries | xsmart.find_smart(pos, {filter=xsmart.is_base}) |
xcreature | Entity identity and queries | xcreature.community(id), xcreature.query():stalkers():each(fn) |
xpda | PDA messages and map markers | xpda.send("Title", "Message") |
xmcm | MCM config management | xmcm.create_config("mymod", defaults, path_builder) |
xlog | Buffered file logging | xlog.get_logger("MY.MOD", {outfile="mymod.log"}) |
xlevel | Level/map queries | xlevel.get_actor_level_id() |
xobject | Entity resolution, item creation | xobject.se(any_input), xobject.create_item(sec, npc_id) |
xtime | Game time | xtime.game_sec() |
xmath | RNG, probability, weighted choice | xmath.chance(30), xmath.sample(tbl) |
xevent | Function hooking, synthetic callbacks | xevent.hook("module", "func", wrapper) |
Full docs: github.com/damiansirbu-stalker/xlibs
The primary way to extend AP. Register a predicate and a handler. The framework handles gates, protection, rate limiting, tracing, PDA routing, squad lifecycle.
-- ap_ext_cause_ambush.script
local CAUSE = ap_core_const.CAUSE
local CAUSE_TYPE = ap_core_const.CAUSE_TYPE
local RESULT = ap_core_const.RESULT
local REASON = ap_core_const.REASON
local cfg = ap_core_mcm.cfg
local function _predicate(trace, squad)
if not cfg.cause_ambush_enabled then return { code = RESULT.FAILED_RULES } end
local level_id = xlevel.get_level_id(squad)
if not level_id then return { code = RESULT.FAILED_RULES, reason = REASON.NO_LEVEL_ID } end
local smart = ap_core_utils.find_smart(squad.position, {
level_id = level_id, max_distance = ap_core_const.RANGE_EYE,
filter = xsmart.is_smart_empty,
})
if not smart then return { code = RESULT.FAILED_RULES, reason = REASON.NO_SMART } end
return {
cause = CAUSE.AMBUSH,
squad_id = squad.id,
community = squad.player_id,
smart_id = smart.id,
level_id = level_id,
position = squad.position,
}
end
function on_game_start()
local cbs = ap_core_const.RADIANT_CALLBACKS
for i = 1, #cbs do
ap_core_producer.register(CAUSE.AMBUSH,
{ callback = cbs[i], cause_type = CAUSE_TYPE.RADIANT },
_predicate)
end
end
-- ap_ext_consequence_ambush_setup.script
local RESULT = ap_core_const.RESULT
local REASON = ap_core_const.REASON
local CONSEQUENCE = ap_core_const.CONSEQUENCE
local CAUSE = ap_core_const.CAUSE
local PHASE = ap_core_const.CONSEQUENCE_PHASE
local PERSONALITY = ap_ext_const.PERSONALITY
local cfg = ap_core_mcm.cfg
local _alignment = ap_ext_const.alignment_outlaw
local _personality = { PERSONALITY.AGGRESSION, PERSONALITY.GREED }
local function _on_arrive(squad, args)
-- runs when squad reaches destination smart
end
local function _handler(event_data)
local trace = event_data._trace
return ap_core_debug.observe(trace, CONSEQUENCE.AMBUSH_SETUP, function()
-- RULES: alignment, personality, validation
if not ap_ext_util.check_alignment(_alignment, event_data.community) then
return { code = RESULT.FAILED_RULES, reason = REASON.WRONG_ALIGNMENT }
end
if not ap_ext_util.check_personality(_personality, event_data.community,
CONSEQUENCE.AMBUSH_SETUP, cfg.consequence_ambush_setup_personality_min,
cfg.consequence_ambush_setup_personality_max) then
return { code = RESULT.FAILED_RULES, reason = REASON.LOW_PERSONALITY }
end
-- EVAL: world queries
local smart = xobject.se(event_data.smart_id)
if not smart then return { code = RESULT.FAILED_EVAL, reason = REASON.NO_SMART } end
local squads = ap_core_utils.find_squads_observed(trace, event_data.position, {
factions = _alignment,
level_id = event_data.level_id,
max_distance = ap_core_const.RANGE_SIGNAL,
max_count = cfg.consequence_ambush_setup_max_squads,
exclude_at_smart_id = smart.id,
})
if #squads == 0 then return { code = RESULT.FAILED_EVAL, reason = REASON.NO_SQUAD } end
-- ACTION: script squads, record activity
local moved = {}
ap_core_debug.observe(trace, PHASE.MOVE_SQUAD, function()
for i = 1, #squads do
local res = ap_core_broker.script_squad(squads[i], smart, {
rush = cfg.consequence_ambush_setup_rush,
on_arrive = CONSEQUENCE.AMBUSH_SETUP,
pre_release_gulag = 1800,
})
if res.code == RESULT.SUCCESS then moved[#moved + 1] = squads[i] end
end
return ap_core_debug.result_squads(moved, { dst_id = smart.id })
end)
if #moved == 0 then return { code = RESULT.FAILED_ACTION, reason = REASON.MOVE_FAILED } end
for i = 1, #moved do
ap_core_broker.record(moved[i].id, CAUSE.AMBUSH, CONSEQUENCE.AMBUSH_SETUP)
end
return ap_core_debug.result_squads(moved, { code = RESULT.SUCCESS, dst_id = smart.id })
end)
end
function on_game_start()
ap_core_consumer.register(CONSEQUENCE.AMBUSH_SETUP, {
event = CAUSE.AMBUSH,
condition = function() return cfg.consequence_ambush_setup_enabled end,
on_arrive = _on_arrive,
}, _handler)
end
For consequences that pursue a moving target, the arrival handler re-scripts the squad to the target's new location. Each re-script increments a chase counter. The squad gives up after max_chases.
local function _on_arrive(squad, args)
if not args or not args.target_squad_id then return end
local chase_count = (args.chase_count or 0) + 1
if chase_count > cfg.consequence_ambush_setup_max_chases then return end
local target_squad = xobject.se(args.target_squad_id)
if not target_squad then return end
local level_id = xlevel.get_level_id(target_squad)
if not level_id or xlevel.get_level_id(squad) ~= level_id then return end
local smart = ap_core_utils.find_smart(target_squad.position, {
level_id = level_id, max_distance = ap_core_const.RANGE_SIGNAL,
})
if not smart then return end
ap_core_broker.script_squad(squad, smart, {
rush = cfg.consequence_ambush_setup_rush,
on_arrive = CONSEQUENCE.AMBUSH_SETUP,
on_arrive_args = { target_squad_id = args.target_squad_id, chase_count = chase_count },
pre_release_gulag = 1800,
})
end
Link arrival to squad: pass on_arrive = CONSEQUENCE.AMBUSH_SETUP in script_squad opts. The broker matches the key to the handler registered via consumer.register(..., { on_arrive = _on_arrive }).
scripted_target is the squad control field. Setting it routes the squad to specific_update (direct A->B movement). xsquad provides three primitives:
xsquad.control_squad(squad, smart, rush) -- acquire: sets scripted_target, clears __lock
xsquad.release_squad(squad) -- release: clears scripted_target + __lock
xsquad.reassert_target(squad, target) -- defend: restores scripted_target if overwritten
Both mods check scripted_target before claiming a squad:
-- ModA before scripting:
if se_squad.scripted_target then return end -- AP (or anyone) has this squad
xsquad.control_squad(squad, smart)
-- AP's PROTECTION gate already does this check automatically.
AP reasserts scripted_target on its squads every 20s. Squads that die or despawn between scans are removed automatically.
For identity (knowing WHO controls a squad), register an ownership filter:
-- In ModA's on_game_start:
ap_core_broker.register_owner("warfare", function(squad)
return squad.registered_with_warfare == true
end)
| Situation | What happens |
|---|---|
| ModA scripts a squad | AP sees scripted_target, skips at PROTECTION gate |
| AP scripts a squad | ModA sees scripted_target, skips |
| ModA marks squad as owned (no scripted_target yet) | AP sees ownership filter, skips at PROTECTION gate |
| Neither owns the squad | Both can compete; first to set scripted_target wins |
There is no shared state or direct imports between mods -- coordination relies entirely on scripted_target and ownership filters.
Subscribe to cause events via xbus. This requires only xlibs, with no dependency on AlifePlus.
function on_game_start()
xbus.subscribe("cause:massacre", function(e)
printf("massacre at %s: %d dead", e.level_id, e.total_deaths)
end, "my_mod_listener")
end
Available events: cause:massacre, cause:squadkill, cause:basekill, cause:alpha, cause:alphakill, cause:wounded, cause:harvest, cause:stash, cause:area, cause:needs, cause:instincts.
Direct access to AP domain systems. APIs may change between versions.
| Module | Function | Returns |
|---|---|---|
| Tracker | get_alpha(entity_id) | alpha data table (level, kills, name) or nil |
| Tracker | get_alpha_level(entity_id) | integer level or 0 |
| Tracker | is_alpha(entity_id) | boolean |
| Tracker | get_alphas() | all alphas table |
| Tracker | get_stalker_needs(squad_id) | needs DTO timestamps |
| Tracker | get_mutant_instincts(squad_id) | instinct DTO timestamps |
| Smart Mutator | conquer_smart(smart_id, faction) | set faction ownership with FIFO eviction |
| Broker | script_squad(squad, smart, opts) | script with lifecycle |
| Broker | is_protected(squad) | check all guards |
| Broker | register_owner(name, filter_fn) | register ownership filter (replaces on name match) |
| Broker | get_owner(squad) | ownership query |
| Broker | record(squad_id, cause, consequence, opts) | record squad activity (markers, external queries) |
| Broker | get_record(opts) | most-recent record matching opts (AND-logic). { squad_id, assigned = true } is O(1) |
| Broker | get_records(opts) | array of records matching opts. { assigned = true } reads live entries only |
| Broker | clear_record(squad_id) | clear assigned entry on entity death (pure entry drop) |
| Broker | register_arrival_handler(key, fn) | register on-arrival callback by key |
| Broker | get_scripted_ids() | read-only scripted squads table |
| Const | ap_core_const.CONSEQUENCE_INFO | static CONSEQUENCE -> { name_key, action_key } for localized rendering |
| Const | ap_core_const.CONSEQUENCE | enum of consequence keys; match against record.consequence |
| Const | ap_core_const.CAUSE | enum of cause event names; use as xbus event keys |
| Const | ap_core_const.CONSEQUENCE_PHASE | trace sub-phase enum (FIND_TARGETS, MOVE_SQUAD, ARRIVE, ...). DEBUG traces only |
Important: script_squad has no inline protection check. Caller must verify with is_protected.
Goal: when your warfare map UI hovers a squad, append what that squad is doing per AlifePlus ("Investigating a Massacre Site", "Guarding an Outpost", "Hunting the Wounded"). Strings are localized; both English and Russian ship with AP. Copy-paste example, warfare author edits only the call site at the bottom:
--- Return localized AP action phrase for a squad, or nil if nothing to show.
--- Graceful no-op when AlifePlus is absent, squad is owned by warfare, or squad has no record.
local function get_ap_action(squad_id)
if not ap_core_broker or not ap_core_const then return nil end
local record = ap_core_broker.get_record({ squad_id = squad_id, assigned = true })
if not record then return nil end
local info = ap_core_const.CONSEQUENCE_INFO[record.consequence]
if not info then return nil end
local text = game.translate_string(info.action_key)
if not text or text == info.action_key or text == "" then return nil end
return text
end
-- Call site (warfare's tooltip builder, wherever you assemble the hover text):
local ap_action = get_ap_action(squad.id)
if ap_action then
tooltip_text = tooltip_text .. "\n" .. ap_action
end
Notes for warfare authors:
get_record fresh on every render tick. Records mutate as squads move between consequences.ap_core_const.CONSEQUENCE_INFO is a static const table -- read it directly, don't cache.action_key (full phrase shown to the player) and name_key (short caption for config / debug). Pick whichever fits your UI.game.translate_string.register_owner("warfare", ...) is registered by default in ap_core_compat). They never enter the pipeline, never get a record, so get_record returns nil and you render nothing. No additional filtering needed on warfare's side.Typical action outputs (EN): "Investigating a Massacre Site", "Scavenging a Massacre Site", "Reinforcing Attacked Base", "Evacuating Attacked Base", "Hunting the Wounded", "Guarding an Outpost", "Out Exploring", "Heading to a Campfire to Rest", "Restocking at Trader", "Harvesting Artefacts". 36 entries total, one per AP consequence. Full map: ap_core_const.CONSEQUENCE_INFO keys.
If warfare overrides the default filter (to scope warfare ownership more precisely than squad.registered_with_warfare), re-register on on_game_start:
-- In warfare's on_game_start:
ap_core_broker.register_owner("warfare", function(squad)
return squad.my_warfare_flag == true -- whatever warfare actually uses
end)
register_owner replaces the existing filter on name match. This lets warfare scope ownership natively and override AP's default proxy. After registration, AP excludes matching squads at four layers (producer gate, cause predicate, find_squads, squad scripting), so warfare-owned squads are fully invisible to AP.
CAUSE.X to ap_core_const CAUSE tableCONSEQUENCE.X_VERB to ap_core_const CONSEQUENCE tableap_ext_cause_x.script with predicate, register in on_game_startap_ext_consequence_x_verb.script with handler, register in on_game_startstalker-manager.sh validateFull documentation: integration-guide.md and architecture.md.
Most alife mods for Anomaly hook engine callbacks independently, iterate over squads on a timer, implement their own rate limiting, check squad validity in their own way, and set engine fields directly. Each mod operates as a silo with no awareness of other mods controlling the same squads.
xlibs and AlifePlus are designed as a shared alife layer that mods register to rather than build around. The codebase is split into core (ap_core_*) and ext (ap_ext_*). Core never imports ext. All domain logic reaches the framework through registered function references.
Mods that register with the pipeline get these facilities without implementing them:
| Facility | What it does |
|---|---|
| 4-gate chain | Radiant: PACER_1 -> is_protected -> RATIO -> PACER_2 -> EVAL (cascade). Reactive: PACER -> RATIO -> EVAL (all). Each gate either passes or rejects, with no mod code required. |
| Bresenham ratio gate | Integer-only on-map vs off-map admission. Distributes callback budget fairly with zero floating-point arithmetic. |
| Personality gate | Per-faction 7-trait behavioral profile (aggression, greed, survival, perception, territory, relation, discipline). Consequences declare which traits gate them. Average of declared traits rolled against per evaluation. |
| Rate limiting | Per-cause sliding window, per-consequence token bucket, global radiant consequence counter. All configurable via MCM. |
| Protection stack | Four guard types (permanent, active role, task target, scripted) + ownership registry. Applied at producer, cause, consequence, and squad search layers. |
| Ownership registry | register_owner(name, filter_fn). Squads matching any filter excluded from AP. Warfare and BAO registered by default. |
| Squad lifecycle | script_squad with TTL, periodic arrival detection, post-arrival wait, automatic unscript. Persists across save/load. |
| Tracing | Hierarchical trace context (tid + path) flows through the entire pipeline at zero cost below DEBUG. |
| Logging + profiling | Structured file logging, microsecond timing via engine timer. Per-module log files, configurable verbosity. |
Radiant causes fire on squad_on_update (sole radiant source). Four gates, each either passes or rejects. Reactive uses PACER (per-callback token bucket) + RATIO + EVAL only (protection handled downstream in causes and consequences).
Examples of how existing alife mods could use the framework instead of reimplementing squad control:
| Mod type | As AP plugin |
|---|---|
| Territory warfare | Base-capture and patrol as AP causes/consequences. Squad ownership via framework claim/release. |
| Patrol systems | Patrols as radiant causes with arrival-based release. |
| Guard spawners | Guard-return as cause. Long-TTL ownership claim with auto-renew. |
| Bounty systems | Bounty as consequence using the chase pattern. |
| Faction relations | Pure xbus subscriber. Listens to cause events, adjusts faction goodwill. |
Full documentation: integration-guide.md and architecture.md.