-
Notifications
You must be signed in to change notification settings - Fork 8
Authoring Lua Custom Access Rules
When a location's access_rules need logic that can't be expressed as plain code requirements — multi-step routing, conditional dungeon entrances, runtime settings — you can write the check as a Lua function and reference it from the rule with a $ prefix.
This page is the deep dive on the Lua side of that mechanism. The rule-string syntax is documented on Authoring Locations — Accessibility Logic → Custom logic in Lua; read that first if you want the JSON side.
See Authoring Lua Scripts for the broader scripting overview.
When the runtime parses a rule code that begins with $, it strips the prefix and asks the script manager to provide the count for the resulting code. The script manager treats that code as a Lua function name (with optional |-separated arguments), looks up a global Lua function with that name, calls it, and uses the function's return values to drive the rule.
The dispatch is implemented in EmoTracker.Data/ScriptManager.cs:ProviderCountForCode. The path is:
- Strip the
$prefix inTracker.GetFilteredCodeAndProvider. - Split the remaining string on
|to separate the function name from any arguments. - Look up the global Lua function (
mLua[name]). - Call it with the arguments (as strings).
- Read the return values:
- First return is the count (converted to
uint). - Optional second return is an
AccessibilityLevelenum value that caps the rule's level.
- First return is the count (converted to
- Cache the result until the next accessibility refresh.
Custom-rule Lua functions must return either a count or a (count, AccessibilityLevel) pair:
function some_rule()
return 1 -- present at Normal accessibility (default)
end
function some_other_rule()
return 1, AccessibilityLevel.SequenceBreak -- present, but cap at SequenceBreak
end
function not_satisfied()
return 0 -- the code is "not provided" — rule fails
end| Return | Effect on the rule |
|---|---|
0 |
The code is missing. The rule fails (or downgrades, if the call site uses a sequence-break modifier like [$func]). |
>= 1 (count only) |
The code is satisfied at the rule's natural accessibility level (typically Normal). |
>= 1, AccessibilityLevel.X |
The code is satisfied but caps the rule at level X. Use this to express "this works, but only as a sequence break". |
If the function returns nil (no return statement, or return with no value), the runtime logs an error to the script console: "Lua function <name> did not return a count. All Lua functions used as logical expressions must return a count." Always return at least a count.
A $ token can carry arguments separated by |. The runtime splits the token on |, uses the first piece as the function name, and passes the remaining pieces as string arguments:
function has_n_keys(n)
local count = tonumber(n) or 0 -- arguments arrive as strings
if get_total_keys() >= count then
return 1
end
return 0
endMultiple arguments are also supported:
"$reach_via|moonpearl|flippers"function reach_via(item_a, item_b)
if has(item_a) and has(item_b) then
return 1
end
return 0
endThere's no limit on the number of pipe-separated arguments, but they're always passed as strings — convert them to numbers, booleans, etc. inside your function as needed.
Custom-rule functions are looked up as Lua globals at the moment a rule is evaluated. The simplest pattern is to define them in a script file that you load from init.lua before the location file that references them:
-- scripts/init.lua
ScriptHost:LoadScript("scripts/logic.lua")
Tracker:AddItems("items/common.json")
Tracker:AddLocations("locations/overworld.json") -- access_rules can now reference logic.lua functions-- scripts/logic.lua
function can_reach_dam()
if has("flippers") then
return 1, AccessibilityLevel.Normal
end
if has("moonpearl") then
return 1, AccessibilityLevel.SequenceBreak
end
return 0
endYou can technically define them later (e.g., from inside tracker_on_pack_ready) but rules that fire before then will see them as missing — easier to load logic up front.
The runtime doesn't provide a has() helper — you'll write your own thin wrapper around Tracker:ProviderCountForCode(...):
-- Common helper: returns true if the player has at least one of the code
function has(code)
return Tracker:ProviderCountForCode(code) > 0
end
-- Quantity check
function count(code)
return Tracker:ProviderCountForCode(code)
endThese two functions cover the vast majority of what most pack scripts need. Drop them in scripts/logic.lua and load it from your init.
The runtime caches the result of every custom-rule call until the next accessibility refresh. The first call to $can_reach_dam during a refresh actually invokes your Lua function; subsequent calls during the same refresh just return the cached count. The cache is cleared at the start of every refresh.
The implication: your custom functions should be pure. Return the same count for the same arguments throughout a refresh cycle. Don't track per-call state, don't use random numbers, don't read from external sources that might change mid-refresh.
You also don't need to memoize inside your function — the runtime handles that already.
-- scripts/logic.lua
function has(code)
return Tracker:ProviderCountForCode(code) > 0
end
function can_use_medallion(medallion)
-- Need a sword to use a medallion
if not has("sword") then return 0 end
if has(medallion) then
return 1, AccessibilityLevel.Normal
end
-- If you don't know which medallion the dungeon needs, mark as inspect-only
return 1, AccessibilityLevel.Inspect
end
function can_reach_misery_mire()
if not has("moonpearl") or not has("flute") then
return 0
end
-- Misery Mire requires a medallion (Bombos, Ether, or Quake)
-- The pack's medallion items track which one is required
if has("mm_medallion") then
return can_use_medallion("mm_medallion")
end
return 0
end// locations/dark_world.json
{
"name": "Misery Mire",
"access_rules": [
"$can_reach_misery_mire, glove:1, [pegasusboots]"
],
"sections": [ ... ]
}The rule mixes a custom function with two regular item codes — $can_reach_misery_mire runs the medallion logic, then glove:1 and [pegasusboots] are normal item-database lookups.
-
Always return a count. Returning
nil(forgettingreturn) logs an error and the rule fails. The minimum legal return isreturn 0. -
Wrap
Tracker:ProviderCountForCodein ahas()helper. Calling it directly throughout your rule code is verbose; a thin wrapper is much more readable. - Arguments arrive as strings. Convert them to numbers / bools as needed.
-
Functions are global. Don't define them as
local function ...at file scope — locals aren't visible to the runtime lookup. - Cache is per-refresh, not per-call. Don't try to "save work" with module-level memoization; the runtime already does it for you and clears at the right time.
- Pure functions only. Don't change item state from inside a custom rule function. Anything that mutates the world should be in a callback instead.
-
Returning
Inspectcaps the rule at inspect level — useful for "you can see what's there but the actual reach logic isn't satisfied yet".
- Authoring Lua Scripts — top-level overview
- Authoring Lua — init.lua — where you load the script that defines these functions
- Authoring Locations — Accessibility Logic — the rule-string syntax
- Authoring Locations — Accessibility Logic → Custom logic in Lua — the JSON-side reference
-
Authoring Lua — API Reference —
Tracker:ProviderCountForCode,AccessibilityLevel
- Installation
- Installing and Loading Packages
- Item Types and Mouse Controls
- Map Locations
- Map Location Colors
- Saving and Loading
- Multi-Tab and Window
- Autotracking
- NDI Broadcasting
- Twitch Chat HUD
- Note Taking
- Voice Control
- Keyboard Shortcuts