Import Modules
The 40 Lua modules behind the tlib global — access, config, permission, platform, kvp, settings and the rest.
dependencies { 'tLib' }
shared_scripts {
'@tLib/imports.lua',
}local kvp = tlib.kvpModules load on first access, so you declare nothing.
Declaring modules
tlib_modules { 'coords', 'kvp' } still works and makes a load eager, which fails at boot
on a module that does not compile. Both spellings are accepted (tlib_module,
tlib_modules).
| Declare | Why |
|---|---|
realtime | Required. The server bundle registers the rt* exports only if a manifest names it. |
marketplace | Required to register content types. Otherwise registerType returns false and warns; the resource starts, its types never register. |
menu | Needed when loading the module is the wiring — it defines the globals MenuSchema and MenuCatalog, which shared files may read with no tlib.menu access to trigger the load. |
occupancy and telemetry are in AUTOLOAD and load themselves in every consumer.
telemetry defaults to studio-authored resources; declare it to opt in.
access
Decides who may use your resource, on the server, the way the owner configures — ACE, job, inventory item or their own function, all one shape.
Config.access = {
mode = "item", -- everyone | ace | job | item | custom; a list passes if EITHER does
ace = "tdetector.use",
job = { police = 0, sheriff = 2 }, -- job = MINIMUM grade
item = "radar_detector",
count = 1, -- how many of `item`; default 1
custom = function(src) return false end,
}local gate = tlib.access.gate(Config.access, { key = 'detector', label = 'the detector' })
RegisterNetEvent('tDetector:deploy', function()
if not gate:allowed(source) then return end
end)| Server function | Description |
|---|---|
gate:allowed(src) | The decision. Call it before every privileged action. |
gate:push(src) | Recompute and send the answer to that player's client mirror. |
gate:invalidate(src) | Drop the cached answer after changing what the gate reads. nil = all. |
gate:describe() / gate:report() | One line of prose for <resource>_diag; the same, structured. Assert on report. |
access.jobs(src) | { [job] = grade } from the detected framework. |
access.count(src, item) | How many of an item, from the detected inventory. |
access.framework() / access.inventory() | The name detected, or nil — not the adapter. |
access.use(fw, inv) | Pin detection. 'none', or 'auto' to resume detecting. |
access.registerFramework(a) / registerInventory(a) | Teach it about a fork. |
access.problems(block, label, env) | Check a block without building a gate. |
The client half draws UI only:
local gate = tlib.access.gate({ key = 'detector' }) -- same key as the server
if gate:allowed() then showButton() end
gate:onChange(function(allowed) toggleButton(allowed) end)
gate:refresh() -- after going on duty, picking the item up- It fails closed, always. An absent, unstarted or throwing framework denies; a
customthat errors or returns a truthy non-boolean denies; an unset ACE denies; a gate with config errors denies everyone and says why. problems(block, label, env)withenv = falsecatches file mistakes, which disable the gate.env = truecatchesmode = 'job'with no framework — never check that at startup, because an unstarted framework is indistinguishable from a missing one and the gate starts passing by itself when it appears.registerFrameworktakes{ name, detect, jobs }and must be called from aserver_script, not the sharedconfig.lua.jobsreturns a map of job → grade, so a player who is both police and sheriff is expressible. Under escrow, keep one server file inescrow_ignorefor it.
| Axis | Supported, in detection order |
|---|---|
Framework (job) | qbx_core, qb-core, ND_Core, es_extended, ox_core, nat2k15framework |
Inventory (item) | ox_inventory, qs-inventory, codem-inventory, origen_inventory, core_inventory, tgiann-inventory, qb-inventory, ps-inventory, jpr-inventory |
- The axes are independent, so nat2k15 for jobs beside ox_inventory for items works, as does an item gate with no framework.
- Detection needs the resource started, not present, and reruns whenever any resource starts.
- Grades are normalised across qb, ESX and ND. A framework with no grade concept reports
0, sojob = { LSPD = 3 }denies rather than ignoring the3. - vRP has no adapter. Load a script with
vRP.loadScript, callregisterFramework, and report grade0.
config
Validates an owner-edited config.lua once at startup and refuses to run on a broken one.
| Function | Description |
|---|---|
config.check(tbl, spec) | Validates, prints every problem, returns false if any were fatal. |
config.validate(tbl, spec) | Same checks, returns { ok, errors, warnings }, prints nothing. |
local ok = tlib.config.check(Config, {
file = 'shared/config.lua',
keys = {
detectionRangeMeters = { type = 'number', default = 500.0, min = 1, max = 2000 },
speedUnit = { type = 'string', default = 'kmh', oneOf = { 'kmh', 'mph' } },
},
})
if not ok then return end| Spec field | Description |
|---|---|
file | Path shown in messages. Use the path the owner edits. |
keys | Map of key name → definition. |
prefix | Table name in messages. Default Config. |
allowUnknown | Skip unknown-key warnings. Only for a table that legitimately holds extras. |
A key definition takes type (number, string, boolean, list, table), default,
required, min, max, oneOf, and fix to override the suggested remedy.
- Every problem is reported at once, each naming file, key, expected, found and a fix.
- A default fills a key the owner omitted; a value the owner set is reported and left alone, never silently replaced.
- An unknown key warns with the key probably meant, including unit-less near-misses.
- A missing
requiredkey is fatal.
coords
Vehicle coordinate maths, local ↔ world.
| Function | Description |
|---|---|
Coords.localToWorld(right, fwd, up, pos, lx, ly, lz) | Vehicle-local → world. |
Coords.worldToLocal(right, fwd, up, pos, wx, wy, wz) | World → vehicle-local. |
Coords.buildBoneRotMatrix(rx, ry, rz) | World-space rotation matrix from bone angles (ZXY order). |
Coords.boneLocalToWorld(...) / Coords.worldToBoneLocal(...) | Bone-relative ↔ world. |
local right, fwd, up, pos = GetEntityMatrix(GetVehiclePedIsIn(PlayerPedId(), false))
local wx, wy, wz = tlib.coords.localToWorld(right, fwd, up, pos, 0.5, 1.0, 0.0)demo
Reports whether the server runs in demo mode — the tlib_demo convar, see
Convars.
| Function | Side | Description |
|---|---|---|
demo.active() | both | true while demo mode is on. Cheap enough for a hot path. |
demo.dropWrite(what) | server | true when the caller must skip a write. Prints one notice per target. |
A resource on the normal primitives needs nothing. Your own writer that bypasses
tlib.fs needs one line:
local function saveData(json)
if tlib.demo.dropWrite('data.json') then return true end
return tlib.fs.writeResourceFile('data.json', json)
endNever let demo.active() decide something a player benefits from being wrong about on the
client. The client value is pushed by tLib, so it is correct whether the owner wrote set
or setr.
devgate
A gate for a feature that exists on your own servers and is absent from the shipped
build. tlib.devgate(name) returns a predicate for the convar name, read on every call,
on both sides.
local isInternal = tlib.devgate('my_resource_convar')
if isInternal() then
RegisterCommand('my_internal_tool', runTool, false)
end- Put the call in a script not in
escrow_ignore, or there is a convar name a customer can read. - Gate registration, not behaviour. No command, keybind, menu row, panel or export
should exist at all — one that exists and refuses advertises the feature in
cmdlist. - Read per call, so a replicated convar flipped mid-session applies at once. Where registration happens at load, off → on needs a restart.
- A client VM only sees
setr. A client gate on asetconvar is permanently off. 1,true,yes,onenable it; anything else, including unset, is off.
diag
Console blocks for the things an operator must go and fix. You write the prose; diag does
the wrapping, dashed rules, ^colour codes and the [resource] prefix.
| Function | Description |
|---|---|
Diag.fatal(title, opts) | Red, prefixed FATAL:. Nothing works until this is fixed. |
Diag.error(title, opts) | Red. Something is denied or broken; the resource still runs. |
Diag.warn(title, opts) | Yellow. Works, but not as intended. |
Diag.info(title, opts) | White. |
Diag.render(level, title, o) | The lines, without printing — for a support dump or a panel. |
Diag.wrap(text, width, ind) | The word wrap on its own. |
Diag.said(key) / Diag.reset() | Has this once key printed? Forget every once key. |
tlib.diag.fatal('Config does not exist. tRadio cannot start.', {
cause = 'config/config.lua failed to load. Almost always a Lua syntax error.',
fix = 'Scroll UP: FiveM printed the exact file and line just before this message.',
once = 'no-config',
})opts is { cause, fix, detail, scope, once }, all optional. A fix list renders as "Fix
it one of these ways". It prints and never logs.
kvp
Typed KVP wrapper, prefixed per community server via tlib_community_id (needs setr) or
sv_projectName — see Convars.
| Function | Description |
|---|---|
kvp.getString/getFloat/getInt/getBool/getJson(key, default) | Typed reads. |
kvp.setString/setFloat/setInt/setBool/setJson(key, value) | Typed writes. |
kvp.get(key, default) / kvp.set(key, value) | Auto-detect the type. |
kvp.has(key) / kvp.delete(key) | Existence, removal. |
kvp.key(key) | The full prefixed key string. |
settings
Typed settings store backed by KVP, with defaults, user-set tracking and server-pushed defaults.
| Function | Description |
|---|---|
settings.create(defs) | A store from { key, type, default }[]. |
store:get(key) / store:set(key, value) | Read; write, persisted to KVP. |
store:isUserSet(key) | true if the player set this explicitly. |
store:applyDefaults(table) | Apply server defaults only to keys the player has not set. |
store:reset(key) | Back to the default, and remove from KVP. |
local store = tlib.settings.create({
{ key = 'volume', type = 'float', default = 0.8 },
{ key = 'darkMode', type = 'bool', default = false },
})modules
Server-side content discovery. A module is a folder plus a module.json declaring what
it is, as in layouts/QRX-720/module.json.
| Function | Description |
|---|---|
modules.scan(basePath, kind, resourceName?) | The ids of every usable module of kind, sorted. |
modules.list(basePath, kind, resourceName?) | The same as { id, manifest }. |
modules.read(basePath, id, kind?, resourceName?) | One validated manifest, or nil, err. |
modules.load(basePath, id, fileName, resourceName?) | Read a file out of a module folder. |
modules.hasFile(basePath, id, fileName, resourceName?) | Whether a module ships an optional asset. |
modules.validate(raw, id, kind?) | Validate a decoded manifest directly. |
modules.clearCache() | Clear the scan cache, for modules added at runtime. |
modules.onRefusal(basePath, id, err) | Replace with your logger; called for every folder refused. |
schema, kind, id, name, version are required; author, license, description,
slug optional. A marketplace sound module adds sound.file and may add
sound.durationSeconds, sound.toneKeys, sound.peaks.
idmust equal the folder name, byte for byte, which catches a rename on copy.kindis declared, never inferred from location. A mismatch is refused at start rather than 404ing onui.htmlon a player's machine.soundsandthemesare reserved and cannot be module ids.- Content comes from a live scan, so a folder dropped in at runtime is found without a
restart —
clearCache()makes it visible to a scan already run.
logger
Levelled console logging with one format everywhere: [scope][LEVEL] message.
| Function / field | Description |
|---|---|
logger.create(scope) | A log(...) function that prefixes every line. |
log(...) / log(..., level) | Space-joined arguments, INFO by default. A trailing number is the level and is not printed. |
logger.DEBUG / INFO / WARN / ERROR | 1 / 2 / 3 / 4. |
logger.setLevel(level) | Minimum level printed. Default INFO. |
permission
Admin and permission checks matching tLib's own rules.
| Server function | Description |
|---|---|
permission.isAdmin(src) | true for tlib.admin or tlib.superadmin. |
permission.level(src) | 0 none, 1 admin, 2 superadmin. |
permission.has(src, key) | Any key, resolved by tLib — ACE, or a registered framework resolver. |
permission.hasBatch(src, keys) | A key list in one round trip. { [key] = boolean }. |
permission.hasAll(src, keys) | true only if every key is held. An empty list is true. |
| Client function | Description |
|---|---|
permission.isAdmin() / .level() | This player, as last answered by the server. |
permission.onChange(fn) | fn(isAdmin, level) on every change, and once immediately. |
permission.refresh() | Ask the server for a fresh answer. |
isAdminis answered inside your resource, so it works while tLib is still starting.hasmust ask tLib and returnsfalseif tLib cannot answer, since a resolver may deny what ACE would grant.- Use
onChange, not a single read ofexports['tLib']:IsAdmin()at start — that export is cached when tLib starts, so a revoked ace leaves a panel drawing its admin section until the player reconnects.
net
A net event with its guard built into the registration.
net.fromServer (client). Runs the handler only for an event that really came from the
server (source == 65535), not a local TriggerEvent from another resource or a cheat
menu. A rejection is reported once per event name, never per call.
tlib.net.fromServer('myres:serverDefaults', function(defaults)
applyServerDefaults(defaults)
end)net.fromClient (server). The source guard, a rate limit and an optional permission
gate, applied once. The handler is called as fn(src, ...).
tlib.net.fromClient('myres:saveLayout', {
allow = function(src) return gate:allowed(src) end,
onDenied = function(src) TriggerClientEvent('myres:denied', src) end,
}, function(src, layout)
saveLayout(src, layout)
end)| Option | Default | Description |
|---|---|---|
rateLimit | 1000 | Minimum ms between one player's calls. 0 disables it. |
allow | none | fn(src) -> boolean. Only true is a grant; throwing denies. |
onDenied | none | fn(src), so you can tell the player. |
A rate-limited call is dropped silently, so a feature that must reopen a panel it just
closed uses panel:reopen() from opengate, not the request path.
opengate
The "may I open my panel?" handshake: the client asks, the server decides fresh on every open, and the answer is one of three things.
tlib.opengate.serve({
key = 'settings',
allow = function(src) return gate:allowed(src) end,
configOk = function() return myresConfigOk() end,
payload = function(src) return { isAdmin = tlib.permission.isAdmin(src) } end,
})| Server option | Default | Description |
|---|---|---|
key | settings | Namespaces the wire events. Must match the client's. |
allow | required | fn(src) -> boolean. A missing one denies everyone and says so at boot. |
configOk | none | fn() -> boolean. Omit it with no config spec to check. |
payload | none | fn(src) -> table, travelling with the reply. Draw hints only. |
rateLimit | 1000 | Passed through to net.fromClient. |
Checks run in order: rate limit, configOk, then allow. A panel open to everyone writes
allow = function() return true end.
local panel = tlib.opengate.panel({
key = 'settings',
onOpen = function(data) openSettingsDialog(data) end,
onDenied = function() toast("You don't have permission to use this.") end,
onBroken = function() toast("This server's config has errors — ask an admin.") end,
})| Client member | Description |
|---|---|
panel:open() | Ask the server. The answer arrives as one of the three callbacks. |
panel:reopen() | Re-run onOpen with the last answer, no round trip. false if there was never one. |
onOpen | Required. fn(payload). |
onDenied | Required. A denial the player is never told about reads as a bug. |
onBroken | Optional, but omitting it means a broken-config reply says nothing. |
All three replies register through net.fromServer, so a local TriggerEvent can neither
open the panel nor fake a denial. reopen() replays a grant and cannot create one.
platform
The FiveM native adapter, inside your VM.
| Server function | Description |
|---|---|
platform.getPlayers() / getPlayerName(src) | The roster. getPlayerName is nil for src <= 0 or an empty name. |
platform.getPlayerPed(src) / getPlayerCoords(src) | nil rather than a throw for a disconnected source. |
platform.getPlayerFrontSeat(src) | seat, netId — seats -1 and 0 only. A rear seat reads as nil, like on foot. |
platform.getPlayerSeat(src) | seat, netId across every index. |
platform.getPlayerVehicleNetId(src) | Requires OneSync. |
platform.getNearbyPlayers(coords, radius) | Server IDs by squared distance. {} if either argument is falsy. |
platform.resolveNetworkId(netId) / isNetworkIdValid(netId) / doesEntityExist(e) / getEntityCoords(e) | Entity lookups. |
platform.triggerClientEvent(event, target, ...) / triggerEvent(event, ...) | Event dispatch. |
platform.now() / isServer() | GetGameTimer(), and the duplicity check. |
| Client group | Functions |
|---|---|
| This player | getMyId, getMyName, getMyPosition, getMySpeed, getMyEntityHandle, getClothingItem, isPlayerShooting, getActiveWeaponId, cancelAnimations, getIgnoredWeaponIds |
| This vehicle | getMyVehicle, getMyFrontSeat, getMySeat, getFrontSeatServerIds |
| Other entities | getRemotePlayerPosition, getNetworkEntityPosition, isNetworkIdValid, isEntityValid, getNetworkId, resolveNetworkId, getEntityPosition, getCameraState |
| Blips | createMapMarker, removeMapMarker, isMapMarkerValid, moveMapMarker, setMarkerRotation/Icon/Color/Scale/ShortRange/Display/Opacity/Label/HeadingIndicator, getPlayerMapMarker |
| NUI focus | showCursor, releaseCursor, setInputMode, setKeepInputActive, releaseCursorSuppressPause, releaseCursorDeferred |
| Keybinds | bindKey, keybinds, keybindConflicts |
| Voice | voice.isAvailable, voice.backend, voice.onAvailabilityChanged, voice.overrideProximityRange, voice.clearProximityOverride, voice.setSubmixForServerId, voice.setAudioInputIntent |
| Misc | now, getGameTime, loadData/saveData/removeData, sendToEngine, sendToServer, showChatMessage, emitEvent, getResourceId, wrapExport |
getMyVehicle() answers nil or
{ handle, type, displayType, modelHash, isDriver, isEmergency, isEngineOn }, where type
is heli | plane | boat | car and displayType is Air | Boat | Vehicle.
tlib.platform.bindKey({
id = 'toggle',
product = 'tRadar',
action = 'Toggle the radar',
default = 'F5',
press = function() toggle() end,
})Defaults: group keyboard, pair true, default '', which appends "(unbound)" to
the label. The positional form bindKey(key, cb, eventType, bindingId, label) also works.
Each bind is reported to the keybind registry.
MAX_SEAT_INDEX is 16.
occupancy
One shared answer to who is aboard a vehicle, in which tier, and which of them may act on
it. The controller is the driver if one is seated, otherwise the front passenger. In
AUTOLOAD.
| Client function | Description |
|---|---|
occupancy.poll() / .state() | A copy of { seat, vehicle, netId, driver, passenger, partner, isController, tier, cabinSeat, cabinVehicle, cabinNetId }. |
occupancy.isController() | May this client act on the vehicle? |
occupancy.isFrontSeat() / .isDriver() / .isPassenger() / .isRearPassenger() / .isAboard() | Predicates about this player; no arguments. |
occupancy.tier() | driver | passenger | rear | nil. |
occupancy.myVehicle() | handle, netId — nil unless in a front seat. |
occupancy.cabin() | handle, netId, seat, tier — any seat, so nil only on foot. |
occupancy.partner() | The other front seat's server id. |
occupancy.onChange(fn) | fn(state). Returns an unsubscribe function. |
occupancy.startReporting() / .startSweep(ms) / .stopSweep() | Opt in to the move ping and poll loop. Sweep default 250. |
| Server function | Description |
|---|---|
occupancy.isControllerOf(src, netId) / .isFrontSeat(src, netId) / .isAboard(src, netId) | Gates. Each re-reads the host; none can be stale. |
occupancy.refresh(src) / .refreshOccupant(src) | Re-read and repair the cache. seat, netId[, tier]. |
occupancy.seatOf(src) / .occupantSeatOf(src) / .tierOf(src) | The cache. |
occupancy.frontSeats(netId) / .occupantsOf(netId) / .controllerOf(netId) | By vehicle. Copies. |
occupancy.partnerOf(src) / .peersOf(src) | The other front seat / everyone aboard. |
occupancy.onChange(fn) / .onCabinChange(fn) | Front-seat edges / every-seat edges. Both return an unsubscribe. |
occupancy.seatTier(seat) / .clear(src) / .startSweep(ms) / .stopSweep() | Helpers. Sweep default 1000. |
seat,vehicle,netId,partnerandisControllerare front-seat-only andnilfor a rear passenger, sostate.seat ~= nilreads as "may I operate this". A rear passenger appears only intierand thecabin*fields, and onlyonCabinChangefires for them.tlib:occupancy:movedis one event name for every resource; its payload is a hint, since the seat is re-read from the host.- Neither sweep runs until you start it.
jsonstore
The single memoised writer for one resource's JSON data file, with per-owner schema seeding, versioning and forward migration.
| Function | Description |
|---|---|
jsonstore.open(opts) | The store. opts.log is required; file defaults to data.json, resource to GetCurrentResourceName(). Memoised per resource and file. |
store:read() / store:section(name) | The whole document, or one section. {} when absent. |
store:write(sections) | Merge and persist. false when nothing reached the disk. |
store:ensure(schema) | Seed or migrate this owner's slice. Returns what it did. |
store:schemaState(owner) | That result again, or nil. |
store:locked() | Why writes are refused, or nil. |
local store = tlib.jsonstore.open({ log = tlib.logger.create('tDetector') })
store:write({ props = placements })- Migrations must be idempotent. A
writecan report success without the bytes landing, so the next start migrates again. - A migration dropping a top-level key not listed in
allowRemoveis refused and nothing is written. Write a rename as a copy plus anallowRemove. - A malformed schema raises rather than locking.
__schemais keyed by owner. Never stamp a version when applying defaults, or your migrations will skip it.
fs
One function. fs.writeResourceFile(relPath, contents, resource, log) returns true, or
false, err with err never empty. resource defaults to GetCurrentResourceName();
log only controls the one-time warning.
local ok, err = tlib.fs.writeResourceFile('data.json', encoded)
if not ok then log('could not write data.json — ' .. err, tlib.logger.ERROR) endSaveResourceFile is tried first, falling back to the calling resource's own Node
bridge, which exists only if that resource calls registerWriteResourceFile() from
tLib/shared/ts/fs/server.ts and rebuilds its bundle; otherwise err says so by name. No
server.cfg line changes this.
assets
Reconciles a stored list of operator-dropped files against what is in the folder.
| Function | Description |
|---|---|
assets.scan(subdir, opts) | Sorted filenames, or nil, reason. |
assets.reconcile(tracked, diskFiles, opts) | list, changed. Pure. |
assets.sync(tracked, subdir, opts) | Both at once: list, changed, reason. |
assets.slug(filename) | The name with its final extension stripped. |
assets.AUDIO | { "wav", "ogg", "mp3" }. |
local list, changed, reason = tlib.assets.sync(installedSounds, 'layouts/sounds',
{ extensions = tlib.assets.AUDIO })changed reports membership, not length, so a one-in-one-out swap reports true.
Retained records come back byte-identical. scan needs tLib's Node bundle
(ScanResourceFiles); a missing export reads back as "unavailable" rather than throwing.
audio3d
Positional device audio: the server decides who is near enough to hear a vehicle-mounted device, and the client pushes one batched frame per tick into the consumer's own NUI.
local bus = tlib.audio3d.createBus({
event = 'tradar:worldSound',
range = Config.audioRange,
fields = { kind = { type = 'string', max = 16 } },
})
RegisterNetEvent('tradar:emitWorldSound', function(payload) bus:emit(source, payload) end)| Server member | Description |
|---|---|
bus:emit(src, payload) | Receivers reached. 0 for every rejection — on foot, throttled, malformed, nobody near. |
bus:sanitize(payload) | The whitelisted fields, or nil if any present field is malformed. |
bus:receivers(netId, sender) | serverIds, coords. |
bus:setRange(r) / bus:getRange() | Metres, clamped to MAX_RANGE (250). |
bus:stats() | { sent, delivered, throttled, rejected, notAboard, capped }. |
bus:forget(netId) | Drop a vehicle's throttle state. |
createBus asserts event and range. Defaults: maxReceivers 24 (1–128),
minIntervalMs 100 (0–10000), maxPerSecond 20 (1–120). The emitter is the vehicle, so
a sender on foot emits nothing.
| Client member | Description |
|---|---|
audio3d.createReceiver(opts) | Asserts event, send, range. prefix defaults to audio3d, keepAliveMs to 6000. |
rx:setLocal(netId) | Mark the unit this player's own UI already sounds. nil on leaving. |
rx:accept(payload) | true only when the frame was forwarded to the NUI. |
rx:pushFrame() | How long to wait next: 250 idle, else 16/33/50 by listener speed. |
rx:activeEmitters() / rx:getLocal() / rx:stats() | State. |
rx:setRange(r) / rx:stopAll() | Range, and a full teardown. |
Mark your own unit with setLocal, or the player hears two copies of the tone and they
comb-filter.
framerelay
Publishes the device picture computed by the occupant who owns it onto the vehicle's statebag, for every other client in scope to read.
| Member | Description |
|---|---|
framerelay.create(descriptor) | Asserts key, frame, sanitise, differs. |
relay.publishTick() | Safe every frame; writes rarely. |
relay.read(vehicle) | The sanitised frame, or nil — covering none published, malformed and stale. |
relay.withdraw() | Clear the key. Idempotent. |
relay.publishedVehicle() / relay.publishedSeq() | State. |
local relay = tlib.framerelay.create({
key = 'tdetScreenFrame',
frame = function() return localFrame end,
hasScreen = function(vehicle) return tdetGetAttachedProp(vehicle) ~= nil end,
sanitise = tdetScreenSanitiseFrame,
differs = tdetScreenFrameDiffers,
})Defaults: absent hasScreen means yes, heartbeatMs 1000, staleMs 3000.
seq and netId are written by the relay and powered is read by it, so those names are
reserved; powered = false publishes nothing. staleMs clears a crashed driver's last
frame. publishTick reads occupancy.isController() live, so the occupancy sweep must be
running.
drivenprop
Drives an unattached, frozen prop's world matrix from its parent vehicle's, each frame. Use
it when a prop needs a non-unit scale: scale lives in the basis-vector lengths of
SetEntityMatrix, which AttachEntityToEntity does not carry.
| Function | Description |
|---|---|
drivenprop.create(opts) | A driver. opts.onError defaults to a print. |
driver.add(prop, parent, placement) / .update(prop, placement) / .remove(prop) | Membership. |
driver.tick() | written, skipped. |
driver.write(prop) / .isDriven(prop) / .count() / .clear() | The rest. |
drivenprop.normaliseScale(v) / .isUnitScale(v) | Clamped to [0.1, 10.0]; anything unusable lands on 1.0. |
drivenprop.transform(basis, placement) / .localRotation(rx, ry, rz) / .toWorld(basis, L) | The pure maths. |
drivenprop.handedness(f, r, u) / .assertHandedness(...) / .dot / .cross | Basis checks. |
drivenprop.hasMoved(last, basis) | Whether a rewrite is needed. |
adddetaches, freezes and disables collision, then writes the matrix. Undo any of the three and it breaks: attached loses the scale, unfrozen falls through the world, collision-enabled bounces off the vehicle.removeandclearneither delete the prop nor re-attach it; re-attaching discards the scale.transformraises on a left-handed basis, and returns the native's(forward, right, up, at)ordering, not the(right, forward, up)the rest of the module takes.createstarts a per-frame thread and anonResourceStophandler; importing does not. Share one driver per resource.
lifecycle
Keeps a registration against a dependency alive across that dependency's restarts. The worked example is in Menu Consumers.
| Member | Description |
|---|---|
lifecycle.version | 1. Assert it. |
lifecycle.watch({ resource, install, cleanup?, readyEvent? }) | The handle below. |
handle.ready() / .retry() / .invalidate() / .dispose() | Lifecycle. dispose is idempotent. |
ctx.once(name, fn) / ctx.isActive() | Inside install. |
- Watch a dependency, never yourself — your own resource name asserts.
ctx.oncerequiresfnto return exactlytrue; a truthy non-truefails the step. Completed steps are remembered across retries within one generation.- Retries stop after 300 attempts 100 ms apart and print the last real error. Only another
start event or your own
retry()re-arms it.
menuhost
Lets one resource expose navigation slots that other resources register links into. See Menu Consumers.
| Member | Description |
|---|---|
menuhost.version | 1. Assert it. |
menuhost.install(slots) | Once only — a second call asserts. |
RegisterPluginMenu(spec) | Host-scoped export it registers. { id, label, menu, slot, description? }. |
UnregisterPluginMenu(id) | The other one. |
Per slot, enabled defaults to on, an absent resources allowlist admits any resource, and
an unranked entry sorts at 1000. A slot's menu must be one of the host's own pages and a
plugin's menu one of the plugin's. The caller comes from GetInvokingResource(), so the
host cannot register on its own behalf. install fires tlib:menu:hostReady before
returning — wait on that, not on the host resource starting.
menu
Loads tLib's menu validators into your VM: tlib.menu.schema is MenuSchema,
tlib.menu.catalog is MenuCatalog.
Loading it also defines the bare globals MenuSchema and MenuCatalog. If your shared
files read those globals, declare tlib_module { 'menu' }, or the lazy load has not run and
they are nil.
healthcheck
Ordered HTTP GET probes against a base URL, with retries, delivering one aggregate result.
tlib.healthcheck(opts) and .check(opts) are the same call and return nothing.
Everything arrives at opts.callback as
{ success, steps, attempts, failedStep, message, url }, each step being
{ name, passed, status, error }.
- Defaults: one step (
reachable, path/),retries2 (three attempts),retryDelay1000,timeout8000,delay500. With noexpect, a step passes on any status 200–599. - Give it
opts.urloropts.waitEvent;waitEventwins and takes the URL from the event payload. With neither it does nothing. - The sequence short-circuits, so
result.stepsis shorter thanopts.stepson a failure and a retry restarts at step 1. A timeout isstatus = 0, not an error. %port%in a step'serrorMsgbecomes the port parsed from the URL — 443, 80, or a literal?.
marketplace
The consumer's half of the in-game marketplace: the payload that opens the browser, and the server-side installed-sound registry.
| Client function | Description |
|---|---|
marketplace.openPanel(resource, layoutNames, soundEntries, opts) | Open the standalone panel. |
marketplace.provide(resource, layoutNames, soundEntries, opts) | The same payload into a tab of a dialog you already opened. Takes no focus. |
marketplace.baseUrl() | The studio API host. Re-read per call, so tlib_api_base_url needs no rejoin. |
opts carries initialTab (uis or sounds), previewFn, previewState and
panelPreviewUrl, which lets a theme be shown on a real dispatch panel instead of as a
swatch strip. Only tRadio publishes one.
| Server function | Description |
|---|---|
marketplace.standardTypes(resourceName, opts) | Register the standard ui and sound types and wire their lifecycle events. |
marketplace.registerType(kind, installPath, warn) | One type. true when tLib accepted it. |
marketplace.upsertSound(installed, slug) | The slug already owning that filename, or nil. |
marketplace.removeSound(installed, toneWavMap, slug, scopedMaps) | The filename removed, or nil. |
marketplace.forgetSoundFile(filename, flatMap, scopedMaps) | changed. |
marketplace.soundConflict(conflict, slug, meta, warnEvent, warn) | true only when a client was really messaged. |
tlib.marketplace.standardTypes(GetCurrentResourceName(), {
note = note, warn = warn,
layouts = { installed = onLayoutInstalled, uninstalled = onLayoutRemoved },
sounds = { warnEvent = 'tdet:soundOverwriteWarning',
installed = onSoundInstalled, uninstalled = onSoundRemoved },
})Paths default to layouts and layouts/sounds; layouts = false or sounds = false skips
a type. note and warn take one argument, a message — not a logger plus a level.
meta.src is nil when the installing admin disconnected mid-install.
hud
A transport bridge for a consumer with its own NUI that wants its widget in tLib's shared HUD editor.
| Function | Description |
|---|---|
tLibHud.register(id, definition) | ok, err. |
tLibHud.unregister(id) | Also purges the cache, so the widget cannot come back. |
tLibHud.set(id, layout) | ok, err. |
tLibHud.edit() | Open the shared editor. |
- Updates arrive as
SendNUIMessage({ tlibHud = data })— one wrapper key, not anactionstring — queued until your NUI calls__tlib_hud_ready. - Definitions survive a tLib restart. Register once; never re-register on
onClientResourceStart. A registration made while tLib is stopped, or refused because the HUD editor is open, is cached and retried for a minute. - When tLib stops you receive the last committed geometry with
phase = 'rollback'andediting = false. Handle that phase, or a stop mid-edit leaves a preview the player cannot cancel.
ratelimit
A per-player minimum interval on a net event, as a closure you call at the top of the handler.
| Function | Description |
|---|---|
ratelimit.bucket(name, intervalMs) | Returns fn(src) -> boolean. true means refused. |
ratelimit.forget(src) | Drop one player's timestamps. nil drops every player. |
ratelimit.reset() | Drop the buckets themselves. Tests only. |
local gate = tlib.ratelimit.bucket('requestVehicleProps', 1000)
RegisterNetEvent('tdet:requestVehicleProps', function()
local src = source
if not src or src <= 0 then return end
if gate(src) then return end
end)gate(nil) answers false. Names are scoped to your own VM; the same name twice hands back
two closures over one timestamp table and the last intervalMs wins. Disconnected
players are swept for you. net.fromClient already applies a rate limit, so use this
directly only for something that is not a net event, or to put several events on one budget.
realtime
Rooms, typed events and the upstream control socket, over tLib's WebSocket router.
tlib_module { 'realtime' } is required, or the rt* exports are never registered.
| Function | Description |
|---|---|
realtime:issueToken(playerId, opts) | { token, expiresAt, wsUrl }. opts.meta travels as JSON. |
realtime:connectionInfo() | { wsUrl, port, host }. |
realtime:kick(playerId, reason) | Sessions closed. reason defaults to kicked. |
realtime:setRooms(playerId, roomIds) / :getRooms(playerId) | Membership. getRooms never returns nil. |
realtime:sendEvent(playerId, type, payload) | Sessions reached. |
realtime:broadcastEvent(roomId, type, payload, opts) | Sessions reached. opts.exceptPlayerId skips one. |
realtime:publish(roomId, bytes) | A fanout frame, stamped senderId 0. |
realtime:onConnect(fn) / :onDisconnect(fn) | fn(playerId, sessionId, meta) / (…, code, reason). |
realtime:onEvent(type, fn) / :onEvent(fn) | One type, or all as fn(playerId, sessionId, type, payload). |
realtime:stats() | Transport counters. |
realtime:startControl(cfg) / :stopControl() | The upstream socket. cfg is { license, tokenEndpoint }. |
realtime:sendControl(type, data, correlation) | One control frame. |
realtime:onControlRecv(fn) / :onControlState(fn) | fn(type, data, correlation) / fn(state, wsUrl). |
realtime:controlState() | The state string, or nil. |
tlib.realtime:setRooms(playerId, { 'radio:freq:154.125' })
tlib.realtime:onEvent('ptt', function(playerId, sessionId, payload) end)Handler lists are per consumer VM, so two resources can subscribe to one event. A handler that throws is caught and printed, not propagated. Audio frames do not come through here.
inference
Announce a model runtime this resource started, and find one another resource already has up
for the same weights. Colon-called, like realtime. The exports underneath are in
Exports; to start the engine, see
Child processes.
| Function | Description |
|---|---|
inference:advertise(descriptor) | A handle, or nil if the advertisement was rejected. |
inference:lookup(capability, workload) | Another resource's advertisement, or nil. Never your own. |
inference:withdraw(handle) | Drop one of yours. Stopping the resource does this anyway. |
inference:list() | Everything registered, for diagnostics. |
local shared = tlib.inference:lookup('text', 'qwen3-1.7b-q4_k_m')
if not shared then
local handle = tlib.inference:advertise({
capability = 'text',
workload = 'qwen3-1.7b-q4_k_m',
url = 'http://127.0.0.1:18291/v1',
model = 'local',
})
endGive url as the full URL including the route; nothing is appended. Only 127.0.0.1,
[::1] and localhost are accepted. Re-advertising replaces every entry for the same
resource and capability, so moving an engine to a new port needs no withdraw.
soundstore
The tone-key to .wav registry behind a resource's sound settings, with a boot
reconciliation against what is on disk.
| Function | Description |
|---|---|
soundstore.open(opts) | The store. opts.log is required. |
store:config() | { soundWavMap, installedSounds, defaultSoundKeys }. |
store:setMapping(key, filename) | Map a key to a file; an empty filename clears it. Saves. |
store:removeSound(filename) | Forget a file everywhere it is mapped. Saves. |
store:install(slug) / store:uninstall(slug) | The slug already claiming that filename / the filename removed. Saves. |
store:save() | Write now. Every mutator above already does. |
Defaults: resource to GetCurrentResourceName(), dir to layouts/sounds, file to
data.json, keys to {}.
An empty directory scan counts as a failed scan. The store shares your resource's
jsonstore object.
secrets
Overlays a gitignored config.local.lua and then matching convars onto Config in place,
so the tracked config file ships blank. Server-only.
| Function | Description |
|---|---|
secrets.overlay(config, opts) | Apply both layers. { applied, errors, sidecar }. |
secrets.read(resource) | The sidecar table, or nil, err. nil, nil when there is no file. |
secrets.credentialPaths(config) | Dotted paths of every credential-shaped key, sorted. |
Precedence is convar, then config.local.lua, then config.lua. A convar is named
<prefix>_<dotted_path_with_underscores>. opts.convarPrefix defaults to the resource name
lowercased, opts.resource to GetCurrentResourceName(), and opts.quiet is falsy, so it
prints what it applied. The sidecar loads into an empty environment: it must return a
plain table and can call nothing, not even print. A list in the sidecar replaces the
one in config.lua rather than appending.
spatial
A throttled index of player positions: the cached counterpart to
tlib.platform.getNearbyPlayers.
| Function | Side | Description |
|---|---|---|
spatial.getNearbyPlayers(coords, radius) | both | Server IDs within radius. |
spatial.setReady() | server | Start building the index. |
spatial.setRefreshRate(ms) | both | Rebuild interval. Default 100. |
spatial.clear() | both | Empty the index. |
The two halves are separate indexes with separate clocks. setRefreshRate applies to the
whole VM.
telemetry
The diagnostics pipeline: scrubbing, fingerprinting, operation timing and the batched send.
| Function | Description |
|---|---|
telemetry.error(err, site, kind) | Report a fault. Returns the stack hash — even when deduped. |
telemetry.warn(code, kind, message) | Report a warning. A code that slugs to empty is dropped. |
telemetry.timed(operation, thresholdMs, fn) | Run fn, recording how long it took. Re-raises a throw. |
telemetry.record(operation, thresholdMs, ms) | The same, for something you timed yourself. |
telemetry.consent() | { enabled, source, locked }. A cache, not the authority. |
telemetry.flush() | Send the pending batch now. |
telemetry.scrub(text) / .scrubIdentifiers(text) | Redact registered values / anything identifier-shaped. |
telemetry.registerScrubValue(value) | Add a value to redact. Refuses anything under 3 characters. |
telemetry.createTimers(opts) | A private { record, timed, reset }. opts.report is required. |
While consent is off, events are dropped rather than queued, so turning diagnostics on sends no backlog. With it on, the queue caps at 32 and discards oldest first. A fault is deduped for 120 seconds per fingerprint.
screentarget
Picks the one vehicle whose device screen this client renders — AddReplaceTexture is
keyed on the texture name, so there can only be one. Your own vehicle always wins,
otherwise the nearest in range, with hysteresis.
| Member | Description |
|---|---|
screentarget.create(opts) | An arbiter. opts.hasScreen is required. |
self.update() | vehicle, netId, source — or a single nil when nothing qualifies. |
self.current() | The same, without re-deciding. |
self.currentDistance() | Distance at the last update; 0 for your own vehicle. |
self.onChange(fn) | fn(vehicle, netId, source). Returns an unsubscribe function. |
self.reset() | Forget the incumbent. |
self.settings() | { range, switchMargin, dwellMs }. |
Defaults: range 8.0 m, switchMargin 2.0 m (absolute metres, not a fraction), dwellMs
1500. source is own or nearest.
onChange fires only on a vehicle or source change, never on distance, so it is safe to
rebuild a browser inside it. Treat update() returning nil as the signal to tear that
browser down, or it paints stale pixels onto a vehicle the player has left. dwellMs delays
displacement only. Create one arbiter per device kind.
runtimetxd
Calls CREATE_RUNTIME_TXD at most once per resource load, and mints names a restart
cannot collide with.
| Function | Description |
|---|---|
runtimetxd.create(prefix) | A per-resource txd owner. |
txds.ensure() | The txd handle, created on first call and cached. |
txds.name() / txds.tag() | The dictionary name, and the per-load tag inside it. |
txds.textureName(stem, serial) | <stem>_<tag>, or <stem>_<tag>_<serial>. |
txds.count() | How many times the native was called, not how many names. |
local txds = tlib.runtimetxd.create('tdet_scr_txd')
local name = txds.textureName('tdet_screen', serial)Budget one permanently pinned streaming slot per resource load: the engine frees neither a
runtime txd nor a runtime texture, so repeated restarts accumulate them. Reusing a name
across a restart instead makes AddReplaceTexture resolve against the previous load's
dictionary and silently do nothing.
soundfields
Builds the Sounds tab of a settings dialog and keeps it live, remembering which keys the open dialog drew.
| Function | Description |
|---|---|
soundfields.append(fields, opts) | Append the dropdowns. Mutates fields; returns the keys drawn. |
soundfields.refresh(dialogId, data, patch) | Re-push options after a server change. Returns how many it patched. |
soundfields.keyOf(fieldId) / .fieldId(key) | Convert between a tone key and its field id. |
soundfields.drawn() / soundfields.clear() | The remembered key list; forget it. |
The remembered list is module-level, so keep one dialog open at a time per resource.
refresh patches what was drawn, not the current config, so a sound key added
server-side after the dialog opened appears only at the next open.
propeditor and vehicleprop
The in-vehicle prop stack. vehicleprop owns the model registry, the placement store and
the attach/detach lifecycle; propeditor owns the orbit camera and the editing UI. Create
vehicleprop and reach the editor through instance.editor.
local prop = tlib.vehicleprop.create({
prefix = 'tdet', model = 'prop_cs_police_torch', decor = 'tdet_prop',
saveEvent = 'tdet:savePlacement', removeEvent = 'tdet:removePlacement',
})| Member | Description |
|---|---|
instance.setRegistry(cfg) / .isRegisteredModel(n) / .resolveModel(n) / .defaultModel() | The model registry. |
instance.getPlacement(veh) / .setPlacements(src) / .getAttached(veh) / .detach(veh) | Placements and props. |
instance.reconcile() / .tick() / .reclaimOrphans() / .cleanupAll() | Lifecycle, exposed so a suite can drive it without a thread. |
instance.editor | The propeditor instance, including editor.driver. |
propeditor.create(descriptor) | The editor on its own, without vehicleprop. |
propeditor.orbit.* | The camera maths — new, eye, lookFrom, applyDrag, zoom, command. |
vehicleprop.normaliseModelName(m) / .buildRegistry(builtin, cfg) | Pure helpers. |
create asserts prefix, model, decor, saveEvent and removeEvent.
- Scale decides the attach mechanism. A unit scale uses
AttachEntityToEntity; anything else is driven per frame throughdrivenprop. Never test "is this attached" to decide whether a prop is live — ask the tracking table. CreateObjectsignals failure with0, which Lua treats as true.if not prop thennever catches it, and the prop simply never appears.- The placement, attached and model tables are emptied in place, never replaced.
- A record the editor did not change round-trips byte-identical.
receiveEventaccepts server-triggered events only (source == 65535).
versioncheck
tlib.versioncheck(opts) — the startup banner, version comparison and changelog. It is both
callable and a table, so tlib.versioncheck(opts) and .check(opts) are the same call,
alongside .github(repo), .studios(product, current) and .lean(...). It has its own
page: Version Check.
tLib
Still stuck? Support can answer it directly.
Was this page helpful?
TIMMYG Studios