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 onlyRead any field the game declares on an entity, by the name the game uses. Fields on base classes work too.
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
endMethods 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.
-- 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) endOnly names starting with m_ are looked up, so a typo cannot use up a slot.
Types
| Declared type | Lua value |
|---|---|
bool | boolean |
int8 to int64, uint8 to uint64, enums | number |
float32, float64 | number |
Vector, VectorWS, QAngle, Vector2D | vec3 |
CHandle<...> | number, the raw handle |
CUtlString, CUtlSymbolLarge | string, cut at 63 bytes |
CUtlVector<...> | raw_array |
| Embedded class or pointer | raw_struct |
| Bitfields | nil, 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_internalis 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.
local node = me.m_pGameSceneNode
if node then
local pos = node.m_vecAbsOrigin
print(node:is_valid())
endA 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.
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| Member | Meaning |
|---|---|
#a, a.count | how many elements |
a.elem_size | size of one element |
a.elem_type | the element's type name |
a.base | the 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.
me.m_flRichPresenceUpdateInterval = 7.0
node.m_bDebugAbsOriginChanges = trueFive 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".
- 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.
- 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.5into an int is an error,780.0is fine. Nothing is converted for you. - Scalars only. You can write numbers, booleans, vectors and handles. You cannot write strings, lists or nested objects.
- The write is queued. It applies within about 25 ms.
- 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 | niltype 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:
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.
local token = raw_set(me, "m_flRichPresenceUpdateInterval", 7.0)
-- on a later tick:
local status = raw_write_status(token)| Status | Meaning |
|---|---|
"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 |
nil | unknown 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.
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.
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| Member | Meaning |
|---|---|
.value | the current value |
.age | ms since it was read, nil if it never arrived |
.changed | true if it changed on this tick |
.field | the field name |
.alive | false 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
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 readniland log once, andinternal.properties_diag().overflowcounts 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.