Cod3xDocs
guide

Engine Boundaries

The Lua code is only one layer; bindings, userdata, queries, and C++ work often dominate.

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.

  1. source

    Lua code

    Your expression or loop.

  2. service

    LuaJIT VM / JIT

    Optimizes Lua around the call when it can.

  3. boundary

    Binding dispatch

    Resolves and invokes the exposed API.

  4. component

    Argument conversion

    Converts Lua values for the native call.

  5. engine

    C++ API

    The engine-facing implementation begins here.

  6. engine

    Engine subsystem

    World, physics, records, rendering, or another subsystem does the work.

  7. boundary

    Return-value conversion

    The result crosses back through the binding layer.

  8. allocation

    Userdata / value allocation

    The result may construct a temporary native-backed value.

  9. 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.

  1. engine

    Sol-bound C++ value return

    A native API returns a value object.

  2. allocation

    Usertype allocation

    The binding creates a Lua-visible wrapper.

  3. boundary

    lua_newuserdata

    Storage is allocated for the exposed value.

  4. component

    Inline C++ value construction

    The native value is constructed in that storage.

  5. 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.

  1. tool

    Cost to find the callable

    Property and method resolution.

  2. boundary

    Cost to cross the binding

    Dispatch and argument conversion.

  3. engine

    Cost of the engine operation

    The actual world, physics, record, or subsystem work.

  4. 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.

See When Lua-Side Optimization Stops Being Enough.