High‑performance, cache-first API for translating between ephemeral (dynamic) player IDs and stable (static/database) IDs. Supports ESX, QBCore, pure standalone mode, and oxmysql — with robust persistence, localization, and integrity tooling.
- O(1) lookups via layered in‑memory cache: identifier ⇄ static_id ⇄ dynamic_id
- Periodic refresh (bounded) & pruning of stale dynamic mappings
- Preload on join for zero-latency first access
- Locales (EN / DE) – easily extend with your own
- Optional persistent snapshot (JSON + checksum) for fast cold starts
- Parameterized SQL everywhere (oxmysql) – minimal injection risk
- Clean, consistent exports & safe (error-coded) wrapper variants
- Modular configuration (
config.lua
) with documented sections - Optional dedicated
static_ids
table + one-time migration helper - ESX, QBCore, or fully standalone (identifier prefix priority)
- Conflict detection loop (multi-server divergence guard)
- Bulk resolution helper & rich admin/diagnostic commands
- Client HUD exports:
ShowStaticID
+ safe tupleSafeShowStaticID
+ reactiveRegisterStaticIDListener
- Drop the folder into
resources/
(optionally rename tostaticid
). - Ensure dependency order in
server.cfg
:
ensure oxmysql
ensure es_extended # or qb-core
ensure staticid
- (Optional) Enable separate table mode for clean sequential IDs (recommended):
-- config.lua
Config.DB.UseSeparateStaticTable = true
Config.DB.AutoCreateTable = true
- In any server script:
local sid = exports['FiveM-Static-ID']:GetClientStaticID(source)
- In any client script (HUD):
local ok, sid = exports['FiveM-Static-ID']:SafeShowStaticID()
if ok then print('Static ID', sid) end
- See detailed docs below for advanced features (persistence, conflict scan, migration).
For full change history see CHANGELOG.md
.
fxmanifest.lua
config.lua
shared.lua
commands.lua
locales/de.lua
locales/en.lua
README.md
LICENSE
- Clone into your FiveM
resources
directory. - (Optional) Rename folder to
staticid
for brevity. - In
server.cfg
(adjust for framework actually used):
ensure oxmysql
ensure es_extended # or qb-core if using QBCore
ensure staticid
Export | Description |
---|---|
GetClientStaticID(dynamicId) |
Returns static ID or nil |
GetClientDynamicID(staticId) |
Returns dynamic ID if player online else nil |
CheckStaticIDValid(staticId) |
true/false/nil (invalid param) |
CheckDynamicIDOnline(dynamicId) |
true/false/nil (invalid param) |
GetIdentifierFromStaticID(staticId) |
Returns framework identifier (license/citizenid or ESX identifier) |
GetStaticIDFromIdentifier(identifier) |
Returns static ID for a raw framework identifier (license/citizenid) or nil |
BulkResolveIDs(table) |
Batch resolve mixed list -> array of result objects |
StaticID_ForceRefresh() |
Force immediate cache refresh (returns true if executed) |
StaticID_GetConfig() |
Shallow copy of current config table |
IsUsingSeparateTable() |
true if separate static_ids table mode is active |
GetCacheStats() |
Table with counts, dirty flags, lastSave/lastLoad timestamps |
GetConflictStats() |
Conflict scan totals + recent conflict records |
ClearConflictStats() |
Clears stored conflict records and resets counter |
Export | Description |
---|---|
ShowStaticID() |
Returns cached static ID (number) or nil if not yet resolved |
SafeShowStaticID() |
Returns `(ok:boolean, staticId |
RegisterStaticIDListener(cb) |
(Local function) Register callback fired immediately if ID known & on future updates |
Listener notes:
- Register as early as possible (resource start) for immediate push.
- Safe polling fallback: call
SafeShowStaticID()
every second untilok=true
.
Uniform return contract: (ok, value, err)
Export | Success (ok=true) value | Failure (ok=false) value | err values |
---|---|---|---|
SafeGetClientStaticID(dynamicId) |
staticId (number) | nil | invalid_param , offline , not_found |
SafeGetClientDynamicID(staticId) |
dynamicId (number) | nil | invalid_param , not_found_or_offline |
SafeGetIdentifierFromStaticID(staticId) |
identifier (string) | nil | invalid_param , not_found |
SafeCheckStaticIDValid(staticId) |
boolean (true/false) | false | invalid_param , error |
SafeCheckDynamicIDOnline(dynamicId) |
boolean (true/false) | false | invalid_param , error |
SafeGetStaticIDFromIdentifier(identifier) |
staticId (number) | nil | invalid_param , not_found |
SafeGetConflictStats() |
stats table | nil | error |
SafeClearConflictStats() |
true | false | error |
Rules:
ok
strictly boolean.- On failure:
value
= nil (or false where a boolean probe),err
= short machine string. - Legacy (non-safe) exports unchanged for backward compatibility.
Example (safe wrapper):
local ok, staticId, err = exports['FiveM-Static-ID']:SafeGetClientStaticID(source)
if not ok then
if err == 'offline' then
print('Player offline; cannot resolve static ID')
else
print('Static ID lookup failed reason =', err)
end
else
print('Static ID is', staticId)
end
Example (direct):
local staticId = exports['FiveM-Static-ID']:GetClientStaticID(source)
if staticId then
print('Static ID:', staticId)
end
Command | Alias | Description |
---|---|---|
/getstatic [DynID] |
/gs |
Show static ID for a dynamic ID |
/getdynamic [StaticID] |
/gd |
Show dynamic ID if player online |
/staticidhelp |
– | Print quick help to server console |
/staticidsave |
– | Force immediate persistent cache save |
/staticidclear |
– | Delete persistent cache file |
/whois [ID] |
– | Auto-detect dynamic or static and show mapping |
/resolve <id1,id2,...> |
– | Batch resolve several IDs (dyn or static) |
/staticidinfo |
– | Show framework/mode/persistence info |
/staticidconflicts |
– | Show conflict detection summary & recent conflicts (console/admin) |
/staticidconflictsclear |
– | Clear stored conflict stats (console/admin) |
Create locales/<lang>.lua
:
Locales = Locales or {}
Locales['fr'] = {
cmd_usage_getstatic = 'Usage: /getstatic [ID dynamique]',
-- weitere Keys …
}
Set Config.Locale = 'fr'
in config.lua
.
- Hot path fully in-memory (only misses go to DB during refresh cycles).
- Refresh queries capped by
MaxRefreshRows
to avoid large scans. - Dynamic map pruner keeps memory lean for long runtimes.
- Persistent snapshot removes burst of queries after restart.
- Checksum rejects tampered / truncated snapshot files silently.
If enabled, snapshot loads before the first DB refresh (warm cache sooner).
Checksum flow:
- Save: additive checksum stored under
__checksum
(+ optional dynamic section checksum). - Load: recompute & compare; mismatch → snapshot ignored (fails closed, no crash).
Manual save / clear:
/staticidsave
/staticidclear
/whois <ID>
logic:
- Assume dynamic first: resolve → show static, identifier, status.
- If not a dynamic entry, treat as static ID → show identifier + dynamic (if present).
Default: users.id
is the stable ID.
Separate mode: isolates static IDs from users
auto-increment (safer during resets / migrations).
Configuration (config.lua
):
Config.DB.UseSeparateStaticTable = true
Config.DB.SeparateTableName = 'static_ids'
Config.DB.SeparateTablePK = 'static_id'
Config.DB.SeparateTableIdentifier = 'identifier'
Config.DB.MigrateUsersOnFirstRun = true -- einmalige Migration (nur wenn Tabelle (fast) leer)
SQL migration (included under sql/static_ids.sql
):
CREATE TABLE IF NOT EXISTS `static_ids` (
`static_id` INT NOT NULL AUTO_INCREMENT,
`identifier` VARCHAR(64) NOT NULL,
PRIMARY KEY (`static_id`),
UNIQUE KEY `uniq_identifier` (`identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
When enabled and table nearly empty (<=5 rows): bulk import all distinct identifiers via INSERT IGNORE
.
After first success: set MigrateUsersOnFirstRun = false
for faster startups.
Benefits:
- Immunity from
users
auto-increment churn & accidental resets. - Stable anchor for cross-system references / audit logs.
- Lower coupling to framework schema changes.
Fallback behavior: new player -> row is auto-created lazily on first join. Persistent cache semantics identical across both modes.
In config.lua
choose:
Config.Framework = 'esx' -- or 'qb' or 'standalone' or 'auto'
If you set 'auto'
the script will probe in priority order (default: ESX → QBCore) and fall back to standalone if neither framework is detected. See the next section "Auto Framework Detection" for details.
Notifications:
- ESX:
esx:showNotification
- QBCore:
QBCore:Notify
- Standalone: simple
chat:addMessage
fallback
Identifier extraction:
- ESX:
xPlayer.identifier
- QBCore: ordered keys via
Config.QB.IdentifierOrder
- Standalone: prefers
license:
identifier, else first available player identifier
Player lookup differences:
- ESX:
GetPlayerFromId
/GetPlayerFromIdentifier
- QBCore: indexed iteration + cache
- Standalone: raw FiveM player list &
GetPlayerIdentifier
Join events:
- ESX:
esx:playerLoaded
- QBCore:
QBCore:Server:PlayerLoaded
- Standalone:
playerJoining
Recommendation: In standalone mode strongly consider enabling the separate table for resiliency.
Priority list (first existing prefix match wins):
Config.StandaloneIdentifierOrder = { 'license:', 'fivem:', 'discord:', 'steam:' }
Common prefixes: license:
, steam:
, fivem:
, discord:
.
/staticidwarn
prints standalone diagnostics (table mode + identifier order).
Standalone-only: emitted when a new static ID is issued:
AddEventHandler('staticid:assigned', function(identifier, staticId)
print(('New static id %d for %s'):format(staticId, identifier))
end)
Emitted for every detected divergence (cache vs DB) during a scan:
AddEventHandler('staticid:conflict', function(identifier, cacheStatic, dbStatic)
print(('Conflict: %s cache=%d db=%d'):format(identifier, cacheStatic, dbStatic))
end)
Config example:
Config.QB.IdentifierOrder = { 'license', 'citizenid' }
First existing wins.
QBCore core player storage often differs from the classic ESX users
table. This resource only requires a stable numeric static ID and an identifier string.
Recommended approaches:
- Easiest (portable): enable the separate static table (no schema edits to QB tables).
- Reuse existing numeric PK: if you already added a custom auto-increment column to
players
, setConfig.DB.StaticIDColumn
to that field and keepUseSeparateStaticTable=false
. - Hybrid migration: start with separate table; later (if you design a global identity table) migrate those static IDs there and just update config keys.
Why separate table is safer:
- Shields against accidental wipes / structure changes during core updates.
- Keeps static IDs monotonically growing and never reused.
- Lets you share the same mapping across ESX/QB/standalone without reassigning IDs.
Example (recommended) static table (already shown earlier):
CREATE TABLE IF NOT EXISTS `static_ids` (
`static_id` INT NOT NULL AUTO_INCREMENT,
`identifier` VARCHAR(64) NOT NULL,
PRIMARY KEY (`static_id`),
UNIQUE KEY `uniq_identifier` (`identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
If you insist on embedding into players
(NOT advised unless you control migrations):
ALTER TABLE `players` ADD COLUMN `static_id` INT NOT NULL AUTO_INCREMENT UNIQUE FIRST;
Then set in config.lua
:
Config.DB.UsersTable = 'players'
Config.DB.StaticIDColumn = 'static_id'
Config.DB.UseSeparateStaticTable = false
Diagnostics:
- On startup the script prints schema warnings if expected columns are missing.
- If you see a warning about missing static ID column under QBCore, either enable the separate table or add the column as above.
Migration from embedded to separate table later:
- Create
static_ids
table. - Copy existing pairs:
INSERT INTO static_ids (static_id, identifier) SELECT static_id, license FROM players ON DUPLICATE KEY UPDATE identifier=VALUES(identifier);
- Switch config to
UseSeparateStaticTable=true
and removeStaticIDColumn
reliance.
This keeps all previously assigned numbers intact.
Set Config.Framework = 'auto'
to let the resource decide at runtime which framework is active.
- Reads
Config.AutoFramework.Priority
(default{ 'esx', 'qb' }
). - For each entry it safely probes the corresponding global/export:
esx
: checks thees_extended
export (exports['es_extended']
) and major helpers.qb
: checks theqb-core
export (exports['qb-core']
).
- First successful probe wins →
Config.Framework
is rewritten internally. - If none found it uses
Config.AutoFramework.Fallback
(default'standalone'
). - If
Config.AutoFramework.Log
is true a log line prints the decision.
Config.Framework = 'auto'
Config.AutoFramework = {
Priority = { 'esx', 'qb' }, -- order matters
Fallback = 'standalone',
Log = true
}
If you force a framework (e.g. Config.Framework='qb'
) auto detection is skipped entirely.
- One code artifact deployable across ESX, QBCore, or a lightweight standalone test server.
- Avoids misconfiguration when moving between environments.
- Clean fallback ensures the script still operates (standalone mode) even if a framework fails to load.
If you expected ESX/QB but it falls back to standalone:
- Make sure the framework resource starts BEFORE this resource in
server.cfg
. - Confirm the resource names (
es_extended
,qb-core
) are not renamed; if they are, adjust detection (you can patch the code or simply specify the framework explicitly). - Enable logging (
Config.AutoFramework.Log = true
) to see detection attempts.
If you run a fork of ESX/QBCore with different export names, set Config.Framework
manually.
/staticidinfo
→ framework, separate table flag, persistence state.
local stats = exports['FiveM-Static-ID']:GetCacheStats()
print(('Static=%d Identifiers=%d DynamicOnline=%d Dirty(S/D)=%s/%s LastSave=%s LastLoad=%s Separate=%s Persist=%s')
:format(
stats.statics,
stats.identifiers,
stats.dynamicOnline,
tostring(stats.dirtyStatic),
tostring(stats.dirtyDynamic),
tostring(stats.lastSave),
tostring(stats.lastLoad),
tostring(stats.separateTable),
tostring(stats.persistentEnabled)
))
Mixed input set (dynamic, static, raw identifiers):
local results = exports['FiveM-Static-ID']:BulkResolveIDs({ 12, 77, 'license:1234', 150 })
for _, r in ipairs(results) do
print(('[%s] type=%s static=%s dynamic=%s identifier=%s online=%s')
:format(tostring(r.input), r.type, tostring(r.staticId), tostring(r.dynamicId), tostring(r.identifier), tostring(r.online)))
end
- Invalid parameters produce consistent
[StaticID]
log lines. - SQL failures trapped via pcall and logged (no hard crash).
MIT – see LICENSE
.
Author: Simple
German locale included (locales/de.lua
).
PRs, suggestions & issues welcome.
The resource now exposes lightweight client exports for HUD/UI scripts.
local sid = exports['FiveM-Static-ID']:ShowStaticID()
if sid then
DrawTxt(('Static ID: %d'):format(sid), 0.50, 0.95)
end
local ok, sid = exports['FiveM-Static-ID']:SafeShowStaticID()
if ok then
print('My static ID =', sid)
else
print('Static ID not yet assigned (still loading?)')
end
Inside a client script in the SAME resource (or adapt with an event wrapper):
RegisterStaticIDListener(function(id)
print('Static ID became available:', id)
-- e.g. update NUI frame, set a global, etc.
end)
Create a client script (or extend an existing one):
local display = ''
-- React as soon as the ID is known
RegisterStaticIDListener(function(id)
display = ('Static ID: %d'):format(id)
end)
-- Fallback poll (in case listener registered after initial push)
CreateThread(function()
local tries = 0
while display == '' and tries < 30 do
local ok, sid = exports['FiveM-Static-ID']:SafeShowStaticID()
if ok then
display = ('Static ID: %d'):format(sid)
break
end
tries = tries + 1
Wait(1000)
end
end)
-- Simple 2D text draw helper
local function drawTxt(text, x, y)
SetTextFont(0)
SetTextProportional(1)
SetTextScale(0.3, 0.3)
SetTextColour(255, 255, 255, 180)
SetTextEntry('STRING')
SetTextCentre(true)
AddTextComponentString(text)
DrawText(x, y)
end
CreateThread(function()
while true do
Wait(0)
if display ~= '' then
drawTxt(display, 0.50, 0.95)
end
end
end)
- Server sends
staticid:client:set
with the player's static ID after first resolution. - Client requests it on startup via
staticid:server:requestStaticID
for restart resilience. ShowStaticID()
returns the cached number ornil
if not assigned yet.SafeShowStaticID()
normalizes to(ok:boolean, value|nil)
for consistency with other Safe exports.
- If you call the export too early (before server push), you'll get
nil
/ok=false
— use a listener or poll with a short backoff. - Ensure this resource starts after your framework and oxmysql in
server.cfg
. - Do NOT cache the return of
exports[...]
function itself; call it each time or store the numeric result.
In a NUI-focused resource, forward the value to JS once:
RegisterStaticIDListener(function(id)
SendNUIMessage({ type = 'staticid', value = id })
end)
Then in your JS:
window.addEventListener('message', (e) => {
if (e.data.type === 'staticid') {
document.getElementById('static-id').textContent = e.data.value;
}
});