// writeup

Unreal Engine - External Read Only Chams/Glow

17 min read
unreal-enginereverse-engineering

Chams means an enemy gets rendered as a flat fill so you can see them through walls. Done from inside the game it's a one-line material swap. Done from a separate process, with no injection and no hooks, it's a lot more work.

Every frame I have to find the enemy's skeletal mesh in memory, read where the bones are right now, skin the reference vertices against that live pose on my own GPU pipeline, and rasterise the result onto a D3D11 overlay sitting on top of the game.

The naive version of that cost 45 ms a frame. The shipped version is under 1 ms. The gap between those two numbers is the actual story.

Implementation is AngelScript inside Perceptionsource/features/visuals/chams.as for the pipeline, source/engine/offsets.as for the offsets, dumper/chams_resolver.as for the auto-resolver. Technique ports to any memory-read + overlay setup. The offsets below come from one shipped UE5.3 build – yours will differ.

The object graph

You can't go offset-hunting blind. The headers to read in UnrealEngine-5.3/ are SkinnedMeshComponent.h, SkeletalMesh.h, ReferenceSkeleton.h, SkeletalMeshRenderData.h, SkeletalMeshLODRenderData.h, PositionVertexBuffer.h, SkinWeightVertexBuffer.h, and Matrix.h / Transform.h. For the authoritative LBS implementation, read SkeletalRenderGPUSkin.cpp.

The graph I walk every frame:

APawn (enemy)
  └─ Mesh (USkeletalMeshComponent : USkinnedMeshComponent)
       ├─ SkeletalMesh  (USkeletalMesh*)                          asset shared by every instance of the model
       │    ├─ SkeletalMeshRenderData (FSkeletalMeshRenderData*)  GPU geometry. VOLATILE – LOD streaming reallocs it
       │    │    └─ LODRenderData[0] (FSkeletalMeshLODRenderData) highest detail LOD
       │    │         ├─ PositionVertexBuffer                      ref-pose vertices (component space, FVector3f)
       │    │         ├─ SkinWeightVertexBuffer                    per-vertex bone indices + weights
       │    │         ├─ MultiSizeIndexContainer                   triangle list (16- or 32-bit)
       │    │         └─ RenderSections[]                          per section: BoneMap, vertex range
       │    ├─ RefSkeleton.RawRefBoneInfo (TArray<FMeshBoneInfo>) bone names + parent indices
       │    └─ component-space bind pose (FMatrix44d[])            the big landmine, see below
       └─ ComponentSpaceTransformsArray[2]                         LIVE bone pose, double-buffered
            └─ GetCurrentReadComponentTransforms()                 TArray<FTransform> of the bones right now

Split it in half. The USkeletalMesh asset – ref vertices, triangles, skin weights, reference skeleton, bind pose – is shared across every instance of the same model. Extract it once, cache by SkeletalMesh*. The component half – live ComponentSpaceTransforms, ComponentToWorld – is per instance, per frame.

Per frame, per enemy, the actual work is upload a few KB of bone data and issue one draw call. The rest is GPU.

Finding the offsets

Two completely different problems here.

The reflected ones are easy. UE keeps a global GObjects array and a GNames pool, every UClass and UProperty is reflected, so any named field – SkinnedMeshComponent::SkeletalMesh, for example – is one reflection lookup:

UClass* c = FindClassByName("SkinnedMeshComponent");
UProperty* p = FindPropertyByName(c, "SkeletalMesh");
offset = *(int32*)(p + FProperty::Offset_Internal);   // 0x6D8

My dumper walks GObjects/GNames and emits the whole SDK plus an offsets.as by reflection. Update-agnostic for anything with a UProperty.

The non-reflected ones are the problem. FSkeletalMeshRenderData, the LOD buffers, the render sections, the bind pose – render-thread C++ structs with no UProperty reflection. Name lookup finds nothing. So I derive every offset from structural invariants by walking a live mesh. That's what dumper/chams_resolver.as does:

Offset Invariant
PositionVertexBuffer the only LOD sub-buffer whose stride is 12 (FVector3f), with a sane vertex count and finite positions under 10000u
RenderData a pointer whose [+8] is a small LOD count (1–8) and whose LOD0 contains that position buffer
RenderSections first small TArray in LOD0 (Data heap-ptr, 1 ≤ Num ≤ 64)
IndexBuffer a uint8 DataTypeSize (2 or 4) immediately followed by a heap pointer, just past RenderSections
index storage heap array with Num % 3 == 0 and Num ≥ vertexCount
SkinWeightBuffer heap array whose Num == vertexCount and NumBoneWeights an integer multiple of it
RefBoneInfo a TArray whose element[0].ParentIndex == -1 (the root) and whose first parents are in range
section BoneMap a TArray<uint16> in the section with the first two entries < 1024

Every derived offset gets cross-validated against two live meshes (two different bots must agree) before it's trusted. Anything that doesn't resolve is left at the template value rather than guessed at. The resolver pushes results back into the dumper manifest, so a normal dump rewrites the skeletal_* block of offsets.as automatically every patch. The only requirement is a live character in GObjects so a mesh exists.

For the bits structural invariants can't crack I fall back to IDA on the shipping binary plus perception-re for live memory walks. A couple of Python scripts dump regions of memory and try decoding them as different candidate struct layouts to see which produces sane numbers. That's exactly how the bind-pose decode in the next section got cracked.

The offset table

One UE5.3 build. The character mesh I worked from has 5844 vertices, 7036 triangles, 103 bones, one render section, and a 21-bone BoneMap.

skinned_mesh_component (USkinnedMeshComponent)
    skeletal_mesh        0x6D8   USkeletalMesh*
    bone_array           0x730   ComponentSpaceTransformsArray[0] : TArray<FTransform>
    bone_array_num       0x778   CurrentReadComponentTransforms (0/1 SELECT, NOT a count)

skeletal_mesh (USkeletalMesh)
    render_data          0xF0    FSkeletalMeshRenderData*       VOLATILE – re-read every cycle
    ref_bone_info        0x320   TArray<FMeshBoneInfo>          RawRefBoneInfo
    ref_bone_pose        0x4B8   TArray<FMatrix44d>             COMPONENT-space bind matrices

mesh_bone_info_elem (FMeshBoneInfo)   stride 0x18
    name                 0x00    FName
    parent_index         0x10    int32  (-1 = root)

skeletal_render_data (FSkeletalMeshRenderData)
    lod_render_data      0x00    TIndirectArray<FSkeletalMeshLODRenderData>
    num_lods             0x08    int32

skeletal_lod (FSkeletalMeshLODRenderData)
    render_sections      0x10    TArray<FSkelMeshRenderSection>
    index_datatype_size  0x20    uint8   (2 = 16-bit indices, 4 = 32-bit)
    index_buffer         0x28    FRawStaticIndexBuffer16or32Interface*
    position_data        0xE0    FVector3f*  (PositionVertexBuffer.Data)
    position_stride      0xE8    uint32  (= 12)
    position_num         0xEC    uint32  (vertex count)
    skin_weight_data     0x170   FSkinWeightDataVertexBuffer.Data
    skin_weight_num      0x178   uint32
    skin_weight_bone_weights 0x17C uint32 (= num × max_influences)

skel_render_section (FSkelMeshRenderSection)
    bone_map             0x38    TArray<uint16>
    num_vertices         0x48    uint32
    max_bone_influences  0x4C    int32  (= 4)

skel_index_buffer (FRawStaticIndexBuffer16or32)
    data                 0x28    uint16[] / uint32[]
    num                  0x30    int32

skin_weight (FSkinWeightDataVertexBuffer per-vertex packing)
    stride 0x8 : [idx0 idx1 idx2 idx3][w0 w1 w2 w3]   uint8s, weights sum to 255
    indices are SECTION-LOCAL, they index skel_render_section.bone_map

scene_component (USceneComponent)
    component_to_world   0x2D0   FTransform (double precision)   component → world

render_data at 0xF0 is volatile. LOD streaming reallocs the FSkeletalMeshRenderData pointer at runtime so caching the LOD pointer across frames will deref a freed allocation. I lost a couple of hours to that and to an off-by-one where I first read render_data at 0xF8 instead of 0xF0. The resolver scans a small window around the hint and validates structurally, never trusting the literal.

The big landmine: FMatrix44d, not FTransform

This is the most important thing in the writeup.

UE's FReferenceSkeleton exposes RawRefBonePose and FinalRefBonePose as TArray<FTransform> – local, parent-relative bind transforms. An FTransform in UE5 is 0x60 bytes: Rotation (FQuat, 4×f64) at 0x00, Translation (FVector, 3×f64) at 0x20, Scale3D at 0x40.

I read ref_bone_pose as that FTransform array. Stride 0x60. The result was an exploded, smeared mess – vertices flung across the map in long stray spikes. A decode script printing the same memory at both 0x60 and 0x80 strides showed the tell – period-4 aliasing. Reading 0x60-strided structs out of a 0x80-strided array makes every fourth bone's rotation line up with the previous bone's data.

The array at ref_bone_pose (0x4B8) is actually a TArray<FMatrix44d> – the component-space bind pose as 4×4 double matrices, stride 0x80 (16 doubles per matrix). Row-major, row-vector convention:

[ m00 m01 m02 m03 ]   row 0
[ m10 m11 m12 m13 ]   row 1
[ m20 m21 m22 m23 ]   row 2
[ Tx  Ty  Tz  m33 ]   row 3   ← translation in row 3, i.e. at byte +0x60

Bone b's bind matrix is 16 doubles at ref_bone_pose.Data + b*0x80, with the component-space translation at +0x60. I read all 16, build the 4×4, and invert it (affine inverse) to get the inverse-bind matrix used for skinning.

Why component-space matrices instead of the local FTransforms the source code exposes? Because the live pose I read every frame is also component-space, so skinning is a direct matrix multiply with no parent-chain accumulation. Verification: invBind · bind should be identity, and refVert · invBind[dominantBone] should be a small bone-local offset. If it's huge the matrix is wrong.

The dumper's structural resolver originally derived a FTransform bind array – it probes for Scale3D.X ≈ 1.0 at +0x40. That predates this discovery. The representation the runtime actually uses is the FMatrix44d at 0x4B8. If you port this, read it as a matrix.

Live bone pose

USkinnedMeshComponent holds two component-space transform arrays (ComponentSpaceTransformsArray[2]) and an index telling you which is the current read buffer.

bone_array (0x730) is the base of slot 0. Slot 1 is at +0x10. Each slot is a TArray<FTransform> with Data at +0 and Num at +0x8.

bone_array_num (0x778) is a misnomer. It's CurrentReadComponentTransforms, the 0/1 select index, not a count.

sel       = ru32(mesh + 0x778)            // 0 or 1
slot      = mesh + 0x730 + 16*sel         // pick the live slot
boneData  = ru64(slot)                    // TArray.Data
boneCount = r32(slot + 0x8)               // TArray.Num  (103 for the test bot)
// per bone, stride 0x60:
//   quat  (4×f64) @ boneData + i*0x60 + 0x00
//   pos   (3×f64) @ boneData + i*0x60 + 0x20    (component space)

read_ftransform() in engine/ue/bones.as reads rotation, translation, and scale. The chams worker only needs quat and position per bone.

ComponentToWorld is an FTransform at scene_component::component_to_world (0x2D0). Read it, convert to a 4×4 matrix with to_matrix_with_scale, and that lifts component-space verts into world space.

Skinning math

Linear blend skinning, row-vector convention:

worldVertex = ( Σ_i  w_i · ( bonePos_i + qrot( boneQuat_i, off_i ) ) ) · ComponentToWorld

off_i = refVertex · invBind[ bone_i ]               // ref vertex in bone i's local space
w_i   = the vertex's weight for bone i              // weights sum to 1
bonePos_i, boneQuat_i = live component-space pose of bone i
qrot(q,v) = v + 2·q.w·(q.xyz × v) + 2·(q.xyz × (q.xyz × v))   // unit-quat rotate

Inside-out:

  1. off_i = refVertex · invBind[bone_i] pushes the reference vertex into bone-local space. Constant per mesh, computed once at extraction.
  2. bonePos_i + qrot(boneQuat_i, off_i) applies the bone's current component-space transform. Per frame on the GPU.
  3. Weighted sum over the influences gives the component-space skinned vertex.
  4. · ComponentToWorld lifts to world space.
  5. Then world → clip via the view-projection matrix built from the live camera.

Row-vector / row-major throughout, which is UE convention: p' = p · M. Get this backwards and the model mirrors.

Building the view-projection matrix

clip = world · VP. I rebuild VP from the camera every frame in chams_build_vp. Inputs are the engine's view-matrix basis, the camera position, and the FOV.

right   = viewMat rows[4..6]        engine view-matrix basis
up      = viewMat rows[8..10]
forward = viewMat rows[0..2]
sx = 1 / tan(hFOV / 2)              horizontal cotangent
sy = sx · (screenW / screenH)       vertical cotangent

View matrix V (world → view, row-vector, translation folded in as −dot(axis, cam)):

| right.x   up.x   fwd.x    0 |
| right.y   up.y   fwd.y    0 |
| right.z   up.z   fwd.z    0 |
| −(R·c)   −(U·c)  −(F·c)   1 |     R·c = dot(right, camPos), etc.

Projection P (view → clip, near 1, far 500000):

| sx   0    0                0 |
| 0    sy   0                0 |
| 0    0    zf/(zf−zn)        1 |   ← P[2][3] = 1, so clip.w = view-space forward depth
| 0    0    −zn·zf/(zf−zn)    0 |

VP = V · P, stored row-major into a 16-float constant buffer.

The subtle bit: I store row-major (VP[i][j] at index i*4+j), but HLSL loads a float4x4 from raw bytes column-major (each register is a column), so the shader's matrix is VPᵀ. Then mul(vp, float4(world,1)) computes VPᵀ · world == world · VP, which is the row-vector multiply I want. Net rule: store row-major, write mul(vp, v). Get the convention wrong and the model comes out transposed.

Because P[2][3] = 1, clip.w equals the view-space forward distance. The occlusion split reuses this as linear depth (clip.w / far), so chams and the collider depth-map share one depth space.

Bone weights – the A-pose trap

The first skinning attempt bound each vertex to its single nearest bind bone, by distance to the bone's bind translation. On a T-pose that's fine. On an A-pose – arms down at the sides, hands beside the thighs, common for game characters – it breaks badly.

A thigh vertex's nearest bone can be the hand bone, because in A-pose the hand sits right next to the thigh. The moment the character raised an arm in-game, those mis-bound thigh verts flew up to the hand, dragging long stray triangle spikes from thigh to arm. Distance-to-bone is fundamentally wrong for A-pose meshes.

A two-nearest-bone, distance-weighted blend smooths joints and kills cracks but still suffers the same mis-binding for the spike. The only correct fix is the game's real per-vertex skin weights.

The SkinWeightVertexBuffer packs 8 bytes per vertex: four uint8 bone indices, then four uint8 weights. Stride 0x8, max 4 influences, weights sum to 255. The indices are section-local – they index the render section's BoneMap (TArray<uint16>), which maps section-local to real skeleton-bone index.

per vertex v at  skin_weight_data + v*8 :
    idx[0..3] = bytes 0..3         into RenderSection.BoneMap
    w[0..3]   = bytes 4..7         uint8, Σ = 255
skeletonBone_k = BoneMap[ idx[k] ]

The shipped extractor reads the section's BoneMap once. For each vertex it reads the 8 weight bytes (one ru64, unpacked), picks the top two by weight, maps them through BoneMap to skeleton bones, renormalises the two weights, and computes off_1 and off_2. Two influences capture over 95% of the weight and look flawless in practice. The distance heuristic stays as a fallback for the case where the section/weight buffers can't be read.

That's what made the chams sit tight on the mesh in every pose. The thigh verts are now bound to the thigh bone, not the A-pose-adjacent hand bone, and the spike vanishes.

The test character above is a single render section so every vert uses BoneMap[0]. Other characters can be multi-section – body, head, accessories, each their own section with its own BoneMap and vertex range. Mapping those correctly needs the FSkelMeshRenderSection stride to walk each section's range. I derive it live before trusting multi-section weights.

The GPU pipeline – skin once, upload bones per frame

The first version CPU-skinned every vertex and re-uploaded the whole skinned mesh every frame. For about ten enemies at 21k verts each, 28 bytes per vert, that's 5.5 MB per frame of GPU upload on the render thread. The result was 45 ms a frame and a visibly lagging camera.

The fix is how a real GPU-skinned cheat and UE itself do it. Upload the static skin data once. Per frame upload only the tiny bone array. Let the vertex shader do the skinning.

Two structured buffers and a dummy mesh:

  • skinBuf is uploaded once per mesh, one entry per vertex, 48 bytes: float4 o1 (bone-local offset 1, bone index in .w), float4 o2 (offset 2, bone index 2), float4 wt (the two weights). This is the baked output of the skinning math step 1.
  • boneBuf is uploaded every frame, one entry per bone, 32 bytes: float4 pos + float4 quat from the live pose.
  • The dummy mesh, via load_mesh_mem, exists only to give the draw the right vertex count and topology. The vertex shader ignores its positions and skins via SV_VertexID into skinBuf.

There's a subtle trap with the SV_VertexID indexing. The VS reads skinBuf[SV_VertexID], so the dummy mesh's vertex order must match skinBuf 1:1. If load_mesh_mem deduplicates or reorders vertices, the mapping breaks. So I de-index the mesh – one unique vertex per triangle corner – and give each dummy vertex a unique x ("v 0 0 0", "v 1 0 0", ...) so the loader can't merge them. skinBuf is built in the same de-indexed order and SV_VertexID maps 1:1.

The vertex shader core, 2-influence LBS, component → world → clip:

SkinVert sv = skinBuf[vid];                          // vid = SV_VertexID
BoneData b1 = boneBuf[uint(sv.o1.w)];
BoneData b2 = boneBuf[uint(sv.o2.w)];
float3 p1 = b1.pos.xyz + qrot(b1.quat, sv.o1.xyz);   // bone 1 transform
float3 p2 = b2.pos.xyz + qrot(b2.quat, sv.o2.xyz);   // bone 2 transform
float3 cp = sv.wt.x*p1 + sv.wt.y*p2;                 // blend in component space
float3 wp;                                            // component → world via c2w rows
wp.x = cp.x*c2w0.x + cp.y*c2w1.x + cp.z*c2w2.x + c2w3.x;
// ... y, z similar
o.pos = mul(vp, float4(wp,1));                       // world → clip

Per frame, per enemy: update the bone structured buffer (a few KB), bind the two structured buffers, pack the constant buffer (VP, ComponentToWorld rows, colours, time, screen size, occlusion params), call draw_mesh. The 5.5 MB skin data is gone. Under 2 ms. Camera smooth.

The render-state gotcha that ate an afternoon

After the GPU pipeline compiled and the worker happily reported draw=10, ten enemies, mesh built, nothing appeared on screen. The geometry was computed and submitted and never written.

draw_mesh to the overlay backbuffer runs with the default depth-stencil state. Depth testing is on. There's no meaningful depth buffer behind the overlay so every triangle failed the test and got discarded.

The fix, confirmed against the render-API's own draw_mesh-to-screen examples:

custom_set_rasterizer_state(CULL_NONE, FILL_SOLID)        // see the whole silhouette regardless of winding
custom_set_depth_stencil_state(depth_enable=false, ...)   // CMP_ALWAYS, no depth test
... draw_mesh per enemy ...
custom_set_rasterizer_state(0)
custom_set_depth_stencil_state(0)
custom_restore_state()

gpu_wireframe never hit this because custom_draw doesn't default to depth-testing the way draw_mesh does. Easy to forget.

"Works only after reload"

Chams worked after an unload/reload but never on a cold load. chams_init() ran in on_load, before the overlay's render device was up. Every GPU resource created at that point – shaders, buffers, blend/raster/depth states – was invalid until a reload re-ran init over the now-live device.

Pattern that fixes it: chams_init() creates only CPU state (mutexes, buffers). All GPU resources are created lazily on the render thread the first time chams_render() runs, with per-handle == 0 guards so it retries each frame until it succeeds and logs a failure at most once. The worker gates on a CPU-ready flag (memory reads, CPU extraction). The render gates on the GPU-ready flag.

Threading and the perf numbers

Perception runs all script callbacks on a shared context. A stalling callback blocks everything else. So the heavy work splits across two threads:

The worker thread runs at about 30 Hz. Pure memory reads and CPU. It resolves the LOD chain, extracts each mesh once (verts, indices, skin weights into baked skinBuf bytes plus the dummy OBJ, cached by SkeletalMesh*), and each tick reads every visible enemy's live bone pose and ComponentToWorld into a double-buffered snapshot. It never touches the GPU or the menu – it reads thread-safe Settings:: only.

The render thread lazily builds GPU resources, then per enemy uploads the bone buffer and constant buffer and calls draw_mesh. Tiny.

The handoff between them is a lock-free double buffer. Worker writes buffer A while render reads B, then they swap under a brief mutex.

The original CPU-skinning path uploaded 5.5 MB a frame on the render thread for ten enemies at 21k verts. 45 ms per frame, visible camera lag. GPU skinning uploads only the bone buffer (a few KB per enemy per frame) and the 5.5 MB ships once. Under 2 ms.

A couple of other things bit me here. Perception's dictionary doesn't reliably ref-count stored script-object handles. Caching ch_mesh_t@ in a dictionary gave back a dangling handle and a use-after-free a few extraction cycles later. The fix is parallel array<uint64> and array<ch_mesh_t@> – script arrays ref-count their elements correctly. And the classic AngelScript handle gotcha: reassigning a handle needs @cm = .... Plain cm = ... is a value-assign that derefs a null handle.

The bloom glow

A second style. A real RT-based bloom so the enemy gets a glowing outline that extends outside the silhouette, not a fill.

Mask pass first. Render the skinned silhouette in the glow colour into a full-res render target, opaque. Full-res because the body-cutout in the composite step has to be crisp.

Then blur. Separable Gaussian, 13 taps, two iterations, ping-ponging two half-res RTs. Lower res because that's cheap wide spread. Half-res keeps thin features – arms, fingers – from eroding and the halo from peaking off-body.

Composite as an additive fullscreen pass. halo = blurred.rgb · intensity · (1 − sharpMask.a). The (1 − sharpMask) subtracts the body so only the outer glow survives. Output alpha is 0 under additive blend so the overlay's alpha channel isn't corrupted. Write alpha = 1 over the whole screen and you black out the rest of the overlay.

Two RT rules that matter. A render target can't be the active RTV and a sampled SRV at once, so custom_reset_render_target() or bind a different RT before custom_bind_rt_as_texture() on the same RT. Ping-pong so source isn't destination. custom_restore_state() must be the last call so the menu and ESP draw normally after.

Per-pixel occlusion split

Splitting each enemy exactly where a wall covers them – visible colour where you can see them, occluded colour where you can't, per pixel.

Depth pass every frame. Render the world's collider geometry – the cheat's collider_cache boxes and convexes near the camera, same tessellation the wireframe uses – into a depth RT, writing linear view depth (clip.w / far) to R, with depth-stencil CMP_LESS so the nearest collider wins per pixel.

Bind that RT as a texture (t2) for the chams draws. In the chams pixel shader, compute each pixel's own linear depth and sample the collider depth at the same screen UV (SV_POSITION.xy / screenSize). If the collider is meaningfully closer (collider < pixel − bias) the pixel is occluded, so use the occluded colour. Otherwise the visible colour. lerp(visColor, invColor, occluded).

The player vertex and the colliders normalise depth by the same CH_OCC_FAR, which has to match in the chams VS, the occlusion VS, and both constant buffers. 8-bit RT precision means the per-step quantisation is far/256. Pick far so that's below the bias or you get edge shimmer.

The occlusion master toggle is a feature flag. On means draw enemies through walls with the split. Off means only-visible – occluded enemies are culled entirely.

Where it lives

source/features/visuals/chams.as     extract, skin, GPU, bloom, occlusion
source/engine/offsets.as             the offset table
source/engine/ue/bones.as            read_ftransform, to_matrix_with_scale
dumper/chams_resolver.as             structural auto-derivation of the non-reflected offsets
source/config/config.as              Settings:: bridge (menu → worker-safe snapshot)
source/gui/players.as                menu (chams / glow / occlusion + per-feature colours)

The object graph and the algorithm port across UE versions unchanged. Only byte offsets and a couple of type widths shift. UE4 halves everything – FTransform becomes 0x30, the bind matrix becomes FMatrix44f 0x40, the live bone pose becomes single-precision. UE5's Large World Coordinates are why bind pose is the 0x80 FMatrix44d here. The structural invariants in section 3 still apply on any build, and the invBind · bind ≈ identity check still tells you whether you've got the right layout. Skin-weight indices can be 16-bit if a mesh has over 256 bones, and MaxBoneInfluences can be 8 instead of 4 – read both from the render section before assuming.