Skip to content

aowlspt_callrva.h

Source: abi/aowlspt_callrva.h — 615 lines, 20 file-scope functions.

What this header owns

Reproduced verbatim from the header's own banner comment — these notes are frequently the only written record of why the subsystem is shaped the way it is.

text
 aowlspt_callrva.h -- calling a game method AT A STATIC RVA, from a mod.

WHY THIS EXISTS
===============
Our mods can read the game and cannot call it. Every "it declines" in the
registry traces to one missing capability: there is no supported way for a
mod to invoke a method whose address we know statically.

`aowlspt_fast.h` already solves the hard half -- 189 generated cases that
cover every register shape Win64 can present -- but its only entry is
`bindMethod`, which resolves BY NAME through the runtime. And fact #145 is
that a by-NAME route is fatal the moment it is USED: calling
`CameraManager::get_Instance` by name, binding `Physics::Raycast` by name and
patching `AddActivePLayer` by name each killed the client instantly and
silently. Resolving ~70 names is harmless; using one is not.

So the call path a mod needs is: an RVA it already has, byte-verified against
the bytes that RVA is DECLARED to start with, dispatched through the shape
table that already exists.

WHAT IS MEASURED HERE AND WHAT IS ASSERTED
==========================================
The by-value aggregate convention used to be the one guess in this project.
`mods/sain/client/live.nim` said so in `shapedWhy`: it ASSERTED the Win64
rule that an aggregate not of size 1/2/4/8 passes by hidden pointer, and had
never proved it against a real callee.

It is now MEASURED, from the bytes of three unrelated methods on this build
(`tools/il2cpp_resolve.py <GameAssembly.dll> <metadata> bytes <RVA> 32`).
These are the callee's own instructions, so they are not an inference about
the convention -- they ARE the convention, as compiled:

  UnityEngine.Vector3::Dot(Vector3 lhs, Vector3 rhs) -> float   @0x5297BF0
    66 90              nop
    F3 0F 10 41 04     movss xmm0, [rcx+4]     <- lhs.y  THROUGH RCX
    F3 0F 59 42 04     mulss xmm0, [rdx+4]     <- rhs.y  THROUGH RDX
    F3 0F 10 09        movss xmm1, [rcx]       <- lhs.x
    F3 0F 59 0A        mulss xmm1, [rdx]       <- rhs.x
    F3 0F 10 51 08     movss xmm2, [rcx+8]     <- lhs.z
    F3 0F 59 52 08     mulss xmm2, [rdx+8]     <- rhs.z

  A by-value `Vector3` argument arrives as a POINTER in the ordinary
  integer-class register for its position. The callee dereferences RCX and
  RDX at +0/+4/+8. It is not a guess and it never was a coin flip; it simply
  had never been read. The same bytes independently confirm the unboxed
  Vector3 layout x@0 y@4 z@8, and that a static method has no `this`, so the
  trailing MethodInfo* here is R8.

  UnityEngine.Vector3::Cross(Vector3 lhs, Vector3 rhs) -> Vector3 @0x5297A60
    F3 0F 10 5A 04     movss xmm3, [rdx+4]     <- lhs is RDX, not RCX
    F3 0F 10 42 08     movss xmm0, [rdx+8]
    F3 41 0F 59 40 04  mulss xmm0, [r8+4]      <- rhs is R8
    F3 41 0F 59 58 08  mulss xmm3, [r8+8]

  The arguments have SHIFTED ONE REGISTER RIGHT versus `Dot`, and the only
  difference between the two signatures is that this one returns a 12-byte
  struct. So RCX is the hidden return buffer: for a static method returning
  an aggregate wider than 8 bytes the shape is
      RCX = retbuf, RDX = arg0, R8 = arg1, R9 = MethodInfo*.

  UnityEngine.Camera::WorldToScreenPoint(Vector3) -> Vector3    @0x525F940
    48 89 5C 24 08     mov [rsp+8], rbx
    57                 push rdi
    48 83 EC 40        sub rsp, 0x40
    F2 41 0F 10 00     movsd xmm0, [r8]        <- the ARGUMENT, through R8
    33 C0              xor eax, eax
    48 89 01           mov [rcx], rax          <- writes the RETBUF, RCX
    48 8B FA           mov rdi, rdx            <- `this`, RDX
    89 41 08           mov [rcx+8], eax
    48 8B D9           mov rbx, rcx
    41 8B 40 08        mov eax, [r8+8]         <- argument .z, through R8

  The INSTANCE form of the same rule, in one function:
      RCX = retbuf, RDX = this, R8 = arg0, R9 = MethodInfo*.
  Note `movsd [r8]` + `mov eax,[r8+8]` = a 12-byte read in 8+4, which is
  what a by-hidden-pointer Vector3 looks like and what a by-value one in a
  register could not possibly look like.

  UnityEngine.Physics::Raycast(Vector3 origin, Vector3 dir, float maxDist)
  -> bool                                                       @0x5328830
    48 8B DA           mov rbx, rdx            <- dir, a POINTER
    0F 29 74 24 60     movaps [rsp+0x60], xmm6
    48 8B F9           mov rdi, rcx            <- origin, a POINTER
    0F 28 F2           movaps xmm6, xmm2       <- maxDistance, XMM2

  The float lands in XMM2 -- the register for POSITION 2 -- while positions
  0 and 1 consume RCX and RDX as pointers. That is exactly the model
  `aowlspt_fast.h` already implements: register class is chosen per slot by
  a mask, and the slot INDEX picks the register in both files. Two by-value
  Vector3s in one call are therefore two pointer slots and need no new
  machinery at all -- only two buffers, which is what the arena below is.

MEASURED SINCE: 8 BYTES GOES IN THE REGISTER
--------------------------------------------
The 1/2/4/8-byte case -- the one Win64 passes IN the register rather than by
pointer -- used to be refused wholesale as unproven. The 8-byte half of it
is now measured, from `UnityEngine.Vector2::Dot(Vector2,Vector2)`@0x529BB60,
whose entire body spills RCX and RDX to the stack and reads the two floats
out of the SPILLED BYTES without ever dereferencing either register. The
bytes are quoted in full at `aowl_crva_agg_class` below, and the live check
is `Dot((1,2),(3,4)) == 11.0` exactly.

1, 2 and 4 bytes stay REFUSED. The same ABI rule covers them, and that is
precisely the extrapolation that had 8 bytes filed as unknowable while the
answer sat in a callee we could already read. Nothing needs them yet;
whatever needs one first should measure it, not inherit this sentence.

MEASURED SINCE: THE STACK, NOT JUST THE REGISTERS
-------------------------------------------------
A call was capped at five slots and a wider shape was refused rather than
spilled, which blocked every 8- and 9-slot method. `aowlspt_fast.h` now
emits stack cases up to twelve slots. Shadow-space size, stack-slot stride
and argument order were read off `UnityEngine.Matrix4x4::Ortho`@0x5294C30,
which takes three of its six floats on the stack; see the header of
`tools/gen_fast_table.py` for the three `movss` instructions that fix all
three numbers. Nothing here writes assembly: every case is a C call through
a full prototype, so the compiler owns alignment and cleanup.

SAFETY
======
Everything a live path must satisfy, satisfied here rather than in each mod:

  1. 16-byte prologue byte-verify, against a CAPTURE-ONCE SNAPSHOT and never
     against live memory, so a second feature detouring the same function
     does not make this one self-reject and blame the game build.
  2. VirtualQuery on every hop: the code page, and every argument buffer.
  3. ONE guard around the whole call, and it REFUSES TO ARM if a guard is
     already armed on this thread rather than nesting -- nesting disarms the
     outer one, which removes protection instead of adding it.
  4. No iteration that is not capped.
  5/6. The mod owns the flag; `aowl_crva_fault_count` is here so a mod can
     self-disable after N faults without inventing its own counter.
  7. The argument arena is static and thread-local: no allocation per call,
     managed or otherwise.
  8. Nothing is written anywhere except into the arena this file owns.

WHAT THIS FILE DELIBERATELY WILL NOT DO
=======================================
It will not resolve a NAME. Not one. That is fact #145 and it is also the
job of the offline symbol table being built separately -- see
`aowl_crva_target` below for the shape this consumes when that lands.

It will not DETOUR anything. Calling a shared RVA is fine (it is correct
code for the receiver passed); detouring one is a write with unbounded blast
radius, and two detours on one function overwrite each other's trampoline.
This header only ever reads the code page.

Constants

  • AOWLSPT_CALLRVA_H
  • AOWL_CRVA_AGG_INREG
  • AOWL_CRVA_AGG_POINTER
  • AOWL_CRVA_AGG_REFUSE
  • AOWL_CRVA_ARENA_BYTES
  • AOWL_CRVA_ARENA_SLOTS
  • AOWL_CRVA_BAD_ARG
  • AOWL_CRVA_DETOURED
  • AOWL_CRVA_FAULTED
  • AOWL_CRVA_GUARD_BUSY
  • AOWL_CRVA_MISMATCH
  • AOWL_CRVA_NOT_CODE
  • AOWL_CRVA_NO_MODULE
  • AOWL_CRVA_NO_SIG
  • AOWL_CRVA_OK
  • AOWL_CRVA_OUT_OF_IMAGE
  • AOWL_CRVA_SHAPE
  • AOWL_CRVA_SIG_BYTES
  • AOWL_CRVA_SNAP_ROWS
  • AOWL_CRVA_WRONG_SECTION

Functions

SignatureLine
char aowl_crva_reason(int32_t r)202
void aowl_crva_scan_sections(void)229
unsigned char aowl_crva_base(void)258
void aowl_crva_code(uint32_t rva, int32_t* why)265
int32_t aowl_crva_looks_detoured(const unsigned char* p)290
AowlCrvaSnap aowl_crva_snap_find(uint32_t rva)323
AowlCrvaSnap aowl_crva_snap_take(uint32_t rva, const unsigned char* p)332
int32_t aowl_crva_verify(AowlCrvaTarget* t)376
void aowl_crva_cell(int32_t i)419
void aowl_crva_cell_zero(int32_t i, int32_t nbytes)424
int32_t aowl_crva_agg_class(int32_t size)472
int32_t aowl_crva_agg_by_pointer(int32_t size)483
int32_t aowl_crva_agg8(const void* src, int64_t* out)492
void aowl_crva_arg_agg(int32_t i, const void* src, int32_t size)504
void aowl_crva_arg_out(int32_t i, int32_t size)521
float aowl_crva_cell_f32(int32_t i, int32_t off, int32_t* ok)528
int32_t aowl_crva_cell_i32(int32_t i, int32_t off, int32_t* ok)538
LONG CALLBACK aowl_crva_veh(PEXCEPTION_POINTERS ep)564
int32_t aowl_crva_fault_count(void)573
int32_t aowl_crva_invoke(AowlCrvaTarget* t, void* mi, int32_t nslots, uint32_t mask, const AowlFastSlot* a, int32_t retclass, /* 0=int/ptr 1=float 2=double */ int64_t* out_g, double* out_f)579

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