03 — Member binding (bind-time rules)
This document specifies how the EnsureBinding phase scans, classifies, and writes C# type members into the static/instance three tables (methodTable, fieldGetterTable, fieldSetterTable). After binding completes, runtime index behavior is fully defined by 02-INDEX.md; this document does not cover how native code generates bridge closures (see impl/metatable/, impl/codegen/).
Related docs: three-table roles → 02-INDEX.md; metatable layout → 01-LAYOUT.md; method overloads → ../04-METHOD-OVERLOAD.md; C# extensions → ../13-EXTENSION-METHODS.md.
1. Visibility
Only public members are visible to Lua and enter the binding tables. internal, protected, private, and explicit interface implementations (unless a dedicated spec says otherwise) are not registered.
Constructors, _default, SMT.__call, array __len, delegate __call, and similar items do not enter the three tables; they hang separately on SMT / IMT / the type table per 01-LAYOUT.md and 04-SPECIAL-TYPES.md.
2. Static / instance isolation
Each TypeBinding maintains:
- One static three-table set (on the SMT indexer)
- One instance three-table set (on the ByObj IMT indexer; structs also get a ByVal set with the same member names but closures that resolve
thisas ByVal)
Static members are written only into the static three tables; instance members only into the instance three tables. Sharing or mixing upvalues is forbidden. Scripts must not access static members through an instance userdata's __index.
3. Member classification
At Bind time, each public member is written into a unique target table (or into both getter and setter tables as a pair) under the following rules.
3.1 Methods
| C# member | Target table | Value |
|---|---|---|
| Instance / static methods | methodTable | Single overload: direct bridge closure; same-name multiple overloads: default name → dispatch closure, plus a full-signature direct key MethodName(ParamTypeFullNames…) for each candidate (see ../04-METHOD-OVERLOAD.md §3.7) |
| C# extensions (configured as visible) | instance methodTable | CLR marks them static, but they are written as static-as-instance into the IMT (structs include ByVal/ByObj); same-name real instance methods compete/merge. See ../13-EXTENSION-METHODS.md |
| Indexer properties | methodTable | get_Item / set_Item or equivalent dispatch closure |
| C# events | methodTable | add_EventName / remove_EventName method closures; do not create an event subtable |
| Generic methods | methodTable | By default only full-signature keys; a single generic overload may also occupy the default method name |
Parameterized properties must not enter fieldGetterTable / fieldSetterTable; Lua can only access them via obj:get_PropName(args) / obj:set_PropName(args, value) or indexer method names (see ../02-TYPE-SYSTEM.md §properties).
Array element read/write is registered uniformly as instance methods get / set (not get_Item naming); see 04-SPECIAL-TYPES.md.
3.2 Fields
| Accessibility | fieldGetterTable | fieldSetterTable |
|---|---|---|
| Readable instance / static field | ✅ getter closure | ✅ setter if writable |
readonly / init-only | ✅ getter | ❌ |
| Compile-time constants only (enum literals, etc.) | optional: write integer directly on type table T | ❌ |
Public static enum literals are preferably written directly onto type table T as the underlying integer (prefer Lua 5.4+ integer); do not create userdata getters for constants.
3.3 Parameterless properties
| Property shape | fieldGetterTable | fieldSetterTable |
|---|---|---|
| Readable and writable | ✅ if getter present | ✅ if setter present |
| Read-only | ✅ getter | ❌ |
| Write-only | ❌ | ✅ setter |
Read-only property: __newindex errors after a setter-table miss. Write-only property: __index returns nil when both method and getter tables miss.
3.4 Constructors
Public instance constructors do not enter any of the three tables. Binding only collects .ctor overloads declared on the current type and configures SMT.__call dispatch (does not merge base-class constructors along the inheritance chain). If there is no public constructor, Type(...) errors; structs also provide SMT._default (parameterless; see 04-SPECIAL-TYPES.md).
4. Inheritance: Bind-time flattening
At runtime, __index / __newindex do not walk up the inheritance chain. To match C# (“inherited members are reachable via the derived type name”), EnsureBinding must pre-merge base public members into the derived type's three tables.
4.1 Static members
Collect public static fields / properties / methods from base toward derived, and write them into the derived type's static three tables. Same-name members declared on the derived type override base entries (including new static hide). Runtime SMT.__index is only an O(1) lookup of the derived static three tables plus SMT fallback.
4.2 Instance members
Collect public instance fields / properties / methods along the base chain and write them into the derived type's instance three tables (ByObj; for structs, also the ByVal three tables). Subclass override / new same-name members override base entries. Virtual methods still go through generated bridges to CLR virtual dispatch; flattening only affects Lua key → closure lookup, not virtual-call semantics.
4.3 Items that do not participate in inheritance
- Constructors: only public instance constructors declared on the current type.
- Enums: no inheritance; do not flatten members from other types.
- Structs: value types have no instance inheritance; static members are not merged upward from bases (C# value-type scenarios typically have no derived static inheritance).
4.4 No runtime promotion
The spec uses pure Bind-time flattening: after first bind, all inherited members are already in the current type's three tables; a runtime miss is nil / error. Do not walk the inheritance chain on __index miss and write results back (promotion). Both Mono and Il2Cpp must follow this ruling.
5. Struct ByVal / ByObj dual binding
For structs that are neither enums nor Nullable:
- Scan instance members; generate ByObj closures (
isByVal = false) intobyobjInstanceMap(conceptually the ByObj instance three tables). - Copy the same member-name set; generate ByVal closures (
isByVal = true) intobyvalInstanceMap(conceptually the ByVal instance three tables). - Static members exist only once and go into the static three tables.
Field offsets and method this resolution differences are in ../marshal/05-STRUCT.md. Do not bind struct instance members on the ByObj side only and omit ByVal.
6. Method overloads and aliases
Multiple candidate methods under the same final Lua name: write a dispatch closure into methodTable, and write a full-signature direct key for each candidate (../04-METHOD-OVERLOAD.md §3.7). Final names also come from [LuaAlias] / XML (same document §3, §5). Runtime zlua.register_method must not take an existing method name or overload-group name (§6.1); its purpose is to hang an existing direct (including full-signature keys) under a short name for colon calls.
[LuaAlias] may duplicate the default method name or other aliases; duplicates merge into the same overload group.
Overload grouping for static vs instance, and ByVal vs ByObj, is independent.
7. Name conflicts
7.1 Method ↔ method
Multiple methods under the same final name are legal and go through overload (previous section). Bind does not fail merely because method keys “collide”.
7.2 Method ↔ field / property
If a field / property shares a name with a method, methodTable wins at __index time (see 02-INDEX.md §2.4).
7.3 Inheritance flattening
If at Bind time a key is already occupied by a higher-priority declaration (for example a subclass already overrode a base same-name member slot under existing policy), handle base entries per inheritance rules. This does not conflict with “same-name methods enter an overload group”: flattening subclass and base same-name instance methods still aggregates candidates under the final name (details in ../04-METHOD-OVERLOAD.md).
8. Event handling
C# event is not registered as a { get, set, fire } subtable. Compiler-generated add_* / remove_* (and visible raise_* if any) enter methodTable as ordinary methods. Script usage:
obj:add_SomeEvent(function() ... end)
obj:remove_SomeEvent(handler)
Not supported: obj.SomeEvent = handler or __newindex assignment to an event name.
9. Lazy binding flow (summary)
- Resolve
Il2CppClass*/Type; return if aTypeBindingalready exists. - Collect public members from the base chain and the current class (constructors from the current class only).
- Write static/instance three tables per §3; for structs, run §5 dual instance closures.
- Configure
SMT.__call, struct_default, Nullable special SMT, etc. (04-SPECIAL-TYPES.md). - Create SMT / IMT, attach indexers, establish
T↔ IMT cross-lookup (01-LAYOUT.md §5–§6).
Binding scope for generic definition types and types with open generic parameters is specified in ../02-TYPE-SYSTEM.md; once visible to scripts, bound members must follow this document's classification and flattening rules.
10. Acceptance criteria (semantics)
- Registered methods / fields / properties /
add_*/remove_*: the index hot path does not depend on C# string reflection table lookup. - Unregistered keys:
__index→nil;__newindex→ error. - Static/instance three tables are isolated; inherited members are Bind-time flattened; no runtime chain lookup.
- No event subtable; no reflection fallback.
- Mono and Il2Cpp agree on the member set and read/write semantics.