An OpenMW API call is not equivalent to calling a pure Lua function.
The path may involve:
The engine-boundary path
A small Lua expression can cross several layers before it returns to Lua.
-
source
Lua code
Your expression or loop.
-
service
LuaJIT VM / JIT
Optimizes Lua around the call when it can.
-
boundary
Binding dispatch
Resolves and invokes the exposed API.
-
component
Argument conversion
Converts Lua values for the native call.
-
engine
C++ API
The engine-facing implementation begins here.
-
engine
Engine subsystem
World, physics, records, rendering, or another subsystem does the work.
-
boundary
Return-value conversion
The result crosses back through the binding layer.
-
allocation
Userdata / value allocation
The result may construct a temporary native-backed value.
-
result
Lua code again
Only now can the script consume the result.
How the pieces relate
- Lua code LuaJIT VM / JIT
- LuaJIT VM / JIT Binding dispatch
- Binding dispatch Argument conversion
- Argument conversion C++ API
- C++ API Engine subsystem
- Engine subsystem Return-value conversion
- Return-value conversion Userdata / value allocation
- Userdata / value allocation Lua code again
The exact path depends on the API, but the important lesson is general: optimize the boundary you actually cross.
Bound methods can hide work
A property such as object.position may return a value userdata. A method such as getBoundingBox() can compute/construct a result. A type function can query engine state. A ray cast can involve physics/world traversal.
Do not reason about these using only Lua syntax size.
local center = object:getBoundingBox().center
looks tiny. The work is not necessarily tiny.
Returned value types can allocate
OpenMW's vector and related value types historically pass through Sol-bound userdata paths.
Rubic0n's OpenMW userdata benchmark was created specifically to reproduce common value-return patterns without booting the full engine:
Why returned values can create pressure
Vector-heavy code can create allocation and garbage-collection work even when the Lua expression looks harmless.
-
engine
Sol-bound C++ value return
A native API returns a value object.
-
allocation
Usertype allocation
The binding creates a Lua-visible wrapper.
-
boundary
lua_newuserdata
Storage is allocated for the exposed value.
-
component
Inline C++ value construction
The native value is constructed in that storage.
-
result
Finalizer path
Eventually the wrapper participates in collection and cleanup.
How the pieces relate
- Sol-bound C++ value return Usertype allocation
- Usertype allocation lua_newuserdata
- lua_newuserdata Inline C++ value construction
- Inline C++ value construction Finalizer path
That is why a chain of innocent-looking vector producers can become an allocation/GC problem.
The benchmark does not mean “never use vectors.” It gives us a mechanism for understanding when vector-heavy hot paths create pressure.
JIT compilation cannot erase an engine call
LuaJIT can optimize Lua around a C boundary, but it cannot transform arbitrary engine internals into traced Lua.
If a loop is dominated by a C++ query, perfect trace formation around it may not move the result much.
The better optimization may be:
- call less often;
- move query outside the inner loop;
- cache for a valid lifetime;
- restructure ownership so the state is observed where it naturally changes;
- add a narrower/faster engine API.
Localize the binding lookup only when it matters
S3maphore localizes frequently used engine functions because the call path is hot enough that repeated property resolution itself can accumulate.
That optimization does not make the C++ operation free.
Always distinguish:
Where the cost goes
Localizing a callable can remove lookup overhead, but it does not erase the rest of the boundary.
-
tool
Cost to find the callable
Property and method resolution.
-
boundary
Cost to cross the binding
Dispatch and argument conversion.
-
engine
Cost of the engine operation
The actual world, physics, record, or subsystem work.
-
allocation
Cost to construct the result
Returned values and userdata may add allocation and cleanup pressure.
How the pieces relate
- Cost to find the callable Cost to cross the binding
- Cost to cross the binding Cost of the engine operation
- Cost of the engine operation Cost to construct the result
The dominant term decides the strategy.
Userdata is not a Lua table
Do not assume table idioms apply to engine wrappers.
Historical St4sh commit 3649154c fixed code that attempted to iterate CellPresence elements with table-oriented next patterns even though the values were userdata.
DreamScripts similarly discovered that GMST values that looked semantically numeric were userdata at runtime.
Runtime representation matters.
See Userdata Is Not a Table and The Number Was Userdata.
Crossing context boundaries also costs architecture
Events and storage subscriptions are engine-mediated coordination mechanisms.
Even if their raw CPU cost is acceptable, they add:
- payload construction;
- scheduling/dispatch;
- lifetime questions;
- serialization constraints;
- stale-message possibilities.
A direct interface call inside the same context can be both faster and easier to reason about when it fits the ownership model.
When the boundary itself is the problem
At some point Lua-side changes stop being enough.
Rubic0n exists because profiling pushed into allocation, userdata finalization, GC pacing, and LuaJIT runtime behavior. Some experiments helped; some were reverted; some required explicit embedder contracts that ordinary mod code cannot safely touch.
That work is valuable to mod authors because it teaches where the ceiling comes from.