Skip to content

Authoring Lua Callbacks

codemann8 edited this page Apr 23, 2026 · 3 revisions

Authoring Lua — Standard Callbacks

EmoTracker calls a fixed set of well-known function names when major events happen during a pack's lifetime. These callbacks let your script react to the runtime — set up state once the pack is fully loaded, persist data when a save file is being read, react to memory state changes, etc.

See Authoring Lua Scripts for the broader scripting overview, and Authoring Lua — init.lua for where these callbacks typically live.

How callbacks work

You implement a callback by defining a Lua global function with the matching name anywhere in your scripts — typically in init.lua or in a file it loads via ScriptHost:LoadScript. The runtime looks up the function by name at the moment the event fires (via mLua[functionName]), so you can replace or hot-add callbacks at any time and the next event will pick up the new version.

If a callback isn't defined, the runtime simply doesn't call anything. There's no error for "missing" callbacks, so you only implement the ones you actually need.

function tracker_on_pack_ready()
    print("Pack is ready")
end

That's the entire contract — define a global with the right name and the runtime takes care of invoking it.

The callback list

Implemented in EmoTracker.Data/ScriptManager.cs:InvokeStandardCallback. Most callbacks are no-arg, no-return functions. The exceptions are the location callbacks, which receive a section argument (see below). No callback's return value is used by the runtime.

Function name When the runtime calls it
tracker_on_pack_ready Once, after the pack's items / locations / layouts have all finished loading and the runtime is ready to be used. The right place to do "set up everything that needs to happen exactly once".
tracker_on_accessibility_updating Right before the runtime begins refreshing accessibility state across the location database. Useful for caching whatever you need to read during the refresh, or recording the previous state for diffing.
tracker_on_accessibility_updated Right after the runtime finishes refreshing accessibility state. Useful for any cross-location bookkeeping that needs the latest accessibility values to be in place.
tracker_on_location_updating(section) Right before any values on a location are changed. Useful for caching whatever you need to read during the update, or recording the previous state for diffing. Only triggers on AvailableChestCount and CapturedItem changes. Callback passes in the corresponding Location Section object: section.Owner.Name and section.Name can be referenced to retrieve the full location name.
tracker_on_location_updated(section) Right after the location has changed. Useful for any additional actions needing to be performed following a change to a location. Only triggers on AvailableChestCount and CapturedItem changes. Callback passes in the corresponding Location Section object: section.Owner.Name and section.Name can be referenced to retrieve the full location name.
tracker_on_begin_loading_save_file Just before the runtime starts loading state from a save file. Use it to clear any in-Lua caches that might collide with the loaded state.
tracker_on_finish_loading_save_file Just after a save file finishes loading. Use it to recompute anything derived from item / location state.
autotracker_started When the user has connected the autotracker. Not the place to register memory watches — those have to be registered at pack init for the autotracker icon to appear at all. Use this for per-connection setup like notifications, logging, or resetting session-specific Lua state. Optional. See Authoring Lua — Autotracking for the full lifecycle.
autotracker_stopped When the autotracker disconnects. Use it for matching teardown of anything autotracker_started set up. Your registered memory watches are not removed — they stay registered and resume polling the next time a provider connects.

Re-entrancy protection

The runtime guards against callbacks invoking themselves with a mbInPostLogicUpdate flag. If your callback (or anything it triggers) leads to another standard callback firing while you're still executing the first one, the nested call is silently dropped.

In practice this means:

  • A callback can modify item/location state and trigger an accessibility refresh — but the resulting tracker_on_accessibility_updating / _updated callbacks for that refresh are skipped while you're still inside the original callback.
  • If you want to react to "the state I just changed", do that work directly in the callback rather than expecting another callback to fire afterward.
  • This guard exists per-callback-call, not per-callback-name, so the next time something legitimately triggers an accessibility refresh, the callbacks fire normally.

Examples

Set up state on pack ready

function tracker_on_pack_ready()
    -- Compute one-time derived data and stash it for later use
    DUNGEON_PRIZES = {
        ["ep"] = "ep_prize",
        ["dp"] = "dp_prize",
        ["th"] = "th_prize",
    }

    -- Force any layout / item state that should be initialised here
    print("Pack initialised with " .. tostring(#DUNGEON_PRIZES) .. " dungeons")
end

Refresh derived state after every accessibility update

function tracker_on_accessibility_updated()
    -- Maybe a script item that summarises overall progress
    local total = count_cleared_dungeons()
    local item = Tracker:FindObjectForCode("dungeon_count")
    if item ~= nil then
        item.AcquiredCount = total
    end
end

Save and load custom state alongside the user's save file

local STATE_KEY = "my_pack_custom_state"
local custom_state = {}

function tracker_on_begin_loading_save_file()
    custom_state = {}
end

function tracker_on_finish_loading_save_file()
    -- Custom save data is normally serialized via LuaItem's Save/Load
    -- callbacks; this is the right place to refresh anything else
    -- you derive from the just-loaded item state.
    refresh_progress_summary()
end

For per-item save data, use the LuaItem SaveFunc / LoadFunc instead — they integrate with the standard save/load pipeline cleanly.

React to a location change

local previous_chest_count = {}

function tracker_on_location_updating(section)
    -- Record the state before the change so we can diff it afterward
    local key = section.Owner.Name .. "/" .. section.Name
    previous_chest_count[key] = section.AvailableChestCount
end

function tracker_on_location_updated(section)
    local key = section.Owner.Name .. "/" .. section.Name
    local prev = previous_chest_count[key]
    if prev ~= nil and section.AvailableChestCount ~= prev then
        print(key .. " chest count changed from " .. tostring(prev)
              .. " to " .. tostring(section.AvailableChestCount))
    end
end

React to an autotracker connection

-- Register watches at pack init time, NOT inside autotracker_started.
-- The autotracker icon won't even appear until at least one watch exists.
ScriptHost:AddMemoryWatch("Items",    0x7EF340, 0x100, items_callback)
ScriptHost:AddMemoryWatch("Dungeons", 0x7EF4C0, 0x040, dungeons_callback)

function items_callback(segment)
    -- Read bytes via segment:ReadUInt8/16/etc. and update items
    local bow = segment:ReadUInt8(0x7EF340)
    -- ...
    return true
end

-- Optional: do per-connection setup (logging, notifications, session resets)
function autotracker_started()
    print("Autotracker connected")
end

function autotracker_stopped()
    print("Autotracker disconnected")
    -- Watches stay registered; nothing to clean up unless you have
    -- session-specific Lua state of your own.
end

See Authoring Lua — Autotracking for the full memory-watch lifecycle.

Tips and pitfalls

  • Most callbacks are no-arg, but tracker_on_location_updating and tracker_on_location_updated each receive a section argument. For all other callbacks, pull the data you need from the global objects (Tracker, LocationDatabase, etc.) directly.
  • Define callbacks as Lua globals, not as table fields. The runtime looks them up via mLua[functionName], which only resolves globals.
  • Don't write callbacks with the same name as a built-in Lua function. Avoid print, pairs, ipairs, etc. as callback names.
  • Be careful with tracker_on_accessibility_updating / _updated. They fire on every accessibility refresh, which can be many times per second during normal play. Keep them fast — don't do heavy work in them, and use the re-entrancy guard to your advantage instead of fighting it.
  • autotracker_started doesn't fire until a device is selected and connected. It's not "the user opened the autotracker menu" — it's "memory reads are now actually happening".
  • Don't put AddMemoryWatch calls inside autotracker_started. The autotracker icon depends on at least one watch existing — if you wait until autotracker_started fires to register watches, the icon never appears, the user can't connect, and the callback never gets called. Register watches in init.lua (or scripts loaded from it).
  • Save/load callbacks fire around the whole save file, not per-item. They're for global state, not per-item persistence; for per-item state see Lua Items.

See also

Clone this wiki locally