Skip to main content

ref / in / out Marshal

Normative: Marshal semantics for byref parameters (element type A) in C# ↔ Lua bidirectional calls. C#→Lua: Default OpaqueValue; details in 04-OPAQUE.md. Lua→C#: From §3 onward in this document.

1. Scope

Pathref / out / in
C# → Lua (GetFunction-obtained delegate, delegate bridge)Supported; default Push OpaqueValue
Lua → C# (ordinary method / ctor, delegate Invoke / __call)Supported; rules in §3

Unified rule (Lua→C#): Lua does not distinguish ref / out / in; all use the same Pop rules. C# still keeps CLR semantics (e.g. in is read-only).

Below, C# parameters are written ref A / in A / out A, where A is the element type after stripping byref.

2. C# → Lua

When the parameter is ref / in / out A, the default Push is OpaqueValue (lightuserdata handle pointing at that parameter slot on the C# call stack).

  • Scripts read/write via zlua.get_opaquevalue / zlua.set_opaquevalue.
  • This path does not Push ByValUserData / ClassUserData.
  • Full rules: 04-OPAQUE.md.

3. Lua → C#: general principles

For ref / in / out A, the binding layer ultimately passes some A* (or equivalent managed byref) address into C#.

Lua argument shapeBehavior summaryCan C# writes to *slot reflect on that Lua value?
OpaqueValue (type-compatible)Use the address pointed to by the handleYes (write back to original C# stack slot / bound address)
ByValUserData with type equal to AUse userdata payload addressYes (write back to userdata payload)
Any other acceptable shapeMarshal a value first, write into a stack temporary for this call, then pass temporary addressNo (temp slot discarded after Invoke)

Thus bare number / Lua string / most ByObjUserData passed to ref A do not error, but the original Lua value does not see C# write-backs (consistent with “no Lua lvalue”).

local x = 5
CS.Demo.Increment(x) -- copied into temp int; C# mutates temp; x stays 5

local p = Point2D(1, 2) -- ByValUserData, type == Point2D
CS.Demo.Offset(p, 10, 20) -- pass payload address; after Invoke, p fields changed

4. Lua → C#: branch details

Decide by Lua argument shape and category of A. Type mismatch → luaL_error (e.g. OpaqueValue for ref object must not be passed to ref int).

4.1 OpaqueValue

  1. Validate handle (generation / scope; see 04-OPAQUE.md.
  2. Validate handle's registered element type is compatible with A (exact match or implementation-defined assignability; forbid incompatible pairs such as ref objectref int).
  3. Hand the address pointed to by the handle to C# byref (do not copy into a temp slot).

4.2 A is a value type (struct / enum, etc., excluding the primitive path below)

Lua argumentBehavior
ByValUserData with userdata bound type equal to APass payload address (can write back to userdata)
ByValUserData with type ≠ A, but A is Nullable<T> and userdata type equals TCopy T into a stack Nullable<T> temporary, pass that address (do not write back to original ByValUserData)
Other (including integer-represented enums, tables, etc., when by-val rules allow)Obtain A via by-val rules → write stack temp → pass temp address

4.3 A is a primitive (bool / char / integers / floats, etc.)

Lua argumentBehavior
Matching primitive Lua value (integer / number / boolean, etc., same rules as 01-OVERVIEW.md)Copy to stack temp, pass temp address
OpaqueValue (type-compatible)See §4.1
ByValUserDataUsually unused for primitives; if implementation has no primitive ByValUserData, treat as illegal or do not pass payload directly

Primitives have no path that mutates a Lua local: even if C# modifies ref int, a bare number argument only affects the temp slot.

4.4 A is string

Lua argumentBehavior
string ByObjUserDataWrite managed object pointer into stack temp, pass temp address
Lua stringCreate managed System.String, write pointer into stack temp, pass temp address
nilTemp slot is null; pass temp address (out same; see §5)
OpaqueValue (type-compatible)See §4.1

Both non-Opaque paths use a temp slot: C# reassigning ref string does not rewrite the original Lua userdata / Lua string.

4.5 A is another reference type (class / interface / array / delegate / object, etc.)

Lua argumentBehavior
Shapes that Pop to a managed object (ByObjUserData, nil, and other by-val shapes allowed by the declared type)Obtain managed pointer (or null) → write stack temp → pass temp address
OpaqueValue (type-compatible)See §4.1

Likewise: temp slot ⇒ C# ref rebinding (refParam = other) does not reflect to Lua; in-place mutable fields remain visible via shared reference (see 06-CLASS.md.

5. out and omitted / nil

CaseBehavior
Argument omitted or nil, and temp-slot pathTemp set to default(A) (reference types null); discarded after Invoke
Argument is ByValUserData (type == A) or compatible OpaqueValueBind existing address; out writes back to that address

If a script needs to observe out / ref write-backs, pass:

  • type-matching ByValUserData (value types), or
  • a still-valid compatible OpaqueValue (common when calling back into C# from a C#→Lua callback).

6. Bridge flow (conceptual)

PopRefArgument(luaIndex, A):
if IsOpaqueValue(luaIndex):
CheckCompatible(opaque.ElementType, A) // e.g. forbid object → int
return BindRef(opaque.Address)

if A is valuetype:
if IsByValUserData(luaIndex):
U = userdata.Type
if U == A:
return BindRef(&payload)
if A is Nullable<T> && U == T:
temp = (Nullable<T>)CopyFromPayload()
return BindRef(&temp)
// fallthrough → by-val into temp

if A is primitive:
value = PopPrimitive(luaIndex, A)
temp = value
return BindRef(&temp)

if A is string:
obj = PopStringAsManagedObject(luaIndex) // ByObj or Lua string→new String
temp = obj
return BindRef(&temp)

// other reference types
obj = PopReference(luaIndex, A)
temp = obj
return BindRef(&temp)

Il2Cpp: temp lives in the current MethodBridge / call frame; ByValUserData is lua_newuserdata payload. Mono: implementation may differ (pin / box); observable semantics must match the tables above.

7. Bidirectional comparison

DirectionDefault shapeWrite-back
C# → LuaOpaqueValueset_opaquevalue or pass handle again into compatible ref A
Lua → C#Opaque / matching ByValUserData → pass address; other → temp slotOnly the direct-address path can mutate “original” storage

8. Examples

-- Temp slot: bare number
CS.Demo.Increment(5)

-- ByValUserData: true payload write-back
local p = Point2D(1, 2)
CS.Demo.Offset(p, 10, 20)
assert.equal(p.x, 11)

-- Nullable: ByValUserData(T) → copy into Nullable<T> temp (no write-back to original userdata)
-- CS.Demo.SetNullable(p) -- void SetNullable(ref Nullable<Point2D> n)

-- string: Lua string / ByObj → temp slot (rebind not visible)
CS.Demo.Replace(refStrHolder) -- depends on API; bare "hello" also goes to temp

-- Inside C#→Lua callback: pass Opaque back
function OnRefInt(h)
zlua.set_opaquevalue(h, zlua.get_opaquevalue(h) + 1)
CS.Demo.IncrementOpaque(h) -- compatible ref int Opaque → pass address
end
TopicDoc
OpaqueValue lifetime and get/set04-OPAQUE.md
ByValUserData / struct05-STRUCT.md
Reference façade and rebind06-CLASS.md
Default by-val matrix01-OVERVIEW.md
GetFunction / delegate bridge09-FUNCTION.md, ../01-HOST-API.md