Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit61a33237e0

README.md

probe

Get told when something happens in the running world, without editing anything.

A probe attaches to code that is already running. You say what to watch, what condition matters, and what to compute at that instant; you get a notice on a later tool call saying it happened and carrying the value. You never wait, you never guess a delay, and there is nothing to remember to undo — no source file is touched, nothing reloads, and cancelling removes every trace.

This is what to reach for instead of adding a log line to a component and reloading it, and instead of running a script, waiting, and capturing in the hope the timing was right.

The three things you can watch

ToolFires when
onCallA named function runs. Sees its arguments and what it returned.
onLineExecution reaches a file and a line. Sees that frame's locals.
onChangeA value becomes different from what it was.

And three to manage them: list (what is armed and what it has seen), hits (the detail behind the notices), cancel (take one off, or all).

What a probe tells you

Three parts, every time:

  1. Where it came from — which probe, and what it is attached to. Written by the probe, so several armed at once never blur together.
  2. What it is about — your notice text. Required. A value with no idea what it is about is not a report.
  3. What your body returned — a string becomes the line, a table becomes the values beside it.
probe.onCall("modules.combat.applyDamage", {
    notice = "a hit killed something",
    when   = "ret.killed",
    run    = "return ret.target .. ' at ' .. tostring(ret.hp) .. 'hp'",
})

That reads back as its source, then "a hit killed something", then goblin at -40hp. Repeat hits collapse into one line with a count and the values that differed, which is why the text stays fixed and the value varies.

Seeing the moment

Pass capture to have a hit bring back a picture as well: Luau, read against the hit's context the way run is, that aims a capture tool at what the hit is about and returns the picture.

probe.onCall("modules.combat.applyDamage", {
    notice  = "a hit killed something",
    when    = "ret.killed",
    capture = "return tools.use('capture', 'fromEntity', { entity = args[1], angle = { 30, 20 } })",
    freeze  = true,
})

A capture waits for the renderer, so it runs as the hit is recorded rather than inside the code the probe is attached to; the hit's notice goes out once the picture is back and carries it under shot, delivered to you as a picture beside the line, and the picture says which frame it is of. probe.hits holds the same picture on the hit's row. On onCall, freeze also stops the world at the hit. A capture that fails still reports the hit, with captureError saying why.

Picking a target

  • A function on a required module — onCall, by module.field.
  • A local inside a module, or a helper a component keeps to itself — onLine, by chunk and line. This is the one that reaches code no name can address.
  • A value: a component field, a light's intensity, a position — onChange.

Pair any of them with when to name the moment rather than every step toward it. when = "value >= 5" fires the first time it reaches five.

A line probe's when is evaluated inside the frame it stopped in, so it reads that frame's variables by their own names and with their own types — doubled >= 6. The other two read the context: ret.killed, value >= 5.

What it costs

  • onCall costs one extra call on the wrapped function, and nothing anywhere else.
  • onLine costs nothing on an unarmed line, but a breakpoint drops the enclosing function out of native code until that script reloads. On a function that runs every frame that is real, so cancel it when done.
  • onChange reads its expression once per frame while armed, which is also its limit: something that changes and changes back within a frame is not a change it can see. Probe the code that sets it with onLine instead.

Every probe carries a hit limit and a cooldown, both defaulted low. A probe that reaches its limit disarms and says so; hits the cooldown held back are counted and shown by list, so a busy probe never leaves you believing it fired only as often as it spoke.

Where it stops

Nothing about a probe is written to disk and nothing survives the engine stopping. A body runs inside a protected call and must not yield; three errors in a row disarm the probe and report why.

Globals are sealed read-only, so onCall cannot wrap one. A component's own method is not a field on a module either — reach it with onLine.