Using xlibs and AlifePlus

Guide for mod authors · Damian Sirbu · 2026-04-09

Want to build on these systems? See the architecture section for the design and integration examples, or jump straight to integration levels. Contact details at the bottom.

What They Are

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.

Your Moddomain logic: predicate + handler
AlifePlus Coreap_core_*: pipeline, gates, protection, ownership, squad lifecycle, rate limiting
xlibsxbus, xsquad, xsmart, xlevel, xobject, xtime, xlog, xmcm, xtrace, xprofiler
X-Ray Enginecallbacks, luabind, server objects, A-Life simulation

Both are published under the PolyForm Perimeter License. Addons, integrations, and modpacks are encouraged with visible credit.

License

AllowedNot 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: The Modder's Toolbox

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:

ModuleWhat it doesExample
xbusEvent bus (pub/sub)xbus.subscribe("cause:massacre", fn, "my_handler")
xsquadFind, script, release, protect squadsxsquad.find_squads(pos, {factions=t, max_distance=500})
xsmartSmart terrain queriesxsmart.find_smart(pos, {filter=xsmart.is_base})
xcreatureEntity identity and queriesxcreature.community(id), xcreature.query():stalkers():each(fn)
xpdaPDA messages and map markersxpda.send("Title", "Message")
xmcmMCM config managementxmcm.create_config("mymod", defaults, path_builder)
xlogBuffered file loggingxlog.get_logger("MY.MOD", {outfile="mymod.log"})
xlevelLevel/map queriesxlevel.get_actor_level_id()
xobjectEntity resolution, item creationxobject.se(any_input), xobject.create_item(sec, npc_id)
xtimeGame timextime.game_sec()
xmathRNG, probability, weighted choicexmath.chance(30), xmath.sample(tbl)
xeventFunction hooking, synthetic callbacksxevent.hook("module", "func", wrapper)

Full docs: github.com/damiansirbu-stalker/xlibs

AlifePlus Integration

Register: Add a Cause + Consequence

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

Chase (re-script on arrival)

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 }).

Two Alife Mods Collaborating

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)
SituationWhat happens
ModA scripts a squadAP sees scripted_target, skips at PROTECTION gate
AP scripts a squadModA sees scripted_target, skips
ModA marks squad as owned (no scripted_target yet)AP sees ownership filter, skips at PROTECTION gate
Neither owns the squadBoth 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.

Listen: Subscribe to Events

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.

Coordinate: Deep Integration

Direct access to AP domain systems. APIs may change between versions.

ModuleFunctionReturns
Trackerget_alpha(entity_id)alpha data table (level, kills, name) or nil
Trackerget_alpha_level(entity_id)integer level or 0
Trackeris_alpha(entity_id)boolean
Trackerget_alphas()all alphas table
Trackerget_stalker_needs(squad_id)needs DTO timestamps
Trackerget_mutant_instincts(squad_id)instinct DTO timestamps
Smart Mutatorconquer_smart(smart_id, faction)set faction ownership with FIFO eviction
Brokerscript_squad(squad, smart, opts)script with lifecycle
Brokeris_protected(squad)check all guards
Brokerregister_owner(name, filter_fn)register ownership filter (replaces on name match)
Brokerget_owner(squad)ownership query
Brokerrecord(squad_id, cause, consequence, opts)record squad activity (markers, external queries)
Brokerget_record(opts)most-recent record matching opts (AND-logic). { squad_id, assigned = true } is O(1)
Brokerget_records(opts)array of records matching opts. { assigned = true } reads live entries only
Brokerclear_record(squad_id)clear assigned entry on entity death (pure entry drop)
Brokerregister_arrival_handler(key, fn)register on-arrival callback by key
Brokerget_scripted_ids()read-only scripted squads table
Constap_core_const.CONSEQUENCE_INFOstatic CONSEQUENCE -> { name_key, action_key } for localized rendering
Constap_core_const.CONSEQUENCEenum of consequence keys; match against record.consequence
Constap_core_const.CAUSEenum of cause event names; use as xbus event keys
Constap_core_const.CONSEQUENCE_PHASEtrace 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.

Warfare map tooltip: display AP action

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:

  • Call 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.
  • Each entry has action_key (full phrase shown to the player) and name_key (short caption for config / debug). Pick whichever fits your UI.
  • Locale switch (English/Russian) works automatically because the resolve happens at render time via game.translate_string.
  • A nil return means the squad has no recent AP activity (or is warfare-owned). Render nothing.
  • Squads warfare owns are already excluded from AP via the ownership registry (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.

Warfare ownership: suppress AP for warfare-owned squads

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.

Checklist: Adding a New Cause + Consequence

  1. Add CAUSE.X to ap_core_const CAUSE table
  2. Add CONSEQUENCE.X_VERB to ap_core_const CONSEQUENCE table
  3. Add MCM defaults to ap_core_mcm.defaults
  4. Create ap_ext_cause_x.script with predicate, register in on_game_start
  5. Create ap_ext_consequence_x_verb.script with handler, register in on_game_start
  6. Add PDA messages to ap_ext_messages if needed
  7. Add MCM UI entries to ap_core_mcm menu builder
  8. Run validator: stalker-manager.sh validate

Full documentation: integration-guide.md and architecture.md.

Architecture

Problem

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.

Design

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.

  • xlibs provides invariants: event bus (publish/subscribe), squad safety (protection checks via is_protected composite guard, scripting, release), smart terrain queries, entity resolution, and shared utilities (logging, profiling, time, math)
  • AlifePlus core provides the pipeline: a 4-gate radiant dispatcher and 3-gate reactive dispatcher, cause/consequence routing via xbus, a personality gate (per-faction trait-based behavioral filtering), an ownership registry (register_owner/get_owner -- mods declare which squads they control), squad lifecycle management (scripting with TTL, arrival detection, post-arrival wait, automatic unscript), and multi-layer rate limiting (pacer, Bresenham ratio, per-cause/consequence budgets, global radiant consequence counter)
  • Mods register domain logic: a cause predicate and a consequence handler. The framework handles gates, protection, rate limiting, tracing, arrival, and cleanup.

What the framework provides

Mods that register with the pipeline get these facilities without implementing them:

FacilityWhat it does
4-gate chainRadiant: 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 gateInteger-only on-map vs off-map admission. Distributes callback budget fairly with zero floating-point arithmetic.
Personality gatePer-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 limitingPer-cause sliding window, per-consequence token bucket, global radiant consequence counter. All configurable via MCM.
Protection stackFour guard types (permanent, active role, task target, scripted) + ownership registry. Applied at producer, cause, consequence, and squad search layers.
Ownership registryregister_owner(name, filter_fn). Squads matching any filter excluded from AP. Warfare and BAO registered by default.
Squad lifecyclescript_squad with TTL, periodic arrival detection, post-arrival wait, automatic unscript. Persists across save/load.
TracingHierarchical trace context (tid + path) flows through the entire pipeline at zero cost below DEBUG.
Logging + profilingStructured file logging, microsecond timing via engine timer. Per-module log files, configurable verbosity.

Pipeline (radiant)

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).

squad_on_updateengine callback, fires every tick per squad
1. PACER_1os.clock 100ms coarse limiter, ~10/sec. No squad fields, 0 luabind.rejects ~98% of calls
2. is_protectedowned? scripted? permanent? active role? task target?protected: skip
3. RATIOBresenham integer admission, on-map vs off-mapratio exceeded: skip
4. PACER_2budget limiter, MCM-configurable interval (default 5s)budget exhausted: skip
EVAL (cascade)per-cause rate limit, round-robin predicate evaluation, stop on first publishnil or rate limited: try next cause
xbus publishcause event dispatched to all subscribers (synchronous)
Consumer gatesglobal rate limit (radiant only), per-type rate limit, enabled conditiongate failed: next handler
Handlerrules -> eval -> action: alignment, personality, find squads, script, record, PDA

Potential integrations

Examples of how existing alife mods could use the framework instead of reimplementing squad control:

Mod typeAs AP plugin
Territory warfareBase-capture and patrol as AP causes/consequences. Squad ownership via framework claim/release.
Patrol systemsPatrols as radiant causes with arrival-based release.
Guard spawnersGuard-return as cause. Long-TTL ownership claim with auto-renew.
Bounty systemsBounty as consequence using the chase pattern.
Faction relationsPure xbus subscriber. Listens to cause events, adjusts faction goodwill.

Full documentation: integration-guide.md and architecture.md.

Open invitation. If you maintain an alife mod and want to integrate, reach out. The goal is to establish proper APIs based on what mod authors actually need.
Discord: damian_sirbu | ModDB: damian_sirbu | Email: dami.sirbu@gmail.com