Bridge
The bridge is how every tm_* resource talks to a framework, inventory, target, notification, text UI or radial menu system without knowing which one is installed. Feature resources call tm.fw, tm.inventory, tm.target, tm.notify, tm.showTextUI and tm.radial and never a framework API directly.
local job = tm.fw.getJob(source) -- same table shape on ox_core, Qbox, QBCore and ESX
if tm.fw.hasGroup(source, { police = 2 }) then ... end
tm.inventory.addItem(source, 'water', 2)
tm.target.addSphere({ coords = coords, radius = 1.5, options = { ... } })How it loads
tm_lib/data/config.luadetects what is installed once at boot (tm.getConfig()returns{ framework, inventory, target, notify, textui, radial }).tm.fw,tm.inventoryandtm.targetare imports. The first access runstm.bridge.load(kind)inside the calling resource, which loadsbridge/<kind>/<name>/<side>.lua, checks it against the contract below and logs every missing function once. A missing optional function isnil, so check before calling an optional one.- Adapters live in
tm_lib/bridge/framework/<name>/,bridge/inventory/<name>/andbridge/target/<name>/, oneserver.luaand/orclient.luaeach. Client files must match thebridge/**/client.luaentry in the manifestfileslist. tm_libitself loads each adapter once and re-emits the framework's native events as normalized events (below), so consumers never listen to framework events.
Rules every adapter follows
- Same arguments, same return shape on every framework. Normalize inside the adapter.
- Never throw. Wrap framework calls in
pcallwhere they can fail. On failure returnnil, 'reason'(orfalse, 'reason'for actions). A framework that cannot do something returnsnil, 'unsupported'and says so in a comment at the top of its file. - Never call a framework function that blocks (a database call, a callback round trip) from a function documented as cheap. Anything that hits the database says so in its doc line and is only allowed where listed.
- Framework tables are only read inside an adapter. If a feature needs
playersorplayer_vehiclesdata it goes through a contract function (for examplegetVehicleOwner), implemented per framework. - No globals, and no UI calls except through
tm. Usetm.logfor logging andlib.require/requireonly if needed. - Detect, do not assume. Framework exports must be fetched lazily (the framework may start after
tm_lib) and re-fetched after a restart of the framework resource. Cache a handle, but re-resolve it inAddEventHandler('onResourceStart', ...)for that resource. Soft dependencies checkGetResourceState(name) == 'started'before calling. - Cache. Anything called per frame or per request (identifier, job) is read from the framework's own cached player object, never rebuilt.
Vocabulary (normalized)
| Term | Meaning |
|---|---|
source | Server id (number). |
identifier | The active character id as a string: ox_core charId, Qbox/QBCore citizenid, ESX identifier. Stable across sessions. |
license | The Rockstar license identifier string (license:...) of the account, from the player identifiers. |
gender | 'male', 'female' or 'other'. Map each framework's native values. |
| account | 'cash' and 'bank' are the two normalized accounts ('money' is accepted as an alias for 'cash'). Any other account name is passed through unchanged for that framework (for example 'black_money', 'crypto'). |
| job | { name, label, grade, gradeName, gradeLabel, isBoss, onDuty }. grade is the framework's own number (so it can be passed back to setJob). name/label strings, isBoss/onDuty booleans (onDuty is true when the framework has no duty concept). |
| group filter | A string ('police'), an array ({ 'police', 'sheriff' }) or a map of name to minimum grade ({ police = 2 }). |
Framework contract
Server (tm.fw, file bridge/framework/<name>/server.lua)
Required:
| Function | Returns | Notes |
|---|---|---|
getPlayers() | source[] | Loaded players only (a character is active). |
isLoaded(source) | boolean | A character is active for this source. |
getIdentifier(source) | string? | Character id. nil when not loaded. |
getLicense(source) | string? | Account license. |
getName(source) | string? | 'First Last' of the active character. |
getCharacter(source) | { identifier, firstName, lastName, birthDate, gender, phone? }? | birthDate as the framework stores it, phone only if the framework has one. |
getSource(identifier) | number? | Online source for a character id. |
getJob(source) | job? | See vocabulary. |
setJob(source, name, grade) | boolean, string? | Grade is the framework's own number. |
setDuty(source, onDuty) | boolean, string? | false, 'unsupported' where there is no duty. |
getGroups(source) | table<string, number> | Every job, gang and group the character is in, name to grade. Empty table when none. |
hasGroup(source, filter) | string?, number? | Name and grade of the first match, otherwise nil. Duty is not considered. |
getMoney(source, account) | number | 0 for an unknown account or player. |
addMoney(source, account, amount, reason?) | boolean, string? | amount is a positive integer. |
removeMoney(source, account, amount, reason?) | boolean, string? | Fails (returns false, 'insufficient') instead of going negative. |
getMetadata(source, key) | any | Character metadata (hunger, licenses, anything the framework stores). |
setMetadata(source, key, value) | boolean, string? | |
isAdmin(source) | boolean | Staff check the admin hub uses: the framework's admin permission or ace group.admin. Console (0) is not an admin here. |
Optional (implement when the framework makes it possible, otherwise leave nil):
| Function | Returns | Notes |
|---|---|---|
getVehicleOwner(plate) | { identifier, name }? | Reads the framework's vehicle table. May query the database. |
getPlayerObject(source) | any | The framework's native player object, for escape hatches. Not for general use. |
listen(emit) | none | Only called once by tm_lib itself. Subscribe to native events and call emit(eventName, ...) with the normalized names below. |
Client (tm.fw, file bridge/framework/<name>/client.lua)
Required: isLoaded(), getIdentifier(), getName(), getGender(), getJob() (job), getGroups() (same shape as server), hasGroup(filter), isOnDuty(), isDead() (dead or in last stand, whichever the framework tracks). Optional: listen(emit) as above (client side).
Normalized events
Fired by tm_lib once, as plain local events on the side they happen. Listen with AddEventHandler:
| Event | Server arguments | Client arguments |
|---|---|---|
tm_lib:fw:playerLoaded | source | none |
tm_lib:fw:playerUnloaded | source, identifier | none |
tm_lib:fw:jobChanged | source, job | job |
tm_lib:fw:dutyChanged | source, onDuty | onDuty |
Behavior every adapter shares (agreed while writing the four adapters)
getJob()isnilonly when no character is loaded. A character with no job has a real job namedunemployed(ox_core builds one, because it has groups instead of jobs). ClientisOnDuty()isfalsewhile no character is loaded.getGroupsandhasGroupinclude jobs, gangs and framework groups, but leave out placeholder entries (the "no gang"noneof QBCore and Qbox) and the ESX permission group (that one is staff data, read throughisAdmin).removeMoneynever goes negative: it returnsfalse, 'insufficient'.- Money calls can yield. ox_core accounts query the database, and so does bank on some setups. Never call money functions per frame.
- On QBCore and ESX the server adapters keep a per-source cache of identity, job and group reads. The framework's own events invalidate it, a short time limit is the safety net, and money and metadata are never cached. Still never call framework reads in a per-tick loop. Client reads are cached.
setMetadatacan returntrue, 'volatile'(accepted but not saved, ox_core outside status keys) orfalse, 'unsupported_type'(ESX refuses booleans).- Duty mapping: QBCore and Qbox have a duty flag. ESX 1.16 sets duty through
setJob. ox_core has no duty, so the active group stands in for it. Older ESX has none andsetDutyreturnsfalse, 'unsupported'. isBoss: QBCore and Qbox use the framework flag. ESX uses the grade namedboss. ox_core uses an account role ofmanagerorowner.getVehicleOwnerreads the framework's vehicle table throughtm.dband may take a database round trip.- Client adapters keep their local mirror of the player current from module load, in every resource that loads them, because the mirror has to be live where it is read.
listen(emit)(called once by tm_lib) only switches on the normalized events.
Inventory contract
Server (bridge/inventory/<name>/server.lua)
| Function | Returns |
|---|---|
getItemCount(source, item, metadata?) | number |
hasItem(source, item, count?, metadata?) | boolean |
canCarry(source, item, count, metadata?) | boolean |
addItem(source, item, count, metadata?) | boolean, string? |
removeItem(source, item, count, metadata?) | boolean, string? |
getItems(source) | { { name, label, count, slot, metadata }... } |
getItemLabel(item) | string? |
getItemSlot(source, slot) | { name, count, slot, metadata }? |
setItemMetadata(source, slot, metadata) | boolean |
clearInventory(source) | boolean |
registerStash(id, label, slots, maxWeight, owner?) | boolean |
registerShop(id, shop) | boolean (shop = { name, items = { { name, price, count? }... } }) |
openStash(source, id) | boolean |
Client (bridge/inventory/<name>/client.lua)
openPlayerInventory(), openStash(id), openShop(id), getImagePath() (URL prefix for item images, used by the UI kit), isBusy().
tm_lib/data/bridge.lua lists the required functions per kind and side. The contract check logs each one that is missing.
Inventory behavior every adapter shares
metadataarguments are partial matches (an item matches when it has at least those keys).setItemMetadatareplaces the slot's metadata. An adapter whose inventory has no item metadata (the ESX native inventory) setssupportsMetadata = falseand answersnil, 'unsupported'orfalse, 'unsupported', so check the field before relying on metadata.registerStash(id, label, slots, maxWeight, owner?, groups?, coords?):ownerisnilfor a shared stash,truefor one stash per player, or a string for a fixed owner.groupsuses the group filter.coordslimits opening to a place.registerShop(id, shop)accepts the samegroupsandcoordsinsideshop. Stashes, shops and target registrations are remembered and registered again when the inventory or target resource restarts, soensureorder does not matter.itemsfilters (target options, shops) mean all of the listed items are required.isBusy()is true while the inventory is open or busy.- Client
openPlayerInventory,openStashandopenShopreturntrue, orfalse, reasonwhen the inventory cannot do it (ESX native). On qb-inventory, which has no client open API, the client asks the same resource's server adapter, so a resource that opens stashes or shops from the client on qb-inventory must also touchtm.inventoryon its server side. The call returnstrueonce the request is sent. - Optional server function:
openShop(source, id). - Capacity:
canCarryis exact on ox_inventory and qb-inventory, computed from the limits in itsconfig.luaon ps-inventory (optimistictrueif they cannot be read), and optimistic on the framework-native QBCore-style inventories. - ps-inventory shares the qb-inventory adapter.
Target contract (bridge/target/<name>/client.lua)
Option table (normalized, translated by each adapter): { name, label, icon, distance, groups, items, canInteract(entity, distance, coords, name), onSelect(data), event, serverEvent }. groups uses the group filter, items is an item name or array.
| Function | Returns |
|---|---|
addEntity(netIds, options) / addLocalEntity(entities, options) | none |
addModel(models, options) | none |
addSphere({ coords, radius, options, debug? }) | zone id |
addBox({ coords, size, rotation, options, debug? }) | zone id |
removeZone(id), removeEntity(netIds, names?), removeLocalEntity(entities, names?), removeModel(models, names?) | none |
disable(state) | none, hides and ignores targets while true |
target = 'none' loads a no-op adapter so resources never branch on it.
Target adapters may return true or false, reason from registration functions (zones return an id). qb-target has no onSelect, serverEvent or groups, so its adapter wraps them: groups is checked through tm.fw.hasGroup, canInteract coordinates are the zone center or the entity position (qb-target has no hit point), and a box rotation is passed as the PolyZone heading.
Notify and text UI contract (bridge/notify/<name>/client.lua, bridge/textui/<name>/client.lua)
These two differ from the others in three ways. They are client only, they are never auto detected (ox_lib is always installed, so detection would pick it on every server and the Twilight Moon interface would never show), and they run inside tm_lib only. A resource keeps calling tm.notify(...), tm.showTextUI(...) and friends, and tm_lib's dispatcher (resource/interface/client/notify.lua and textui.lua) normalizes the request and hands it to the adapter. The server sets setr tm:notify <name> and setr tm:textui <name> (default tm, the built in interface). An adapter that defines isAvailable() is skipped while it returns false, and the built in interface shows instead, so a stopped notify resource never swallows a message. A failing adapter call is logged, never thrown.
| Adapter | tm:notify | tm:textui |
|---|---|---|
tm | built in toasts (default) | built in prompts (default) |
ox_lib | lib.notify | lib.showTextUI (one prompt at a time) |
qbx_core | exports.qbx_core:Notify | none, Qbox uses ox_lib |
qb-core | QBCore:Notify event | DrawText, ChangeText, HideText (one at a time) |
es_extended | esx:showNotification event | ESX.TextUI, ESX.HideUI (one at a time) |
okokNotify | Alert export | none |
wasabi_notify | notify export | none |
none | shows nothing | shows nothing |
Not bridged yet: ps-ui (archived, merged into ps_lib, and it has no notify or text UI to call) and okokTextUI (no official documentation to write the adapter against). Both can be added as a custom adapter from the templates below.
Notify (show, dismiss, clear, all required)
| Function | Notes |
|---|---|
show(item) | item is { id, type, tone, message, caption, duration, position, icon, iconColor, avatar, color, progress, spinner, html, multiLine }. id is always set, message always a string. tone is type reduced to 'success', 'error', 'warning' or 'info', so an adapter for a system with fixed types maps tone, not type. duration is milliseconds, 0 keeps it until dismissed, nil means the system's default. |
dismiss(id) | Removes one toast early. Systems that cannot (every foreign adapter here) do nothing. |
clear() | Removes every toast. Same rule. |
Text UI (show, hide required, multi and single flags)
| Function | Notes |
|---|---|
show(item) | item is { id, message, type, tone, icon, iconColor, avatar, color, position, html, multiLine }. The dispatcher already skips a call whose content did not change. |
hide(id) | Takes the prompt with this id off the screen. |
An adapter whose system shows one prompt at a time sets single = true. The dispatcher then shows the newest prompt, and when it is hidden it shows the newest one still open instead of leaving the screen empty. isTextUIOpen is answered by the dispatcher from its own list, so it works the same on every adapter. Prompts opened by a resource are closed when that resource stops, whatever the adapter.
Writing your own notify or text UI adapter
Copy a template, edit the marked lines, and set the convar to custom (the radial menu has a template too, see the next section):
| For | Copy | To | Convar |
|---|---|---|---|
| Notifications | templates/bridge/notify/client.lua | bridge/notify/custom/client.lua | setr tm:notify custom |
| Text UI | templates/bridge/textui/client.lua | bridge/textui/custom/client.lua | setr tm:textui custom |
Each template is a working adapter for an imaginary resource, with comments on every field the adapter receives. It includes the optional isAvailable(), so a stopped resource falls back to the built in UI. Restart tm_lib after changing it. If the custom file is missing, boot logs it and the built in toasts and prompts show instead. The full walkthrough, for every kind of adapter, is Writing your own bridge.
What a foreign system cannot do
Each adapter lists what it ignores at the top of its file. In short: ox_lib, qbx_core, qb-core and es_extended cannot remove a toast early (so ongoing toasts and duration = 0 fall back to the system's default duration), none of them render html, spinner or avatar, and the text UI of all three is one prompt at a time with fewer positions than the built in nine zones. The qb-core, ESX, ox_lib and wasabi_notify adapters are written against those systems' public docs, qbx_core's against its source, and okokNotify's against community guides only (it is a paid script with no public docs). None has been run in game yet.
Radial menu contract (bridge/radial/<name>/client.lua)
The radial (wheel) menu follows the notify and text UI rules: client only, never auto detected, and run inside tm_lib. A resource keeps calling tm.radial.addItem(...) and friends (see Radial menu), and tm_lib's dispatcher (resource/interface/client/radial.lua) keeps the model and hands it to the adapter. The server sets setr tm:radial <name> (default tm, the built in wheel). An adapter that defines isAvailable() is skipped while it returns false, and the built in wheel serves instead.
| Adapter | tm:radial |
|---|---|
tm | built in Twilight Moon wheel (default, F1, rebindable) |
ox_lib | lib.addRadialItem, lib.removeRadialItem, lib.registerRadial, lib.hideRadial, lib.disableRadial, lib.getCurrentRadialId |
qb-radialmenu | AddOption and RemoveOption exports |
none | shows nothing |
Functions
addItem, removeItem, registerMenu, close and disable are required. removeMenu, open and isOpen are optional.
| Function | Notes |
|---|---|
addItem(item) | Adds an item to the main wheel, or replaces the one with the same id. |
removeItem(id) | Removes it. |
registerMenu(menu) | menu is { id, title?, items }, items already sorted. Replaces a menu with the same id. The dispatcher registers a menu before the items that point at it. |
removeMenu(id) | Optional. Forgets a menu. |
open(menuId?) | Optional. Returns true, or false, reason. A system that only opens from its own key leaves it out or answers false, 'unsupported'. |
close() | Closes the wheel if it is open. |
disable(state) | While state is true the wheel must not open. |
isOpen() | Optional. Whether the wheel is on screen. |
isAvailable() | Optional, like notify. |
resource | Optional field, the name of the resource that draws the wheel. tm_lib then notices when it stops or restarts (see below). |
dynamic | Optional flag, set only by the built in wheel: it evaluates canShow itself every time it opens. |
item is { id, label, icon, order, menu, keepOpen, disabled, onSelect, canShow }, and menu is { id, title, items }. An adapter never sees a callback of another resource: onSelect is a closure that runs the dispatcher's own activate (the item's onSelect, event, serverEvent and command, each protected), and canShow is a closure that calls the resource's function protected. An item that opens a menu has menu set and no onSelect, the adapter opens that menu. icon is a string or an ox_lib style tuple, so map it or leave it out.
Behavior every adapter shares
- The dispatcher keeps the model. It pushes every change to the active adapter, and when the active adapter changes it moves the whole model: menus first, then the items in display order (
order, then the order they were added in), then the disabled flag, after closing and emptying the one it came from. - The configured system stopping, or being missing at boot, moves the model into the built in wheel. When the system starts again (or restarts, which wipes its own items) the model is moved back after a short delay. An adapter must set
resourcefor a restart that is faster than that delay to be noticed. Nothing is sent to a system that was stopped. - Everything a resource added is removed from the active adapter when that resource stops, and everything tm_lib added is removed when tm_lib stops. Adapters never clear a system's whole wheel, so items other resources gave it directly stay.
canShowon systems that cannot hide an item per open: the item is shown, and picking it does nothing whilecanShowsays no.disableditems on those systems do nothing either.- An adapter call that fails is logged, never thrown.
false, reasonfrom an adapter is logged at debug level only. - The Twilight Moon key (
tm_radial) only acts while the built in wheel is the active one.
What each bundled adapter cannot do
ox_lib | qb-radialmenu | |
|---|---|---|
| Open from Lua | no, open answers false, 'unsupported' | no |
| Close from Lua | yes (lib.hideRadial) | no |
| Disable | yes | no, disable has no effect |
| Remove a menu | no, a registered menu stays (nothing points at it) | menus are nested into the items that open them, so removing one republishes those items |
isOpen | only true inside a submenu | no |
order | only when the model is moved in, incremental changes are listed in the order they were added | ignored |
canShow, disabled | checked when the item is picked | checked when the item is picked |
| Icons | Font Awesome names only | Font Awesome names only |
| Menu titles | not shown | not shown |
The ox_lib adapter is written against the public ox_lib docs. The qb-radialmenu adapter is written from how illenium-appearance and Qbox's qbx_radialmenu call it, because the copy installed on the QB test server has no files in it, so its own source could not be read: the two exports are certain, the nested items list used for submenus is written from its public config format and is the least certain part. Neither has been run in game.
Writing your own radial menu adapter
| For | Copy | To | Convar |
|---|---|---|---|
| Radial menu | templates/bridge/radial/client.lua | bridge/radial/custom/client.lua | setr tm:radial custom |
The template is a working adapter for an imaginary resource and follows the same steps as the others in Writing your own bridge. If the custom file is missing, boot logs it and the built in wheel is used.