SAPP Utils
Table of Contents
- Loading table of contents...
Helpers, notes, and gotchas for SAPP Lua scripting. Lookup tables (pointers, signatures, variables, commands) are documented in SAPP Cheat Sheet. Offsets, unit conversions, and the helpers shared with Chimera are in Common Reference.
Conventions
Naming conventions are shared with Chimera. See Conventions.
Ternary Emulation
SAPP picks PC or CE values with halo_type. The and / or rules are in
Ternary Emulation.
local var1, var2 = halo_type == 'PC' and 1 or 2, halo_type == 'PC' and 3 or 4
When several values change together, index a lookup table instead of repeating the condition:
local version = ({ PC = { 1, 2 }, CE = { 3, 4 } })[halo_type]
local var1, var2 = version[1], version[2]
Player Index Convention
- SAPP API functions use player indices 1-16; Halo memory tables and arrays use 0-15.
- Check
player_present(i)andplayer_alive(i)before accessing player data. get_dynamic_player(i) == 0means the player is dead. It is redundant ifplayer_alive(i)is already checked.
if player_present(player_id) and player_alive(player_id) then
local dyn = get_dynamic_player(player_id)
if dyn ~= 0 then
-- ...
end
end
Index Conversion
-
to_real_index(player_index)Converts a SAPP index (1-16) to a Halo index (0-15). Use it for zero-based memory arrays. -
to_player_index(real_index)Converts a Halo index (0-15) to a SAPP index (1-16). Use it for SAPP API calls.
Example 1: using to_real_index
function OnTick()
for i = 1, 16 do
if player_present(i) then
local real = to_real_index(i)
local checkpoint_addr = race_globals + 0x44 + (real * 4)
local checkpoint = read_dword(checkpoint_addr)
end
end
end
Example 2: using to_player_index
function OnTick()
local tick_id = read_dword(tick_counter_address)
for i = 1, 16 do
local dyn = get_dynamic_player(i)
if dyn == 0 or not player_alive(i) then goto continue end
for j = 0, 3 do
local struct = dyn + 0x430 + (j * 0x10)
local damager_time = read_dword(struct)
if damager_time == tick_id then
local internal_idx = read_word(struct + 0xC)
if internal_idx == 0xFFFF then goto continue end
local damager = to_player_index(internal_idx)
if player_present(damager) then
say(damager, "You damaged a teammate!")
end
end
end
::continue::
end
end
Signature Scanning
sig_scan finds byte patterns (?? = wildcard) and works on both PC and CE. It returns the
match-start address or 0. Add the offset, then read_dword to get the pointer. Always check for
0.
halo_typeis a case-sensitive string:"PC"or"CE". Use it only as a fallback when no reliable signature exists.- Pre-computed signatures: Signature Reference.
- Hardcoded PC/CE pointer table: Hardcoded Pointers.
Some CE patterns do not match on PC because the compilers emitted different instruction shapes for
the same code. The gametype_base sig is one example. Always use the version-fallback form below
rather than returning early on sig failure.
-- Returns the pointer at sig_scan(sig) + offset, or nil if invalid.
-- Guards against sig_scan returning 0 and against read_dword returning
-- something outside Halo's data range (0x400000-0x800000).
---@param sig string Signature byte pattern to scan for.
---@param offset number Offset from the signature match to the pointer.
---@return number|nil ptr Resolved pointer, or nil if invalid.
local function safeScan(sig, offset)
local hit = sig_scan(sig)
if hit == 0 then return nil end
local ptr = read_dword(hit + offset)
if not ptr or ptr < 0x400000 or ptr > 0x800000 then return nil end
return ptr
end
local network_struct_address, gametype_base_address
function OnScriptLoad()
-- Sig that works on both PC and CE. Fallback is here for robustness.
network_struct_address = safeScan("F3ABA1????????BA????????C740??????????E8????????668B0D", 0x3)
or (halo_type == 'PC' and 0x745B80 or 0x6C7960)
-- Sig that works on CE but returns 0 on PC (different instruction shape). Fallback handles PC.
gametype_base_address = safeScan("B9360000008BF3BF????????", 0x8)
or (halo_type == 'PC' and 0x671320 or 0x5F5478)
end
-- Returns the current server's name (UTF-16LE).
---@return string|nil server_name Server name, or nil if the network struct pointer is unavailable.
local function getServerName()
if not network_struct_address then return nil end
return readWideString(network_struct_address + 0x8, 0x42)
end
-- Returns the current gametype's score limit.
---@return number|nil score_limit Score limit, or nil if the gametype pointer is unavailable.
local function getScoreLimit()
if not gametype_base_address then return nil end
return read_byte(gametype_base_address + 0x58)
end
Notes:
-
Calling
read_dword(sig_scan(...) + offset)unguarded crashes the server whensig_scanreturns0. ThesafeScanhelper above checkssig_scan ~= 0, thatread_dwordreturns a number, and that the number is in Halo’s data range. -
Do not return early when a sig fails. A single-platform miss disables the entire script on the other platform. Fall back to the hardcoded value via the
orexpression shown above. -
Wildcard the address bytes rather than embedding a specific immediate. This survives builds where the address moves but not when the instruction shape changed (as with
gametype_baseon PC). -
readWideStringis defined under Wide Strings. -
Some pointers have no signature at all.
map_pointeris a static data slot the map loader writes once per load; no fixed instruction references it. Pick the value byhalo_typefrom Hardcoded Pointers.
SAPP timer ()
timer(ms, "GlobalFnName", [args...])
- Calls global
GlobalFnNameonce afterms, forwarding any extra args. It cannot be a local or anonymous function. timer()returnsnil, so there is no cancellation handle.- Callback return:
truerepeats indefinitely;false,nil, or no return fires once. Other types do not repeat. lua_unloaddisables the script’s timers.- SAPP API functions are globals, so
timercan call them directly (see Assigning Weapons).
---@param player_id number Player index (1-16).
function OnJoin(player_id)
timer(3000, "Welcome", player_id)
end
---@param player_id number Player index (1-16).
function Welcome(player_id)
if player_present(player_id) then -- They may have left during the delay.
say(player_id, "Welcome!")
end
end
Player and Chat
Message Excluding a Player
Sends a chat message to every connected player except one.
---@param message string The message to send.
---@param exclude_player_id number Player index (1-16) to skip.
local function sendExclude(message, exclude_player_id)
for i = 1, 16 do
if player_present(i) and i ~= exclude_player_id then
say(i, message)
end
end
end
Use case: announce “Player X found the secret” without revealing it to X.
Get Player Vehicle
Returns the vehicle object address the player is currently occupying, or nil if none.
---@param player_id number Player index (1-16).
---@return number|nil vehicle_object Vehicle object address, or nil.
local function getPlayerVehicleObject(player_id)
if not player_present(player_id) or not player_alive(player_id) then return nil end
local dyn = get_dynamic_player(player_id)
if dyn == 0 then return nil end
local vehicle_id = read_dword(dyn + 0x11C)
if vehicle_id == 0xFFFFFFFF then return nil end
local vehicle_object = get_object_memory(vehicle_id)
return vehicle_object ~= 0 and vehicle_object or nil
end
Vehicle Occupied Check
---@param vehicle_object number Memory address of the vehicle.
---@return boolean occupied True if at least one player is inside.
local function isVehicleOccupied(vehicle_object)
for i = 1, 16 do
if getPlayerVehicleObject(i) == vehicle_object then
return true
end
end
return false
end
Example: eject any player who enters a reserved vehicle.
Player Invisible Check
Detects active camouflage or script-based invisibility.
---@param player_id number Player index (1-16).
---@return boolean invisible True if invisible.
local function isPlayerInvisible(player_id)
local dyn = get_dynamic_player(player_id)
if dyn == 0 then return false end
return read_float(dyn + 0x37C) == 1
end
Clear Console
Prints blank lines to push previous messages out of view.
---@param player_id number Player index (1-16).
local function clearConsole(player_id)
for _ = 1, 25 do
rprint(player_id, " ")
end
end
Teleport Player or Object
Writes the position and forward vector straight to a player’s dynamic object or a vehicle.
---@param object number Dynamic player or vehicle memory address.
---@param x number Target X coordinate.
---@param y number Target Y coordinate.
---@param z number Target Z coordinate.
---@param rotation number Rotation in radians.
---@param z_offset number|nil Vertical lift. Defaults to 0.3.
local function teleportPlayer(object, x, y, z, rotation, z_offset)
z_offset = z_offset or 0.3
write_vector3d(object + 0x5C, x, y, z + z_offset)
write_vector3d(object + 0x74, math.cos(rotation), math.sin(rotation), 0)
end
Offset 0x5C is the object’s position and 0x74 is its forward vector. For vehicles, also apply
the physics fix under Known SAPP Quirks.
Get Player Position
Returns the player’s eye-level position, accounting for vehicles and crouching. read_vector3d is
SAPP only; Chimera reads three floats
(see Position and Aim).
---@param dyn_player number Dynamic player address.
---@return number|nil x Player X coordinate, or nil if the object is invalid.
---@return number|nil y Player Y coordinate, or nil if the object is invalid.
---@return number|nil z Player eye-level Z coordinate, or nil if the object is invalid.
local function getPlayerPosition(dyn_player)
local crouch = read_float(dyn_player + 0x50C) -- 0 = standing, 1 = fully crouched
local vehicle_id = read_dword(dyn_player + 0x11C)
-- Seated players use the vehicle's position:
local object = vehicle_id == 0xFFFFFFFF and dyn_player or get_object_memory(vehicle_id)
if object == 0 then return nil end
local x, y, z = read_vector3d(object + 0x5C)
return x, y, z + 0.65 - (0.3 * crouch)
end
Shared Helpers
These work on a pointer and are the same on SAPP and Chimera, so they live in the Common Reference:
inVehicle,getWeaponSlot,isLowAmmo: Vehicle and WeapongetAimVector: Position and AimgetHealthAndShields,getHealthPercent,getShieldsPercent: Health and ShieldsgetSpeed: SpeedgetTeamName,getKdRatio,getColorName: Static Player FieldsreadPlayerName: Wide Strings
For names and teams, prefer get_var(player_id, "$name") and get_var(player_id, "$team").
Get Player Ping
---@param player_id number Player index (1-16).
---@return number|nil ping Player ping in milliseconds, or nil if unavailable.
local function getPlayerPing(player_id)
local static = get_player(player_id)
return static and read_dword(static + 0xDC) or nil
end
Set Player Color
A respawn is usually required for the change to fully apply. Color values are listed under Team and Color.
---@param player_id number Player index (1-16).
---@param color number Color index (0-17).
local function setPlayerColor(player_id, color)
local static = get_player(player_id)
write_byte(static + 0x60, color)
end
Spawning Projectiles
-- Example: Fire a rocket projectile from a player's position
local ROCKET_PROJECTILE_TAG_ID = getTagId("proj", "weapons\\rocket launcher\\rocket")
local rocket_projectile = spawn_projectile(
ROCKET_PROJECTILE_TAG_ID,
player_id,
x, y, z,
rx, ry, rz -- Optional rotation in radians (defaults to 0, 0, 0 if omitted)
)
Vanish Player
Moves a player far off-map so others cannot see them. Call it every tick to keep them hidden.
---@param player_id number Player index (1-16).
local function vanishPlayer(player_id)
if not player_present(player_id) then return end
local dyn = get_dynamic_player(player_id)
if dyn == 0 then return end
local x, y, z = getPlayerPosition(dyn)
if not x then return end
local static = get_player(player_id)
write_float(static + 0xF8, x - 1000)
write_float(static + 0xFC, y - 1000)
write_float(static + 0x100, z - 1000)
end
Override Respawn Time
Writes to the static player table to change the respawn delay.
---@param player_id number Player index (1-16).
---@param respawn_time number|nil Seconds until respawn. Defaults to 3.
local function setRespawnTime(player_id, respawn_time)
if not player_present(player_id) then return end
-- The timer is stored in ticks (30 per second).
write_dword(get_player(player_id) + 0x2C, (respawn_time or 3) * 30)
end
Get Base Directory
Finds the Halo server folder at runtime, so no hardcoded paths.
---@param folder string|nil Subfolder to append. Defaults to "".
---@return string base_path Halo server directory path, optionally including the subfolder.
local function getBaseDir(folder)
folder = folder or ""
local exe_path = read_string(read_dword(sig_scan('0000BE??????005657C605') + 0x3))
local base_path = exe_path:match("(.*\\)")
return base_path .. folder
end
---@return string config_path Path to the SAPP configuration file.
local function getProfilePath()
return read_string(read_dword(sig_scan('68??????008D54245468') + 0x1))
end
Examples:
local base = getBaseDir()
local maps = getBaseDir("maps")
local sapp_cg = getProfilePath()
Vehicle and Weapon Functions
Get Object Tag
Returns the object type byte and the tag name of any object.
---@param object number Memory address of the object.
---@return number object_type Object type identifier (byte at offset 0xB4).
---@return string tag_name Full tag path associated with the object.
local function getTagFromObject(object)
local object_type = read_byte(object + 0xB4)
local tag_index = read_word(object)
local tag_array = read_dword(0x40440000)
local tag_address = tag_index * 32 + tag_array
local tag_name = read_string(read_dword(tag_address + 0x10))
return object_type, tag_name
end
Example:
---@param player_id number Player index (1-16).
function OnVehicleEnter(player_id)
local dyn = get_dynamic_player(player_id)
if dyn == 0 then return end
local vehicle_id = read_dword(dyn + 0x11C)
if vehicle_id == 0xFFFFFFFF then return end
local vehicle_obj = get_object_memory(vehicle_id)
if vehicle_obj == 0 then return end
local object_type, tag_name = getTagFromObject(vehicle_obj)
cprint(object_type .. " " .. tag_name, 11)
end
Has Objective Check
Tests whether a player is holding a flag or oddball.
local BASE_TAG_TABLE = 0x40440000 -- same for PC and CE
---@param dyn_player number Dynamic player address.
---@param objective_type string|nil "oddball", "flag", or "any". Defaults to "any".
---@return boolean holding True if holding the specified objective.
local function hasObjective(dyn_player, objective_type)
objective_type = objective_type or "any"
local weapon_id = read_dword(dyn_player + 0x118)
local weapon_obj = get_object_memory(weapon_id)
if not weapon_obj or weapon_obj == 0 then return false end
local tag_array = read_dword(BASE_TAG_TABLE)
local tag_index = read_word(weapon_obj)
local tag_entry = tag_array + tag_index * 0x20
local tag_data = read_dword(tag_entry + 0x14)
if read_bit(tag_data + 0x308, 3) ~= 1 then return false end
local obj_byte = read_byte(tag_data + 2)
return (objective_type == "oddball" and obj_byte == 4) or
(objective_type == "flag" and obj_byte == 0) or
(objective_type == "any" and (obj_byte == 4 or obj_byte == 0))
end
Get Inventory
Returns a table of all weapon slots (1-4) with detailed stats. Empty slots are omitted. Grenade counts belong to the player, so they are repeated on every slot entry.
---@class WeaponInventoryEntry
---@field id number Weapon tag ID.
---@field ammo number Primary reserve ammunition.
---@field clip number Primary magazine ammunition.
---@field ammo2 number Secondary reserve ammunition.
---@field clip2 number Secondary magazine ammunition.
---@field heat number Weapon overheat value (0-1).
---@field frags number Player frag grenade count.
---@field plasmas number Player plasma grenade count.
---@param dyn_player number Dynamic player address.
---@return table<number, WeaponInventoryEntry> inventory Weapon inventory indexed by slot (1-4).
local function getInventory(dyn_player)
local inv = {}
for slot = 0, 3 do
local weapon_id = read_dword(dyn_player + 0x2F8 + slot * 4)
local weapon_obj = get_object_memory(weapon_id)
if weapon_obj and weapon_obj ~= 0 then
inv[slot + 1] = {
id = read_dword(weapon_obj),
ammo = read_word(weapon_obj + 0x2B6),
clip = read_word(weapon_obj + 0x2B8),
ammo2 = read_word(weapon_obj + 0x2C6),
clip2 = read_word(weapon_obj + 0x2C8),
heat = read_float(weapon_obj + 0x240),
frags = read_byte(dyn_player + 0x31E),
plasmas = read_byte(dyn_player + 0x31F)
}
end
end
return inv
end
Tag and Map Helpers
The tag table base is documented under Tag Table (SAPP Cheat Sheet) and the entry layout under Tag Entry.
classToDword is defined under Tag Entry Helpers.
Find Tag
Scans the tag table for a tag whose name contains a substring (case-insensitive). Optionally filter by class.
---@param substring string Text to search for.
---@param class_filter string|nil Optional class filter, e.g. "weap" or "vehi".
---@return number|nil meta_index Meta index of the first match.
local function findTagBySubstring(substring, class_filter)
substring = substring:lower()
local tag_array = read_dword(BASE_TAG_TABLE)
local tag_count = read_dword(BASE_TAG_TABLE + 0xC)
local filter_class = class_filter and classToDword(class_filter) or nil
for i = 0, tag_count - 1 do
local tag = tag_array + 0x20 * i
if filter_class and read_dword(tag) ~= filter_class then goto continue end
local name_ptr = read_dword(tag + 0x10)
if name_ptr and name_ptr ~= 0 then
local name = read_string(name_ptr)
if name and name:lower():find(substring, 1, true) then
return read_dword(tag + 0xC)
end
end
::continue::
end
return nil
end
Example:
local rocket_meta = findTagBySubstring("rocket", "weap")
if rocket_meta then
spawn_object("", "", 100, 100, 100, 0, rocket_meta)
end
Get Flag Data
Finds the flag (objective) tag on the current map. Returns its meta id and tag name, or nil if
there is no flag.
---@return number|nil meta_id Flag weapon tag meta ID, or nil if no flag exists.
---@return string|nil tag_name Flag weapon tag path, or nil if no flag exists.
local function getFlagData()
local tag_array = read_dword(BASE_TAG_TABLE)
local tag_count = read_dword(BASE_TAG_TABLE + 0xC)
for i = 0, tag_count - 1 do
local tag = tag_array + 0x20 * i
if read_dword(tag) == 0x77656170 then -- weap
local tag_data = read_dword(tag + 0x14)
if read_bit(tag_data + 0x308, 3) == 1 and read_byte(tag_data + 2) == 0 then
local meta = read_dword(tag + 0xC)
local name = read_string(read_dword(tag + 0x10))
return meta, name
end
end
end
return nil, nil
end
Get Tag ID
Wrapper around lookup_tag that returns the tag ID (meta id) you pass to spawn_object and similar
functions. lookup_tag returns 0 when the tag does not exist.
---@param class string Tag class, e.g. "weap".
---@param name string Tag path, e.g. "weapons\\pistol\\pistol".
---@return number|nil tag_id Tag meta ID, or nil if the tag does not exist.
local function getTagId(class, name)
local tag = lookup_tag(class, name)
return tag ~= 0 and read_dword(tag + 0xC) or nil
end
Scan Map Objects
Prints tag information for every weapon, vehicle, and equipment tag on the map. Handy during development. It will not work on protected maps or some custom maps.
local function scanMapObjects()
-- Read the tag table base address and total number of entries.
local tag_array = read_dword(BASE_TAG_TABLE)
local tag_count = read_dword(BASE_TAG_TABLE + 0xC)
-- Map-specific tag class dwords to their four-letter identifiers.
local class_names = {
[0x76656869] = "vehi",
[0x77656170] = "weap",
[0x65716970] = "eqip"
}
-- Iterate through every tag entry (each entry is 0x20 bytes).
for i = 0, tag_count - 1 do
local tag = tag_array + 0x20 * i
-- Identify the tag class and skip anything other than vehicles, weapons, and equipment.
local class_name = class_names[read_dword(tag)]
if class_name then
-- Resolve the tag name through its string pointer at offset 0x10.
local name_ptr = read_dword(tag + 0x10)
local name = name_ptr ~= 0 and read_string(name_ptr) or "<no-name>"
-- Read the tag's metadata ID from offset 0xC.
local meta = read_dword(tag + 0xC)
-- Print the tag class, metadata ID, and name to the server console.
cprint(string.format("%s meta=%u name=%s", class_name, meta, name), 12)
end
end
end
Distance Helpers
distance3d and distanceMeters are shared with Chimera.
See Distance.
Unit conversions are under Timing and Units.
On SAPP, read the position with read_vector3d:
local px, py, pz = read_vector3d(dynamic_player + 0x5C)
local distance_m = distanceMeters(px, py, pz, { 0, 0, 0 })
IP and Network Helpers
SAPP uses bit, not bit32. These helpers validate IPv4 strings, convert between dotted and
integer forms, convert wildcard notation to CIDR, and match an IP against CIDR, wildcard, or range
definitions.
local bit = require("bit")
-- validateIpv4("192.168.*.*") -> "192.168.*.*"
-- validateIpv4("10.0.0.1-10.0.0.255") -> "10.0.0.1-10.0.0.255"
---@param ip string|nil IPv4 address, wildcard, CIDR, or range string.
---@return string|boolean normalized_ip Normalized IPv4 expression, or false if rejected.
local function validateIpv4(ip)
if not ip then return false end
ip = string.gsub(string.gsub(ip, "[%s]*", ""), "x+", "*")
local a, b, c, slash, d, finish = ip:match("^([^%.]+)%.([^%.]*)%.?([^%./]*)%.?(/?)([^%.]*)()")
a = a == "" and "*" or a:match("[%d%*]+")
b = b == "" and "*" or b:match("[%d%*]+")
c = c == "" and "*" or c:match("[%d%*]+")
slash = slash ~= ""
d = d or ""
if slash then
if d:find("/") or not d:match("[%d%*]+") then return false end
d = "0/" .. d
else
d = d == "" and "*" or d:match("[%d%*/]+")
end
if not a or not b or not c then return false end
local found, a2, b2, c2, d2 = ip:match("(%-)(%d+)%.(%d*)%.?(%d*)%.?(%d*)%c*$", finish)
if not found then
if a2 and a ~= "" then return false end
return string.format("%s.%s.%s.%s", a, b, c, d)
elseif slash then
return false
end
a2 = a2 == "" and "*" or a2:match("[%d%*]+")
b2 = b2 == "" and "*" or b2:match("[%d%*]+")
c2 = c2 == "" and "*" or c2:match("[%d%*]+")
d2 = d2 == "" and "*" or d2:match("[%d%*]+")
if not a2 or not b2 or not c2 then return false end
if c2:find("/") and d2:find("/") then return false end
return string.format("%s.%s.%s.%s-%s.%s.%s.%s", a, b, c, d, a2, b2, c2, d2)
end
-- Returns an unsigned 32-bit value, or nil if the address is invalid.
---@param ip_addr string Dotted-decimal IPv4 address.
---@return number|nil address Unsigned 32-bit integer, or nil if the input cannot be parsed.
local function ipToLong(ip_addr)
local a, b, c, d = ip_addr:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
if not a then return nil end
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
if not (a and b and c and d) then return nil end
return bit.bor(bit.lshift(a, 24), bit.lshift(b, 16), bit.lshift(c, 8), d) % 0x100000000
end
-- Converts an unsigned 32-bit IPv4 value to dotted-decimal notation.
---@param addr number Unsigned 32-bit IPv4 address.
---@return string ip_addr Dotted-decimal IPv4 address.
local function longToIp(addr)
local a = bit.rshift(bit.band(addr, 0xFF000000), 24)
local b = bit.rshift(bit.band(addr, 0x00FF0000), 16)
local c = bit.rshift(bit.band(addr, 0x0000FF00), 8)
local d = bit.band(addr, 0x000000FF)
return string.format("%i.%i.%i.%i", a, b, c, d)
end
-- wildcardToCidr("10.*.*.*") -> "10.0.0.0/8"
---@param addr string IPv4 address containing wildcard octets.
---@return string cidr_address CIDR representation of the wildcard address.
local function wildcardToCidr(addr)
local count = select(2, addr:gsub("%*", "*"))
if count == 1 then
return addr:gsub("%*", "0") .. "/24"
elseif count == 2 then
return addr:gsub("%*", "0") .. "/16"
elseif count == 3 then
return addr:gsub("%*", "0") .. "/8"
elseif count > 3 then
return "0.0.0.0/0"
end
return addr
end
-- ipMatchesNetwork("192.168.1.0/24", "192.168.1.100") -> true
---@param network string Network definition in CIDR, wildcard, or range notation.
---@param ip string|nil IPv4 address or wildcard notation to check.
---@return boolean matches True if the IP matches the network definition.
local function ipMatchesNetwork(network, ip)
network = validateIpv4(network)
if not ip then return false end
ip = validateIpv4(ip)
if not network or not ip then return false end
-- Normalize wildcard to CIDR
if network:find("%*") then network = wildcardToCidr(network) end
if ip:find("%*") then ip = wildcardToCidr(ip) end
local dash = network:find("-")
if not dash then
local net_part, mask_len = network:match("^(.-)/(%d+)$")
mask_len = tonumber(mask_len) or 32
local net_long = ipToLong(net_part)
if not net_long then return false end
local ip_part, ip_mask_len = ip:match("^(.-)/(%d+)$")
ip_mask_len = tonumber(ip_mask_len) or 32
local ip_long = ipToLong(ip_part)
if not ip_long then return false end
local mask = bit.lshift(0xFFFFFFFF, (32 - mask_len))
local ip_mask = bit.lshift(0xFFFFFFFF, (32 - ip_mask_len))
return bit.band(net_long, mask, ip_mask) == bit.band(ip_long, mask, ip_mask)
else
local from = ipToLong(network:sub(1, dash - 1))
local to = ipToLong(network:sub(dash + 1))
if not from or not to then return false end
local ip_long = ipToLong(ip)
if not ip_long then return false end
return ip_long >= from and ip_long <= to
end
end
Assigning Weapons
Note: Assigning 3 or more weapons at once makes the third and fourth drop on the ground. Delay them
by at least 250 ms. assign_weapon silently fails if the player is dead.
---@param weapon_id number|nil Spawned weapon object ID.
---@param player_id number Player index (1-16).
function AssignWeapon(weapon_id, player_id) -- used in both examples below
if weapon_id then assign_weapon(weapon_id, player_id) end
end
execute_command("wdel " .. player_id) -- clear their inventory first (for both examples)
-- Example 1 (simple):
AssignWeapon(spawn_object(...), player_id) -- x2 (receive now)
timer(250, "AssignWeapon", spawn_object(...), player_id) -- x2 (receive 250ms later)
-- Example 2 (advanced assignment logic):
local WEAPONS = {
["pistol"] = {
tag_name = "weapons\\pistol\\pistol",
attributes = { clip = 12, ammo = 100, frags = 1, plasmas = 1 }
},
--...
}
local weapon_datums = {}
local function cacheWeaponDatums()
for name, data in pairs(WEAPONS) do
local tag_id = getTagId("weap", data.tag_name)
if tag_id then
weapon_datums[name] = {
tag_id = tag_id,
attributes = data.attributes
}
end
end
end
---@param dyn_player number Dynamic player address.
---@param frags number Frag grenade count.
---@param plasmas number Plasma grenade count.
local function setGrenades(dyn_player, frags, plasmas)
write_byte(dyn_player + 0x31E, frags)
write_byte(dyn_player + 0x31F, plasmas)
end
---@param player_id number Player index (1-16).
---@param weapon_name string Weapon name key in weapon_datums.
---@param slot number Weapon slot (1-4).
local function spawnWeapon(player_id, weapon_name, slot)
local dyn = get_dynamic_player(player_id)
if dyn == 0 then return end
local tag_datum = weapon_datums[weapon_name]
if not tag_datum then return end
local tag_id = tag_datum.tag_id
local attributes = tag_datum.attributes
local weapon = spawn_object("", "", 0, 0, 0, 0, tag_id)
if weapon == 0 then return end
local weapon_mem = get_object_memory(weapon)
if weapon_mem == 0 then return end
write_word(weapon_mem + 0x2B6, attributes.ammo) -- reserve ammo
write_word(weapon_mem + 0x2B8, attributes.clip) -- rounds in magazine
sync_ammo(weapon) -- sync before assigning
if slot <= 2 then
AssignWeapon(weapon, player_id)
else
timer(250, 'AssignWeapon', weapon, player_id)
end
-- Set last
setGrenades(dyn, attributes.frags, attributes.plasmas)
end
Platform-Independent Helpers
Table, string, message formatting, time, and math helpers work on any platform and are shared with Chimera. See Pure Lua Helpers in the Common Reference, including the math.atan2 guard.
Many Halo strings are UTF-16LE: player name (get_player(id) + 0x4), server name
(network_struct + 0x8), gametype name (network_struct + 0x10C), and map display name
(map_name_address). read_string returns "" for these fields; use readWideString from
Wide Strings instead. The narrow (8-bit) form is used only
for filenames and paths (e.g. map_name_address2, server_path_address).
LuaJIT and FFI
- Lua 5.1 compatible, plus LuaJIT extensions:
goto,bit,table.new,table.clear. _ENVis not supported. Usesetfenvandgetfenvfor environments.ffigives you C interop and raw memory access.- The JIT speeds up math-heavy and iterative code.
- Scripts are sandboxed, so some OS APIs may be blocked. Unsafe memory access can crash the server.
Check for FFI
function OnScriptLoad()
if pcall(function() require("ffi") end) then
cprint("ffi is available")
else
cprint("ffi is NOT available")
end
end
pcall keeps the script from erroring out if ffi is blocked.
FFI Demo
Calls GetTickCount from the Windows API to get the milliseconds since system boot.
api_version = '1.12.0.0'
local ffi = require("ffi")
-- Declare the Windows API function signature for LuaJIT FFI.
ffi.cdef[[
unsigned long GetTickCount(void);
]]
-- Tracks elapsed EVENT_TICK callbacks.
local tick_count = 0
function OnScriptLoad()
-- Display the system uptime immediately when the script loads.
cprint(string.format("Ticks since boot: %d", ffi.C.GetTickCount()), 10)
register_callback(cb.EVENT_TICK, "OnTick")
end
function OnTick()
tick_count = tick_count + 1
if tick_count % 300 == 0 then -- every 10 seconds
cprint(string.format("Ticks since boot: %d", ffi.C.GetTickCount()), 10)
end
end
table.new
table.new(narray, nhash) pre-allocates a table with narray array slots and nhash hash slots,
so it does not resize while you fill it.
local table_new = require("table.new")
local my_table = table_new(10, 5)
my_table[1] = "a"
my_table[2] = "b"
my_table.name = "LuaJIT"
my_table.version = "2.1"
for i = 1, #my_table do
cprint("Index " .. i .. ": " .. tostring(my_table[i]))
end
for key, value in pairs(my_table) do
if type(key) ~= "number" then
cprint("Key '" .. key .. "': " .. tostring(value))
end
end
table.clear
table.clear(tab) empties a table but keeps the allocated memory for reuse.
local table_clear = require("table.clear")
local scores = { player1 = 5, player2 = 3, player3 = 8 }
cprint("Has entries before clear: " .. tostring(next(scores) ~= nil))
table_clear(scores)
cprint("Has entries after clear: " .. tostring(next(scores) ~= nil))
Environments
setfenv swaps the globals a function sees. Useful for restricting what a chunk can touch.
local sandbox_env = { cprint = cprint, math = math }
---@param fn function Function to run inside the sandbox.
---@return boolean success True if the function executed without an error.
---@return any result_or_error Function result on success, or error value on failure.
local function runInSandbox(fn)
setfenv(fn, sandbox_env)
return pcall(fn)
end
runInSandbox(function() cprint("Hello from sandbox") end)
runInSandbox(function() return os.time() end) -- fails: `os` is nil in the sandbox
bit Library
SAPP Lua does not support require("bit32") (use require("bit")), the native bitwise operators
(& | ~ << >>), or integer division (//). Use bit.band, bit.bor, bit.bxor, bit.bnot,
bit.lshift, bit.rshift, and math.floor(a / b) instead.
local bit = require("bit")
-- Initialize an empty set of flags.
local flags = 0
-- Set bits 2 and 5 using bitwise OR.
flags = bit.bor(flags, 4, 32)
-- Check whether bit 2 is set using bitwise AND.
if bit.band(flags, 4) ~= 0 then
cprint("Bit 2 is set")
end
-- Clear bit 5 using NOT to invert its mask, then AND to remove it.
flags = bit.band(flags, bit.bnot(32))
-- Shift 1 three positions to the left (equivalent to 1 * 2^3).
local shifted = bit.lshift(1, 3)
cprint("1 << 3 = " .. shifted)
-- Shift 8 three positions to the right (equivalent to 8 / 2^3).
local original = bit.rshift(8, 3)
cprint("8 >> 3 = " .. original)
-- Pack two 16-bit values into a single 32-bit value.
local high = 0xABCD
local low = 0x1234
local packed = bit.bor(bit.lshift(high, 16), low)
cprint(string.format("Packed: 0x%08X", packed))
-- Extract the upper 16 bits by shifting right.
local high_unpacked = bit.rshift(packed, 16)
-- Extract the lower 16 bits using a 16-bit mask.
local low_unpacked = bit.band(packed, 0xFFFF)
cprint(string.format("Unpacked: 0x%04X, 0x%04X", high_unpacked, low_unpacked))
These pair well with SAPP events: pre-allocate tables with table.new, clear them between maps with
table.clear, track player states with bit flags, and run admin command handlers in a setfenv
sandbox.
Known SAPP Quirks
rand () Upper Bound
rand(min, max) excludes the upper bound, so rand(1, 4) returns 1-3 and never 4. To include the
last index when picking from a table, add 1: local i = rand(1, #t + 1).
execute_command_sequence () Quoting
For chained commands (e.g. say_all), use single-quoted Lua strings, double-quoted arguments, and
semicolons as separators:
execute_command_sequence('say_all "Hello world";god 1')execute_command_sequence("camo 1;god 1")
$pn Is Stale During EVENT_LEAVE
During EVENT_LEAVE, get_var(0, "$pn") has not updated yet. Subtract 1 to get the right player
count: tonumber(get_var(0, "$pn")) - 1.
math.atan2 Is Missing
Define the guard from Math and Geometry before using it,
or before using any helper that calls it such as directionToCardinal.
Vehicle Physics Fix
Writing a vehicle’s position with write_vector3d() can cause glitchy physics. This reduces the
teleport glitches but does not remove them completely.
-- Set the vehicle's position as usual:
write_vector3d(object + 0x5C, x, y, z)
-- Apply the fix:
write_float(object + 0x70, -0.025) -- tiny downward velocity
write_bit(object + 0x10, 0, 0) -- unset no-collision
write_bit(object + 0x10, 5, 0) -- unset ignore-physics