Skip to main content

Struct Marshal

Normative: Passing, construction, and write-back rules for C# structs (value types) with Lua. OpaqueValue / byref (C#→Lua): 04-OPAQUE.md. Lua→C# byref true ref: 03-BYREF.md. Table / UnpackedValues: 02-MARSHAL-AS.md §5–§6. Implementation (GC, Registry, etc.):../../impl/marshal/.

1. Design goals

GoalNotes
Zero-copy (default Handle)When the struct already lives on the bridge stack frame, C#→Lua only records the address and Pushes a lightuserdata handle; no extra box
SafetyLua does not hold a raw struct address; handle is only an opaque token; expired access errors
Uniformitystruct and class both use CSharp.*, obj:Method(); difference is carrying shape
Explicit choice[LuaMarshalAs]: UserData, Table, UnpackedValues, OpaqueValue, etc.

Terms:

  • Blittable struct: memcpy-able; no managed reference fields.
  • Non-blittable struct: Contains string, class, or other reference fields; userdata path must let GC see instance memory.

2. Default Marshal (summary)

Without [LuaMarshalAs], same as 01-OVERVIEW.md:

DirectionDefault shapeNotes
C# → Lua (by-val)ByValUserData or OpaqueValueLong-lived / explicit StructUserData path Pushes ByValUserData; sync-chain by-val may also be OpaqueValue handle
C# → Lua (ref/in/out)OpaqueValueSee 04-OPAQUE.md
Lua → C#StructUserData or Type(...) productDefault does not accept table / multi-stack args; needs [LuaMarshalAs(Table | UnpackedValues)]

3. Three Lua-visible shapes

┌─────────────────────────────────────────────────────────────┐
│ C# struct carrying shapes on Lua side │
├─────────────────┬───────────────────┬───────────────────────┤
│ OpaqueValue │ ByValUserData │ ByObjUserData │
│ (Handle) │ (StructUserData) │ (boxed) │
├─────────────────┼───────────────────┼───────────────────────┤
│ lightuserdata │ full userdata │ full userdata │
│ no metatable │ ByVal instance MT│ ByObj instance MT │
│ sync-only │ long-lived OK │ boxed object path │
│ get/set_opaque │ : / . members │ : / . members │
└─────────────────┴───────────────────┴───────────────────────┘
ShapeTypical sourceMember accessLifetime
OpaqueValueC#→Lua by-val (sync) or ref/in/out defaultNo :/.; need get_opaquevalue / set_opaquevalue or to_user_dataValid only for this C#→Lua call
ByValUserData (StructUserData)C#→Lua Push copy, Type(...), to_user_dataByVal instance metatableLua GC; non-blittable needs Registry
ByObjUserDataC# box path, zlua.box (struct boxing)ByObj instance metatableSame family as class object path

Opaque ↔ StructUserData: zlua.to_user_data(opaque) copies to StructUserData; they are independent; mutating userdata does not affect the original opaque (if still valid).

4. Dual instance metatables: ByVal and ByObj

In CLR, a value type may be boxed then passed as object to Lua, or passed by value. Thus the same struct type has two instance metatables on the Lua side:

Pathuserdata typePayloadInstance metatable
ByObjObjectUserDataManaged object pointer (boxed instance)ByObj instance metatable
ByValByValUserDataValue type actual data (payload)ByVal instance metatable (type table __instance_mt)

There is still one type table T; T.__instance_mt describes ByVal semantics. The ByObj path attaches the ByObj instance metatable.

4.1 Instance method this resolution

For an instance method defined on type DefType:

PathDefType is current structDefType is a class base
ByObjthis = object pointer + skip object header → unboxed payloadthis = boxed object pointer
ByValthis = payload start addressMust Box first, then use object pointer as this

Forbid mixing: ByVal userdata with ByObj metatable (or vice versa) → validation failure or luaL_error.

4.2 C# → Lua Push path selection

ConditionPush result
box / object parameter, etc.ByObjUserData + ByObj MT
Explicit StructUserData / blittable copyByValUserData + ByVal MT
Sync-chain by-val (default Handle)OpaqueValue lightuserdata
[LuaMarshalAs(OpaqueValue)] on by-val structOpaqueValue

5. Lua → C#: accepted argument shapes

ShapeNotes
OpaqueValue (lightuserdata)Only if just Push'd by C#→Lua and still in valid scope; Pop validates + binds; cannot R/W fields on Lua side
StructUserData (ByValUserData)by-val: copy payload on Pop; ref/out/in: bind payload address, true ref (§6)
Type(...) construction productStructUserData payload; to ref T is true ref
UnpackedValues[LuaMarshalAs(UnpackedValues)] + Members: contiguous multi-stack args
Table[LuaMarshalAs(Table)] + Members: single table

No Lua-side API creates lightuserdata except OpaqueValue produced by C#→Lua.

5.1 Default rejects table / multi-arg

Without [LuaMarshalAs], you cannot assemble a struct into C# via { X=1, Y=2 } or foo(x, y); use StructUserData, Type(...), or explicit Table/UnpackedValues.

6. Write-back semantics and ref / out / in

Overview and full branches: 03-BYREF.md. Value-type highlights:

Lua argumentref/out/in A (A is value type)
ByValUserData with type == APass payload address (can write back to userdata)
ByValUserData with A = Nullable<T>, userdata type == TCopy into stack Nullable<T> temporary, pass temp address (no write-back to original userdata)
OpaqueValue (type-compatible)Pass handle address
Other (including shapes Pop-able by-val)Copy into stack temp, pass temp address (no write-back to Lua)
local p = Point2D(1, 2)
CS.Demo.Offset(p, 10, 20) -- true payload write-back
assert.equal(p.x, 11)

7. Table / UnpackedValues (struct / Nullable)

Rules follow 02-MARSHAL-AS.md §5–§6; struct specifics:

LuaMarshalTypeLua → C#C# → Lua
UnpackedValuesContiguous Pop of N stack values, write by Members order (non-Nullable struct only)Push N values by list order
TablePop one table, write listed members by key name; Nullable<struct> also accepts nil→no valuePush one table; Nullable no value → nil
  • Requires Members (relative to underlying struct); errors: 02-MARSHAL-AS.md §4.
  • Table, Lua→C#: optional keys use member name ? suffix (e.g. "Tag?"); missing key skips assignment.
  • Stack-slot occupancy and calling convention: 02-MARSHAL-AS.md §5.6.

Type-level annotation example:

[LuaMarshalAs(LuaMarshalType.Table, Members = new[] { "X", "Y" })]
public struct Vector2
{
public float X;
public float Y;
}

Resolution priority (same as 02-MARSHAL-AS.md §8): parameter/return > field/property > type-level > default; same target Attribute > XML.

8. Enum vs struct

Enums default C#↔Lua to integer/number and do not use struct userdata (see 08-ENUM.md.

Capabilitystructenum
Type table SMT.__callYes (Point2D(...))No
ByVal StructUserDataYesNo SMT.__call; ref uses copy or C#-pushed StructUserData
boxed instancezlua.box → ByObjUserDatazlua.box → ByObjUserData
Default parameterStructUserData / integer etc.integer/number or boxed
local Color = CSharp.AC['MyGame.Color']
local v = Color.Red -- integer/number constant
local boxed = zlua.box(Color, Color.Green) -- ByObjUserData
SetColor(Color.Red) -- default param OK
SetColor(boxed) -- default param OK

ref Color: bare integer takes the copy branch; for observable write-back need C#→Lua StructUserData, or change to a struct parameter. Boxed scenarios use zlua.box, not the StructUserData ref model.

9. zlua.box / zlua.unbox / zlua.cast

APIstruct semantics
zlua.box(typeArg, value)Box value type as ByObjUserData; value may be ByVal userdata, scalar, or construction args
zlua.unbox(boxedValue)Unbox ByObjUserData to ByVal StructUserData (or equivalent accessible instance)
zlua.cast(obj, targetType)Reference-type façade switch; struct cases: 06-CLASS.md and type system

unbox does not accept ByVal userdata (already unboxed) → error.

10. Lifetime

[C# calls Lua, sync chain]
Push OpaqueValue(handle) → h
lua_pcall(...) -- Lua may only get/set_opaque or to_user_data(h)
C# returns -- h invalid

Lua saves h until next call → error on use

Need long-lived → zlua.to_user_data(h) or C#→Lua StructUserData path
ShapeInvalidation
OpaqueValueC#→Lua call returns; or scope EndScope bumps generation
StructUserDataLua GC collects userdata (__gc releases Registry entry)

11. Non-blittable struct requirements (spec layer)

ByValUserData path for non-blittable structs must:

  1. Fully copy the struct instance into userdata (including reference fields).
  2. GC must scan managed references inside struct memory in userdata (implementation may use Registry + push root callbacks; Mono may use GCHandle).
  3. userdata __gc and Registry release must be symmetric.

Blittable structs only memcpy into userdata payload; no extra GC registration.

12. Linkage with metatables / type tables

  • value-type type table: __struct = true (see ../02-TYPE-SYSTEM.md.
  • ByVal: ByValUserData + ByVal instance metatable (__instance_mt).
  • ByObj: ObjectUserData + ByObj instance metatable.
  • Opaque: no instance metatable; no : / ..
  • Forbid accessing static members via instance.

13. Mono / Il2Cpp consistency

ItemRequirement
Three shapes (Opaque / ByVal / ByObj)Lua scripts do not distinguish platforms
Handle expiryBoth luaL_error
to_user_dataCopy semantics match
Table / UnpackedValues / ? suffixSame [LuaMarshalAs] config on both
ref/out/in StructUserData true refSemantics match; Mono may pin/box but behavior must align
TopicDoc
Default matrix01-OVERVIEW.md
[LuaMarshalAs]02-MARSHAL-AS.md
byref03-BYREF.md
OpaqueValue04-OPAQUE.md
Enum08-ENUM.md
zlua.* API../05-LIB.md
Type tables / __call../02-TYPE-SYSTEM.md