Browse docsEvents and lifecycle
On this page
Execution lifecycle

Events

Atlas has seven runtime callbacks: five for the script lifecycle, video, and controller input, and two network events that run only while a script captures network traffic. Video frames, controller packets, and network packets arrive on separate schedules, so each event has a specific job and a specific set of APIs it may use.

The exact lifecycle

  1. Load: Atlas parses, validates, compiles, and runs global initializers once for the loaded source.
  2. Run: on_start runs first, followed immediately by on_connect.
  3. Video: Each accepted capture frame increments frame, refreshes frame metadata, and runs on_frame.
  4. Controller: Each queued physical controller packet refreshes input and runs on_input.
  5. Network (optional): After network.capture(filter) starts capture, each captured packet runs on_network_recv or on_network_send.
  6. Stop: If the script was running, on_stop runs before Atlas neutralizes script output and clears script feedback and overlays. Network capture ends after on_stop.
Current on_connect behavior: it runs once after on_start for each Run. The native runtime does not currently dispatch it again when the detected controller family changes. Read the current family in on_input when a live change matters.

Global initializers belong to the Load phase, not the Run phase. Reloading the script creates its initial state again. Re-running an already loaded VM without loading it again does not repeat those initializers.

Event names are exact and case-sensitive. Only on_start, on_connect, on_frame, on_input, on_stop, on_network_recv, and on_network_send receive runtime slots. The native analyzer currently allows an unknown event name, but the compiler never dispatches it; the editor normally reports that typo. Declaring the same event twice produces a native warning and the last declaration wins.

Event availability

Controller access is a lexical rule. A controller call must appear directly inside on_input; putting it in a helper function is rejected even when that helper is called only by on_input.

Event When it runs Controller APIs Typical work
on_start Once at Run, before on_connect No Configure models and vision tools; initialize run state; log startup
on_connect Once immediately after on_start No Inspect system.controller and record the initial family
on_frame Once per processed capture frame No Read vision results, update shared decisions, and draw overlays
on_input Once per queued controller input packet Yes Read the live pad and override only the output fields needed for this packet
on_stop Once when a running script stops No Log custom shutdown state; optional non-controller cleanup
on_network_recv Once per captured incoming packet, only while capture is active No Inspect, edit, or drop the packet with packet.*
on_network_send Once per captured outgoing packet, only while capture is active No Inspect, edit, or drop the packet with packet.*

The restricted roots are input, output, rumble, lightbar, gyro, accel, ps5, ps4, and xb. The one exception is input.time, which may be read in any event or function. Constants such as controller.ps5, host.cronuszen, and vision.directml are not live controller reads and remain available anywhere.

packet follows the same lexical rule for the two network events: it must appear directly inside on_network_recv or on_network_send, and a helper function that uses it is rejected with atlas.packet_outside_network_event. network.* may appear in any event or function, but network.capture() starts capture only while the script is running; see Where capture can start.

Event parameters

The recommended spelling is parameterless: event on_input() { }. Atlas also accepts the compatibility spelling event on_input(input, output) { }. Those names are not populated local parameters; input and output work because they are built-in namespace roots. Other events must be parameterless.

Keep it simple: omit event parameters. User functions have real parameters; events select a runtime callback and read their context through built-in properties.

on_start

Use on_start for work that should happen once after the loaded module has been initialized. Model source values may be created globally; the live detector can then be configured here. The configuration call returns a typed value that should be retained.

Configure a detector once
model sourceModel = models.set("models/object_detector.onnx");
model detector;

event on_start() {
    detector = vision.model(
        sourceModel,
        ["object"],
        null,
        0.55,
        true,
        vision.directml
    );
    system.log("Detector configured");
}

Do not read input, write output, or send controller feedback here. The controller packet stream has its own callback.

on_connect

on_connect follows on_start during Run. Use system.controller, because input.controller is restricted to on_input. The family value may be controller.ps5, controller.ps4, controller.xb (any XInput or Xbox controller), or controller.unknown in the current host path.

Inspect the initial controller family
event on_connect() {
    system.log("Initial controller: " + system.controller);

    if (system.controller == controller.ps5) {
        system.log("PlayStation 5 controller selected");
    }
}
Do not use this as a reconnect listener yet. The current runtime does not call it again when the family changes, and an initially unknown device can remain unknown in this callback until the first real packet arrives.

on_frame

This is the video callback. Read typed vision values, update globals that on_input can consume, and redraw frame-owned shapes. Atlas clears all ui--prefixed drawings at the beginning of each frame, so rectangles, lines, circles, and printed text should be emitted again while they should remain visible. ui.status uses a separate persistent status entry.

Share a vision decision with on_input
bool objectVisible = false;

event on_frame() {
    target hit = detector.target;
    objectVisible = hit != null && hit.confidence >= 0.75;

    if (hit == null) {
        ui.status("Waiting for an object");
        return;
    }

    ui.rect(
        hit.box.x,
        hit.box.y,
        hit.box.width,
        hit.box.height,
        rgb.lime,
        2,
        false
    );
    ui.status(string.format("Confidence {}", hit.confidence));
}

Video and input rates are independent. Never assume one on_frame occurs for each on_input, or vice versa.

on_input

This is the only event that may read or modify the live controller. The VM begins each invocation from the real incoming state, records the fields touched by the script, and emits a minimal patch. Every untouched button, axis, trigger, touch point, and motion value continues through normal passthrough.

Per-packet passthrough and override
event on_input() {
    // Qualified platform members are button-name strings.
    bool modifierHeld = input.held(ps5.l1);

    // Override Cross for this packet. Other controls pass through.
    output.set_button(ps5.cross, modifierHeld && objectVisible);

    // input.get returns live numeric state: 0..100 for this trigger.
    if (input.get(ps5.l2) > 50) {
        output.set_trigger(ps5.r2, 100);
    }
}
Per-packet means non-sticky. If an axis or trigger setter is skipped on the next packet, that field returns to passthrough. To mirror a button reliably, call output.set_button every packet with the desired boolean value.

Helpers may calculate, but may not touch the controller

Lexically valid helper
function ScaleAxis(value) {
    return math.clamp(value * 1.25, -100, 100);
}

event on_input() {
    var adjusted = ScaleAxis(input.right_x);
    output.set_stick_x("right", adjusted);
}

A helper containing input.right_x or output.set_stick_x would fail validation. Pass ordinary values into the helper and keep the physical read and write in on_input itself.

on_stop

on_stop runs only when a script that was actually running is stopped. It may log state or perform non-controller cleanup, but the analyzer does not permit controller namespaces here. After the callback, Atlas sends a full output clear, clears script-owned rumble, player LEDs, and lightbar ownership, and removes script overlays.

Custom shutdown logging
event on_stop() {
    system.log("Script stopped");
    ui.clear();
    ui.status("");
}
Do not put output.clear() in on_stop. It is outside the permitted controller context and is unnecessary because the host performs its own final clear after the callback.

Network events

on_network_recv and on_network_send let a script inspect, edit, or drop network packets. They are optional: a script that does not declare them never touches the network, and nothing else in Atlas needs the setup described here.

The two events run only after network.capture(filter) has started capture. Declaring them alone captures nothing. Capture uses WinDivert, a separate packet-capture driver, and a small Atlas helper that runs with administrator rights and only moves packets. Your script still runs inside Atlas.

Before a script can capture

  • WinDivert 2.2.2 (x64), installed by you. Atlas does not include or download WinDivert. Get it from the official release page and put WinDivert.dll and WinDivert64.sys in %ProgramFiles%\Atlas\WinDivert, or in another folder you choose when Atlas asks.
  • A folder only administrators can change. It must be a real folder, not a junction or link, on a local fixed drive. Atlas checks it and every folder above it: each must be owned by Administrators, SYSTEM, or TrustedInstaller, and no other account may be able to change it, delete it, or add files to it. The WinDivert folder also may not let other accounts create subfolders, and the two files may not let other accounts change them. A folder under Program Files meets these rules. Atlas only checks; it never creates or repairs a folder, and it refuses an unsafe one with the reason.
  • The official files. Both files must match the SHA-256 of the official 2.2.2 release.
  • Administrator approval. The first network.capture() in each Run opens a Windows administrator (UAC) prompt for Atlas.NetworkBroker.exe, the Atlas helper. Later calls in the same Run reuse it. Stopping the script closes the helper, so the next Run asks again.
  • Interfaces. Capture covers all network interfaces. The Atlas window has no interface selector yet, so network.interface normally reads "All interfaces".

When you Run a script that declares on_network_recv or on_network_send and the WinDivert folder does not pass these checks, Atlas shows a Set up WinDivert dialog. It names the folder it checked and what is wrong, then offers three choices: Yes opens a folder picker, No opens the official WinDivert download page, and Cancel does nothing. Atlas checks a picked folder again before it saves it, and explains a refusal.

Declining does not block the script. If WinDivert is still not set up, Atlas logs WinDivert is not set up, so on_network_recv and on_network_send will not fire. and runs the script anyway. network.capture() still asks for administrator approval; the helper then refuses to load WinDivert, network.active stays false, and network.error says what is missing. After you download WinDivert, stop and run the script again.
If the administrator prompt is declined, network.error reads Network capture needs administrator approval, and the prompt was declined. Restart the script to try again. Later network.capture() calls in the same Run return false without asking again. The rest of the script keeps running.

Choosing packets with a filter

network.capture(filter) takes a filter in the WinDivert filter language. Only packets that match it reach your events; every other packet flows normally without involving Atlas. Atlas applies the same filter to this PC's own traffic and to traffic forwarded through this PC.

FilterDiverts
"udp and udp.DstPort == 9000"UDP packets sent to port 9000
"udp.DstPort >= 30000 and udp.DstPort <= 30020"UDP packets sent to a port range
"tcp.DstPort == 443 or tcp.SrcPort == 443"TCP traffic on port 443 in both directions
"ip.DstAddr == 192.0.2.10 or ip.SrcAddr == 192.0.2.10"IPv4 traffic to or from one address
""Every packet
  • A field test is false for a packet without that header, so tcp.DstPort == 443 never matches UDP.
  • A filter may be at most 2048 characters of printable ASCII (tabs and line breaks count as spaces) with balanced parentheses. network.capture() returns false for a filter that breaks these rules, and network.error says which rule.
  • WinDivert checks the grammar itself when capture starts. A filter it cannot compile leaves network.active false, and network.error starts with Capture filter error:.
  • Every matching packet waits for your script before it continues, so keep the filter narrow. An empty filter sends all of this PC's traffic through the script.

network.capture() returns true as soon as Atlas accepts the request; the prompt and WinDivert start in the background. In the event that called it, network.active is still false. Read network.active, and any error from starting in the background, from a later event, such as on_frame. Calling network.capture() again replaces the filter, and the counters restart when the new capture begins.

When Atlas refuses the request itself, network.capture() returns false and network.error already holds the reason in the same event. It keeps that reason in later events until a request is accepted or the script is loaded or run again, unless capture reports a newer error first.

Where capture can start

network.capture() starts capture only while the script is running. A function behaves like the event that called it.

Wherenetwork.capture()
on_start, on_connect, on_frame, on_inputStarts or replaces capture.
on_network_recv, on_network_sendReplaces the filter of the running capture.
Global initializersReturns false. They run when the script is loaded, before Run.
on_stopReturns false. The script has already stopped, and capture ends right after on_stop.

In the two refused cases, network.error reads Network capture can only start while the script is running. network.stop() and the network.* status members work everywhere.

Which event runs

Captured packetEvent
Sent by this PCon_network_send
Arriving at this PCon_network_recv
Forwarded through this PC (for example, for a console that shares this PC's connection) and leaving through the selected interface; with all interfaces, the interface that carries the default routeon_network_send
Any other forwarded packeton_network_recv

Inside the event, packet.forwarded, packet.outbound, packet.loopback, and packet.ipv6 tell you how the packet was captured. A packet whose event the script does not declare passes unchanged.

What the events may use

  • Available: packet.* (only here), network.*, math, string, system, your globals, and your functions, as long as those functions do not use packet.
  • Not available: the controller roots, exactly as in on_frame. input.time is the only controller value a network event may read.
  • One at a time: packets are decided one after another, and never while another event such as on_frame or on_input is running. Globals are therefore safe to share, but slow work in any event also delays packets.
  • Logging: system.log writes one line per call. Limit how often a network event logs, or a busy connection floods the log.

The API reference lists every network.* and packet.* member.

Passing, dropping, and editing

  • Pass is the default. A packet the event leaves alone continues byte for byte.
  • Drop with packet.drop(). The packet is not delivered. A drop takes precedence over any edits, and packet.pass() later in the same event cancels it.
  • Edit with packet.set_byte, packet.set_bit, packet.write, packet.set_bytes, packet.copy_from, or the raw_* writers. Edits happen in place: a script cannot make a packet longer or shorter. Atlas recalculates the checksums of an edited packet before sending it.
  • Invalid edits are not sent. If an edited packet is no longer a valid IP packet of the same version, its length field claims more bytes than it has, or WinDivert refuses to send it, Atlas sends the original packet instead and counts it in network.failed_open.

Slow scripts and errors fail open

Your connection never waits on a script that cannot answer. In each of these cases the original packet continues unchanged, and network.failed_open counts it for the current capture:

  • No decision arrives within 50 milliseconds.
  • The event stops with a runtime error, such as the call-depth limit, or using up the instruction budget. Each packet gets its own budget. Atlas writes the error to the session log; see Diagnostics.
  • 4096 packets are already waiting for a decision.
  • An edit cannot be sent, as described above.

Packets still waiting when capture stops or is replaced also continue unchanged. Atlas counts a packet as lost only when WinDivert refuses to put it back on the network. It then logs Network capture could not put a captured packet back on the wire. once for that capture, and the number of lost packets when the capture ends.

When capture stops

  • network.stop() stops capture. Packets still waiting pass unchanged, and a later network.capture() in the same Run starts again without another prompt.
  • Stopping the script runs on_stop first, so it can still read the network.* counters, but it cannot start a new capture. Atlas then stops capture, passes any waiting packets unchanged, and closes the helper.
  • Losing authorization for a protected Library script stops the script and ends its capture too.
  • If Atlas closes or loses its connection to the helper, the helper passes the packets it is holding, stops capturing, and exits.
Count and filter UDP traffic on one port
var sent = 0;
var received = 0;

event on_start() {
    // Divert only UDP traffic to or from port 9000.
    if (!network.capture("udp.DstPort == 9000 or udp.SrcPort == 9000")) {
        system.log("Network capture could not start");
    }
}

event on_network_send() {
    sent += 1;
    // Drop empty datagrams; every other packet passes unchanged.
    if (packet.payload_length == 0) {
        packet.drop();
    }
}

event on_network_recv() {
    received += 1;
}

event on_frame() {
    if (network.error != "") {
        ui.status("Capture unavailable: " + network.error);
    } else if (network.active) {
        ui.status(string.format("UDP 9000: {} sent, {} received", sent, received));
    }
}

event on_stop() {
    system.log(string.format("{} dropped, {} failed open", network.dropped, network.failed_open));
}

More complete scripts are in the Network recipes.

Frame and timing values

Value Exact behavior Use it for
frame Resets to 0 at Run and becomes 1 for the first dispatched video frame. Frame counters, visual cadence, periodic video work
time_ms Milliseconds since the native script runtime object was created, not since the latest Run. Monotonic elapsed comparisons where the absolute zero does not matter
input.time 0 initially, then a smoothed 0.9-EMA of positive packet intervals; individual samples are capped at 50 ms. Stable per-input integration such as rate-based stick adjustments
system.width / system.height Latest captured frame dimensions in pixels; 0 before valid frame metadata arrives. Converting normalized target coordinates to pixels
Integrate at the input packet rate
var accumulated = 0;

event on_input() {
    if (input.held("r1")) {
        accumulated += input.time;
    } else {
        accumulated = 0;
    }
}

Schema event index

The cards below are generated from the reference schema so every callback entry in the reference schema is represented. The lifecycle and restriction notes above describe the verified native runtime when a schema description is broader than the current implementation.

on_start

Runs once when the script starts. Use it to configure vision tools, model paths, and starting values.

Event signature
event on_start() { }
on_connect

Runs once immediately after on_start for each script Run, using the controller family known at startup. It is not retriggered when the controller family later changes. Controller input/output is not available here; use system.controller for the startup family and on_input for controller packets.

Event signature
event on_connect() { }
on_frame

Runs every frame for video processing only: vision.* and ui.*. Controller input, output, feedback, and motion are not available here; use on_input for the pad.

Event signature
event on_frame() { }
on_input

Runs for each controller input packet and is the only place controller APIs (input, output, rumble, lightbar, gyro, accel, platform views) are available. The real packet is forwarded automatically; override only the fields you change for this one packet. The parser accepts zero, one, or two declared parameter names for compatibility, but those names are cosmetic and receive no values; always use the global input and output roots.

Event signature
event on_input() { }
on_stop

Runs once before the worker stops. Controller APIs are unavailable here; use it for UI, logging, or other non-controller cleanup. After it returns, Atlas automatically neutralizes controller output, clears rumble and player LEDs, releases lightbar ownership, and clears script overlays.

Event signature
event on_stop() { }
on_network_recv

Runs for each captured packet arriving at this PC, and for forwarded packets that are not leaving through the selected interface, while network capture is active. Use packet.* to inspect or edit it, and packet.drop() to block it.

Event signature
event on_network_recv() { }
on_network_send

Runs for each captured packet leaving this PC, and for forwarded packets leaving through the selected interface (the interface that carries the default route when capturing all interfaces), while network capture is active. Use packet.* to inspect or edit it, and packet.drop() to block it.

Event signature
event on_network_send() { }
Event behavior is written from the native compiler, VM, and runtime dispatcher sources in this source snapshot.