Skip to content

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*: bool

A 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 it

aowl/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 returns

aowl/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*: string

The 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* = 12

Total 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* = 4

Positions 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* = 16

aowl/src/aowlspt/callrva.nim:215

ArenaCellBytes

nim
  ArenaCellBytes* = 64

aowl/src/aowlspt/callrva.nim:216

Routines

rvaTarget

nim
proc rvaTarget*(name: string; rva: uint32; prologueHex: string; owners: int32 = 0): RvaTarget

Declare 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): string

Everything 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): bool

The 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): CallOutcome

Byte-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*(): int32

How 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*(): CallArgs

aowl/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): int32

An 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): int32

The 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): float

Read 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): int32

aowl/src/aowlspt/callrva.nim:588

outPtr

nim
proc outPtr*(cell: int32): Il2CppPtr

The 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): CallOutcome

aowl/src/aowlspt/callrva.nim:664

callInt

nim
proc callInt*(t: var RvaTarget; a: var CallArgs): CallOutcome

Every 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): CallOutcome

A 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): CallOutcome

aowl/src/aowlspt/callrva.nim:678

callFloat

nim
proc callFloat*(t: var RvaTarget; a: var CallArgs): CallOutcome

A 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): CallOutcome

aowl/src/aowlspt/callrva.nim:686

asPtr

nim
proc asPtr*(o: CallOutcome): Il2CppPtr

The 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): bool

aowl/src/aowlspt/callrva.nim:694

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