Skip to content

aowlspt/fast

Source: aowl/src/aowlspt/fast.nim — 1454 lines.

The fast path: calling the game without going through il2cpp_runtime_invoke.

aowlspt/game is the comfortable API. It reaches the game the way the ABI does: a target string, a JSON argument list, a name lookup, a box per argument, il2cpp_runtime_invoke, and a boxed return parsed back out of JSON. That is the right shape for a mod that calls into the game when something happens, and the wrong shape for one that calls into it on every frame for every entity.

This module is the other shape. A mod binds a method or a field once -- at load, or the first frame the game exists -- and gets back a small object it can call through every frame:

var health: FieldBinding var damage: Binding

proc onUpdate(elapsedMs: int64): Status = if not health.ok: health = bindField(rt, "EFT.Player", "Health") damage = bindMethod(rt, "EFT.Player", "Damage", 1) if not damage.ok: warn damage.why let hp = readFloat(health, player) # a load var a = argsF(1.0) discard callFloat(damage, player, a) # a call

callFloat takes its Args as a var, so the pack has to be a variable: callFloat(damage, player, argsF(1.0)) does not compile.

What is actually removed. A bound call is: pack the arguments into a five-slot buffer, one switch on a byte, one indirect call to the compiled method. No name lookup, no allocation, no boxing, no JSON, no crossing the host ABI at all. A bound field read is a load from object + offset.

Why a mod can do this without the host. openIl2Cpp() binds the already loaded GameAssembly.dll by GetModuleHandleW, and a mod DLL is in the same process as the host. So a mod holds its own Il2Cpp and talks to the runtime directly; the host is not on the path and its handle table, its target-string parser and its JSON are all skipped. The host is still what got the mod loaded, what attached the calling thread, and what a mod should use for everything that is not hot.

Three rules that are not negotiable.

  1. Bind inside a proc, never as a global initialiser. In a nimony --app:lib build a global initialised by a call is silently left zeroed -- the same trap gameType is a template to avoid. var b = bindMethod(...) at module scope produces a zeroed Binding, whose ok is false, so it fails safe; but it never becomes true either. Declare the global bare and assign to it from onLoad/onUpdate.

  2. A bound call takes a raw object pointer, not a Handle. Handles exist because the collector moves objects; a pointer held across two frames is a use-after-free waiting for its moment. The fast path cannot afford to dereference a GC handle per call, so the caller owns that problem: get the pointer once per frame from a handle you hold, or from a patch that handed you this, and do not keep it.

  3. A binding refuses rather than approximating. Every signature this cannot express -- a double argument, a Vector3, a fifth argument, a type the runtime will not classify -- comes back with ok = false and a why that names it. A call through a wrongly classified signature does not crash: it reads a float out of a general-purpose register and hands the game a number, which is the worst failure mode available.

The C side is abi/aowlspt_fast.h, which explains how 189 generated cases cover every signature this accepts and why the ones it rejects are rejected. Compile with --passC:-I<repo>/abi.

Types

FastKind

nim
  FastKind* = enum
    fkNone, fkVoid, fkBool, fkI32, fkI64, fkF32, fkF64, fkPtr

The register class of one slot, which is all Win64 cares about.

fkPtr is every reference type -- a string, an object, an array. They are indistinguishable to the call: one general-purpose register holding an address. fkNone is "this module will not classify it", which is a refusal rather than a kind.

aowl/src/aowlspt/fast.nim:361

Args

nim
  Args* = object
    n*: int32
    mask*: uint32       ## bit i set => declared argument i is a float
    slots*: array[5, uint64]

The argument buffer for one call.

Slot 0 is reserved for this and filled at call time, so a static and an instance call use the same buffer with a different base -- which is why n counts the declared arguments and the slots start at index 1.

Declared as uint64 for the alignment and never read or written from nimony: every access goes through the union in aowlspt_fast.h. Reading a float out of storage declared as an integer is precisely the aliasing case a compiler may reorder.

aowl/src/aowlspt/fast.nim:520

Binding

nim
  Binding* = object
    ok*: bool
    why*: string
    target*: string          ## "EFT.Player::Damage", for messages
    fn*: Il2CppPtr           ## MethodInfo.methodPointer, the compiled function
    info*: Il2CppMethod      ## the MethodInfo*, passed as the trailing argument
    argc*: int32
    slots*: int32            ## argc, plus one if this is an instance method
    mask*: uint32            ## bit i set => native slot i is a float
    ret*: FastKind
    isStatic*: bool
    bindNs*: int64           ## what the binding cost, in nanoseconds

A method, resolved down to a function pointer and a signature code.

why is filled on failure and on success: on success it says what the binding is, which is what a mod wants in its log the one time it runs.

aowl/src/aowlspt/fast.nim:590

ShapedArgs

nim
  ShapedArgs* = object
    slots*: array[MaxSlots, uint64]
    n*: int32

Slots for a shaped call, filled positionally by the mod. Unlike Args, nothing here is inferred: slot 0 is whatever the mod says slot 0 is.

aowl/src/aowlspt/fast.nim:875

FieldBinding

nim
  FieldBinding* = object
    ok*: bool
    why*: string
    target*: string
    offset*: int32
    kind*: FastKind
    bindNs*: int64
    wbarrier*: Il2CppPtr

An instance field, resolved to a byte offset.

After this, reading it is a load. The boxed path for the same read is: find the FieldInfo by name, ask for its type, ask the runtime for the type's name (which it allocates and the caller frees), branch on the string, call il2cpp_field_get_value into a scratch buffer, and format the result as JSON.

aowl/src/aowlspt/fast.nim:1049

StaticFieldBinding

nim
  StaticFieldBinding* = object
    ok*: bool
    why*: string
    target*: string
    base*: Il2CppPtr    ## the class's static data block
    offset*: int32      ## where in it this field lives
    kind*: FastKind
    bindNs*: int64

A static field, resolved to the address of its storage.

Deliberately not a FieldBinding with a flag: see the note above. The readers below take no object, so an instance binding and a static one cannot be swapped by accident even when both are in scope and both are called f.

aowl/src/aowlspt/fast.nim:1281

Routines

perfCounter

nim
proc perfCounter*(): int64

QueryPerformanceCounter. Exposed because a mod measuring its own hot loop wants the same clock the bindings report their cost on.

aowl/src/aowlspt/fast.nim:167

perfFreq

nim
proc perfFreq*(): int64

aowl/src/aowlspt/fast.nim:172

currentThreadId

nim
proc currentThreadId*(): int64

The calling thread's id, as Windows numbers them.

For checking that a callback ran where it was supposed to. A mod that touches a Unity object from a worker thread does not fail: it usually works, until the frame it does not, and the crash is nowhere near the call. Comparing this between two firings is how a mod establishes that everyMain really reached the game's thread rather than merely running.

aowl/src/aowlspt/fast.nim:176

nanosBetween

nim
proc nanosBetween*(startTicks, endTicks: int64): int64

Ticks to nanoseconds. Multiplies before dividing, because the frequency is around 10 MHz and dividing first turns every measurement under a microsecond into zero.

aowl/src/aowlspt/fast.nim:186

allocationCount

nim
proc allocationCount*(): int64

How many blocks this binary's allocator has ever handed out, or -1 when the counter could not be read.

Monotone: a free does not lower it. That is the whole reason it is usable as a proof -- take it, run a loop, take it again, and an unchanged number means the loop allocated nothing at all rather than nothing net. A live-bytes figure like getOccupiedMem cannot say that: a string built and freed once per firing leaves it exactly where it was.

Per binary, because the allocator is: a mod DLL and the host DLL each link their own mimalloc. That is the shape of the question rather than a limitation -- "does the mod side allocate" and "does the host side allocate" are two claims, and the host answers its own through call("aowlspt.host::patch_stats").

aowl/src/aowlspt/fast.nim:301

allocatedBytes

nim
proc allocatedBytes*(): int64

The same, in bytes of block size. Reported beside the count because a number of bytes is what a reader wants to see; the count is what an assertion should be written against, since nothing lowers it.

aowl/src/aowlspt/fast.nim:318

liveBytes

nim
proc liveBytes*(): int64

How many bytes this binary's allocator is holding right now, or -1 when the counter could not be read.

A snapshot rather than a proof -- see allocationCount for the difference and why the cumulative one is the one to assert against. This is the figure to watch over time: a heap that is the same size after ten thousand cycles as after ten is a heap nothing is accumulating in, and no cumulative counter can say that.

aowl/src/aowlspt/fast.nim:324

allocProbeOk

nim
proc allocProbeOk*(): bool

Whether allocationCount is actually reading the allocator.

It makes an allocation of a known size and checks that the count moved. Without this, a build where the counters are compiled out reports a constant -- and a constant is indistinguishable from "your loop allocated nothing", which is the one answer this must never give by accident. That is not hypothetical: the first version of this read malloc_requested, which is only maintained in a debug build, and reported a confident zero for a loop allocating at frame rate.

aowl/src/aowlspt/fast.nim:335

describe

nim
proc describe*(k: FastKind): string

aowl/src/aowlspt/fast.nim:370

classifyClass

nim
proc classifyClass*(rt: Il2Cpp; c: Il2CppClass): FastKind

A class, as a register class.

An enum lands here, and it used to be refused: it passes as its underlying integer, nothing in the C API names which one, and guessing Int32 is wrong for the long enums that exist. But the width is not a guess -- it is instance size minus the object header, and the header is measured rather than assumed (boxHeaderBytes). So an enum, and any other value type small enough to travel in a register, is classified by the size the runtime reports for it.

A value type too wide for a register is still refused, and must be: Win64 passes it by hidden pointer, which is a different call shape rather than a different register. bindRaw is how a mod states that shape.

aowl/src/aowlspt/fast.nim:400

classifyDeclared

nim
proc classifyDeclared*(rt: Il2Cpp; t: Il2CppType): FastKind

The same, from the declared type rather than from its printed name.

This is the one that works on a generic instantiation, an array or a nested type: il2cpp_class_from_il2cpp_type answers exactly, where the printed name is not always a name the resolver accepts. Prefer it wherever the type itself is in hand, which is everywhere the signature is being walked.

aowl/src/aowlspt/fast.nim:442

classifyType

nim
proc classifyType*(rt: Il2Cpp; t: string): FastKind

A parameter or return type, as a register class.

Anything not a primitive has to be decided by asking the runtime whether it is a value type, because that is the whole question: a reference is one register holding an address, and a value type of any size above eight bytes is passed by a hidden pointer, which is a different call shape entirely rather than a different register.

The lookup here is by qualified name, so a generic instantiation, an array type or a nested type whose name findClass cannot resolve still comes back fkNone and the binding is refused. classifyDeclared below takes the declared type itself and has no such problem; this overload remains for the callers that only ever have a name.

Conservative in the one direction that matters: the failure is "you must classify this yourself", not a call with the arguments in the wrong registers.

aowl/src/aowlspt/fast.nim:458

noArgs

nim
proc noArgs*(): Args

aowl/src/aowlspt/fast.nim:535

reset

nim
proc reset*(a: var Args)

aowl/src/aowlspt/fast.nim:538

addInt

nim
proc addInt*(a: var Args; v: int64)

Every integer, every bool and every enum: one general-purpose register.

aowl/src/aowlspt/fast.nim:542

addBool

nim
proc addBool*(a: var Args; v: bool)

aowl/src/aowlspt/fast.nim:548

addFloat

nim
proc addFloat*(a: var Args; v: float)

A C# float. Narrowed to 32 bits in C, where the slot's type is known.

aowl/src/aowlspt/fast.nim:551

addPtr

nim
proc addPtr*(a: var Args; v: Il2CppPtr)

A reference: an object, a string, an array. The pointer itself, not a pointer to it -- that difference is the whole reason the boxed path takes a void** and gets it wrong when a caller guesses.

aowl/src/aowlspt/fast.nim:558

argsI

nim
proc argsI*(a: int64): Args

aowl/src/aowlspt/fast.nim:567

argsF

nim
proc argsF*(a: float): Args

aowl/src/aowlspt/fast.nim:570

argsP

nim
proc argsP*(a: Il2CppPtr): Args

aowl/src/aowlspt/fast.nim:573

argsII

nim
proc argsII*(a, b: int64): Args

aowl/src/aowlspt/fast.nim:576

argsFF

nim
proc argsFF*(a, b: float): Args

aowl/src/aowlspt/fast.nim:580

bindInClass

nim
proc bindInClass*(rt: Il2Cpp; cls: Il2CppClass; owner, member: string; argKinds: openArray[FastKind]; ret: FastKind): Binding

aowl/src/aowlspt/fast.nim:613

bindMethodAs

nim
proc bindMethodAs*(rt: Il2Cpp; owner, member: string; argKinds: openArray[FastKind]; ret: FastKind): Binding

Binds a method with the signature stated rather than inferred.

The escape hatch for everything classifyType refuses: an enum parameter, a generic instantiation, an array. The mod is asserting the register classes, and it is on the mod to be right -- which is why the inferring version exists and this one is the exception.

aowl/src/aowlspt/fast.nim:616

bindInClass

nim
proc bindInClass*(rt: Il2Cpp; cls: Il2CppClass; owner, member: string; argKinds: openArray[FastKind]; ret: FastKind): Binding

The same, against a class already in hand.

Two things need this and neither can use the by-name form. Binding against the object's own class rather than the one the mod named is the difference between calling a subclass's override and silently calling the base implementation -- a bound call is non-virtual by construction, so a binding taken against EFT.Player by name calls EFT.Player's body even on a bot subclass that overrides it. And a generic instantiation such as List<Player> has no name findClass accepts, but every instance of one can hand over its class.

owner here is only used for the messages, so a caller with a class and no good name may pass whatever reads best in a log.

aowl/src/aowlspt/fast.nim:644

bindOnObject

nim
proc bindOnObject*(rt: Il2Cpp; obj: Il2CppPtr; member: string; argKinds: openArray[FastKind]; ret: FastKind): Binding

Binds against the class of a live object, walking its base chain.

This is what a mod holding an instance actually wants: the object knows its own type, including the subclass the game handed it, and including generic instantiations that cannot be named.

A NIL CHECK ON THE CLASS BELOW WOULD NOT BE A CHECK, SO THERE IS NOT ONE. objectClass is il2cpp_object_get_class, which is literally mov rax,[rcx]; ret -- it validates nothing, so a bad object yields a plausible NON-NIL number. fullName then reaches il2cpp_class_get_name, which is TOKEN-GATED and answers a uniform random NON-ZERO uint64 on gate mismatch rather than NULL. That pair is what killed the client at 10:30:54 (fact #198): every nil check on the path passed and the first dereference was 0xC0000005, inside readCString.

So the gates here are the ones that CAN fail: VirtualQuery on the object before its first word is read, VirtualQuery on the class handle before it is used, and an EMPTY NAME treated as a refusal -- readCString now returns "" and bumps gReadCStringRefusals when the pointer it was given is not readable or is not printable ASCII, which is how a random answer announces itself instead of faulting.

This narrows a certain crash to an unlikely one. It does not make a gated export safe to call, and a byte-verified static RVA remains the only route that bypasses the gate outright.

aowl/src/aowlspt/fast.nim:719

bindMethod

nim
proc bindMethod*(rt: Il2Cpp; owner, member: string; argc: int): Binding

Binds a method, inferring the signature from the runtime's own metadata.

This is the one to use. argc is needed because IL2CPP looks a method up by name and arity -- an overload set has no other discriminator here.

Everything about the signature comes from il2cpp_method_get_param and il2cpp_method_get_return_type, so the register classes are the runtime's answer rather than the mod author's memory of the C#. A type it will not classify refuses the binding; bindMethodAs is the way past that.

aowl/src/aowlspt/fast.nim:778

shaped

nim
proc shaped*(): ShapedArgs

aowl/src/aowlspt/fast.nim:881

addSlotPtr

nim
proc addSlotPtr*(a: var ShapedArgs; p: Il2CppPtr)

aowl/src/aowlspt/fast.nim:884

addSlotInt

nim
proc addSlotInt*(a: var ShapedArgs; v: int64)

aowl/src/aowlspt/fast.nim:889

addSlotFloat

nim
proc addSlotFloat*(a: var ShapedArgs; v: float)

The slot's mask bit lives on the binding, not here: a shaped call states its whole shape up front, so which slots are floats is fixed at bind time and cannot be varied per call.

aowl/src/aowlspt/fast.nim:894

bindRaw

nim
proc bindRaw*(rt: Il2Cpp; cls: Il2CppClass; owner, member: string; declaredArgc: int; slots: int; floatMask: uint32; ret: FastKind): Binding

Binds a method by its native slot shape rather than its signature.

declaredArgc finds the method (IL2CPP looks up by name and arity); slots is how many registers the call actually uses, counting a hidden return pointer and this; floatMask says which of those are floats.

The mod is asserting the convention. Getting slots wrong reads an uninitialised register as an argument -- a plausible number rather than a crash -- so this is the last resort, not the first, and every use of it should say in a comment why the shape is what it claims.

aowl/src/aowlspt/fast.nim:902

callShapedPtr

nim
proc callShapedPtr*(b: Binding; a: var ShapedArgs): Il2CppPtr

A shaped call returning a pointer -- which is what a struct return is: the address the callee wrote through, and the same address the caller passed.

aowl/src/aowlspt/fast.nim:952

callShapedVoid

nim
proc callShapedVoid*(b: Binding; a: var ShapedArgs)

aowl/src/aowlspt/fast.nim:961

callShapedFloat

nim
proc callShapedFloat*(b: Binding; a: var ShapedArgs): float

aowl/src/aowlspt/fast.nim:966

callVoid

nim
proc callVoid*(b: Binding; self: Il2CppPtr; a: var Args)

Calls, discarding whatever came back. Safe for a non-void method too -- the return register is simply not read.

aowl/src/aowlspt/fast.nim:997

callInt

nim
proc callInt*(b: Binding; self: Il2CppPtr; a: var Args): int64

Every integer return, at its full width. A method declared Int32 leaves the upper 32 bits of RAX undefined, so narrow with int32(...) if the sign matters.

aowl/src/aowlspt/fast.nim:1003

callInt32

nim
proc callInt32*(b: Binding; self: Il2CppPtr; a: var Args): int32

aowl/src/aowlspt/fast.nim:1011

callBool

nim
proc callBool*(b: Binding; self: Il2CppPtr; a: var Args): bool

A C# bool is one byte in AL and the rest of RAX is undefined, so only the low byte is looked at.

aowl/src/aowlspt/fast.nim:1014

callPtr

nim
proc callPtr*(b: Binding; self: Il2CppPtr; a: var Args): Il2CppPtr

A reference return -- an object, a string, an array.

This is a raw pointer into the managed heap and the collector may move or free it. Use it within the frame, or take a GC handle if it must outlive one; do not store it.

aowl/src/aowlspt/fast.nim:1019

callFloat

nim
proc callFloat*(b: Binding; self: Il2CppPtr; a: var Args): float

A float or double return, widened to nimony's 64-bit float.

Which of the two dispatchers is used is decided by the binding, not here: a float return leaves 32 bits in XMM0 and a double leaves 64, and reading one as the other produces a plausible number rather than an error.

aowl/src/aowlspt/fast.nim:1030

bindField

nim
proc bindField*(rt: Il2Cpp; owner, fieldName: string): FieldBinding

Binds an instance field.

A static field is refused by name, not approximated: for a static field il2cpp_field_get_offset returns an offset into the class's static data block rather than into any object, and applying an instance read to it would read some object's payload at the same offset and hand back a number. bindStaticField is the one that binds those, and the two produce different types on purpose -- see the note there.

Staticness is asked of the runtime (il2cpp_field_get_flags) wherever the runtime answers, and only inferred from the offset where it does not. The inference is a guard, not a test: the two offset ranges overlap, so a static field can sit at an offset that is perfectly plausible inside an instance -- which is precisely what the stand-in's Player::SpawnCount does, at the same offset as Player::Health.

aowl/src/aowlspt/fast.nim:1069

readInt

nim
proc readInt*(f: FieldBinding; obj: Il2CppPtr): int64

aowl/src/aowlspt/fast.nim:1143

readInt32

nim
proc readInt32*(f: FieldBinding; obj: Il2CppPtr): int32

aowl/src/aowlspt/fast.nim:1153

readBool

nim
proc readBool*(f: FieldBinding; obj: Il2CppPtr): bool

aowl/src/aowlspt/fast.nim:1156

readFloat

nim
proc readFloat*(f: FieldBinding; obj: Il2CppPtr): float

The read a hot mod actually does. One load; the widening to nimony's 64-bit float happens in C where the field's own width is known.

aowl/src/aowlspt/fast.nim:1161

readPtr

nim
proc readPtr*(f: FieldBinding; obj: Il2CppPtr): Il2CppPtr

A reference field. Same warning as callPtr: a raw managed pointer, good for this frame.

aowl/src/aowlspt/fast.nim:1171

writeInt

nim
proc writeInt*(f: FieldBinding; obj: Il2CppPtr; v: int64)

aowl/src/aowlspt/fast.nim:1178

writeBool

nim
proc writeBool*(f: FieldBinding; obj: Il2CppPtr; v: bool)

aowl/src/aowlspt/fast.nim:1187

writeFloat

nim
proc writeFloat*(f: FieldBinding; obj: Il2CppPtr; v: float)

aowl/src/aowlspt/fast.nim:1191

writePtr

nim
proc writePtr*(f: FieldBinding; obj: Il2CppPtr; v: Il2CppPtr)

Writing a reference field, through the collector's write barrier when the runtime has one.

IL2CPP's collector has to be told when an object starts referring to another one. il2cpp_gc_wbarrier_set_field is what tells it; skipping it and storing the pointer directly can get a young object collected while an old one still references it, and the crash then arrives at the next collection, far from the write and with nothing on the stack to connect the two. That is the worst failure shape this file can produce, so it is the default rather than the option.

This used to be the unbarriered store, documented as such and left to the caller to be careful about -- while writeBarrier sat bound and unused in il2cpp.nim one call site away. A hazard that is documented is still a hazard; the note told a mod author to reason about object age at every write, which is not a thing anyone can reliably do.

On a runtime with no barrier entry the store still happens, unbarriered, because the alternative is a field that silently does not get written. barrierReady is how a mod asks which of those it is getting.

aowl/src/aowlspt/fast.nim:1198

writePtrRaw

nim
proc writePtrRaw*(f: FieldBinding; obj: Il2CppPtr; v: Il2CppPtr)

The unbarriered store, for the one case that is genuinely safe: writing back a pointer the object already reachably holds -- clearing a field to nil, or restoring a value read out of the same object a moment ago.

Nothing in this repository needs it. It exists so that a mod that has measured the barrier and found it in its way can say so explicitly, instead of the default being the dangerous one for everybody.

aowl/src/aowlspt/fast.nim:1225

barrierReady

nim
proc barrierReady*(f: FieldBinding): bool

Whether writePtr on this binding goes through the collector.

aowl/src/aowlspt/fast.nim:1236

bindStaticField

nim
proc bindStaticField*(rt: Il2Cpp; owner, fieldName: string): StaticFieldBinding

Binds a static field, refusing an instance one.

Both directions are refusals rather than approximations, and both are asked of the runtime:

  • A runtime that does not export il2cpp_field_get_flags cannot say whether a field is static, so this refuses rather than assuming. The offset cannot stand in for the flag -- that is the whole point of the note above.
  • A runtime that does not export il2cpp_class_get_static_field_data cannot say where the block is, and adding an offset to nil is how a mod reads address 16.

aowl/src/aowlspt/fast.nim:1296

readInt

nim
proc readInt*(f: StaticFieldBinding): int64

No object argument, and that is the safety property rather than a convenience: an instance binding cannot reach this overload.

aowl/src/aowlspt/fast.nim:1383

readInt32

nim
proc readInt32*(f: StaticFieldBinding): int32

aowl/src/aowlspt/fast.nim:1395

readBool

nim
proc readBool*(f: StaticFieldBinding): bool

aowl/src/aowlspt/fast.nim:1398

readFloat

nim
proc readFloat*(f: StaticFieldBinding): float

aowl/src/aowlspt/fast.nim:1403

readPtr

nim
proc readPtr*(f: StaticFieldBinding): Il2CppPtr

A static reference field -- which is how a hand-rolled singleton is spelled when it is not a property. Same warning as callPtr: a raw managed pointer, good for this frame.

aowl/src/aowlspt/fast.nim:1411

writeInt

nim
proc writeInt*(f: StaticFieldBinding; v: int64)

aowl/src/aowlspt/fast.nim:1419

writeBool

nim
proc writeBool*(f: StaticFieldBinding; v: bool)

aowl/src/aowlspt/fast.nim:1428

writeFloat

nim
proc writeFloat*(f: StaticFieldBinding; v: float)

aowl/src/aowlspt/fast.nim:1432

writePtrRaw

nim
proc writePtrRaw*(f: StaticFieldBinding; v: Il2CppPtr)

Stores a reference into a static field, without a write barrier, and the name says so because there is no barriered version to reach for.

il2cpp_gc_wbarrier_set_field takes the object whose field is being written, so that the collector can mark that object's card. A static field has no such object: the storage is a GC root in its own right, scanned on every collection rather than reached through an owner. Passing the static block where an object header is expected is not a conservative choice, it is a wrong pointer handed to the collector.

So this is the honest shape: the store a static reference field needs, and a name that does not let a caller believe a barrier happened.

aowl/src/aowlspt/fast.nim:1439

aoughwl — self-hosted platform for things n stuff. Contact / Support on Discord for access to the private backends.