Nearly every Cheat Engine table starts the same way: scan for a number, narrow it down, find out what writes to it, write a hook. Mine do too, and after the next patch a good part of that starts over.
In an Unreal Engine game, that routine walks straight past the most useful thing in the process: a complete catalog of every class, every field and every offset, which the engine keeps in memory because it needs it itself.
Once you start reading it, the questions change. Why can a "decreased value" scan never find your health in Deep Rock Galactic? How can a table entry survive a game update without a single stored offset? Why does a harmless readString turn Object into Object^? And what do you do when the catalog has no answer?
A good part of last week went into exactly this, on a private runtime that reads all of it from inside a game with no SDK, no game-specific offsets and no signatures. Almost all of it also works from plain Cheat Engine, and that is what this post is about: finding the catalog by hand, a small Lua script that reads it, and above all how to get to the data you are actually looking for. Everything here comes from single-player games on x64 shipping builds.
In most engines, you find a value first and then try to work out what it means. In Unreal, you can ask for the meaning and get the address for free.
The catalog I had been ignoring#
Unreal keeps a full description of itself in memory, and it has no choice: Blueprints have to see C++ members by name, the garbage collector has to find object references, and serialization has to walk the properties of whatever it saves. All of that happens at runtime, so the metadata cannot be thrown away when a game ships.
Every UCLASS, USTRUCT, UPROPERTY and UFUNCTION survives, with its name, type, offset and size, and so does everything declared in a Blueprint. Plain C++ members without a UPROPERTY do not. They exist in memory, but the engine keeps no record of them. Keep that gap in mind, because it comes back to bite later on.
Three structures hold it all together, and each one points into the next:
GObjects ------> every UObject in the process
|
| +0x10 class ------> UClass
| +0x18 name ---+ | +0x40 parent class
| +0x20 outer | | +0x48 functions
| | | +0x50 properties ---> name, type,
| | | offset, size
| v
| FNamePool: the text of every name
Find GObjects and FNamePool, and every class, every instance and every field offset of the game is one walk away.
A name is just a number#
Unreal does not store strings for names. An FName is 8 bytes: a ComparisonIndex into a global pool, and a Number, which is the suffix you know from the editor. A stored Number of 8 on BP_Enemy_C reads as BP_Enemy_C_7.
Since UE 4.23, the pool is a static FNamePool with a lock, two counters and a table of pointers to blocks, and the index tells you where to look:
FNamePool: +0x08 CurrentBlock +0x0C CurrentByteCursor +0x10 Blocks[8192]
block = index >> 16
offset = (index & 0xFFFF) * 2
entry = Blocks[block] + offset
header = 16 bits at entry
length = header >> 6
wide = header & 1 (0 = one byte per character, 1 = two)
text = entry + 2 (length characters, no terminator)
Block 0 always starts with None, followed by ByteProperty. That small fact is how you find the whole pool.
Stellar Blade gave me the funniest failure of the week here. FNames ignore case and keep whichever spelling was registered first, and in that game the core class Struct is spelled struct. It was the only type name the runtime looked up that was spelled differently, and it was enough to stop the whole reflection pass. All lookups compare without case now.
Before 4.23, names lived in TNameEntryArray: chunks of 0x4000 pointers to entries with an index, a hash link and zero-terminated text. The runtime reads both, but the Lua below only covers 4.23 and newer.
The list of everything#
Every live UObject is registered in one global array. Since UE 4.20 it is chunked, so it can grow without moving:
object array: +0x00 Objects (table of chunk pointers) +0x10 MaxElements +0x14 NumElements
+0x18 MaxChunks +0x1C NumChunks
chunk = Objects[i / 0x10000]
item = chunk + (i % 0x10000) * 0x18
object = first 8 bytes of item
UObject: +0x10 class +0x18 name +0x20 outer
The array sits at +0x10 inside the engine global GUObjectArray, so check which of the two you are holding: an exported symbol gives you the outer struct, the scans below give you the array itself. The object layout was the same in all three games whose logs I kept, and a UE 5.6 PDB that ships with Satisfactory confirms it.
The first objects are the /Script/CoreUObject package and its classes, which is how you tell the real array from something that only looks like it.
Letting the engine explain itself#
A UClass is a UObject too, so it appears in that same list. Its base UStruct describes the members, and since UE 4.25 the properties are FField objects in a linked list. These are the numbers from the UE4 games I tested:
UStruct: +0x40 SuperStruct +0x48 Children (functions)
+0x50 ChildProperties +0x58 PropertiesSize
FProperty: +0x08 field class (its name is the type) +0x20 Next +0x28 name
+0x3C ElementSize +0x4C Offset_Internal +0x78 type-specific
Before 4.25, properties were full UObjects in the Children list, mixed in with the functions. So a table like the one above is exactly what you do not want to depend on.
The way around it is my favorite trick of the whole project. The struct Vector exists in every Unreal game and holds exactly three members named X, Y and Z. Try every plausible offset until a chain of three fields decodes to those three names, and you have just learned where the property list, the name and the next pointer live.
Then look for an integer that reads 0, 4 and 8 across the three fields: that is Offset_Internal. If it reads 0, 8 and 16, you are on UE5 with Large World Coordinates and every vector in the game holds doubles, which also tells you whether a position scan should use Float or Double.
Here is why this matters, straight from the logs. The metadata stays put, and the offsets you would actually use move as soon as a studio touches the engine:
| Crab Champions | Deep Rock Galactic | Stellar Blade | |
|---|---|---|---|
| Objects at scan time | 56,003 | 258,910 | 250,167 |
| Property list, next, name, offset | 0x50, 0x20, 0x28, 0x4C in all three | ||
Actor.RootComponent | 0x130 | 0x130 | 0x178 |
SceneComponent.RelativeLocation | 0x11C | 0x11C | 0x13C |
ComponentToWorld (not reflected) | 0x1C0 | 0x1C0 | 0x1F0 |
Doing it by hand once#
Before any script, I think this is worth doing once with nothing but the scanner. It takes a few minutes, and it keeps working after any update because it does not depend on a stored offset.
First, look at the executable. If it weighs tens or hundreds of megabytes (Crab Champions: 68 MB, Stellar Blade: 342 MB), the engine is inside it. If it is tiny and has files like <App>-CoreUObject-Win64-Shipping.dll next to it, the build is modular. Satisfactory taught me that one the hard way: its .exe is about 270 KB, and the first scans looked only at the main module and found nothing at all.
Modular builds make up for it, though. GUObjectArray, GWorld and GEngine are plain DLL exports, visible in Memory View under View → Enumerate DLL's and Symbols, and Satisfactory even ships a PDB next to every DLL.
The name pool#
- Set Value Type to Array of byte, make sure Writable is ticked, and scan for
?? ?? 4E 6F 6E 65 ?? ?? 42 79 74 65 50 72 6F 70 65 72 74 79. That isNoneandByteProperty, each behind a header you do not need to know. The result is block 0. - Start a new scan: 8 Bytes, Hex ticked, and the address of that result as the value. You are now looking for a pointer to block 0.
- The green result, shown as
game.exe+XXXXXX, isBlocks[0]. Subtract 0x10 and you haveFNamePool. As a check,+0x08should be a small block number and+0x0Ca cursor below 0x20000.
The object array#
Indexing the array compiles to the same three instructions all over the engine: load the chunk table, pick the chunk, multiply the index by the item size.
48 8B 05 ?? ?? ?? ?? mov rax,[GObjects] ; the chunk table
48 8B 0C C8 mov rcx,[rax+rcx*8] ; the chunk
48 8D 04 D1 lea rax,[rcx+rdx*8] ; the item (rdx is already i * 3)
Scan for those bytes with Executable ticked and Writable off, open any result in the disassembler, and read the address in the first instruction. Any result will do, and this table is why:
| Game | Hits | Distinct targets | Target (RVA) |
|---|---|---|---|
| Crab Champions | 435 | 1 | 0x41D19E0 |
| Deep Rock Galactic | 504 | 1 | 0x655FDA0 |
| Stellar Blade | 663 | 1 | 0x6DD7240 |
| Kletka | 295 | 1 | 0xB3DBFA0 |
| Satisfactory (CoreUObject DLL) | 0 | use the export |
Hundreds of hits and every one agrees, and on the three games with logs the address matches what the running game reported. It is hard to ask for more from a signature. The last row is the reason the runtime does not rely on one anyway. To confirm a result, read +0x14: it should hold the object count, and it should change as the game loads and frees things.
The same thing as a script#
Clicking through this once is instructive. Everything after it needs a loop. The snippets from here on add up to a single script that only reads memory. Paste them into the table script (Table → Show Cheat Table Lua Script), press Execute, and call the functions from the Lua Engine window (Memory View → Tools → Lua Engine). So far it has only run in-game on Crab Champions. On UE5 and on UE 4.23/4.24 it has only seen a test process, so treat those as untested.
Finding the roots#
A small foundation first: which address ranges belong to a module, and a wrapper around AOBScan that returns plain numbers.
-- ue.lua: read-only Unreal Engine 4/5 reflection for Cheat Engine, x64 games.
ue = {}
local OBJ_CLASS, OBJ_NAME = 0x10, 0x18
local ITEM_SIZE, PER_CHUNK = 0x18, 0x10000
local pool, objects, engine -- FNamePool, the object array, the GameEngine object
local names, types, L = {}, {}, {} -- name cache, type index, probed layout
local images = {}
local function loadImages()
images = {}
for _, m in ipairs(enumModules()) do
images[#images + 1] = { name = m.Name, from = m.Address, to = m.Address + (m.Size or getModuleSize(m.Name)) }
end
end
local function inImage(address)
for _, image in ipairs(images) do
if address >= image.from and address < image.to then return true end
end
return false
end
local function scan(aob, flags, align)
local found, list = {}, AOBScan(aob, flags, align and 1 or 0, align and tostring(align) or "")
if list then
for i = 0, list.Count - 1 do found[#found + 1] = tonumber(list[i], 16) end
list.destroy()
end
return found
end
Next, the name formula. The first version used readString with a maximum length, and on pool entries, which have no terminator, it returned one byte too many. Object came back as Object^, and ue.type("Struct") found nothing, because the names in the type index had stray bytes on the end. So the script reads exact bytes instead.
function ue.entry(index)
if names[index] then return names[index] end
if index < 0 or (index >> 16) >= 8192 then return nil end
local block = readPointer(pool + 0x10 + (index >> 16) * 8)
local entry = block and block + (index & 0xFFFF) * 2
local header = entry and readSmallInteger(entry)
if not header then return nil end
local length, wide = header >> 6, header & 1 == 1
if length == 0 or length > 1024 then return nil end
-- Entries are not zero-terminated, so read exactly what the header says.
local bytes = readBytes(entry + 2, wide and length * 2 or length, true)
if not bytes then return nil end
names[index] = wide and byteTableToWideString(bytes) or byteTableToString(bytes)
return names[index]
end
-- Reads the FName stored at an address: ComparisonIndex, then Number.
function ue.name(address)
local index, number = readInteger(address), readInteger(address + 4)
local text = index and number and ue.entry(index)
if not text then return nil end
return number > 0 and text .. "_" .. (number - 1) or text
end
Finding the pool is the manual procedure as code: find block 0, then find the static pointer to it.
local function findNamePool()
-- Block 0 starts with "None" and "ByteProperty", each behind a two-byte header.
local blockAob = "?? ?? 4E 6F 6E 65 ?? ?? 42 79 74 65 50 72 6F 70 65 72 74 79"
for _, block in ipairs(scan(blockAob, "+W")) do
local pointerAob = string.pack("<I8", block):gsub(".", function(c) return ("%02X "):format(c:byte()) end)
for _, slot in ipairs(scan(pointerAob, "+W", 8)) do
local candidate = slot - 0x10 -- Blocks[0] sits at +0x10
local current, cursor = readInteger(candidate + 8), readInteger(candidate + 0xC)
if inImage(slot) and current and cursor and current < 8192 and cursor < 0x20000 then
return candidate
end
end
end
end
The object array has no fingerprint in its bytes, but it has a shape: a pointer followed by counts that have to agree with each other. So the script walks the writable sections of the executable, and of the CoreUObject DLL on modular builds, and tests that shape at every 8-byte step. Survivors have to prove themselves: a real array's first objects decode to /Script/CoreUObject or Object.
local function objectAt(i)
local chunk = readPointer(readPointer(objects) + (i // PER_CHUNK) * 8)
return chunk and readPointer(chunk + (i % PER_CHUNK) * ITEM_SIZE)
end
-- Objects, PreAllocated, Max, Num, MaxChunks, NumChunks: could this be the array?
local function plausible(items, max, num, maxChunks, numChunks)
return items > 0x10000 and items < 0x7FFFFFFFFFFF and not inImage(items)
and num > 0x800 and max >= num and max <= 0x10000000
and numChunks >= 1 and maxChunks >= numChunks and maxChunks <= 0x1001
and numChunks * PER_CHUNK >= num and maxChunks * PER_CHUNK >= max
end
-- The first objects are the CoreUObject package and its classes.
local function isLive(address)
objects = address
for i = 0, 127 do
local object = objectAt(i)
local name = object and ue.name(object + OBJ_NAME)
if name == "/Script/CoreUObject" or name == "Object" then return true end
end
return false
end
local function scanSection(from, size)
for at = from, from + size - 0x20, 0x10000 do
local bytes = readBytes(at, math.min(0x10020, from + size - at), true)
if bytes then
local data = byteTableToString(bytes)
for pos = 1, #data - 0x1F, 8 do
local items, _, max, num, maxChunks, numChunks = string.unpack("<I8I8i4i4i4i4", data, pos)
if plausible(items, max, num, maxChunks, numChunks) and isLive(at + pos - 1) then
return at + pos - 1
end
end
end
end
end
local function findObjects()
for i, image in ipairs(images) do
-- The executable, plus CoreUObject in modular builds.
if i == 1 or image.name:find("-CoreUObject-", 1, true) then
local pe = image.from + readInteger(image.from + 0x3C)
local first = pe + 0x18 + readSmallInteger(pe + 0x14)
for s = 0, readSmallInteger(pe + 6) - 1 do
local section = first + s * 0x28
local flags = readInteger(section + 0x24)
if flags & 0x80000000 ~= 0 and flags & 0x20000000 == 0 then -- writable, not executable
local found = scanSection(image.from + readInteger(section + 0xC), readInteger(section + 8))
if found then return found end
end
end
end
end
end
The not inImage check has a story. Kletka's scan suddenly took two minutes, on the same build that had needed less than a second two weeks earlier. ASLR had loaded the image at 0x7FF700F70000. With a low dword that small, two neighboring pointers into the image pass as an old-style array: the low dword reads as the maximum, 0x7FF7 as the count. That made for 83,000 false candidates.
The array's storage is always on the heap, and rejecting anything inside a module took the scan from 119.5 seconds to 370 ms. It is an unpleasant kind of bug, because the same build can be fast on one run and slow on the next.
Learning the layout#
One pass over all objects builds an index of the types. Only objects whose class is itself a metaclass get their name decoded (Class, ScriptStruct, anything ending in GeneratedClass for Blueprints), and the keys are lower case because of Stellar Blade.
-- Calls fn(object) for every live object; stops when fn returns true.
function ue.each(fn)
local chunks, chunk = readPointer(objects), nil
for i = 0, readInteger(objects + 0x14) - 1 do
if i % PER_CHUNK == 0 then chunk = readPointer(chunks + (i // PER_CHUNK) * 8) end
local object = chunk and readPointer(chunk + (i % PER_CHUNK) * ITEM_SIZE)
if object and object ~= 0 and fn(object) then return object end
end
end
local function classOf(object) return readPointer(object + OBJ_CLASS) end
local function nameOf(object) return ue.name(object + OBJ_NAME) end
local function indexTypes()
local kinds = {}
ue.each(function(object)
local meta = classOf(object)
local kind = meta and kinds[meta]
if meta and kind == nil then
local name = (nameOf(meta) or ""):lower()
kind = name == "class" or name == "dynamicclass" or name == "scriptstruct"
or name == "userdefinedstruct" or name:sub(-14) == "generatedclass"
kinds[meta] = kind
end
if kind then
local key = (nameOf(object) or ""):lower() -- FNames ignore case
types[key] = types[key] or object
end
end)
end
function ue.type(name) return types[name:lower()] end
Then the Vector trick in full. The script never assumes whether it is looking at FField or the older UProperty chain. It finds the parent pointer first, because Struct's parent is Field, Field's parent is Object, and Class's parent is Struct.
local function probeSuper()
local struct, field = ue.type("Struct"), ue.type("Field")
for offset = 0x28, 0x80, 8 do
if readPointer(struct + offset) == field
and readPointer(field + offset) == ue.type("Object")
and readPointer(ue.type("Class") + offset) == struct then
return offset
end
end
end
-- "Vector" holds exactly X, Y and Z, at 0/4/8 (float) or 0/8/16 (double).
local function probeMembers(x, y, z)
for offset = 0x20, 0x90, 4 do
local a, b, c = readInteger(x + offset), readInteger(y + offset), readInteger(z + offset)
if a == 0 and (b == 4 or b == 8) and c == b * 2 then
L.offset, L.component = offset, b
break
end
end
for size = 0x24, L.offset - 4, 4 do -- ArrayDim (1), then ElementSize
if readInteger(x + size) == L.component and readInteger(x + size - 4) == 1 then L.size = size; break end
end
-- A UProperty is a UObject; an FField keeps a pointer to its FFieldClass in front of the name.
L.fField = not (L.fieldName == OBJ_NAME and (nameOf(classOf(x)) or ""):find("Property$"))
for class = 8, L.fieldName - 8, 8 do
local fieldClass = L.fField and readPointer(x + class)
if fieldClass and (ue.name(fieldClass) or ""):find("Property$") then L.fieldClass = class; break end
end
end
local function probeChain()
local vector = ue.type("Vector")
for list = L.super + 8, L.super + 0x48, 8 do
local x = readPointer(vector + list)
for name = 8, 0x40, 4 do
if x and ue.name(x + name) == "X" then
for nxt = 8, 0x48, 8 do
local y = readPointer(x + nxt)
local z = y and ue.name(y + name) == "Y" and readPointer(y + nxt)
if z and ue.name(z + name) == "Z" then
L.list, L.fieldName, L.next = list, name, nxt
probeMembers(x, y, z)
return true
end
end
end
end
end
end
-- Structs of known size pin down UStruct::PropertiesSize.
local function probeStructSize()
local c = L.component
local known = { Vector = 3 * c, Vector2D = 2 * c, LinearColor = 16, IntPoint = 8 }
for offset = L.list + 8, L.list + 0x40, 4 do
local ok = true
for name, size in pairs(known) do
ok = ok and ue.type(name) and readInteger(ue.type(name) + offset) == size
end
if ok then L.structSize = offset; return end
end
end
Reading properties#
Now the payoff: walking a class and its parents gives the full member list. Bitfield bools need one extra step, because many of them share a byte and the property stores the byte offset and the mask. On Stellar Blade, Actor.bCanBeDamaged is the bit 0x01 at 0x5B.
local function typeOf(field)
if L.fField then
local class = readPointer(field + L.fieldClass)
return class and ue.name(class) or ""
end
return nameOf(classOf(field)) or ""
end
-- Own and inherited properties of a class or struct, sorted by offset.
function ue.fields(struct, ownOnly)
local out = {}
while struct and struct ~= 0 do
local field, guard = readPointer(struct + L.list), 0
while field and field ~= 0 and guard < 20000 do
local kind = typeOf(field)
if kind:find("Property$") then
local f = {
name = ue.name(field + L.fieldName), type = kind, owner = nameOf(struct),
offset = readInteger(field + L.offset), size = readInteger(field + L.size), address = field,
}
if kind == "BoolProperty" and L.extra then -- FieldSize, ByteOffset, ByteMask, FieldMask
f.offset, f.mask = f.offset + readByte(field + L.extra + 1), readByte(field + L.extra + 3)
end
out[#out + 1] = f
end
field, guard = readPointer(field + L.next), guard + 1
end
struct = not ownOnly and readPointer(struct + L.super) or nil
end
table.sort(out, function(a, b)
if a.offset ~= b.offset then return a.offset < b.offset end
return (a.mask or 0) < (b.mask or 0) -- bitfield bools in bit order
end)
return out
end
function ue.field(struct, name)
name = name:lower()
for _, f in ipairs(ue.fields(struct)) do
if f.name:lower() == name then return f end
end
end
function ue.offset(typeName, name)
local f = ue.field(ue.type(typeName), name)
return f and f.offset
end
-- FObjectProperty::PropertyClass and FStructProperty::Struct share one slot behind the base.
local function probeExtra()
local root = ue.field(ue.type("Actor"), "RootComponent")
local location = ue.field(ue.type("SceneComponent"), "RelativeLocation")
for offset = (L.offset + 4 + 7) & ~7, 0x100, 8 do
if readPointer(root.address + offset) == ue.type("SceneComponent")
and readPointer(location.address + offset) == ue.type("Vector") then
L.extra = offset
return
end
end
end
local function structOf(f) return readPointer(f.address + L.extra) end
function ue.init()
loadImages()
names, types, L, engine = {}, {}, {}, nil
pool = findNamePool()
if not pool then error("FNamePool not found (engine older than UE 4.23?)") end
objects = findObjects()
if not objects then error("object array not found") end
registerSymbol("FNamePool", pool, true)
registerSymbol("GObjects", objects, true)
indexTypes()
L.super = probeSuper()
if not L.super or not probeChain() then error("reflection layout not found") end
probeStructSize()
probeExtra()
print(("FNamePool %X | GObjects %X | %d objects"):format(pool, objects, readInteger(objects + 0x14)))
print(("Super %X | Properties %X | Name %X | Next %X | Offset %X | Vectors: %s"):format(
L.super, L.list, L.fieldName, L.next, L.offset, L.component == 8 and "double" or "float"))
end
After ue.init(), the symbols FNamePool and GObjects exist in Cheat Engine, and the printed lines tell you at a glance whether the layout makes sense. This is what the first run on Crab Champions printed, and the distance between the two addresses matches the runtime's log exactly:
FNamePool 7FF6E7515680 | GObjects 7FF6E75519E0 | 49735 objects
Super 40 | Properties 50 | Name 28 | Next 20 | Offset 4C | Vectors: float
Custom engine builds can still get in the way. The runtime tries several orderings of the array fields and several item sizes, while this script only knows the stock layout. If it fails on a heavily modified engine, look there first.
So where is my health?#
This is the part I actually wanted to get to. With the catalog open, finding a value turns into a different kind of work, and I now try things in a fixed order, cheapest first.
Ask by name#
Before scanning anything, search the metadata. Game programmers name things, and those names are all there.
-- Every property whose name contains the text, across all types.
function ue.search(text)
text = text:lower()
for _, struct in pairs(types) do
for _, f in ipairs(ue.fields(struct, true)) do
if f.name:lower():find(text, 1, true) then
print(("%s.%s +%X %s"):format(nameOf(struct), f.name, f.offset, f.type))
end
end
end
end
-- Lists a class or struct like a header file.
function ue.dump(typeName)
for _, f in ipairs(ue.fields(ue.type(typeName))) do
local mask = f.mask and (" mask %02X"):format(f.mask) or ""
print(("+%04X %-24s %s%s (%s)"):format(f.offset, f.name, f.type, mask, f.owner))
end
end
local function isA(class, base)
for _ = 1, 64 do
if not class or class == 0 then return false end
if class == base then return true end
class = readPointer(class + L.super)
end
return false
end
-- Live instances of a class and its children, without the class default objects.
function ue.instances(typeName, limit)
local base, seen, out = ue.type(typeName), {}, {}
ue.each(function(object)
local class = classOf(object)
if class and seen[class] == nil then seen[class] = isA(class, base) end
if class and seen[class] and (nameOf(object) or ""):sub(1, 9) ~= "Default__" then
out[#out + 1] = object
return limit and #out >= limit
end
end)
return out
end
A search for stamina on Stellar Blade answers with lines like these (values from the runtime's export):
SBCharacterTableProperty.MaxStamina +80 IntProperty
SBCharacterTableProperty.StaminaRegenPerSecond +A8 FloatProperty
SBSaveGameData_Actor.Stamina +30 FloatProperty
Search for synonyms too: health, hp, life, damage; ammo, clip, magazine; money, gold, credits. And for a lot of what people want, the engine's stock classes already have the answer, under the same name in every game:
| You want | Look at first |
|---|---|
| Movement speed, jump, gravity | CharacterMovementComponent: MaxWalkSpeed, JumpZVelocity, GravityScale |
| Position | Actor.RootComponent, then SceneComponent.RelativeLocation (doubles on UE5) |
| Game speed | WorldSettings.TimeDilation, per actor Actor.CustomTimeDilation |
| A simple god mode | Actor.bCanBeDamaged, which works only if the game's damage code honors it |
| Camera and FOV | PlayerController.PlayerCameraManager, then CameraCachePrivate.POV |
| Health in GAS games | An attribute set: GameplayAttributeData with BaseValue and CurrentValue |
| Money, inventory, progress | PlayerState, GameInstance, and the game's own save-data classes |
Enemies work the same way. Most of them in Crab Champions are Blueprints (BP_Enemy_IceCrab_C, BP_Enemy_Slug_C and many more), but they share one parent class, so ue.instances("CrabEnemyC") finds them all. It also leaves out class default objects, named Default__...: those hold the initial values rather than a live actor, so a value you find there will not move when you get hit.
Walk from the player#
Knowing a field's name is half of it. You also need the instance, and the engine has a fixed way to get from itself to the local player:
GameEngine
.GameViewport GameViewportClient
.World World
.OwningGameInstance
.LocalPlayers[0]
.PlayerController
.AcknowledgedPawn your character
Every hop is a reflected property, so you can walk it by name. Each walk also registers its offsets as symbols, which turns out to be the most useful side effect in the whole script:
-- Follows properties by name from a live object. Returns the address of the last
-- step and an expression for CE's address list, built from registered offset symbols.
function ue.walk(object, root, ...)
local address, struct, text = object, classOf(object), root
local steps = { ... }
for i, step in ipairs(steps) do
local name, index = step:match("^(.-)%[(%d+)%]$")
local f = ue.field(struct, name or step)
if not f then error(("%s has no property %s"):format(nameOf(struct), step)) end
local symbol = (f.owner .. "_" .. f.name):gsub("[^%w_]", "_")
registerSymbol(symbol, f.offset, true)
address, text = address + f.offset, text .. "+" .. symbol
local isObject = f.type == "ObjectProperty" or f.type == "ObjectPtrProperty"
if f.type == "StructProperty" then
struct = structOf(f)
elseif f.type == "ArrayProperty" and index then -- TArray<UObject*> only
address, text = readPointer(address) + index * 8, ("[%s]+%X"):format(text, index * 8)
isObject = true
end
if isObject and i < #steps then
address, text = readPointer(address), "[" .. text .. "]"
if not address or address == 0 then return nil end
struct = classOf(address)
end
end
return address, text
end
function ue.engine()
engine = engine or ue.instances("GameEngine", 1)[1]
if engine then registerSymbol("GameEngine", engine, true) end
return engine
end
-- The way from the engine object to the local player's pawn.
ue.PAWN = { "GameViewport", "World", "OwningGameInstance", "LocalPlayers[0]", "PlayerController", "AcknowledgedPawn" }
function ue.pawnField(...)
local steps = { table.unpack(ue.PAWN) }
for _, step in ipairs{ ... } do steps[#steps + 1] = step end
return ue.walk(ue.engine(), "GameEngine", table.unpack(steps))
end
function ue.record(description, vartype, ...)
local address, text = ue.pawnField(...)
if not address then return nil end
local record = getAddressList().createMemoryRecord()
record.Description, record.Address, record.Type = description, text, vartype
return record
end
ue.pawnField("CharacterMovement", "MaxWalkSpeed") returns the live address and an expression with no number in it apart from the array index:
[[[[[[[[GameEngine+Engine_GameViewport]+GameViewportClient_World]+World_OwningGameInstance]
+GameInstance_LocalPlayers]+0]+Player_PlayerController]+PlayerController_AcknowledgedPawn]
+Character_CharacterMovement]+CharacterMovementComponent_MaxWalkSpeed
AcknowledgedPawn is declared as a plain Pawn, but that does not matter: at every step the script reads the class of the actual object, so your game's own character fields are found as well.
Or start from a value and ask who owns it#
The classic scan still has its place, for example when you do not know what a thing is called. What changes is what you do with the result. An address alone tells you nothing, but the object array can name its owner: take the closest object at or below the address and check it against the instance size.
function ue.owner(address)
local best = 0
ue.each(function(object)
if object <= address and object > best then best = object end
end)
local class = best ~= 0 and classOf(best)
if class and address < best + readInteger(class + L.structSize) then return best, class end
end
-- Names the field an address belongs to, descending into nested structs.
function ue.whois(address)
local object, struct = ue.owner(address)
if not object then return nil end
local offset, path = address - object, {}
while struct do
local hit
for _, f in ipairs(ue.fields(struct)) do
if offset >= f.offset and offset < f.offset + f.size then hit = f; break end
end
if not hit then
path[#path + 1] = ("+%X (not reflected)"):format(offset)
break
end
local bitfield = hit.mask and hit.mask ~= 0xFF
path[#path + 1], offset = hit.name .. (bitfield and " (bitfield)" or ""), offset - hit.offset
struct = hit.type == "StructProperty" and structOf(hit) or nil
end
return ("%s (%s) +%X: %s"):format(nameOf(object), nameOf(classOf(object)), address - object, table.concat(path, "."))
end
Here is a real answer from my first test, on the player character in Crab Champions:
BP_C_C_2147481653 (BP_C_C) +130: RootComponent
+130 is exactly what the logs say, so the answer is right. The name looks broken, but it is not. Unreal has a fast path for unique object names that counts down from MAX_int32 - 1000, and that start value sits exactly once in the data section of Crab Champions, Deep Rock Galactic, Stellar Blade and Kletka. This one was 993 steps in. And yes, the player's class really is called BP_C_C.
The point is that you now hold a class and a field name instead of a one-time heap address, and walking from the player gives you a path that survives the next level load. A nil usually means the value is not inside an object at all. A TArray's elements live in their own heap allocation, so inventories often land here. The runtime's in-game inspector also reads every array property of every object to find such owners, which is possible in Lua but slower.
When the number is not in memory#
Sometimes a scan fails because the number on screen does not exist. This line is from the log for Deep Rock Galactic:
Vitals of BP_DrillerCharacter_C: health = max health - damage taken
'Damage' +0x1B0 on 'HealthComponent', Max health 'MaxHealth' +0x2C8 on 'HealthComponent'.
The game does not store health. It stores damage taken, so when your health goes down, the value in memory goes up. A "decreased value" scan will never find it, and a search for health leads you to the component and MaxHealth but not to a current value. The names still get you there, as soon as you search for damage too.
Blueprint struct members have their own way of hiding, because their real names carry a suffix: Radius in a Stellar Blade struct is really Radius_2_75ADB0634054FBB0CCEC9CA9539C9340. A substring search finds it. An exact one does not.
When the engine has no answer#
This is the gap from the beginning. Reflection only knows what was declared to it. The runtime's name-based detection found no health property on any of the 21 pawn classes the Crab Champions log saw, and none on Stellar Blade's characters. SBCharacter alone has 958 reflected fields, and live health is not among them. So the catalog does not replace the scanner. It tells you where to start.
The names around the gap are often enough of a hint. SBSaveGameData_Actor has HP, Shield and Stamina as floats, and BetaGauge as an int. Save data is not the live value, but it tells you what type to scan for. My Stellar Blade table does read health as a float, at +0x11C of a structure it reaches through a hook on the code that changes it. No reflected type holds a health pair at those offsets, so a value scan and a data breakpoint were the right tools there.
Unreflected data can also be recognized by its shape. ComponentToWorld is not reflected, but on a root component with no parent the world transform equals the relative one, so you can search the component for an FTransform whose translation matches RelativeLocation. ULevel::Actors is not reflected either, and sits at 0x98 on UE 4.27 and 0xA0 on UE 5.6. It is simply the first array whose entries are mostly actors. On Crab Champions, this found 656 actors at 0x98:
-- ULevel::Actors is not reflected: take the first array whose entries are mostly actors.
function ue.actors()
local slot = ue.walk(ue.engine(), "GameEngine", "GameViewport", "World", "PersistentLevel")
local level = slot and readPointer(slot)
if not level or level == 0 then return nil end
for offset = 0x20, 0x260, 8 do
local data, count, max = readPointer(level + offset), readInteger(level + offset + 8), readInteger(level + offset + 0xC)
if data and count and max and count > 0 and count <= max and max <= 200000 then
local seen, good = 0, 0
for i = 0, math.min(count, 64) - 1 do
local actor = readPointer(data + i * 8)
if actor and actor ~= 0 then
seen = seen + 1
if isA(classOf(actor), ue.type("Actor")) then good = good + 1 end
end
end
if seen > 0 and good * 4 >= seen * 3 then return data, count, offset end
end
end
end
ue.whois helps here as well. A line ending in (not reflected) means you are inside a known object, in native data between declared fields. That beats "somewhere on the heap" by a long way.
Reading the logic#
Blueprint functions are bytecode, and that bytecode sits in memory: a TArray called Script on every function (UStruct::Script, at 0x60 on Stellar Blade), ending in the opcode EX_EndOfScript (0x53). With a disassembler you can read why something happens.
The runtime's export decoded all 4,686 Blueprint functions of Stellar Blade with no failures, and reading game logic like this is a strange pleasure. This is part of BP_LaserBreakCore_C, whose HP is an IntProperty at 0x834:
0x0022 self.HP = 0
0x0039 self.BaseMeshComp.SetVisibility(false, false)
0x005B self.BaseMeshComp.SetCollisionEnabled(0)
0x011A AddFractureParticle()
0x015B self.IsDestory = true
Yes, IsDestory. Typos ship too. The Lua script does not go this far, since a Kismet disassembler is a project of its own, but the opcodes are defined by EExprToken in the engine's Script.h if you want to try.
What this changes in a table#
Entries without offsets#
The expression from ue.pawnField goes straight into a memory record, so the table stores names instead of numbers. Create the entry once, and have the table's startup script repeat the walk: that registers the numbers behind the names fresh in every session. If a game update moves the field, the entry follows.
-- Once, to create the entry:
ue.record("Max walk speed", vtSingle, "CharacterMovement", "MaxWalkSpeed")
-- On every start of the table:
ue.init()
ue.pawnField("CharacterMovement", "MaxWalkSpeed")
Dissect Data with the engine's names#
If I had to pick one feature from this post, it would be this one. I typed the address of my pawn into Dissect Data, defined a new structure, and instead of Cheat Engine's guesses there was PrimaryActorTick.TickGroup, bHidden, bCanBeDamaged, all with their real names at the right offsets.
Cheat Engine offers two hooks for this window: one supplies a structure's name and real start, the other fills in its members. After ue.hookDissect(), "Structures → Define new structure" suggests the object's class as the name, moves the window to the object's start if you gave it an address somewhere inside, and lists the fields. ue.dissect(address) does the same in one call, without a dialog.
local VARTYPES = {
FloatProperty = vtSingle, DoubleProperty = vtDouble, BoolProperty = vtByte,
ByteProperty = vtByte, Int8Property = vtByte, EnumProperty = vtByte,
Int16Property = vtWord, UInt16Property = vtWord,
IntProperty = vtDword, UInt32Property = vtDword,
Int64Property = vtQword, UInt64Property = vtQword, NameProperty = vtQword,
}
local function fill(structure, struct, base, prefix)
for _, f in ipairs(ue.fields(struct)) do
if f.type == "StructProperty" then
fill(structure, structOf(f), base + f.offset, prefix .. f.name .. ".")
else
local e = structure.addElement()
local mask = f.mask and f.mask ~= 0xFF and (" mask %02X"):format(f.mask) or ""
e.Offset, e.Name = base + f.offset, prefix .. f.name .. " : " .. f.type .. mask
e.Vartype = VARTYPES[f.type] or vtPointer
if f.mask then e.DisplayMethod = "dtHexadecimal" end -- bools share a byte: compare it to the mask
end
end
end
-- Fills a CE structure from the class of the object at this address.
function ue.fillStructure(structure, object)
local class = classOf(object)
if not class or not isA(class, ue.type("Object")) then return false end
structure.beginUpdate()
fill(structure, class, 0, "")
structure.endUpdate()
return true
end
local dissectHooks
-- "Define new structure" now asks the engine for the object's name, start and fields.
function ue.hookDissect()
if dissectHooks then return end
dissectHooks = {
registerStructureNameLookup(function(address)
local object, class = ue.owner(address)
if object then return nameOf(class), object end
end),
registerStructureDissectOverride(ue.fillStructure),
}
end
function ue.unhookDissect()
if not dissectHooks then return end
unregisterStructureNameLookup(dissectHooks[1])
unregisterStructureDissectOverride(dissectHooks[2])
dissectHooks = nil
end
-- Opens Dissect Data on the object that contains an address, without any dialog.
function ue.dissect(address)
local object, class = ue.owner(address)
if not object then return nil end
local structure = createStructure(nameOf(class))
ue.fillStructure(structure, object)
structure.addToGlobalStructureList()
local form = createStructureForm(("%X"):format(object), "Group 1", nameOf(class))
form.Caption = "Structure dissect:" .. nameOf(class)
return form
end
A table entry is a convenient place for the activation:
{$lua}
if syntaxcheck then return end
[ENABLE]
ue.init()
ue.hookDissect()
[DISABLE]
ue.unhookDissect()
It took a few rounds to get here. My first real run had the fields, but the window still said "unnamed structure", and eight bools sharing a byte all showed the same 24. Dissect Data can only show the whole byte, so those rows are now in hex and carry their mask in the name: bHidden : BoolProperty mask 20 showing 18 means the bit is clear.
Two traps in Cheat Engine 7.6 cost time along the way:
- The address must be a string »
createStructureFormwrites a number into the address box in decimal, and the box reads its content as hex. You end up looking at the wrong memory, so format the address first. - Leave
firstunset »registerStructureNameLookuptakes an optional second parameter that puts your function ahead of Cheat Engine's own guess. With it set, the address box ended up readingCCCCCCCCCCCCCCCCand the fill-in function was never called. Without it, everything works as documented.
Hooks that survive updates#
Field offsets in Auto Assembler can come from the same source. A {$lua} block returns text that becomes part of the script, so the offset is looked up by name on every activation. The bytes below are an example, and yours come from the next step. The displacement in the signature is a wildcard, because it moves whenever the class changes.
[ENABLE]
{$lua}
if syntaxcheck then return "define(off_Damage,0)" end
return ("define(off_Damage,%X)"):format(ue.offset("HealthComponent", "Damage"))
{$asm}
// movss [rcx+disp32],xmm1 - the displacement is a wildcard, because it moves with every update
aobscanmodule(INJECT_DAMAGE,$process,F3 0F 11 89 ?? ?? ?? ??)
alloc(newmem,$100,INJECT_DAMAGE)
label(return)
registersymbol(INJECT_DAMAGE)
newmem:
// This game counts damage taken: keeping the old value means taking none.
// A filter belongs here if the instruction also writes other fields.
jmp return
INJECT_DAMAGE:
jmp newmem
nop 3
return:
[DISABLE]
INJECT_DAMAGE:
movss [rcx+off_Damage],xmm1
unregistersymbol(INJECT_DAMAGE)
dealloc(newmem)
Finding the write, and finding it again#
I have one rule for patch sites, and I learned to take it seriously on my Crab Champions table: if I cannot maintain a feature on my own, I would rather leave it out entirely. For Eternal Punishment, the most precise fix was a one-byte branch patch, and I dropped it because I would not have known how to find that spot again after an update. I took a broader patch at a place I had reached myself.
This is where the catalog helps most. With a live address from ue.pawnField, "Find out what writes to this address" takes you to the code. A site you reach from a data breakpoint on a named field passes the rule by construction, since the engine gives you the field's address and the breakpoint does the rest. A site you only know as a signature fails it the day the signature stops matching.
If the field belongs to a Blueprint, the instruction you land on is usually not game code at all. Assignments run through the script VM, so you end up in generic engine code that performs every comparable assignment in the game. Patching it blindly affects far more than one variable. Filter on the object or the offset inside your hook, or go after a native function that the Blueprint calls instead. On native UPROPERTY fields you get specific game code, as you would expect.
Online or offline?#
Reflection can also tell you what kind of session you are in. World.NetDriver is empty in a pure offline session. With a driver present, ServerConnection means you are a client, and a non-empty ClientConnections means you are hosting remote players. That makes it a one-call check before a table writes anything:
-- true = offline, false = connected or hosting remote players, nil = cannot tell.
function ue.isOffline()
local slot = ue.walk(ue.engine(), "GameEngine", "GameViewport", "World")
local world = slot and readPointer(slot)
local driver = world and world ~= 0 and readPointer(world + ue.offset("World", "NetDriver"))
if not driver then return nil end
if driver == 0 then return true end
local server = readPointer(driver + ue.offset("NetDriver", "ServerConnection"))
local clients = readInteger(driver + ue.offset("NetDriver", "ClientConnections") + 8)
return server == 0 and clients == 0
end
A warning about calling functions#
Every UFUNCTION is callable by name through UObject::ProcessEvent, which sits in the vtable. In Crab Champions it is index 68, with FunctionFlags at 0xB0. Since the index varies, the runtime finds it by what it does: the function tests FUNC_Native (0x400) and FUNC_HasOutParms (0x400000) on the same displacement.
The catch is the thread. executeCodeEx runs on a new thread of its own, while most engine functions expect the game thread. The runtime hooks ProcessEvent and makes its calls from inside that hook. Reading memory needs none of this, which is why the script here stays read-only.
The short version#
| Topic | Rule |
|---|---|
| FName | 8 bytes: index, number. Block = index >> 16, offset = (index & 0xFFFF) * 2. No terminator, case ignored. |
| Find the pool | AoB None + ByteProperty = block 0. The static pointer to it, minus 0x10, is the pool. |
| Find GObjects | 48 8B 05 ?? ?? ?? ?? 48 8B 0C C8 48 8D 04 D1, or scan data sections for the array's shape. Modular builds: exports. |
| Layout | Probe it with Vector: X, Y, Z at 0/4/8 = float, 0/8/16 = double (UE5). |
| Local player | GameEngine → GameViewport → World → OwningGameInstance → LocalPlayers[0] → PlayerController → AcknowledgedPawn. |
| Order of attack | Name search, then the chain, then a value scan plus owner lookup, then a data breakpoint. |
| Scan fails? | Maybe it is stored differently: damage instead of health, double instead of float. |
| Online or offline | World.NetDriver, then ServerConnection and ClientConnections. |
Final thoughts#
Looking back, almost everything above began with a game breaking an assumption. The engine is in the main module. A fast scan stays fast. Names are spelled the way the source spells them. Health is stored as health. Each of those felt safe right up to the moment it was not. The habit I am taking away is to let the engine confirm a guess whenever it can, by decoding a name or checking a class, because shape alone lies. While reflection was still failing on Stellar Blade, one of the fallbacks happily reported a "UWorld" that was really the class Object.
I am not going to stop scanning, and the gaps in the catalog make sure of that. But for the next Unreal table, the first thing I type will be ue.search. The offsets stop being something I maintain and become something I look up, and after all those rounds of redoing hooks after patches, that is the part I am most glad about.
The engine source is the source of truth: UObjectArray.h, UnrealNames.cpp, UObjectGlobals.cpp, Class.h, Field.h, UnrealType.h and Script.h. Cheat Engine documents its Lua API in celua.txt in the install folder.
If you want a complete SDK dump rather than understanding the mechanism, Dumper-7 and UE4SS are the established tools.