// writeup

Unreal Engine - Engine Chams

10 min read project: Ded By Daylight
unreal-enginereverse-engineeringenmaperception

UE 5.7 chams through a game-thread hook

This is how the chams in my Dead by Daylight script work. DBD is on UE 5.7, the script is Enma running inside Perception, and nothing about the approach is DBD-specific. If you can read and write memory in a UE game and execute a page you allocated, it ports.

No driver, no renderer hook, no DLL. It's a 136-byte x64 stub swapped into one vtable slot, and that stub lets me call UE's own UObject::ProcessEvent on the game thread from outside the process. Everything else is just asking the engine nicely.

What chams actually are on UE

Most people think of chams as "paint the enemy a flat colour and see them through walls". On UE the real knob is the material each mesh component draws with. Every UMeshComponent has an OverrideMaterials array, one slot per section. Non-null slot, the mesh draws with that material. Null, it falls back to whatever's baked into the asset.

So the plan is short:

  1. Walk every enemy's mesh components.
  2. Save what's in each material slot.
  3. Write my own material into every slot.
  4. Put the originals back on disarm, or when the actor goes away.

Step 3 is where it gets annoying.

Cooked shaders

A material in a cooked build only has shaders for the vertex factories it was cooked against. Character materials get GPUSkinVertexFactory and its variants, static mesh materials get LocalVertexFactory. The usage flags on UMaterial record which is which: bUsedWithSkeletalMesh, bUsedWithStaticMesh, bUsedWithInstancedStaticMeshes and twenty-odd more.

Ask a material to draw on a mesh it wasn't cooked for and GetMaterialWithFallback silently swaps in the engine default material. Grey checker, every time. Flipping the usage bit at runtime doesn't help. The bit describes what's already in the pak, it can't conjure shaders that were never compiled.

The way round it is bUsedAsSpecialEngineMaterial (offset 0x228, mask 0x10 on this build). A material with that set gets compiled for every vertex factory and skips the usage check entirely – in UE 5.7's Material.cpp the condition is !GetUsageByFlag(Usage) && !bUsedAsSpecialEngineMaterial. DBD has about 470 live materials. Eight have the flag, and they're the engine debug materials every UE game ships with:

/Engine/EngineDebugMaterials/WireframeMaterial.WireframeMaterial
/Engine/EngineDebugMaterials/LevelColorationUnlitMaterial.LevelColorationUnlitMaterial
/Engine/EngineDebugMaterials/LevelColorationLitMaterial.LevelColorationLitMaterial
...

Those three became the first chams modes. They're the only materials I could prove draw on every part of a character, cloth included.

Game materials work too if they happen to have skinned-mesh shaders, and a few of DBD's translucent character materials do: M_CAMPER_CUST_GLASS, M_Chrctr_Unlt (through MI_Chrctr_Unlt_BlackLight_Dissolve_Y-Axis) and M_K28_SeeThroughLocker_01 (through MI_K28_SeeThroughLocker_02). Those are the glass, flashbang and "not a clue" modes. I didn't patch anything to get them. They shipped like that.

Through walls

The other half of chams is seeing through walls, and UE has a direct switch for it: UMaterial::bDisableDepthTest (offset 0x220, mask 0x01). It's read at draw time:

// Renderer/Private/BasePassRendering.cpp (UE 5.7)
if (bDisableDepthTest) {
    DrawRenderState.SetDepthStencilState(TStaticDepthStencilState<false, CF_Always>::GetRHI());
}

Flip the bit and the material draws with CF_Always. No depth test. A few catches:

  • It only does anything on translucent materials. MaterialInterface.cpp computes the relevance bit as bIsTranslucent && bDisableDepthTest, so opaque and masked materials ignore it. Wireframe, flat and lit are all opaque, which means those modes are visible-only.
  • It also turns off occlusion culling for the mesh (SkeletalMeshSceneProxy::CanBeOccluded returns !bDisableDepthTest && …). This matters more than it sounds. Without it a character fully behind a wall gets culled before any depth test runs and you see nothing.
  • The engine only picks it up when the render state is rebuilt. Set the bit, then change the material pointer in a slot, which invalidates the proxy so the new one reads the flag. Set the bit on its own and nothing happens until something else rebuilds the proxy.

That's the entire x-ray. One bit in one UMaterial.

Colour, via dynamic material instances

The debug materials and the translucent ones all have a vector parameter the engine tints with. It's "Color", "ColorFresnel" or "Black Light :: Emission Tint Color" depending on the material. You can't set parameters on a base UMaterial, so I make a UMaterialInstanceDynamic (MID) parented to it and set them there.

That's the second game call:

UKismetMaterialLibrary::CreateDynamicMaterialInstance(
    WorldContextObject, Parent, OptionalName, CreationFlags)

The MID is what actually goes into the mesh slots, and SetVectorParameterValue(FName, FLinearColor) tints it. Glass is the odd one out because it has no colour vector. For that one I create a 4×4 UTextureRenderTarget2D with UKismetRenderingLibrary::CreateRenderTarget2D, clear it to the colour with ClearRenderTarget2D, and feed it into GlassColorTexture and Blood Dirt Emissive through SetTextureParameterValue. Bit of a hack. Works fine.

Alpha in the colour picker is brightness. The colour goes sRGB to linear, then gets multiplied by alpha / 255. The unlit modes emit the colour directly, so a pale colour at full alpha just blows out to white.

Getting onto the game thread

None of the above works from outside with WriteProcessMemory. Every call I've listed is a UFunction (UE's reflected scripting entry points) and they all go through UObject::ProcessEvent. That touches GThreadLocalScriptCallstack and allocates scratch for the FFrame. Call it from anything other than the game thread and the game dies.

So the core of the whole thing is a callgate on the game thread. Perception gives me what I need for one: a process handle with memory read/write and alloc_vm, and pages I allocate can be executed. EngineGate, ported over from my Deadlock script, sets it up:

  1. Allocate a 4 KB RWX page in the game with alloc_vm.
  2. Write the 136-byte stub into it. It has three immediate placeholders: the original function pointer, and the DATA pointer twice.
  3. Copy the local APlayerController's vtable into the same page and replace the PlayerTick slot in the copy with the stub.
  4. Point the PlayerController's vtable pointer at the copy. From then on, every PlayerTick the game makes runs my stub first.
  5. The stub checks pending_fn in DATA. If it's zero it calls the original PlayerTick and returns. If not, it calls pending_fn(obj, ufunction, parms) on the game thread, writes the result back into DATA, zeroes pending_fn, then calls the original.

From outside, a call is: write obj, UFunction and Parms into DATA, set pending_fn = &UObject::ProcessEvent, spin until done flips. On timeout it fails closed and leaves the page where it is.

ProcessEvent itself is vtable[0x4C] on any UObject, and that's held for me from UE 5.1 through 5.7. PlayerTick's address and slot get signature-scanned once at install, off the "SmoothTargetViewRotationSpeed" string it references.

Finding UFunctions

UFunctions are reflected, so given a class and a name they're somewhere in GObjects. The lookup is a filtered linear scan:

for each UObject obj:
    if OuterPrivate(obj) != targetClass: continue      # cheap pointer read
    if ClassName(obj)   != "Function":   continue       # one FName decode
    if ObjectName(obj)  == funcName:     return obj

Checking OuterPrivate first skips hundreds of thousands of FName decodes. There are nine UFunctions in total, each looked up once at install and cached for the session.

Static library functions like the KismetMaterialLibrary ones get invoked on the library class's ClassDefaultObject. Component calls like SetMaterial get invoked on the component itself.

Staging parameters

ProcessEvent wants a pointer to a Parms struct laid out to match the UFunction. I allocate 256 bytes in the game once per install and reuse it for every call:

  1. Write the Parms struct at offset 0.
  2. For FString arguments (Conv_StringToName, say) the string payload goes at 0x40, and the FString header at offset 0 is {data_ptr, num, max} pointing at it.
  3. Invoke, then read the return value from its offset in the Parms struct.

When ProcessEvent returns it destroys the function's locals but not its parameters (ScriptCore.cpp, !P->IsInContainer(Function->ParmsSize)). So the FString payload I staged survives, and the game never tries to free memory it doesn't own.

The layouts come straight from my dumper's SDK (Engine_parameters.hpp):

struct KismetMaterialLibrary_CreateDynamicMaterialInstance final {
    UObject*       WorldContextObject;  // 0x00
    UMaterialInterface* Parent;         // 0x08
    FName          OptionalName;        // 0x10 (12 bytes on DBD, see below)
    uint8          CreationFlags;       // 0x1C
    uint8          Pad_1D[3];
    UMaterialInstanceDynamic* Return;   // 0x20
};

FName is 12 bytes on DBD. Stock UE's FName is {ComparisonIndex, Number}, 8 bytes. DBD ships the case-preserving variant with an extra DisplayIndex, so every FName in every Parms struct is 12 bytes and every struct that embeds one shifts with it (MeshComponent::overlayTag, for one). I found this out by writing a UMaterial pointer 4 bytes off and watching the game die.

Parameter names like "Color" and "GlassColorTexture" are FNames too, and the engine compares them by index. You can't build one from outside because the indices live in the game's name pool, so I get the game to make them. UKismetStringLibrary::Conv_StringToName(FString) is reflected and returns an FName. I call it once per parameter name, cache the 12 bytes, and reuse them for every set call after that. Same name pool, same indices, so they match what the material has stored.

World context

Most Kismet calls take a WorldContextObject, which UE resolves to a UWorld through UEngine::GetWorldFromContextObject. Hand it a stale pointer during a level transition and the game dereferences freed memory. So I read GWorld fresh on every call and check it with IsValidUserPtr. If it looks wrong the call gets skipped and retried next tick.

Keeping the MID alive

A MID is a UObject, and UObjects live or die by GC reachability. If nothing the GC can see points at one, the next pass collects it and its GObjects slot gets reused. A MID sitting in a mesh's OverrideMaterials is reachable because that array is a UPROPERTY. A MID I only hold a raw pointer to from my own process isn't.

That makes the order of a mode change matter. Null the slots and then create the new MID, and there's a window where no slot references the old one and GC can take it mid-tick while the renderer still has it in flight. That's exactly what killed the game on 2026-10-03: PrecachePSOs read a freed material inside a SetOverlayMaterial restore and page-faulted.

The fix is just ordering. Create the new MID first, then overwrite each slot with it. The old MID only becomes unreachable once the last slot is swapped, and GC picks it up on a later pass when nothing's using it.

Restoring originals

The script saves every slot's original material the first time it sees a mesh, so disarm can put them back. The problem is the game can free those originals on its own. DBD's cosmetic material instances don't live forever, and if a component unloads and reloads a cosmetic my saved pointer is dangling. Restore a freed pointer and PrecachePSOs crashes on it, same as above.

So next to each saved pointer I keep its GObjects index. On restore I read GObjects[idx] and only write the pointer back if that slot still holds the same object. If it doesn't, I restore nullptr and the mesh falls back to its baked material. You'll see original 0x… was freed; restoring null in the console when that happens.

Finding materials in the first place

Every candidate came out of a dev-tab button that walks GObjects once and writes material_dump.txt. One entry per live UMaterial:

  /Game/Characters/Campers/Materials/M_CAMPER_CUST_GLASS.M_CAMPER_CUST_GLASS
      Surface Translucent Unlit SKEL  NODEPTH
      params: S Texture Intensity, S OpacityMaskMultControl,
              T GlassColorTexture, T Blood Dirt Emissive, …

Each entry has blend mode, shading model and domain, then a set of flags, then the parameter names a MID could set.

Flag Meaning
SPECIAL bUsedAsSpecialEngineMaterial
SKEL bUsedWithSkeletalMesh
NODEPTH bDisableDepthTest
WIRE Wireframe
2SIDED TwoSided
STENCIL bEnableStencilTest
ONCHAR seen on an enemy character during this dump

The parameter names come from every UMaterialInstance of the material rather than the material itself. A UMaterial's own parameter list isn't reflected, but any instance that overrides a parameter exposes its name.

That dump is how I picked every mode, and it'll work on any UE game. Dump the materials, filter for SKEL plus translucent plus a colour parameter, and try whatever's left.

The game fighting back

Early builds re-applied chams every 50 ms, and roughly every 5 seconds about 13 slots would log "drifted back to original". It lined up with my own UBatchMeshCommands::UpdateMaterials call on DBD's MaterialHelper. That function reads the helper's cached material list and pushes it into every mesh the helper manages. So I was writing chams in, calling UpdateMaterials, and the helper was writing the originals straight back over the top.

I dropped the batch-mesh path. Plain per-slot SetMaterial on UMeshComponent doesn't get fought, and normal play logs no drift at all.

Same hook, other calls

All of this runs through one hook, ProcessEvent on the local PlayerController via a stub in one vtable slot. Nothing about that is chams-specific. It'll call anything reflected, for example:

  • Server_LeaveGame(joiningLobby=true) on DBDPlayerController, which is what the Leave Match button sends. No validation, the server just honours it.
  • Server_SetSpectatorMode(On=true) to force spectator. Whether the server accepts it depends on the game state.
  • The Server_*Cheat* RPCs: Server_CheatSelectKiller/Survivor, Server_ForceGameObjectivesCompleted, Server_CallConsoleCmdOnServer. Each one has a server-side _Validate that probably returns false in shipping builds. I haven't tried.

The script only ships the chams.