Skip to content

Schema Fields

Needs the internal running and needs_internal = true in script_info. Without it every call here returns nil.

obj.m_FieldName            -> number | boolean | string | vec3 | raw_struct | raw_array | nil
obj.m_FieldName = value    -- queued write, scalars only

Read any field the game declares on an entity, by the name the game uses. Fields on base classes work too.

lua
script_info = {
    name           = "my_script",
    needs_internal = true,
}

function on_tick()
    local me = local_player()
    if not me or not me:is_alive() then return end
    if not internal.is_loaded() then return end
    local hp = me.m_iHealth
    if hp then draw.text(40, 300, tostring(hp), 0xFFFFFFFF) end
end

Methods win over field names, so me:get_health() is unchanged by me.m_iHealth existing. The cheaper external calls still work as before: me:get_health(), me:get_position(), me:is_alive(), entities.by_class, draw.*, memory.read.

The first read is always nil

Asking for a name registers it. The value arrives a moment later. So read the fields you want every tick and use them once they are not nil. Reading a field on one tick only always gives nil.

lua
-- wrong: reads once, gets nil, gives up
if me.m_iHealth then do_something() end

-- right: read every tick, act when the value arrives
local hp = me.m_iHealth
if hp then do_something(hp) end

Only names starting with m_ are looked up, so a typo cannot use up a slot.

Types

Declared typeLua value
boolboolean
int8 to int64, uint8 to uint64, enumsnumber
float32, float64number
Vector, VectorWS, QAngle, Vector2Dvec3
CHandle<...>number, the raw handle
CUtlString, CUtlSymbolLargestring
CUtlVector<...>raw_array
Embedded class or pointerraw_struct
Bitfieldsnil, they cannot be read

The field has to be on that class

m_vecAbsOrigin is declared on CGameSceneNode, not on the pawn. So me.m_vecAbsOrigin is nil, and the position is me.m_pGameSceneNode.m_vecAbsOrigin.

Returns nil when

  • the internal is not running, or needs_internal is missing
  • it is the first read of that name on that entity
  • the class has no such field
  • the name is 64 characters or longer, logged once
  • 128 fields are already live, logged once
  • the field is a bitfield

Nested objects

A pointer or embedded class gives you a view you can read fields off.

lua
local node = me.m_pGameSceneNode
if node then
    local pos = node.m_vecAbsOrigin
    print(node:is_valid())
end

A view only lasts one tick. The address it holds can belong to something else on the next tick, so reading an old view raises an error. Read the field again each tick instead of keeping the view. tostring() and :is_valid() still work on an old view, so you can check one.

Lists

A CUtlVector field gives you a list view.

lua
local mp = me.m_pModifierProp
if mp then
    local mods = mp.m_vecModifiers
    print(#mods, mods.count)
    print(mods.elem_size, mods.elem_type)

    for _, m in ipairs(mods) do
        print(m.m_flDuration)
    end
end
MemberMeaning
#a, a.counthow many elements
a.elem_sizesize of one element
a.elem_typethe element's type name
a.basethe list's address

Use ipairs or a numeric for. pairs does not work on a list view.

Elements are read one at a time, only when you touch them. #a and a.count are cheap. Walking every element is not, so read what you need and break when you are done. The count is capped at 4096.

An element with an empty pointer comes back nil, and ipairs stops there. Use for i = 1, a.count to walk past gaps.

Writing

Assigning to a field name writes it in the game.

lua
me.m_flRichPresenceUpdateInterval = 7.0
node.m_bDebugAbsOriginChanges     = true

Five rules. Breaking any of them raises an error.

  1. Read the field before you write it. The write is checked against the type the game declares, and that type is only known once the field has been read. Read it on one tick, write it on a later one. Without the check, the size would be guessed from the Lua value, and a write that is too wide would overwrite the fields after it.
  2. Types have to match. A number into a bool, a bool into a float, or a number too big for the field are all refused. Nothing is converted for you.
  3. Scalars only. Numbers, booleans, vectors and handles. Not strings, lists or nested objects.
  4. The write is queued. It applies within about 25 ms.
  5. The game can overwrite you. A field the game recalculates every frame goes back to its own value.

Checking a write landed

Assignment cannot return anything, so raw_set gives you a token to check later.

lua
local token = raw_set(me, "m_flRichPresenceUpdateInterval", 7.0)
-- on a later tick:
local status = raw_write_status(token)
StatusMeaning
"pending"queued, not applied yet
"ok"applied
"not_found"the field no longer resolves on that entity
"type"the type changed between the read and the write
"range"the value does not fit the field
"unwritable"the field cannot be written
"fault"the write was refused at the last moment
"dropped"it aged out of the queue
nilunknown token, never issued or too old. The last 64 are kept

You cannot check "range" yourself, because only the internal knows how wide the field is.

lua
local t = raw_set(me, "m_nFlashMaxAlpha", 300)
-- later: raw_write_status(t) is "range"

The value is refused, not cut down to fit. Up to 32 writes can be queued. Past that raw_set raises an error.

Watching a field

raw_watch follows one field. It refreshes on its own schedule and can call you when the value changes.

lua
local hp = raw_watch(me, "m_iHealth", {
    rate_ms   = 50,
    on_change = function(new, old)
        print(string.format("health %d -> %d", old, new))
    end,
})

function on_tick()
    if hp.value and hp.value < 200 then panic() end
end
MemberMeaning
.valuethe current value
.agems since it was read, nil if it never arrived
.changedtrue if it changed on this tick
.fieldthe field name
.alivefalse once stopped
:stop()unsubscribe

rate_ms sets how often that one field refreshes, from 25 to 2000. Without it the field uses the shared interval, internal_property_interval_ms, which is 250 by default.

on_change runs once per change, before on_tick, so a field you never read still fires it. An error inside the callback is caught and logged, and the other watches keep running.

Use a watch to react to a change, not to speed up reads. Plain reads are already cheap.

A watch holds its field against the 10 second idle drop and counts against the 128 limit until you call :stop(). Watches are dropped when a script reloads. .value is nil for nested objects and lists, so read those through the field itself.

Watch a player, not a view. A watch remembers the address it was given. For a player that is the pawn, which lasts as long as the pawn does. A view only lasts one tick, so a watch on one will keep reading an address that may now hold something else. Watch a field on the player and reach nested fields through it each tick.

How old is a value

lua
local age = raw_age(me, "m_iHealth")

Milliseconds since that field was last read, nil if it has not arrived yet.

A position read this way lags get_position() by up to the refresh interval, which at running speed is a visible gap. That is an old value, not a wrong one. Use raw_age to drop a reading that is too old.

Limits

  • 128 fields at once, counted per entity and class. One that nothing reads for 10 seconds is dropped, so reading different fields over time is fine. Reading 128 fields on 12 players at once is not. Past the limit further names read nil and log once. internal.properties_diag().overflow counts them.
  • A nested view spends one slot per field you read through it, so nested reads hit the limit sooner than they look like they should.
  • Field names have to be under 64 characters. A longer name logs once and counts in internal.properties_diag().toolong.
  • Reading the same field twice in one tick costs twice. Put it in a local if you use it more than once.
  • Bitfields cannot be read.

Cost when you are not using it

None. It only runs when an enabled script declares needs_internal = true. A script without the flag costs the same as no script.

Field names

The full list of classes and fields is at https://s2v.app/SchemaExplorer/deadlock/client/C_CitadelPlayerPawn.