Skip to main content

Delegate / Function Marshal

Normative: Bidirectional Marshal between C# Delegate and Lua functions. Related: class ByObj basics → 06-CLASS.md; GetFunction../01-HOST-API.md; C#→Lua byref → 04-OPAQUE.md; Lua→C# byref → 03-BYREF.md; to_delegate../05-LIB.md.

Platform principle: Mono and Il2Cpp share the same Lua-visible semantics; implementation paths may differ (Il2Cpp: build-time C++ bridge; Mono: runtime Expression Emit).

No Event-specific subtable: ZLua has no Event special Marshal; subscribe/unsubscribe use ordinary methods add_* / remove_* (see ../02-TYPE-SYSTEM.md.

1. Problem and goals

DirectionNeed
C# delegate → LuaDefault treat as ordinary class (DelegateUserData); can Invoke / d(...). If the delegate was marshaled from a Lua function (target is LuaMethod), re-Push must restore the Lua function (§3)
Lua function → C# delegateWhen Lua calls a C# method with a delegate parameter, implicitly marshal the Lua function to that delegate type
GoalNotes
SeamlessLike ordinary args: Lua passes function; conversion happens in the method-call marshal layer
PerformancePlayer zero reflection; reuse bridges by Invoke signature; Editor caches Emit by signature
UnifiedC#→Lua via GetFunction / delegate bridge shares push/pcall/pop rules
SafetyfuncRef lifetime bound to delegate; forbid dangling calls
ControllableUn-codegen'd Il2Cpp signatures error clearly at runtime; Mono signatures that cannot Emit raise clear NotSupportedException (forbid silent degrade to reflection/Method.Invoke hot path)

2. Overall architecture

Core components:

ComponentRole
LuaMethodClosed target for Lua→C# delegates; holds funcRef (registry ref)
DelegateBridges (Il2Cpp)Build-time C++ bridges registered by Invoke signature
DynamicBridgeFactory (Mono)Runtime Expression-compiled bridges by Invoke signature, cached by signature
LuaDelegateBinderCreate delegate: LuaMethod + bridge
ReadDelegateLua function on stack → delegate; parallel to method ReadValue
LuaCallInvokerfuncRef + push + pcall + pop; shared by GetFunction binding and delegate bridges

3. C# delegate → Lua

3.1 Split rules (required reading)

When Pushing a delegate instance onto the Lua stack, split by whether target is LuaMethod:

DecisionC# → Lua shapeNotes
target is LuaMethodLua functionlua_rawgeti(REGISTRY, luaMethod.funcRef); script gets the original Lua closure
Other (native C# multicast, ordinary object target, etc.)DelegateUserDataSame as ordinary class: Invoke + IMT.__call
PushDelegate(d):
if d == null → push nil; return
if IsLuaBoundDelegate(d): // target is LuaMethod (closed)
PushRef(d.LuaMethod.funcRef) // → Lua function
return
PushByObjUserData(d) // → DelegateUserData
ItemRules
RecognitionBoth platforms: target is LuaMethod (or equivalent flag)
MulticastFunction path only when the entire invocation list can restore to a single Lua source; otherwise ByObjUserData, keep C# multicast semantics
Round-tripLua function → C# delegate → Push again: same funcRef → function
[LuaMarshalAs(UserData)]Does not override the split: Lua callback sources still Push function
nullnil

Rationale: When a script-supplied Lua function is retrieved again after a C# hop, it should still be a function, not “C# userdata wrapping the same callback”.

3.2 Carrying shape (non–Lua-callback-source)

Applies only to the DelegateUserData branch of §3.1.

  • Delegate instance is ordinary managed-object userdata (Il2Cpp: ObjectRegistry; Mono: GCHandle).
  • Type table same as ordinary class (including closed generic delegates).
  • Static members via SMT; instance members (including Invoke) via IMT; no need to generate a separate bridge merely to pass a native delegate.

3.3 Lua call styles

Always prefer d(a, b) / handler(...): consistent with “Lua function retrieved via C# is still a function”; works for both branches.

StyleApplies to
d(a, b)function and DelegateUserData (latter via IMT.__callInvoke)
d:Invoke(a, b)Only DelegateUserData; invalid on function; docs and business code do not recommend
local handler = someObj.Handler -- may be native delegate or round-tripped function
handler(42)

-- Round-trip: Lua→C#→Lua yields function
obj:RegisterCallback(function(v) end)
local f = obj.Callback
print(type(f)) -- "function"
f(1)

3.4 Instance metatable __call

For MulticastDelegate subclasses, the instance metatable also registers __call (only DelegateUserData; function branch has no metatable):

Stack layout: [delegate_ud, arg1, arg2, ...]
→ collect args
→ MulticastDelegate.Invoke
→ PushReturn
ItemRules
MulticastKeep C# multicast semantics
nullC# nullnil; calling on nil errors
Open delegateopen delegate with target == null: MVP unsupported
DirectionByObjUserData does not go through §4 bridge again; Lua-bound callbacks already became function on Push per §3.1

4. Lua function → C# delegate

4.1 Main path: implicit marshal of method parameters (default)

Usually no need to create a delegate explicitly:

-- C#: void RegisterCallback(Action<int> onValue)
obj:RegisterCallback(function(v) print(v) end)

Flow:

1. Resolve Nth parameter type as delegateType from MethodInfo
2. Stack slot is Lua function (or nil → null delegate)
3. ReadDelegate(L, index, delegateType)
→ luaL_ref → funcRef
→ LuaDelegateBinder.Create(delegateType, funcRef)
4. Fill generated Delegate into C# call args
5. After method returns: if C# does not retain the delegate long-term, GC reclaims LuaMethod and unrefs (§7)
Lua argumentC# delegate parameter
function ... endLuaDelegateBinder.Create(parameterType, funcRef)
nilnull
delegate userdataPass through
Other typesError

Type source: C# method's declared parameter type; Lua need not pass the delegate type again.

4.2 Shared convention: LuaMethod + closed delegate

All Lua→C# delegates have target = a LuaMethod instance; do not forge an arbitrary C# object's member method as target.

FieldValue
delegate targetLuaMethod (closed)
delegate entryPlatform bridge (§4.3 / §4.4)

4.3 Il2Cpp: build-time C++ DelegateBridges

For each needed delegate Invoke signature, build-time generates a C++ closed-delegate entry (same scan lineage as MethodBridges):

// Example: System.Func<int, int>
static int32_t Bridge_Func_int32__int32(Il2CppObject* target, int32_t a)
{
const LuaMethod* m = reinterpret_cast<LuaMethod*>(target);
lua_State* L = LuaEnv::GetState();
const int top = lua_gettop(L);

lua_rawgeti(L, LUA_REGISTRYINDEX, m->funcRef);
Marshaling::PushDefault<int32_t>(L, a);
Marshaling::LuaPCall(L, 1, 1);
const int32_t ret = Marshaling::PopDefault<int32_t>(L, -1);
lua_settop(L, top);
return ret;
}
  • void return (Action, etc.): LuaPCall(..., 0), no pop.
  • ref / out / in on Invoke: Supported; C#→Lua (bridge calling script) defaults to OpaqueValue (04-OPAQUE.md.
  • Non-default [LuaMarshalAs]: Same resolution as ordinary methods / GetFunction-obtained delegate calls.

Unregistered signature: Runtime table miss → clear error prompting Codegen again.

4.4 Mono: Expression Emit bridge (failures must be explicit)

Mono does not pregenerate LuaDelegateShims; at runtime, compile bridges with Expression trees from delegateType.GetMethod("Invoke") (factory cached by Invoke signature).

ItemNotes
Emit timingFirst encounter of an Invoke signature
CacheSame signature emitted once
marshalpush/pop same rules as LuaCallInvoker
Unsupported signaturesUnmarshalable params / returns → NotSupportedException (forbid silent Method.Invoke / object[] hot path)
ref/out/inSupported (not rejected for containing byref)

Forbidden: Delegate.CreateDelegate on DynamicMethod (may SIGSEGV under Unity Mono); use Expression.Lambda(...).Compile().

Mono rewrite principle: If an Invoke signature cannot Expression Emit, fail clearly at bind or first use; do not invent a temporary slow path that might leak into Player.

4.5 Optional: explicit zlua.to_delegate

Only when you need to construct a delegate first, then pass (non-default path):

local d = zlua.to_delegate(function(a) return a end, closedFuncIntIntType)
obj:RegisterCallback(d)
ParameterNotes
funcLua function
delegateTypeTableClosed delegate type table

Implementation calls the same LuaDelegateBinder.Create; returns delegate userdata.

Native: __zlua_to_delegate

5. GetFunction and delegate bridge

Closed delegate from GetFunction<T>Other C#→Lua closed delegates
Call directionC# → Lua (bound by module + method)C# → Lua (e.g. after Lua→C# implicit marshal, then call back)
Bind timingFirst GetFunction: require module + luaL_reffuncRefImplicit marshal or to_delegate luaL_ref
EntryLuaAppDomain.GetFunction<T>T.Invokeclosed delegate bridge
Marshalpush / pcall / popSame set (LuaCallInvoker)
ref/out/in (C#→Lua)Default OpaqueValueDefault OpaqueValue (04-OPAQUE.md)
paramsUnsupportedUnsupported
CacheCaller's responsibilityDecided by holder

Shared implementation: LuaCallInvoker (Mono) / InvokeFromRegistry (Il2Cpp) shared by GetFunction bind path, DelegateBridges, and DynamicBridgeFactory.

Not a C#→Lua delegate bridge: Implicit marshal of delegate parameters when Lua calls C# (§4.1) goes MethodBridge → ReadDelegate.

6. Lifetime and GC

EventBehavior
Implicit marshal / to_delegatefuncRef = luaL_ref(REGISTRY); delegate holds LuaMethod
Delegate GC'd by C#LuaMethod finalizer / Dispose → queue luaL_unref
Lua function has no other refsRegistry ref still holds until delegate releases
Call while delegate aliveNormal pcall
Call after funcRef invalidError

Mono note: luaL_unref must run on the main thread holding lua_State; ~LuaMethod / Dispose only AddPendingRef; LuaEnv.ProcessPendingRefReleases() batch-releases on the main thread.

C# delegate → Lua:

  • Lua callback source (§3.1): Push function; do not create a new registry ref
  • Native C# delegate: ByObjUserData released by __gc / GCHandle; does not pin a Lua function

7. Mono (Editor) vs Il2Cpp (Player)

ItemIl2Cpp (Player)Mono (Editor)
C# delegate → Lua§3.1 splitSame
Lua→C# bridgeBuild-time C++ DelegateBridges.cppRuntime Expression Emit (cached by signature)
Delegate bindSetClosedDelegateInvokeExpression.Lambda(...).Compile()
CodegenRequired (§8)No pregenerated delegate shims
Unsupported signatureRuntime table miss + Codegen hintNotSupportedException (explicit fail)
Lua-visible semanticsAuthoritativeMust match Il2Cpp

8. Codegen and signature table (Il2Cpp)

Mono does not use this section; Editor uses DynamicBridgeFactory at runtime.

8.1 Generation scope

Shared or same-lineage scan with MethodBridges.cpp:

  • All public methods with delegate parameters (derive Invoke signature from MethodInfo)
  • Optional delegate whitelist in build config

Output: generated/DelegateBridges.h/cpp.

8.2 Signature key

Based on the delegate type's Invoke method:

void(System.Int32) → Action<int>
System.Int32(System.Int32) → Func<int,int>

Mono Emit cache keys align with the above.

8.3 Unregistered signature (Il2Cpp)

unsupported delegate signature for Lua callback: System.Func<...>

Prompt to re-run ZLua Codegen.

9. Edge cases

ScenarioMVP strategy
Action / Func<> / custom delegatesUnify bridge resolution by Invoke signature
C# delegate → Lua§3.1: Lua callback source → function; native C# → DelegateUserData + __call
ref/out/in (Lua→C# calling delegate)See 03-BYREF.md
ref/out/in (delegate bridge C#→Lua)OpaqueValue; see 04-OPAQUE.md
params (GetFunction / delegate bridge)Unsupported
Open delegateMay be unsupported
Multicast Lua callbacksImplicit / explicit create are all unicast
Covariance / contravarianceExact delegate type match only
LuaMarshalAsIl2Cpp: fully generate bridges; Mono: unsupported → explicit error
EventNo dedicated support; use ordinary add_* / remove_* methods
DocContents
06-CLASS.mdDelegateUserData, façade
03-BYREF.mdLua→C# byref
04-OPAQUE.mdC#→Lua byref / bridge callbacks
02-MARSHAL-AS.md[LuaMarshalAs] legal sets
../01-HOST-API.mdGetFunction constraints
../02-TYPE-SYSTEM.mdDelegate type tables, __call
../05-LIB.mdto_delegate
../../impl/codegen/EMIT-MONO.mdMono Expression Emit