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 take priority over field names, so me:get_health() still calls the method even though me.m_iHealth exists. The cheaper external calls still work: 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, cut at 63 bytes
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
  • the field's type is not in the table above, such as a fixed-size array, logged once

Nested objects ​

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

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. An element that is not an object, such as a plain number, comes back as its address. Read the value with memory.read.

Writing ​

Assigning to a field name writes it in the game.

lua
me.m_flRichPresenceUpdateInterval = 7.0
node.m_bDebugAbsOriginChanges     = true

Five rules. Breaking rule 1, 2 or 3 raises an error. The one exception is a number too big for the field. That write is queued, then refused, and raw_write_status reports "range".

  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 and write it on a later one.
  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. So is a fraction into a whole-number field: 5.5 into an int is an error, 780.0 is fine. Nothing is converted for you.
  3. Scalars only. You can write numbers, booleans, vectors and handles. You cannot write 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.

Writing without reading first ​

p:set_property(name, value [, type]) -> token | nil

type is "int", "float", "bool", "vector" or "handle". With a type you do not need to read the field first, so the first write to a name works straight away.

The write is still checked. The internal looks the field up itself and refuses the write if the field is not that type, or if the value does not fit. Nothing is written in either case, and raw_write_status(token) tells you which one happened:

lua
local tok = me:set_property("m_iMaxHealth", 780, "int")
-- later
print(raw_write_status(tok))   -- "ok", "type", "range", "not_found", ...

A fraction for "int" or "handle" raises an error straight away, the same as with assignment.

Without a type, set_property works exactly like raw_set: read the field first, then write.

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. Through a nested view it also means the object is gone and something else is at that address now
"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

function on_tick()
    local me = local_player()
    -- raw_watch raises an error before the internal is up, so create it here
    if not me or not internal.is_loaded() then return end
    if not hp then
        hp = raw_watch(me, "m_iHealth", {
            rate_ms   = 50,
            on_change = function(new, old)
                print(string.format("health %d -> %d", old, new))
            end,
        })
    end
    if hp and hp.value and hp.value < 200 then print("low health") 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. Safe to call twice: an old handle never stops a newer watch

rate_ms sets how often that one field refreshes, from 25 to 2000. It must be a whole number (50 and 50.0 are fine, 25.5 is an error), and 0 means the same as leaving it out. Without it the field uses the shared properties rate, 250 ms by default. When several watches follow the same field, it refreshes at the fastest rate among the ones still running, so stopping the fast one slows it back down.

on_change runs once per change, before on_tick, so it fires even for a field you never read. An error inside the callback is caught and logged, and the other watches keep running. The callback runs as your script. A cmd.*, glow.set or internal.set_rate call inside it belongs to your script, and goes away when your script is turned off or errors. It can start and stop watches, including its own. It does not run while your script is paused (while is_enabled() returns false, or while you are playing a hero the script is not for).

Use a watch to react to a change. Do not use one to speed up reads, because plain reads are already cheap.

Until you call :stop(), the watched field is not dropped after 10 seconds idle, and it counts toward the 128 field limit. Watches are dropped when your script is turned off, errors or reloads. .value is nil for nested objects and lists, so read those through the field itself.

Do not put a watch on a view. A watch keeps the address it was given. On a player that is the pawn, which lasts as long as the pawn does. A view's address can hold something else one tick later. Watch a field on the player instead, 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. The value is correct, only old. Use raw_age to drop a reading that is too old.

Limits ​

  • 128 fields at once, counted per entity and class, shared by every script and by p:get_property. 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, new names read nil and log once, and internal.properties_diag().overflow counts them.
  • A nested view spends one slot per field you read through it, so nested reads use up the limit faster than you might expect.
  • 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.

Field names ​

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