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 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.
-- 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 |
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
Nested objects
A pointer or embedded class gives you a view you can read fields off.
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.
Writing
Assigning to a field name writes it in the game.
me.m_flRichPresenceUpdateInterval = 7.0
node.m_bDebugAbsOriginChanges = trueFive rules. Breaking any of them raises an error.
- 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.
- 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.
- Scalars only. Numbers, booleans, vectors and handles. Not 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.
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 |
"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 = 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| 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 |
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
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
niland log once.internal.properties_diag().overflowcounts 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.