Appearance
aowlspt/callrva
Source: aowl/src/aowlspt/callrva.nim — 696 lines.
Calling a game method AT A STATIC RVA.
THE CAPABILITY THIS ADDS
Our mods could read the game and could not call it. aowlspt/fast binds a method BY NAME, and fact #145 is that a by-name route is fatal the moment it is USED rather than when it is resolved: calling CameraManager::get_Instance by name, binding Physics::Raycast by name and patching AddActivePLayer by name each killed the client instantly and silently. Resolving seventy names is harmless; using one is not.
This module never resolves a name. It takes an RVA the caller already has, byte-verifies the code there against the prologue the caller DECLARES, and dispatches through the same 189-case shape table fast already uses.
WHAT A MOD AUTHOR WRITES
::
import aowlspt/callrva
Declared ONCE, at module scope. The prologue hex is exactly what
python tools/il2cpp_resolve.py <GameAssembly.dll> <metadata> \
bytes 0x5328830 16
prints, and nothing else. Copy it; never type it from memory.
var gRaycast = rvaTarget( "UnityEngine.Physics::Raycast(Vector3,Vector3,float)", 0x5328830'u32, "48 89 5C 24 08 57 48 83 EC 70 80 3D 51 D9 DA 01", owners = 1)
proc lineOfSight(ox, oy, oz, dx, dy, dz, maxDist: float): CallOutcome = var a = callArgs() a.addVec3(ox, oy, oz) # by-value Vector3 -> a pointer slot a.addVec3(dx, dy, dz) # ..and a second, in its own arena cell a.addFloat(maxDist) # a float, in XMM for ITS position result = callBool(gRaycast, a)
let r = lineOfSight(...) if r.kind == coOk: use(r.b) else: log r.why # names WHY, never silently declines
THREE OUTCOMES, NEVER TWO
CallOutcome.kind is coOk / coRefused / coFaulted, and the value is only meaningful for coOk. CLAUDE.md 9b: "I could not look" is not a pass, and a call that faulted is not a call that returned zero. Nothing here folds a refusal into a plausible default -- every failure path carries a why that names the specific reason, because a feature that declines silently is the worst outcome this project produces.
AND THE TRAP THAT MATTERS MOST HERE
"The call returned without faulting" is NOT evidence the convention is right. A wrong convention returns plausible garbage; that is precisely the class of bug that has cost this project the most. Which is why the by-value aggregate rule in abi/aowlspt_callrva.h is backed by the CALLEE'S OWN INSTRUCTION BYTES from four methods on this build, and why aowlspt/callproof exists to settle it against a live client with an answer known exactly in advance rather than against a bool that cannot be wrong-looking.
The C side is abi/aowlspt_callrva.h. Compile with --passC:-I<repo>/abi.
Types
RvaTarget
nim
RvaTarget* = object
name*: string
rva*: uint32
owners*: int32
declaredSig*: string
storage*: array[64, uint8] ## the opaque `AowlCrvaTarget`
prepared*: boolA method identified by its RVA and by the bytes it is DECLARED to start with. Declare one at module scope and reuse it: verification is capture-once and the result is cached, so the cost is paid on the first call and never again.
aowl/src/aowlspt/callrva.nim:223
CallKind
nim
CallKind* = enum
coOk ## the call ran and the value is real
coRefused ## nothing was called; `why` names the reason
coFaulted ## the call took an access violation and the guard caught itaowl/src/aowlspt/callrva.nim:240
CallOutcome
nim
CallOutcome* = object
kind*: CallKind
why*: string
code*: int32 ## the AOWL_CRVA_* verdict
g*: int64 ## RAX, for integer/pointer/bool returns
f*: float ## XMM0, for float/double returnsaowl/src/aowlspt/callrva.nim:245
CallArgs
nim
CallArgs* = object
slots*: array[12, uint64]
n*: int32
mask*: uint32
cells*: int32 ## arena cells consumed so far
bad*: bool ## a staging step refused; the call must not run
badWhy*: stringThe register slots for one call, filled positionally. Nothing is inferred: slot 0 is whatever the caller says slot 0 is -- this for an instance method, the hidden return buffer for an sret call, or the first argument for a plain static one.
aowl/src/aowlspt/callrva.nim:252
Constants
MaxCallSlots
nim
MaxCallSlots* = 12Total argument POSITIONS one call can have, counting an sret buffer, this, every declared argument and the trailing MethodInfo*.
It was 5 -- what Win64 passes in registers before it starts using the stack -- and a wider shape was REFUSED rather than spilled, which blocked every 8- and 9-slot method outright. aowlspt_fast.h now emits the stack cases, so the cap is the table's width and not the register file's. It must equal AOWL_FAST_MAX_SLOTS; aowl_crva_invoke checks against the C constant, so a disagreement is a refusal and never a truncated call.
aowl/src/aowlspt/callrva.nim:200
RegisterSlots
nim
RegisterSlots* = 4Positions 0..3 get a register (RCX/RDX/R8/R9, or XMM0..3 for a float). From position 4 up an argument is an 8-byte stack slot whatever its type, which is why mask only ever describes the first four.
aowl/src/aowlspt/callrva.nim:211
MaxArenaCells
nim
MaxArenaCells* = 16aowl/src/aowlspt/callrva.nim:215
ArenaCellBytes
nim
ArenaCellBytes* = 64aowl/src/aowlspt/callrva.nim:216
Routines
rvaTarget
nim
proc rvaTarget*(name: string; rva: uint32; prologueHex: string; owners: int32 = 0): RvaTargetDeclare a call target.
prologueHex is the DECLARED prologue -- paste it from il2cpp_resolve.py ... bytes <RVA> 16, space-separated or not. It is what makes a stale RVA on a different game build a loud refusal instead of a jump into the middle of an unrelated function, so an empty string is itself refused at call time (AOWL_CRVA_NO_SIG) rather than treated as "nothing asserted".
aowl/src/aowlspt/callrva.nim:299
describe
nim
proc describe*(t: var RvaTarget): stringEverything needed to tell a zeroed target from an unloaded module from a bad address, in one line. A bare "@0x0" was ambiguous between all three and cost a whole live run to disambiguate; it never will again.
aowl/src/aowlspt/callrva.nim:349
looksZeroed
nim
proc looksZeroed*(t: RvaTarget): boolThe signature of the nimony --app:lib module-scope trap: a global initialised by a CALL is silently left zeroed, so name, rva and the declared prologue are ALL empty at once. That triple can only happen one way, and it is worth naming rather than letting it present as "no prologue was declared" -- which is true, and is not the cause.
aowl/src/aowlspt/callrva.nim:376
verify
nim
proc verify*(t: var RvaTarget): CallOutcomeByte-verify the target without calling it. Idempotent and cached. Safe to run at mod init: it only reads the code page and never patches.
aowl/src/aowlspt/callrva.nim:384
faultCount
nim
proc faultCount*(): int32How many guarded calls have faulted, process-wide. A mod self-disables off this rather than inventing its own counter.
aowl/src/aowlspt/callrva.nim:408
callArgs
nim
proc callArgs*(): CallArgsaowl/src/aowlspt/callrva.nim:417
addPtr
nim
proc addPtr*(a: var CallArgs; p: Il2CppPtr)A reference: an object, a string, an array, this. The pointer itself.
aowl/src/aowlspt/callrva.nim:427
addInt
nim
proc addInt*(a: var CallArgs; v: int64)Every integer, bool and enum: one general-purpose register.
aowl/src/aowlspt/callrva.nim:434
addBool
nim
proc addBool*(a: var CallArgs; v: bool)aowl/src/aowlspt/callrva.nim:441
addFloat
nim
proc addFloat*(a: var CallArgs; v: float)A C# float. It lands in XMM_i for its slot INDEX i -- measured on Physics::Raycast@0x5328830, whose third parameter arrives in XMM2 while slots 0 and 1 are consumed by pointers. Position picks the register; the other slots being integer-class does not shift it.
aowl/src/aowlspt/callrva.nim:444
addAggregate
nim
proc addAggregate*(a: var CallArgs; src: Il2CppPtr; size: int32)A by-value struct argument.
MEASURED on this build, from the callee's own bytes: an aggregate wider than a register arrives as a POINTER in the integer-class register for its position. Vector3::Dot@0x5297BF0 dereferences RCX and RDX at +0/+4/+8; Camera::WorldToScreenPoint@0x525F940 dereferences R8. This copies size bytes into an arena cell the call keeps alive and passes that cell.
An aggregate of EXACTLY 8 bytes goes the other way: packed INTO the integer-class register, first field in the low half. That is measured, on this build, from Vector2::Dot@0x529BB60, whose whole body spills RCX and RDX to the stack and reads its four floats out of the spilled bytes -- it never dereferences either register. So this stages no arena cell for a size of 8; it loads the eight bytes and passes them as the value.
Sizes 1, 2 and 4 are STILL REFUSED. The ABI rule that covers 8 covers them, and that extrapolation is exactly what left 8 filed as unknowable for months while the answer sat in a callee we could already read. Nothing needs them yet; whatever needs one first should measure it.
aowl/src/aowlspt/callrva.nim:460
addVec3
nim
proc addVec3*(a: var CallArgs; x, y, z: float)The common case, spelled out. Layout x@0 y@4 z@8 is not assumed -- it is read straight off Vector3::Dot@0x5297BF0, which multiplies [rcx] by [rdx], [rcx+4] by [rdx+4] and [rcx+8] by [rdx+8].
aowl/src/aowlspt/callrva.nim:506
addVec2
nim
proc addVec2*(a: var CallArgs; x, y: float)A by-value UnityEngine.Vector2. Eight bytes, so it travels PACKED IN the register rather than by pointer, and no arena cell is consumed.
Layout x@0 y@4 is not assumed -- it is read straight off Vector2::Dot@0x529BB60, which multiplies the low half of RCX by the low half of RDX and byte 4 of each by byte 4 of the other.
aowl/src/aowlspt/callrva.nim:521
addOut
nim
proc addOut*(a: var CallArgs; size: int32): int32An out/ref parameter: a zeroed arena cell the callee writes through. Returns the CELL INDEX to read back with outFloat/outInt after the call, or -1 if it refused.
Sizing an out-buffer is the caller's responsibility and it must come from instance_size, NOT from subtracting a header from boxed field offsets. The UnityEngine.AI.NavMeshHit case is the live example: boxed offsets give m_Position@0x10 .. m_Hit@0x30, which INFERS a 36-byte unboxed payload -- an inference, not a measurement. Confirm instance_size before allocating against it.
aowl/src/aowlspt/callrva.nim:536
addSret
nim
proc addSret*(a: var CallArgs; size: int32): int32The HIDDEN RETURN BUFFER for a method returning an aggregate wider than 8 bytes. It must be slot 0, before this and before every argument.
MEASURED, twice, and the two cases agree: static Vector3::Cross@0x5297A60 -- args are in RDX and R8, one register right of where Dot (same parameters, scalar return) puts them, so RCX is the buffer. instance Camera::WorldToScreenPoint@0x525F940 -- writes [rcx] and [rcx+8], reads this from RDX and the argument through R8.
So: RCX = retbuf, RDX = this-or-arg0, R8 = next, R9 = MethodInfo*. A Vector2 (8 bytes) does NOT use this shape -- it comes back packed in RAX -- so passing a size of 8 here would be the wrong call entirely and addOut refuses it for you.
aowl/src/aowlspt/callrva.nim:559
outFloat
nim
proc outFloat*(cell: int32; off: int32; ok: var bool): floatRead a float back out of an out/sret cell. ok is false when the read could not be made -- which is not the same answer as 0.0, and this never flattens the two.
aowl/src/aowlspt/callrva.nim:580
outInt
nim
proc outInt*(cell: int32; off: int32; ok: var bool): int32aowl/src/aowlspt/callrva.nim:588
outPtr
nim
proc outPtr*(cell: int32): Il2CppPtrThe address of an out cell, for a caller that wants to read it with the ordinary field helpers.
aowl/src/aowlspt/callrva.nim:593
addString
nim
proc addString*(a: var CallArgs; rt: Il2Cpp; s: string)A System.String argument, built with il2cpp_string_new.
This is a separate entry point rather than something addPtr hides because it is the one argument form that runs GAME code to produce the argument, and that call goes through the EXPORT ABI rather than through this module.
A NOTE ON WHY THAT DISTINCTION NOW MATTERS. This module used to be able to say "the export surface barely works, so RVA calls are all we have". That reason is measured false: docs/IL2CPP_EXPORTS.md (branch feat-il2cpp-export-map) shows the build is stock IL2CPP with a TOKEN-GATED export ABI -- 38 of 241 exports take an extra trailing pointer to 32 bytes and memcmp it, and on mismatch return a UNIFORM RANDOM NON-ZERO uint64 rather than NULL. That is what "a non-nil handle that kills the client on first dereference" always was. il2cpp_string_new is not in the gated set on that map, which is why this works today.
The reason to prefer an RVA call is therefore NOT that the exports are broken. It is that a direct RVA call does not go through the export ABI at all, so it is unaffected by the gates, by a nonce, or by anything else that surface does -- and that it never resolves a NAME (fact #145).
It ALLOCATES on the managed heap, so it must not be on a per-frame path (rule 7). Build the string once, keep the Il2CppString, and pass it with addPtr thereafter.
aowl/src/aowlspt/callrva.nim:602
callVoid
nim
proc callVoid*(t: var RvaTarget; a: var CallArgs): CallOutcomeaowl/src/aowlspt/callrva.nim:664
callInt
nim
proc callInt*(t: var RvaTarget; a: var CallArgs): CallOutcomeEvery integer-class return: int, enum, and any pointer or reference. RAX.
aowl/src/aowlspt/callrva.nim:667
callBool
nim
proc callBool*(t: var RvaTarget; a: var CallArgs): CallOutcomeA bool return. Only the low byte of RAX is defined, so it is masked here rather than left to a caller to remember.
aowl/src/aowlspt/callrva.nim:671
callPtr
nim
proc callPtr*(t: var RvaTarget; a: var CallArgs): CallOutcomeaowl/src/aowlspt/callrva.nim:678
callFloat
nim
proc callFloat*(t: var RvaTarget; a: var CallArgs): CallOutcomeA C# float return: 32 bits in XMM0. Not interchangeable with callDouble -- reading one as the other produces a number rather than an error.
aowl/src/aowlspt/callrva.nim:681
callDouble
nim
proc callDouble*(t: var RvaTarget; a: var CallArgs): CallOutcomeaowl/src/aowlspt/callrva.nim:686
asPtr
nim
proc asPtr*(o: CallOutcome): Il2CppPtrThe returned reference, or nil. nil for a refusal too -- always check kind first; this deliberately does not encode "refused" as a pointer.
aowl/src/aowlspt/callrva.nim:689
asBool
nim
proc asBool*(o: CallOutcome): boolaowl/src/aowlspt/callrva.nim:694

