Resources call each other through marshalled exports and start in dependency order
Status: Accepted (2026-07-07)
Context
The server runs gamemode logic as Lua resources, each in its own sandboxed VM with its own handlers, commands, timers, owned elements and watchdog. To be a platform rather than a single script, the runtime must let several resources work together: a resource must be able to call functions of another one by name, resources must start in an order that satisfies what they need, and the operator must be able to stop and reload one without leaks.
The main technical constraint is that Lua values belong to their VM and cannot cross into another. Resource A cannot hand a table or a function to resource B's VM. A cross-resource call therefore cannot share a reference; it has to copy data through a neutral layer.
Decision
- Exports are declared statically in the manifest.
[server] exports = ["name", ...]inresource.tomllists global functions. They are resolved to live globals right after the resource's scripts run; a declared name that is missing fails the start (MissingExport), like every other start error. The set of exports is visible in the manifest and checked before anybody calls it. - Calls resolve lazily by name. A caller obtains a proxy for another resource with
dlmp.import("<resource>")(ADR 0027) and callsproxy.<function>(...). The function is looked up by name at call time, not captured, so a proxy held across a reload of the target finds the new VM's function on the next call. An unknown or stopped target, an undeclared function or an error inside the callee is a Lua error in the caller, catchable withpcall. - Arguments and results are copied as plain data. Only nil, booleans, integers, numbers, strings and tables of those cross the boundary. Functions, userdata (handles included: pass ids), threads, cycles and nesting deeper than 16 levels are errors. Arguments are copied out of the caller's VM before the target is touched; results are copied back. A call is honest serialization, not an illusion of shared memory.
- The export registry holds the target's VM and function. Each VM is an independent reference-counted state, so a stored function remains callable from another VM without going back through the resource host, which is already borrowed by the caller's running callback. This creates a reference cycle (the VM's globals reach the registry, which holds the VM); stopping a resource removes its export records and breaks the cycle.
- The callee runs under its own watchdog and its own context. The target's watchdog is armed around the call, and
dlmp.this_resourceis the target while it runs (dlmp.event_nameanddlmp.sourceare cleared), then restored: the caller may itself be the target in the middle of its own event. Watchdog scopes nest; a nested arm restores the outer deadline when it ends instead of disarming it. Nested export calls are capped at a depth of 16 as a backstop against runaway recursion that would exhaust the native stack before the wall-clock watchdog fires. - Dependencies are required and order the start.
[server] dependencies = ["name", ...]. The startup set ([resources] startupin the server config) is sorted topologically, keeping the listed order between independent resources. A resource started on demand needs its dependencies already running. A missing dependency or a cycle fails explicitly (MissingDependency,DependencyCycle). A resource cannot depend on itself. - The operator manages the lifecycle from the console.
resource list | start <name> | stop <name> | reload <name>. Mutating commands run under a dispatch context, because a stop removes the resource's owned elements through the facade and itsresource_stophandlers may act. A stop dispatchesresource_stop, removes owned elements, revokes commands, handlers and timers, and drops the VM.reloadis a full stop followed by a fresh start from disk, with a new resource id. - Stopping a resource that others depend on is allowed, with a warning. A dependency constrains the start order; it is not a runtime lock. Calls into the stopped resource fail until it is started again, then work again through the same proxies. On server shutdown every resource is stopped after the resources that depend on it, so its
resource_stopcan still call its dependencies.
Consequences
- VMs stay isolated: each keeps its sandbox, its watchdog and a clean teardown; the cost is a copy per call.
- A reload never leaves dangling references in other resources.
- There are no optional dependencies, versions or permissions between resources, no client-side resources taking part in exports, and no custom events between resources; each would be its own decision.
- The startup set is explicit; other installed resources start only on demand.
Alternatives considered
- A shared heap between VMs. Rejected: it would break the sandbox boundary that isolation exists for.
- Exports registered at run time by a call. Rejected: more dynamic, but the export set would be implicit and surface only when the code runs.
- Capturing a reference to the target function. Rejected: the reference would dangle after a reload of the target.
- Returning
nil, errfrom a failed cross-resource call. Rejected: contract errors such as a misspelled name must be loud, and the callee's traceback must not be lost;pcallremains available where a call is truly optional. - Calling the target through the resource host. Impossible from inside a running callback, where the host is already borrowed; holding the target VM in the registry is the way to reach it, and the cycle it creates is broken on stop.
- Refusing to stop a resource with running dependents, or reloading dependents in cascade. Rejected: the first forces a manual cascade and tracking of reverse dependencies; the second hides side effects from the operator.
