Blog / reverse-engineering · · 11 min read · Signed KyTech Research

Reverse Engineering Apex Legends: A Season 25 Offset Walkthrough

Finding CPlayer, resolving the local player pointer, and pulling world-to-screen matrices out of a stripped Source engine binary.

Apex Legends ships as a stripped 200MB Source engine binary that keeps RTTI intact, and that single design choice is why every serious reverser starts at the same place: mining the .data section for .?AV type descriptors. This post walks through the concrete workflow for locating the classes that matter on Apex Legends Season 25 (spring 2026 build), namely CPlayer, the local player pointer, and the view-projection matrix.

The techniques are general and covered at architectural depth in our companion post on reverse engineering game binaries. Here the focus is a single title, a single build family, and the specific sequence of moves that produces working offsets.

The binary and the tools

r5apex.exe on Season 25 sits at roughly 220MB, MSVC-compiled x86-64, no PDB. Anti-cheat is EasyAntiCheat, which since Epic's 2018 acquisition ships with a kernel driver, module scanning, IAT integrity checks, and behavioral analytics on the server. None of this affects static analysis; anti-cheat only cares when the binary runs.

The workstation setup:

  • IDA Pro 9.x with the Hex-Rays x64 decompiler. Ghidra 11 works equally well if the EUR 2K budget is not there.
  • Diaphora for cross-build diffing. Two IDBs, one for the current build, one for whichever prior build's offsets are still cached.
  • x64dbg for live inspection on a burner account. Never attach on a production account.
  • The stripped r5apex.exe copy from Steam, hashed and versioned. Never analyze the running process image, because EAC may have modified it in memory.

Titanfall 2 source as ground truth

Apex is a Respawn fork of Source, and when the Titanfall 2 code base leaked in 2023 the engine ancestry became reference grade material: struct layouts, ConVar naming, entity registration paths, and CBaseEntity derived hierarchies all landed as searchable text. The community project r5reloaded is a rewritten server plus tooling built directly off that leaked baseline; its GitHub repository is the closest public thing to symbolicated Apex code available. We treat it strictly as an educational cross reference, but for that purpose it saves hours whenever a decompiled Apex function looks unfamiliar and needs a name.

RTTI recovery: finding CPlayer

The MSVC .?AV name mangling drops the class name in .data as an ASCII string. CPlayer on Apex mangles to .?AVCPlayer@@, which is grep-able:

# IDAPython snippet
import idautils, idaapi, ida_bytes
target = b".?AVCPlayer@@\x00"
for ea in idautils.Functions():
    pass  # (walked below)

for s in idautils.Strings():
    if bytes(str(s), 'ascii', errors='ignore') == target[:-1]:
        print(f"CPlayer TypeDescriptor name string at {s.ea:X}")

Take the string address, back up two pointer widths, and you are at the TypeDescriptor structure. Cross-reference the TypeDescriptor and you find every RTTICompleteObjectLocator that names CPlayer as its target. Each RTTICompleteObjectLocator sits exactly sizeof(void*) before a vtable. Step back one pointer width from the COL and you have the CPlayer vftable.

On Season 25 (build published April 2026), the CPlayer vtable resolves to .rdata + 0x143AB000 approximately, with the RTTI walker recovering it deterministically at each patch without any hardcoded offset. This is the entire point of the RTTI approach: the vtable moves every patch, but the walker finds it fresh every time.

The local player pointer

Once you have the CPlayer vtable, you can identify functions that only operate on the local player. The traditional handle is a function that reads a global for the "am I the local player" comparison. Look for a small function that:

  1. Takes this in RCX.
  2. Reads a global (a mov rax, cs:qword_XXXXXX).
  3. Compares this to the global.
  4. Returns a boolean.

That global is the local player pointer. On Season 25 it sits at approximately r5apex.exe + 0x1FBDF000 (verify per-patch), and dereferencing it inside the running process returns the CPlayer* for whoever the client's active local avatar is.

The IDA workflow: find the vtable, jump to any method that begins with mov rax, cs:... followed by cmp rcx, rax, and the referenced global is the local player. Cross-references from that global also reach every "is this the local player?" branch across the binary, which is useful for follow-on reversing.

Debug strings as function fingerprints

Respawn ships r5apex.exe with Source engine DevMsg, Warning, and Error calls left in. Every such call references a format string literal in .rdata, and those literals are the single richest source of function names in a stripped binary. Searching the strings window for "CGameMovement", "CClientState::", "CBaseCombatWeapon", "m_pWeapon", or "switchweapon" typically produces a handful of hits each, and every hit's cross reference lands inside the function the original developer named. In practice a fresh Apex build recovers several hundred function symbols in the first pass this way, with zero pattern signatures required.

The view-projection matrix

Source-family engines store the current camera VP matrix as a 4x4 float array in a fixed global that the render pipeline reads each frame. In Apex on S25, the matrix lives inside CClientState or its render adjunct. The reliable string handle is any of the following, all of which the engine references in code near the VP write:

  • "c_ViewProjMatrix" (some builds use this as a ConVar dump label)
  • "skybox_view" (referenced in the same function on many builds)
  • "r_drawworld" (adjacent function)

Grep .rdata for these, cross-reference each hit, and one of them will land in a function that writes 16 floats to a global. The base of the write is the VP matrix. In S25 it sits at approximately r5apex.exe + 0x2B0C1000 and the exact address should be re-verified after every patch through this same string-cross-reference workflow.

The matrix is row-major and directly usable in the world-to-screen formula covered in our ESP explainer:

struct ViewProj { float m[16]; };  // row-major

bool WorldToScreen(const Vec3& w, const ViewProj& vp,
                   int sw, int sh, Vec2& out)
{
    float cx = w.x*vp.m[0]  + w.y*vp.m[1]  + w.z*vp.m[2]  + vp.m[3];
    float cy = w.x*vp.m[4]  + w.y*vp.m[5]  + w.z*vp.m[6]  + vp.m[7];
    float cw = w.x*vp.m[12] + w.y*vp.m[13] + w.z*vp.m[14] + vp.m[15];
    if (cw < 0.001f) return false;
    out.x = sw*0.5f + (cx/cw)*sw*0.5f;
    out.y = sh*0.5f - (cy/cw)*sh*0.5f;
    return true;
}

Entity list

The entity list on Source-family Apex is a slotted array. Reading it requires the entity list base and the per-slot stride. The list base is another RTTI-adjacent global; the stride is 32 bytes on the current build (a pointer plus a serial number plus padding, aligned).

Iteration:

for (int i = 0; i < MAX_ENTITIES; ++i) {
    uint64_t slot = entity_list_base + i * ENTITY_STRIDE;
    uint64_t ent  = *(uint64_t*)slot;
    if (!ent) continue;
    // classify ent by reading its vtable and comparing to CPlayer vtable
}

The classification step is where CPlayer identification pays off. Every entity's first 8 bytes point to its vtable. Compare against the CPlayer vftable resolved above; matches are players, misses are props, projectiles, or scriptable entities.

Per-entity offsets that matter

Once you know an entity is a CPlayer, the useful fields are:

Field Approximate S25 offset Notes
m_iTeamNum 0x044C Team affiliation for friend/foe classification
m_iHealth 0x0328 Current health
m_iShields 0x032C Current shield value
m_vecOrigin 0x014C World-space position, 3 floats
m_vecViewAngles 0x2470 Pitch/yaw for view direction
m_lifeState 0x0530 Alive/knocked/dead
m_bZooming 0x1E88 Whether ADS is active
m_iName 0x0530 (offset lookup) Player name

Every offset above is patch-drift-sensitive and should be re-verified after each Apex update. Our internal offset database regenerates these automatically via Diaphora diff against the previous build; the human-in-the-loop step catches only the offsets whose structs meaningfully changed.

Bones and hitboxes

The bone matrix array hangs off the CPlayer studiohdr pointer. Getting to bones requires walking two levels of indirection: CPlayer -> m_pStudioHdr -> bone_matrix_array. Each bone is a 3x4 float transform matrix (48 bytes). On Apex the head bone is index 8 and the neck bone is index 6; combine with the origin offset to get world-space head positions for ESP or aim-assist targeting.

Bone offsets are stable within a game version but drift between engine updates. Never hardcode bone indices; resolve them once at cheat init by parsing the studiohdr's bone name table.

Patch-day workflow

The Apex patch cadence is roughly every three weeks during a season, with a major engine touch at each season boundary. The KyTech internal pipeline runs the following on every patch:

  1. Fetch the new r5apex.exe, hash, version-tag.
  2. Auto-analyze in IDA headless mode.
  3. Run the RTTI walker against .?AVCPlayer@@ and other class names in the offset database.
  4. Diaphora diff against the last build. Flag any function whose CFG delta exceeds threshold.
  5. Re-verify every offset in the database against the new struct layouts.
  6. Ship the updated offset table to production.

Steps 1 through 4 take about 20 minutes on our build server. Step 5 is human-in-the-loop and takes an hour if nothing important changed, longer if EA shipped a real engine refactor.

Respawn ships a full season roughly every 90 days, with in-season patches every two to three weeks that occasionally shift struct layouts but usually just reshuffle vtable order. The re-mining sprint is scheduled against that calendar: a full RTTI walk plus Diaphora diff runs the same evening a season boundary build drops, and a shorter re-verify pass runs on each in-season patch. Users see the refresh land as an auto-updater push, no client re-download required, which keeps visible downtime after a patch under one working day in the normal case.

A concrete migration: Season 24 to Season 25

The S25 build in April 2026 shifted the CPlayer vitals region by four bytes: m_iHealth moved from 0x0324 to 0x0328 and m_iShields from 0x0328 to 0x032C, consistent with a new integer field being spliced in ahead of the existing fields. Diaphora flagged the containing accessor on the first cross build pass; the CFG delta was two basic blocks, one extra mov r/m32, r32 in the setter and its matching read in the getter. IDA followed the new write to a nearby Warning format string that named the inserted field, and the resolution took a single manual pass over the affected struct. Turnaround from build fetch to shipped offset table was six hours.

What still catches you at runtime

Static offsets are the easy problem. Runtime detection is harder. Even with perfect offsets, an Apex cheat has to survive:

  • EAC's kernel-mode driver scanning for injected DLLs, RWX regions, and hooked functions.
  • EAC's user-mode integrity checks on r5apex.exe's own code sections.
  • Server-side behavioral analytics on view angles, weapon accuracy distributions, and reaction times.
  • Human review triggered by reports, which can override anything client-side.

The KyTech architecture covered in our ESP explainer sidesteps most of the client-side surface by never touching the game process from user mode. The behavioral surface is a separate battle covered in our aim internals post.

How KyTech handles this

The RTTI walk, local player identification, view matrix extraction, and entity list iteration described in this post are exactly the sequence our internal reverser runs on every Apex patch. The Season 25 offsets in the tables above are approximate at time of writing and reflect the current stable build; we do not publish them because they invalidate within days on the wrong side of an EA patch. The KyTech Apex product ships with a per-user offset table baked into each build, refreshed on every patch, and paired with our HWID spoofer (currently in beta, Apex only). See how this ships in KyTech Apex. Current build status and pricing on the purchase page.

Signed by KyTech Research

We still play these games and we still push every build in production. If something in here is wrong, and eventually something will be, ping us in Discord and we will fix it.

Enough theory. get in.

The loader is one click away. Ring-0 kernel driver, polymorphic per download, memory-only injection. Six games across VAC, EAC, Ricochet, Byfron, and Warden.

Get in ›