DirkScripts
Script Config
โน๏ธ Client & Server Module
The scriptConfig module provides a full configuration system for resources โ JSON Schema-driven defaults, database persistence, admin UI, live watchers, change history, and optimistic concurrency.
For the React/NUI admin panel side of this system, see dirk-cfx-react โ Script Config.
#Full Setup Guide
Setting up scriptConfig in a new resource requires files on three layers: schema, Lua, and React. This section covers the schema and Lua layers. The React layer is covered in the dirk-cfx-react docs.
#1. Create Your Schema
Create a schema.json in your resource root. This JSON Schema defines every configurable setting, its type, and default value.
json{ "type": "object", "properties": { "basic": { "type": "object", "properties": { "debug": { "type": "boolean", "default": false }, "interactionType": { "type": "string", "default": "target" }, "maxPlayers": { "type": "number", "default": 32 }, "secretKey": { "type": "string", "default": "changeme", "x-serverOnly": true } } }, "rewards": { "type": "array", "x-arrayKey": "name", "items": { "type": "object", "properties": { "name": { "type": "string" }, "label": { "type": "string" }, "amount": { "type": "number", "default": 1 } } } } } }
You can optionally create a defaults.json with initial default values. If omitted, defaults are derived from the "default" fields in the schema.
#2. Set Up Your fxmanifest.lua
luafx_version 'cerulean' game 'gta5' shared_scripts { '@dirk_lib/init.lua', 'src/shared/*.lua', } client_scripts { 'src/client/*.lua', } server_scripts { '@oxmysql/lib/MySQL.lua', 'src/server/*.lua', } ui_page 'web/build/index.html' files { 'web/build/**', 'schema.json', 'defaults.json', 'locales/*.json', }
#3. Register on the Server
Create src/server/scriptConfig.lua:
luaCreateThread(function() lib.scriptConfig(schema, function(src) return IsPlayerAceAllowed(src, 'admin') end) end)
That single call handles everything:
- Waits for MySQL to be ready
- Creates the
dirk_scriptConfigDB table if needed - Loads stored settings from the database
- Smart-merges with schema defaults (schema is always source-of-truth)
- Computes a content-hash version for cache invalidation
- Fires all registered watchers with
source = "initial" - Registers all NUI callbacks for the admin panel
#4. Set Up the Client
Create src/client/scriptConfig.lua:
lualocal _ = lib.scriptConfig -- triggers lazy-load + KVP cache
The client:
- Checks the resource KVP cache for a fast-path load
- Calls the server with its cached version number
- If the server has a newer version, receives the delta and merges locally
- Caches updated settings to KVP for next startup
- Fires any registered client-side watchers
#5. Add Watchers (Server)
Register watchers in your server files to react to settings changes in real-time:
lua-- src/server/init.lua lib.scriptConfig.on('rewards', function(rewards) registerRewardItems(rewards) end) lib.scriptConfig.on('basic', function(basic) if basic.debug then RegisterCommand('debugMyResource', function(src) -- debug command logic end, true) end end) -- Wildcard: react to ALL changes lib.scriptConfig.on('*', function() regenerateConfigFiles() end)
#6. Add Watchers (Client โ Optional)
lua-- src/client/controls.lua lib.scriptConfig.on('basic', function(basic) local controls = basic.defaultControls or {} RegisterKeyMapping('+myAction', 'Do Something', controls.main?._type or 'keyboard', controls.main?._key or 'E') end)
#7. Access Settings Anywhere
Read settings directly in any server or client file:
lua-- Direct access (available after init) local debug = scriptConfig?.basic?.debug local rewards = scriptConfig?.rewards or {} -- Via getter local value = lib.scriptConfig.get('basic.maxPlayers')
โน๏ธ Optional helper pattern: Some resources define getLive* convenience functions in shared code for null-safe access:
lua-- src/shared/utils.lua function getLiveBasic() return scriptConfig?.basic or {} end
This is entirely optional โ you can access scriptConfig. directly.
#8. There is no step 8
You do not build an admin panel. Config Studio draws yours from schema.json โ sections, controls, row editors, validation, history and the save bar all come out of the schema, and /<yourResource> opens the shared panel with your script selected.
So the work that used to be a React app is now schema work: see Schema Extensions for the vocabulary, and Your own control / Your own page for the two escape hatches when a screen is genuinely yours to draw.
createScriptConfig in dirk-cfx-react is still what your own UI reads settings through at runtime โ that has not changed. It is only the admin surface that dirk_lib now owns.
#API Reference
#Client
#scriptConfig.get
Returns the current settings. Also callable via scriptConfig.().
lualocal settings = scriptConfig.get() -- or local settings = scriptConfig.()
#scriptConfig.on
Watch for settings changes. Returns an unsubscribe function.
lualocal unsubscribe = scriptConfig.on('basic', function(newValue, oldValue, meta) print('basic settings changed', meta.path) end, { immediate = true }) -- Stop watching unsubscribe()
| Parameter | Type | Required | Description |
|---|---|---|---|
| path | string | yes | Dot-separated path to watch, or '*' for all changes |
| cb | function | yes | function(newValue, oldValue, meta) |
| options | table | no | { once?: boolean, immediate?: boolean } |
immediate defaults to true โ the callback fires immediately with the current value if settings are already loaded. once auto-unsubscribes after the first fire.
Path matching rules:
- Exact match: Watch
"basic", triggered by"basic"change - Parent match: Watch
"basic", triggered by"basic.debug"change - Child match: Watch
"basic.skillSettings", triggered by"basic"change - Wildcard:
"*"catches all changes
Watcher meta table:
| Field | Type | Description |
|---|---|---|
| path | string | Watched path |
| changedPaths | string[] | Which leaf paths actually changed |
| source | string | 'load', 'refresh', 'update', or 'initial' |
| current | table | Full current settings |
| previous | table | Full previous settings |
#scriptConfig.set
Update settings (admin only). Sends the change to the server.
luascriptConfig.set({ basic = { debug = true } }, expectedVersion)
| Parameter | Type | Required | Description |
|---|---|---|---|
| data | table | yes | Partial settings to merge |
| expectedVersion | number | no | Optimistic concurrency version |
#scriptConfig.getAll
Get the full unfiltered settings (admin only).
lualocal allSettings = scriptConfig.getAll(source)
#Server
#scriptConfig.(schema, canEditFn?, rules?)
Initialise the settings system. Call once during resource start. Creates the DB table, loads persisted data, and smart-merges with schema defaults.
lualib.scriptConfig(schema, function(src) return IsPlayerAceAllowed(src, 'admin') end, { migrations = { ['1.2.0'] = function(data) data.basic.newField = data.basic.oldField data.basic.oldField = nil return data end, }, })
| Parameter | Type | Required | Description |
|---|---|---|---|
| schema | table | yes | JSON Schema definition with defaults |
| canEditFn | function | no | function(src) โ boolean โ additional permission grant on top of the master ACE + overrides (see Access Control). Purely additive: cannot lock out the master. |
| rules | table | no | { migrations = { ['1.2.0'] = function(data) ... end } } โ see below. |
Save permission is gated by the master ACE list (
dirk_lib_master_groupconvar), the access block your schema pushes, and thedirk_adminsrows first. YourcanEditFnonly fires as a fallback when all of those deny. If you want to let a non-master role edit just your resource, add them on the Admins page in/dirk_configand scope the grant to your script โ that survives in the database and needs no code change.
Access resolves in this order, and stops at the first true:
- the master group โ
dirk_lib_master_groupconvar, defaultgroup.admin. Alwaysedit, and can never be locked out. - the access block your schema pushes โ predates levels, so it means
edit. It is also folded into the Admins page the first time your resource registers, so a grant made in a file can afterwards be revoked from the panel. - rows on the Admins page (
dirk_admins), which name either a player identifier or an ACE principal, ateditorviewlevel, optionally scoped to particular scripts. An empty scope means every script. - your own
canEditFn.
view may open your script and read its settings and nothing else โ no
server-only values, no saves, no give-item. The rows live in their own
dirk_admins table rather than in dirk_scriptConfig: storing the access list
inside the thing it guards means a bad write locks everyone out of the panel
that would fix it.
#exports.dirk_lib:canEditScriptConfig(src, resourceName)
Authoritative server-side permission check. Returns true if the player can edit the named resource's scriptConfig (master ACE list + overrides + the consumer's own canEditFn). Useful when you want to gate a custom admin command or feature with the same access model dirk_lib uses internally.
luaif exports.dirk_lib:canEditScriptConfig(source, GetCurrentResourceName()) then -- show admin-only menu, run admin command, etc. end
#Rules
luarules = { migrations = { ['1.2.0'] = function(data) return data end, }, }
migrations is the only key. Version-keyed functions run once when the stored
resource_version is older than the key.
โ ๏ธ There is no ui block any more. It used to name a command, a help string,
an ACE restriction and an open event. Config Studio registers the command
centrally instead: every started resource that ships a schema.json gets a
/<resourceName> command from dirk_lib automatically, gated by the access model
above, and it opens the shared panel with your script selected. A resource
started later gets its command too. Nothing to declare, and nothing to keep in
step.
#Schema Extensions
The schema is the single source of truth. It decides what a setting is, which control edits it, where it appears, when it applies, and whether it is valid โ so a new resource ships a schema.json and gets a complete admin panel with no changes to dirk_lib.
โ ๏ธ Never put per-script knowledge inside dirk_lib. Layout, validation and labels all travel in the resource's own schema. If you find yourself wanting to add your resource's name to a list inside dirk_lib, that is a sign the schema is missing an annotation.
Declare the shape
An array of objects should say what its rows contain:
json"stores": { "type": "array", "x-arrayKey": "id", "items": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "lvlRequired": { "type": "integer" } } }, "default": [ ... ] }
Without items.properties the panel works out the columns from the rows you
ship, and that is a guess with two failure modes worth knowing about:
- A field only some rows carry. Columns come from the whole list, so a field
one row uses does get a column โ but its type is read from a row that has a
value for it. Ship a level requirement on four stores out of eight and it is
fine; ship it as
"10"on one and10on another and the panel has to pick. - An emptied list. Delete every row and the shipped defaults still describe it, but a list that ships empty describes nothing at all.
A declared items schema removes both. It also gives every field somewhere to
hang its own annotations, rather than pushing them into x-rowControls and
x-validateRows on the array.
An object whose keys are data is a map, not a record โ say so, or the fields that happen to be in your defaults get read as the only ones allowed:
json"perFishModifiers": { "type": "object", "description": "Per-species overrides, keyed by fish name.", "additionalProperties": { "type": "object", "properties": { "abundanceModifier": { "type": "number" }, "weightModifier": { "type": "number" } } } }
โน๏ธ Inference is still doing useful work above a declared type โ it is what turns a
number bounded 0โ1 into a chance meter, and a [min, max] pair into a range
slider. Declaring the type does not flatten those; it only stops the panel
guessing which fields exist and what kind of thing they are.
Storage & migration
| Extension | Type | Description |
|---|---|---|
x-serverOnly | boolean | Marks a path as server-only โ filtered from client responses and the admin panel. The editing admin fetches it via a separate in-memory sliver; it's never written to the client KVP cache. |
x-renamedFrom | string | Declarative key migration โ old path is automatically moved to the new path on load. |
x-arrayKey | string | Identity key for array-level smart merge (merge by unique field instead of by index). Put it on every editable array so user deletions/edits persist across restarts. |
x-autoDefault | string | array | Convar name(s) whose first non-empty value replaces this field's literal default, resolved on the server at load. ["sv_projectName", "sv_hostname"] makes a server-name field read right on a fresh install. Colour codes are stripped. An admin edit still wins, and nothing is stored for a detected value โ so changing it in server.cfg keeps being followed. |
Items & installation
| Extension | Type | Description |
|---|---|---|
x-installItem | object | On a string field holding an item name โ a metadata block ({ label, weight, description, useable, shouldClose, ... }) that generates INSTALLATION/itemsToAdd/{ox.lua,qb.lua,esx.sql} and feeds the missing-items audit. |
x-installItemList | boolean | object | On an array of item records โ true uses convention field names (name/label/description/weight), or an object overrides them. |
Weight is not optional in practice. A list gives its items a weight one of two ways:
"weight": 500โ one value for every item in the list. Right for hooks, lines, bait."weightField": "weight"โ read it from each row. Right where the row already states it: a 50g sinker weighs 50, a fish weighs what itsweightLimitssay.
weightField wins when the row actually carries that field, and falls back to the fixed weight when it does not โ so a list can set both and let the rows override.
Declare neither and every item is generated at weight 0, silently. dirk_fishing shipped 90 weightless rods, reels, hooks and bait that way: the generator was doing exactly what it was told, and nothing said otherwise until someone noticed the inventory column was empty.
Layout
How the panel is arranged. Without these a script still works โ every top-level block becomes one section โ but a block holding thirty unrelated settings becomes an endless scroll.
| Extension | Where | Description |
|---|---|---|
x-sections | schema root | Re-groups settings into rail sections by path, across blocks. [{ id, label, icon, description, workspace?, paths: [...] }]. A path matches itself and everything under it, and paths also sets the order within the section. Anything unclaimed keeps its schema position. |
x-workspace | on a top-level block, or on a section as workspace: true | This section fills the pane instead of joining the scrolling stack, and its lists become entries in the rail rather than tabs in the body. Right for a map, a card grid, or a single list with a switch above it โ anything that wants the height. Drawing a tab strip and a rail tree for the same choice gives you two switchers stacked on each other. |
x-rowTabs | on an array, at any depth | Tabs for that array's row editor: [{ id, label, icon, keys: [...] }]. Without it, scalars go in General and each nested table gets its own tab. Tabs are collected by path, so an array nested inside a block (logger.routes) is covered as well as a top-level one โ it used to be read off top-level properties only, and a nested array's tabs were silently collected for nobody. The zone map editor honours the same declaration, so a geographic list edits exactly like a list one. icon is any lucide name ("shield", "film") โ only seven short aliases used to draw, and every other tab silently fell back to the info icon. |
x-mapPaths | schema root | Array paths that are geographic and render on the shared map instead of as list rows. Either a bare path, or { path, color, shape } โ color is the layer's colour (declare it here rather than hoping the panel guesses), shape is polygon or marker and overrides detection, which matters when the array ships empty and there are no rows to judge. Two more keys for areas that are decided rather than drawn: toggle names a boolean column shown as a switch on each row in the map's list (a row switched off draws greyed on the map), and readOnly: true drops add, draw, edit, delete and the row modal - the rows are only shown (and switched), with the list's description as a note. dirk_containers' Collision map uses both: { "path": "collision.areas", "shape": "polygon", "toggle": "enabled", "readOnly": true }. |
x-label | schema root | What this script is called in the panel. Without it the raw resource name is shown. |
x-icon | schema root, or a block | The lucide icon name for the script, or for one section. |
x-shared | schema root | This schema is the SHARED layer every script consumes (dirk_lib's own). Its sections are listed under GENERIC rather than as a script you click into. Exactly one schema should set it. |
x-pages | schema root | Whole pages this script supplies, listed in the rail under it: [{ id, label, icon, component, description?, full? }]. full: true gives the page the whole window. See Your own page below. |
x-managedElsewhere | schema root | Block names that have a dedicated page and must NOT also appear as sections โ ["bridging", "access"], which the Bridges and Access pages own. Without it the same settings show up twice, edited two different ways. |
json{ "x-sections": [ { "id": "general", "label": "General", "icon": "sliders-horizontal", "description": "Server-wide behaviour and units.", "paths": ["basic.debug", "basic.weightUnit", "basic.distanceUnit"] }, { "id": "permits", "label": "Permits", "icon": "scroll-text", "paths": ["basic.permitItem", "basic.permitPrice", "basic.permitRevokers"] } ], "x-mapPaths": ["zones", "seaBoundary"], "properties": { "fish": { "type": "array", "x-arrayKey": "name", "x-rowTabs": [ { "id": "general", "label": "General", "icon": "general", "keys": ["name", "label", "description"] }, { "id": "stats", "label": "Stats", "icon": "stats", "keys": ["biteChance", "weightLimits"] } ] } } }
Conditions
x-enabledWhen and x-validate share one condition grammar, because "should this be editable?" and "is this valid?" are both statements about values. There is no need for a custom component to express a comparison.
| Key | Meaning |
|---|---|
path | Which setting to read. Omit, or use "self", for the field being tested. Inside a row, "self.otherField" reads a sibling column. |
equals / in | Exact match / one of a set. |
gt gte lt lte | Numeric comparison against a literal. |
gtField gteField ltField lteField | Numeric comparison against another setting. |
isSet | true = present and non-empty, false = empty. |
minLength / maxLength | For strings and arrays. |
ascending / strictlyAscending | A [min, max] pair that must not run backwards. |
allOf / anyOf / not | Combine conditions. |
x-enabledWhen
Greys a setting out, with the reason, while its condition is unmet. Put it on a block and every field inside inherits it โ which is what a master switch means. The field a rule points at is never disabled by its own rule.
json"theme": { "type": "object", "x-enabledWhen": { "path": "theme.useOverride", "equals": true }, "properties": { "useOverride": { "type": "boolean", "default": false } } }
A gated-off setting is also skipped by validation โ demanding a webhook URL for logging that is switched off would block every save.
x-validate
Most validation needs no extension at all: minimum, maximum, minItems, enum and required are plain JSON Schema and are enforced. Use x-validate for the two things JSON Schema cannot say โ a range that runs backwards, and "required, but only when".
json"weightLimits": { "type": "array", "minItems": 2, "maxItems": 2, "x-validate": [{ "must": { "ascending": true }, "message": "ErrWeightMinMax" }] }, "permitPrice": { "type": "number", "x-validate": [{ "when": { "path": "self.permitRequired", "equals": true }, "must": { "isSet": true }, "message": "ErrPermitPriceRequired" }] }
message is resolved through the resource's own locale bundle first, so a key like ErrMinMax is translated; anything unknown is shown verbatim. Failures show on the row, disable Save, and are summarised in the save bar with a jump to the offending section.
x-validateRows on an array carries rules for arrays that declare no items schema (their shape coming from the default rows). Keys may be dotted to reach a nested table:
json"stores": { "type": "array", "x-validateRows": { "stock.variance": [{ "must": { "ascending": true }, "message": "ErrVarianceMinMax" }] } }
Validation goes all the way down. A row is not flat, so neither is the check.
required, x-validate and x-enabledWhen are applied at every depth, on save
as well as while you type, through all three nested shapes:
| Shape | How it is walked |
|---|---|
a table inside a row (rows) | every row, numbered or named โ a store's stock lines |
an open map of objects (objectMap) | every value, keyed by its own name |
| a plain nested object | just more fields |
Only the top level used to be checked, so a store with six nameless stock rows counted as valid: the inner table was non-empty, and nothing looked inside it.
required reaches a nested column too โ list the field in the inner
items.required array and it is enforced on every row of that inner table:
json"stores": { "type": "array", "items": { "type": "object", "properties": { "stock": { "type": "array", "items": { "type": "object", "required": ["item", "price"], "properties": { "item": { "type": "string", "x-itemPicker": true }, "price": { "type": "integer", "minimum": 0 } } } } } } }
Messages say where, so a failure deep in a row is findable: Stock #4: Item is needed, joined with โบ for anything deeper. The row editor opens on the tab
that holds the first problem rather than on whichever tab you were looking at โ
a row saves whole, so a problem hiding on another tab still blocks it.
โน๏ธ A field its own row has switched off with x-enabledWhen is skipped, the same
way a gated-off setting is. A zone that requires no permit is not asked for a
permit price.
Controls
The control is inferred from the schema โ type, bounds, key name and value shape โ so most fields need no annotation at all. Declare only what inference cannot know.
| Extension | Type | Description |
|---|---|---|
x-enum | array | Constrains a value to a set, like enum, without JSON Schema validation semantics. |
x-enumLabels | object | Display names for enum values: { "zh-TW": "็น้ซไธญๆ (zh-TW)" }. Without it the raw value is humanised, which is useless for codes. |
x-optionsFrom | object | Values that must exist in another setting: { "path": "fish", "key": "name", "labelKey": "label" }. Renders a picker, and flags a selected value that no longer exists rather than hiding it. Inside a row, self. reads this row and parent. the row it sits inside. Honoured on three shapes: an array (several โ pickList), a single value (one โ pickOne, pair it with x-anyLabel), and an open map of objects, where it supplies the keys you may add โ see objectMap. labelKey is what to show when the stored value is an opaque id. |
x-anyLabel | string | The label for "no choice" on a pickOne โ "Anywhere", "Uncategorised". Without it an empty value has no row to pick, so a setting that legitimately allows "none" cannot be set back to it. |
x-iconPicker | boolean | This string is an icon: pick it by looking at it rather than typing a name. Pair with x-iconSet when the value is not a lucide name. |
x-itemPicker | boolean | Force the inventory-item picker on a string. |
x-itemTitle | string | Which field titles an array row in the list. Without it the panel guesses โ label, then name, then x-arrayKey, then the first string column. |
x-groupPicker | boolean | This string is a framework job or gang: pick it from the server's real list. A plain text box here fails silently โ a typo produces, say, a business nobody can ever staff. |
x-enumIcons | object | lucide icon name per enum value: { "fuel": "fuel" }. Any icon lucide ships works. |
x-enumColors | object | Hex colour per enum value: { "fuel": "#0A84FF" }. |
x-validateWith | string | Names a server callback on your own resource โ without its <resource>: prefix โ which says whether the typed value actually works. A range rule can tell you a number is out of bounds; only Fivemanage can tell you a Fivemanage token is live. Checked as you type (debounced), silent while the field is empty. Called with { value }; return { ok, reason? }, or { ok, checks: [{ id, ok, message }] } for several named checks. Use x-action instead for anything with an effect โ see below. |
x-vector4 | boolean | Force a world-position picker: x, y, z and a heading. Somewhere to stand and a way to face. |
x-vector3 | boolean | The same picker, writing x, y, z โ a place with a height but no facing. |
x-vector2 | boolean | The same picker, writing x, y โ a flat point, like a zone corner. |
x-bodyHeight | boolean | This position is stored at body height, not the ground. The walk-and-set capture stores the ground under you; a script written before that stored GetEntityCoords as-is (about a metre up) and subtracts the metre itself when it places a ped. Declaring it lifts the capture back to body height for this field, so old data and new picks agree and nothing has to be migrated. On a list of positions, put it on the items. |
x-secret | boolean | This value is a credential: masked in the panel, and never guessed at. Use it for anything a name rule would not catch โ fivemanageKey ends in key, which reads as a keybind. |
x-keybind | boolean | This value is a key to press, when the name does not say so. |
x-durationBase | string | What unit a duration value is stored in: "seconds", "minutes", "hours" or "days". Never inferred โ fishing has all three sitting next to each other, and the control has to know what the number already means before it can read it back as something else. |
x-skill | string | This object IS a levelling curve โ name the skill it configures ("fishing", "yardRep"). The block then draws the curve it describes underneath itself: the shape of the climb, what a few real levels cost, and the total to max, recomputed live as you drag. Four bare numbers cannot be read โ nobody looks at modifier: 1.4 and pictures how long level 10 takes. Named rather than flagged because a script can have several. Expects baseLevel, maxLevel, baseXP and modifier inside. |
x-bandLabels | array | What a slider calls its own steps, low to high: ["Tiny", "Small", "Normal", "Large", "Huge"]. Any number of words; the range is divided evenly between them. Without it every slider borrows fishing's difficulty bands, so a blip's SIZE reads "Weak" at its smallest. Declaring them also drops the green/amber/red colouring, which is a judgement only a difficulty has an opinion about. |
x-boolLabels | object | The two things a boolChoice boolean picks between: { "true": "Trowel (kneel)", "false": "Spade (stand)" }. The stored value stays a plain boolean, so nothing has to be migrated โ only the reading of it changes. |
x-propModel | string | Which prop the prop control spawns while you position it, and the only model its Pick mode will accept: "prop_laptop_01a". Required โ a placer with nothing to place has nothing to look at, and only the schema knows this field is a laptop and the next one a vending machine. |
x-goTo | object | "That is set over there." Links this setting to the one that actually governs it: { resource, group, label?, when? }. Clicking moves the panel to that script and section. when names the value the link applies to, so it only appears while the pointer is true โ a script's theme.useOverride: false links to dirk_lib's global theme, and the link goes away the moment the script starts overriding it. |
x-control | string | Name the control outright, when none of the above fits. The escape hatch โ a script should never have to rename a field to be rendered properly. Values are the names in Every control below; an unknown name falls back to inference. |
The three vector flags are one control with three shapes. The picker always produces four numbers โ a player always has a height and a heading โ and the field trims what it keeps, so a flat corner never grows a z and a w that nothing reads and every diff shows. Undeclared, the shape is counted from the field's own properties (or its default), so an existing {x,y,z,w} block needs no annotation.
Set is a real in-game flow, not a form: the panel hides, an instruction card appears, you walk to the spot and press E (Backspace cancels). It runs through lib.adminTool, which is gated on the panel actually being open โ so neither Set nor Goto is reachable from CEF devtools with the panel shut.
When a field declares x-enumIcons / x-enumColors, the picker stops being a dropdown of words and becomes a grid of the real pins โ for a place category the colour and icon are the setting. The same styling draws that row's marker on the map, so the admin map matches what players see.
Every control
x-control (and x-rowControls) accept any of these. Most fields need none of
them โ inference covers the common shapes โ but nothing here can be guessed
reliably, so declaring is always allowed and never wrong.
Text and numbers
| Name | What it is |
|---|---|
string | A single-line box. The fallback for anything unrecognised. |
text | Multi-line, for a description. |
secret | A credential: masked, never guessed at. |
number / integer | A number box; integer refuses decimals. |
percent | A number understood as a percentage. |
slider | A bounded number you drag. Inferred automatically for 0..1 and 0..2. |
range | A [min, max] pair. Bounds are read from items โ items.minimum / items.maximum โ because beside type: array a bare minimum means "at least this many entries", not "no lower than this". items.multipleOf, or an integer items.type, makes it step in whole numbers. Grows a dual-handle slider when both bounds are known โ see x-boundsFrom. |
time | A time of day. |
duration | A length of time, read in whatever unit divides cleanly โ 86400 shows as "24 hours", 90 stays "90 seconds" rather than becoming "1.5 minutes". Declare x-durationBase so it knows what the stored number means. Nothing is rewritten unless you actually edit it. |
hourOfDay | An hour of the day, 0โ23, listed by name. A number box asks you to think in 24-hour time and remember that 0 is midnight. |
Meters โ a bounded number, drawn as the thing it actually means. Same value, different vocabulary, so the reader is told what "high" does:
| Name | Reads as |
|---|---|
chance | Never โ Always. Accepts 0โ1 or 0โ100. |
multiplier | Fills from the centre: below 1 is a reduction, above 1 a boost. |
difficulty | Easier โ Harder. |
forgiveness | Strict โ Forgiving. |
rarity | Common โ Legendary. |
balance | A centre-out trade-off between two sides. |
progression | Much easier โ Much harder. |
Choices
| Name | What it is |
|---|---|
boolean | A switch. |
enum | One of a fixed set. Add x-enumLabels / x-enumIcons / x-enumColors. |
enumList | Several of a fixed set. |
pickOne / pickList | One / several, read from another setting โ see x-optionsFrom. |
boolChoice | A boolean drawn as the two things it actually picks between, rather than on/off. useScenario tells nobody that off is a spade and on is a trowel โ x-boolLabels does. |
weekdays | Days of the week, because nobody remembers which end Sunday is. |
Things in the game world
| Name | What it is |
|---|---|
item | An inventory item, picked by its picture. Unknown names are allowed but flagged. |
model | Any model, searched by name. |
ped / peds | One / several ped models, shown as the peds themselves. |
vehicle | A vehicle model. |
coords | A world position, with Goto and Set. |
camera | A camera shot, framed by flying a free camera to it rather than typed: { pos: {x,y,z}, rot: {x,y,z}, fov }. Opens at the saved shot, or your view the first time. W/A/S/D and the mouse fly, Shift/Alt change speed, Q/E zoom, F keeps it, Backspace cancels. Other keys on the object are kept, and any boolean field on it is drawn as a switch under the shot โ declaring the control makes the object one field, and a scene camera's "follow the player" would otherwise vanish from the form. |
prop | Where a prop goes, put there rather than typed. The game spawns the real model and you position it โ rough mode follows where you look, G hands over to the gizmo for fine work โ or Pick adopts one the map already has, validated against x-propModel. Which button was used IS the mode โ Pick writes existing: true into the value, Place clears it โ so a field needs no second switch beside it saying the same thing. Stores {x,y,z,w,rx,ry}: full rotation, because a placer can tilt a prop onto a surface and a heading alone cannot say that. Not to be confused with objectMap, which is key/value pairs and has nothing to do with entities. |
positions | Several of them. |
zones | Polygons, drawn on the shared map. |
group / groups | One / several framework jobs or gangs, from the server's real list. |
groupGrades | Jobs with a minimum rank โ "LSPD, rank 2 and above". |
account / accounts | Framework money accounts. |
Blips
| Name | What it is |
|---|---|
blipColor | The real colour list, not a shortlist. |
blipSprite | Browsable by artwork โ nobody knows a sprite's name before seeing it. |
blipDisplay | Where the blip shows. |
Appearance
| Name | What it is |
|---|---|
color | A hex colour. |
mantineColor / shade | A theme colour and its shade. |
palette | A full ten-step palette. |
icon | An icon, picked by looking at it. Pair with x-iconSet. |
anchor | Where something sits on the screen โ one of nine positions, drawn as a 3ร3 grid of screens with a block in the corner, plus as-drawn for "leave it where it was laid out". The values are a plain enum (as-drawn, top-left โฆ bottom-right) and x-enumLabels names them, so it translates like any other enum. For a design-driven script (a chat, a loading screen, a HUD) whose look is authored elsewhere and only PLACED here. A dropdown would work and is the wrong shape: it asks you to read "middle right", picture it, and then picture what it does to a design you cannot see. |
Input
| Name | What it is |
|---|---|
keybind | A key, captured by pressing it. |
control / controls | One / several GTA control IDs, by name. |
keybindMap | Named actions mapped to keys. |
Structured
| Name | What it is |
|---|---|
list | An array of rows, with the schema-built row editor. |
tags | Loose values as chips. |
keyvalue | A free-form map โ keys are data, not schema. |
weightMap | Named things with a weighting, as bars. |
objectMap | An open map whose values are objects โ { "pike": { "abundanceModifier": 1.2 } } โ as rows of a key plus its own fields. Inferred from additionalProperties: { "type": "object", "properties": {โฆ} }; which keys may be added comes from x-optionsFrom. Without it the keys froze to whatever the shipped default happened to name, so a zone could never override a fourth species. |
custom | The owning script supplies the editor โ see x-component. |
Discord โ both read the bot configured in dirk_lib's own Discord settings:
| Name | What it is |
|---|---|
discordChannel | A channel your bot can post in, picked from the live list and grouped by category, instead of asking someone to turn on developer mode and copy an id. Says why it is empty โ no bot set up, or a bot that can see nothing โ rather than showing an empty dropdown. |
redirectKind | Which way a redirect reaches Discord: a webhook URL, or your own bot. An explicit choice rather than "fill in one of these two and leave the other blank". The bot option is disabled, with the reason, when no bot is configured. |
Row-level controls
A row inside a list is still a field, but there is often nowhere to annotate it:
several arrays describe themselves through their default rows rather than an
items schema. These say it on the array instead.
| Extension | Type | Description |
|---|---|---|
x-rowControls | object | Controls for the columns of an array that declares no items schema โ its shape comes from the default rows, so there is no per-field node to put x-control on. Keyed by column, dotted to reach inside a nested object or a nested row list: { "description": "text", "blip.color": "blipColor", "categories.description": "text" }. Same idea as x-validateRows, which exists for the same reason. Takes a long form too โ see below. |
x-rowOrder | array | The order fields appear in a row editor: ["id", "name", "description"]. Listed keys lead; everything else keeps its existing position behind them. Without it the order is whatever order the keys sit in โ the items schema, or the first default row โ which is an accident of how the data was written rather than a decision about what you read first. Declaring it also stops the editor hoisting the row's name to the top, which is the guess it makes when nothing says otherwise. |
x-boundsFrom | object | Limits taken from another field on the same row: { "path": "self.weightLimits" }. A permit's weight limit only means anything inside the weights that species actually reaches, and that is a different pair of numbers for every row โ so the field points at the one that can say. A pair is read as [min, max]; a single number as a ceiling. With both bounds known, a range field grows a dual-handle slider above its number boxes. |
x-iconSet | string | Which icon set an icon field's value belongs to: "lucide" (default), "fontawesome", or "both". Not inferred. A script whose own UI draws Font Awesome must keep getting Font Awesome back โ writing a lucide name into a field your store screen passes to Font Awesome just breaks the icon. |
"both" gives the picker a Lucide / Font Awesome switch and lets one field hold
either. It is safe because the stored value already says which set it came from:
Font Awesome writes fa-solid fa-fish, lucide writes a bare fish. Nothing has
to be migrated, and no second field records the choice.
The catch is on your side, not the panel's. A field declared "both" can
arrive at your UI in either spelling, so whatever draws it must read the value
rather than assume a library โ the way dirk_lib's own AnyIcon does. Declare
"both" only where your renderer handles both; a renderer wired to one library
draws a silent blank for every icon picked from the other.
| x-action | object | A button beside the field that does something: { label, callback, icon?, sendSection? }. callback names a server callback on your own resource, without its <resource>: prefix. sendSection: true sends the whole section's staged values rather than just this field. See Actions below. |
Your own control or page
| Extension | Type | Description |
|---|---|---|
x-component | string | Path to a React component the OWNING resource ships, rendered inline for this setting. For anything only that script understands, when no built-in control fits. See Your own control below. |
x-componentFull | boolean | That component fills the whole workspace โ no row card, no label column, no frame. For a map or a card grid, which is boxed in twice otherwise. |
x-rowControls long form. A bare string names the control. An object says
more, which matters when the array has no items schema and there is nowhere
else to put it:
json"x-rowControls": { "description": "text", "type": { "control": "enum", "options": [ { "value": "equipment", "label": "Equipment store" }, { "value": "fishMarket", "label": "Fish market" } ] }, "stock.category": { "control": "pickOne", "optionsFrom": { "path": "parent.categories", "key": "name" }, "anyLabel": "Uncategorised" }, "icon": { "control": "icon", "iconSet": "fontawesome" }, "permitPrice": { "control": "integer", "enabledWhen": { "path": "self.permitRequired", "equals": true } } }
| Key | Meaning |
|---|---|
control | The control name, as the bare string form. |
options | Fixed choices, as strings or { value, label }. |
optionsFrom | Choices read from the config: { path, key?, labelKey? }. self. is this row, parent. the row it sits inside โ a stock line's category is one of the categories of the store it belongs to. |
anyLabel | The label for "no choice", when empty is allowed. |
iconSet | As x-iconSet. |
boundsFrom | As x-boundsFrom. |
enabledWhen | As x-enabledWhen. Honoured by every row editor โ the list's, a nested table's, and the map's โ and by validation, so a field its own row has switched off is not asked to be valid. |
Your own control
Built-in controls cover the shapes every script shares. When one genuinely does not fit, a resource can ship its own:
jsonc"places": { "x-component": "web/build/studio/places-map.js" }
The panel imports it at runtime from nui://<resource>/<path> โ the same scheme it already uses for item images โ and renders it inline in the section, inside its own React tree. It therefore inherits the theme, the fonts, the scroll and the save bar; it is not an iframe and needs no message plumbing.
Author it as .tsx and build it to that path. React, Mantine, lucide, framer-motion and dirk-cfx-react must be external, mapped to window.__dirkStudio.* โ two copies of React on one page breaks hooks, and it keeps the file at kilobytes instead of megabytes.
Anything holding a React context has to be shared for the same reason, not just React itself. That now includes React Query: a component bundling its own copy reads its own client, with no provider above it, and every hook throws.
js// vite.config โ build.lib, formats: ['es'] rollupOptions: { external: [ 'react', 'react/jsx-runtime', '@mantine/core', 'lucide-react', 'framer-motion', 'dirk-cfx-react', 'react-leaflet', 'leaflet', 'react-dom', '@tanstack/react-query', '@tanstack/react-virtual', ], output: { globals: { react: 'window.__dirkStudio.React', /* ...etc */ } }, }
Default-export a component taking { value, onChange, canEdit } โ the same props the map layers already take. onChange stages the value like any other edit: the save bar counts it, Undo steps through it, and nothing writes until Save.
A control standing in for a list is also handed openRow, addRow and
deleteRow. Use them: the panel already builds a row editor from your schema,
honours x-rowTabs, validates, and offers the item and coordinate pickers.
Draw the list however your data deserves โ a wall of fish with their images โ
and hand editing back. Re-implementing the form per script is the sprawl this
whole design exists to stop.
Two more props come with it: t(key, fallback) resolves against your own
locales/*.json, so your control has no untranslatable corner; and
notify(kind, message) raises the panel's toast, which anything with an
immediate effect needs or the button looks broken whether it worked or not.
Requirements and behaviour:
- the file must be under
web/build/, and listed in yourfxmanifestfiles{} - Define
process.env.NODE_ENVaway at build time (define: { 'process.env.NODE_ENV': '"production"' }). A bundle that leaves the reference in dies on its first line in a browser. The panel shims a minimalprocessas a safety net, but relying on it is asking for trouble. window.__dirkStudio.versionis the contract version (currently6); check it if you want to fail loudly on a future React major rather than halfway through a renderreact-leafletandleafletare shared too, for the same reason React is: leaflet's hooks read a context that<Map>provides, so a component bundling its own copy gets a different one anduseMap()returns null- a component that fails to load, or throws while rendering, is caught and replaced with a message naming the resource, the file and the reason โ it cannot take the panel down
- failures are not cached, so restarting the resource retries
The panel does not guess from field NAMES. There used to be a run of rules that picked a control from what a key was called โ /account$/ meant an account picker, /model$/ a model picker. They were wrong in ways nothing in the schema could predict: maxBackupsPerAccount is a number and got an account picker. They are gone.
What is left derives mostly from the DATA, which is a fact rather than a guess: a hex string is a colour, a {_key} object is a keybind, rows with an x and a y are pins on a map, an enum is a select. Four name rules survive, listed below, because nothing in the value could say it. Anything else declares x-control.
What inference already handles, so you do not annotate it:
| Schema or value shape | Control |
|---|---|
enum / x-enum | select (searchable past 5 options) |
array with items.enum | constrained multi-select |
[min, max] of numbers (minItems/maxItems 2) | range |
object with bounded numeric additionalProperties (it has a maximum) | weight map with sliders |
object with additionalProperties of objects | objectMap โ add x-optionsFrom to say what the keys may be |
object of unbounded numbers, or key ending revokers | group + grade pickers |
object whose values all carry a {_key} main | keybind map |
object with no declared properties | free-form key/value editor |
| a hex string value, whatever the field is called | colour picker |
a { _type, _key } value | keybind |
an { x, y } value or properties | world position |
array of {x,y,z} | world-position list with goto / set-from-here |
| a map layer whose rows have an outline | polygon ยท rows with sibling numeric x/y get a pin per row โ draggable to move, click to select. Declare the layer in x-mapPaths; shape there overrides this. |
a bounded 0..2 number inside a row | slider. A top-level bounded number stays a number box โ declare x-control: "slider" if you want the drag there too. |
Four rules still read the field name, because the value cannot say it:
| Key | Control |
|---|---|
exactly daysOfWeek | SunโSat toggles |
ending accounts, or exactly paymentMethods, on an array | framework account picker |
ending groups, on an array | job / gang / ACE group picker |
containing control or key, on an array of numbers | GTA control-ID picker โ which is a different thing from a keybind |
๐จ The rules that used to read a name are gone โ declare these. A key ending
token / password / apiKey / webhookUrl is no longer a masked field;
description / notes is no longer a textarea; color, sprite, display
and model no longer pick their pickers. Every one of those now needs
x-secret, x-control: "text", x-control: "blipColor" and so on. A
credential left to inference is a plain visible text box.
โ
Authoring a new resource: write the schema with x-arrayKey on every editable array, x-serverOnly and x-secret on every credential, x-sections for the rail, and required/minimum/maximum wherever a value has real limits. Everything else is inferred. Then check it in the panel โ a field showing the wrong control almost always means the schema is under-specified, not that the panel needs changing.
Actions
x-validateWith asks a passive question on every debounced keystroke. Some
things are not questions: "Test webhook" posts a real message into a real
Discord channel, and firing that while someone types a URL fills the channel
with test messages.
So an action is a button, and it happens when asked:
json"webhookUrl": { "type": "string", "x-control": "secret", "x-serverOnly": true, "x-action": { "label": "Test webhook", "callback": "admin:testWebhook", "icon": "send", "sendSection": true } }
callback names a server callback on your own resource. dirk_lib prefixes it
with your resource name and forwards it โ a page can only ever reach the script
that declared it, and your permission check still runs on the far side.
sendSection: true sends every setting in that top-level block, keyed as your
script knows them (logging.webhookUrl arrives as webhookUrl), plus value.
Testing a webhook posts a preview of the events that are switched on, so the
URL alone does not say what to send. Values are the ones staged in the panel,
not the saved ones โ pressing Test checks what is in the box.
Return { success } or { ok }. Add message for what the panel should say โ
"Sent 3 of 4 event previews" beats "worked" โ and error for why not.
๐จ Check permissions inside the callback. The panel is not a guard.
lib.callback travels as a net event, so any client can already trigger any
registered server callback directly, with source set to themselves โ reaching
it through the panel is not required and never was. The relay's rule that a
callback must be named <yourResource>:... stops a page reaching the wrong
script; it does not stop a player reaching yours.
So every callback named by x-action or x-validateWith must gate itself, the
same way the rest of your config surface does:
lualib.callback.register('dirk_fishing:admin:testWebhook', function(src, data) if not lib.scriptConfig.hasPerm(src) then return { success = false, error = 'NoPermission' } end ... end)
โ ๏ธ An x-serverOnly value is stripped from every client snapshot, so the panel
does not have the saved one โ only a URL just typed in. Fall back to your own
stored config when the field arrives empty, or Test only works immediately
after typing.
Lending the screen to your script
Some things a page wants to show are not a value: "play this intro", "run this scene". That is your script's own code, and the panel just has to get out of the way while it runs โ otherwise it plays behind a full-screen panel that still holds the mouse, and closing the panel to watch throws away whatever was staged.
The handOff admin tool does exactly that. From your component:
tsconst begin = useAdminToolStore((s) => s.begin); const done = begin({ id: 'handOff', title: 'Playing the intro', keys: [{ key: 'โซ', action: 'Stop' }] }); fetchNui('ADMIN_TOOL_BEGIN', { id: 'handOff', instructions, timeoutMs: 300000 }); const reply = await fetchNui('STUDIO_REQUEST', { resource, callback: `${resource}:studio:playIntro`, payload }); if (!reply?.success) { // nothing will run, so nothing will hand back useAdminToolStore.getState().cancelActive(); fetchNui('ADMIN_TOOL_INVOKE', { id: 'handOffCancel' }); } await done; // the panel is back
Arming the store is what hides the panel; ADMIN_TOOL_BEGIN hands the mouse to
the game. Your server callback checks permission and tells your client what to
do, and when it is over your client gives the screen back, from any resource:
luaTriggerEvent('dirk_lib:studioHandBack', { played = true })
The panel returns exactly as it was left. A script that never hands back cannot
strand anyone: after timeoutMs (default two minutes) the panel comes back on
its own with { timedOut = true }. Add useAdminToolStore to your studio
build's cfx-react name list. Reference: dirk_multichar's backstory cards.
Your own page
x-component covers a control standing in for one setting. Some screens are
not settings at all: a players list reads live server data, pages through it and
acts on it. There is nothing in the config for it to be, and inventing a fake
entry just to have somewhere to mount it puts a setting in your schema that is
not one.
json"x-pages": [ { "id": "players", "label": "Players", "icon": "users", "component": "web/build/studio/players.js", "description": "Everyone who has fished, and their pots and permits." } ]
It appears in the rail under your script, in your script's accent colour,
below What's new and above every section โ because a page is a place you go,
not a setting you tune, and burying it under fourteen sections hides it. Pages
are listed in the order you declare them, so the one you most want found goes
first. dirk_lib supplies the frame, the theme, the loader and a React Query
client; the component gets { resource, canEdit, t, notify, onBack }.
A page that wants the whole window
A settings pane is a column beside a rail. A canvas is not. Add "full": true
and opening the page grows the Config Studio window itself to the screen,
folds the rail away, and puts a back arrow in the header:
json"x-pages": [ { "id": "designs", "label": "Designs", "icon": "layout-template", "full": true, "component": "web/build/studio/designs.js" } ]
Closing it animates back to the panel at its normal size, on the page you came
from. onBack is the same exit, for a button of your own. The player's own
full-screen preference is untouched โ this is taken off again when the page
closes.
Do not portal a full-screen editor onto document.body yourself. It looks
the same for one release and then drifts: it sits over the panel instead of
being it, it misses the theme, and every product that copies you writes its own
z-index. full is the supported way.
Reaching your own server from inside it needs the relay, because the page renders
in dirk_lib's NUI frame โ a plain fetchNui posts to dirk_lib, and your
callbacks are never reached. Worse, fetchNui answers a failed fetch with its
mock data, so that failure looks exactly like success:
tsconst reply = await fetchNui<{ success: boolean; data?: T; _error?: string }>( 'STUDIO_REQUEST', { resource: 'dirk_fishing', callback: 'dirk_fishing:getPlayers', payload }, ); if (!reply?.success) throw new Error(reply?._error ?? 'NoAnswer');
The callback name must start with your own resource name; anything else is
refused. Keep your mock data behind isEnvBrowser() so a real failure in game
still looks like one.
The Designs tab
Every script whose look a server owner builds rather than configures โ the character screen, the loading screen, the chat โ surfaces that the same way, so one is learnt and the rest are recognised. This is that shape; copy it exactly rather than inventing a second one.
One page, declared first.
json"x-pages": [ { "id": "designs", "label": "Designs", "icon": "layout-template", "full": true, "component": "web/build/studio/designs.js", "description": "Every look this screen can wear." } ]
Declared first, it lands directly under What's new and above every settings section, in the script's accent colour โ the same weight as the other places worth going, which is what it is.
Two modes behind one page.
Browse is a gallery: a card per design with a live thumbnail, an ACTIVE chip on the one players see, and per-card set-active, duplicate, rename, delete, plus + New. It is an ordinary full-window page.
Edit is the canvas. Opening a design swaps the page's contents for the editor;
the window is already full screen, so nothing grows a second time and nothing
floats over the panel. The page's own breadcrumb goes back to the gallery, and
the header's back arrow (or onBack) leaves the page entirely.
What is stored where. The active design is the only one every player needs, so it lives in scriptConfig client and server; the rest are server-only, because admins edit them and players never read them. The gallery fetches metadata only โ name, description, thumbnail โ and the full element tree is loaded when a design is opened. A gallery that loads every tree is a gallery that stalls the panel.
What is shared. The gallery, the editor shell and the storage contract are written once in the studio package; a product supplies its own manifest and mounts them. If you are writing a second copy of the gallery, stop โ that is the thing this section exists to prevent.
What a setting is called
Three things can name a setting, and they are tried in this order:
- the resource's own locale bundle for the active language โ
settings.<path>.label - the same bundle in English, the fallback the whole chain leans on
- the schema: a declared
title, else the property key humanised (maxArtificialDepthโ "Max Artificial Depth")
title works everywhere a name is drawn โ a setting, a column inside a row, and
the modal a list opens. Without it a column could only ever be called what its
property is called, so channelId read as "Channel Id" and a list titled
"Redirects" still opened a modal called "New Route".
๐จ The locale file beats title, and this is the trap.
Add a title to a setting that already has a settings.<path>.label entry in
locales/en.json and nothing changes in the panel โ the bundle answers
first and the schema is never reached. It reads exactly like a broken
annotation. Change the locale entry, or delete it, and the title appears.
The same order applies to descriptions (settings.<path>.description over the
schema's description) and to sections (sections.<id>.label).
title does not rename a section. A top-level block becomes a section named
from its key; to call it something else, declare it in x-sections or translate
sections.<id>.label.
Locales
Labels, descriptions and validation messages live in the resource's own locales/<lang>.json, not dirk_lib's, under a settings. namespace:
json{ "sections.permits.label": "Permits", "settings.basic.permitPrice.label": "Permit Price", "settings.basic.permitPrice.description": "Cost of a fishing licence.", "ErrPermitPriceRequired": "Needed when this fish requires a permit" }
Anything untranslated falls through to the schema's English per string, so a part-translated resource is fine. Changing the language in Shared Settings re-renders immediately.
The words INSIDE a control are translatable too, and by the same derived keys โ nothing in the schema needs annotating for it:
| What | Key |
|---|---|
| a setting's name | settings.<path>.label |
| its help text | settings.<path>.description |
| an enum option | settings.<path>.enum.<value> |
a slider band (x-bandLabels) | settings.<path>.bands.<index> |
a boolChoice side (x-boolLabels) | settings.<path>.bool.true / settings.<path>.bool.false |
| a section | sections.<id>.label / .description |
Inside a list row the path reaches the column, so a column called speciality on the scrapyards.locations list is settings.scrapyards.locations.speciality.enum.sports.
Bands are keyed by index, not by their English word: they are a ladder, and renaming a rung in English should not orphan its translation.
#scriptConfig.get
Get settings at a path.
lualocal val = scriptConfig.get('basic.maxPlayers') local all = scriptConfig.get() -- returns everything
#scriptConfig.set
Apply a partial settings update. Merges changes, persists to DB, broadcasts to clients, and fires watchers.
luascriptConfig.set({ basic = { maxPlayers = 64 } })
#scriptConfig.on
Same watcher API as the client side.
lualib.scriptConfig.on('basic', function(newVal, oldVal, meta) print('basic changed, source:', meta.source) end)
#scriptConfig.reset
Reset all settings to schema defaults.
luascriptConfig.reset()
#Server callbacks
These lib.callback handlers are registered on your resource automatically when
you call lib.scriptConfig(). They are what the panel talks to โ you never
register them yourself, and every one of them re-checks permission on its own:
| Callback | Purpose |
|---|---|
<resource>:getScriptConfig | Client fetch with version check โ returns a delta if changed |
<resource>:getFullScriptConfig | Panel fetch of everything a viewer may see |
<resource>:getServerOnlyScriptConfig | The x-serverOnly values, edit level only โ never in the client snapshot |
<resource>:updateScriptConfig | Save, with expectedVersion for conflict detection |
<resource>:resetScriptConfig | Reset to defaults |
<resource>:getScriptConfigHistory | Paginated audit log with search / filter |
<resource>:getMissingItems | Which x-installItem names are not in the inventory yet |
<resource>:giveScriptConfigItem | Admin give-item action (for testing) |
#Database
Settings are stored in the dirk_scriptConfig table (created automatically):
| Column | Type | Description |
|---|---|---|
| script | varchar (PK) | Resource name |
| data | longtext | JSON settings data |
| client_version | int | Content-based hash (31-bit) for cache invalidation |
| resource_version | varchar | Resource version from manifest at last save |
| change_log | longtext | JSON array of audit entries (max 250) |
| last_editor | longtext | JSON { source, name, identifier } of last admin |
| lastupdated | timestamp | Automatic update timestamp |
Change log entry format:
lua{ at_unix = 1710454800, at_utc = "2024-03-14T15:20:00Z", script = "my_resource", admin = { source = 5, name = "Admin", identifier = "license:abc123" }, expected_version = 12345, applied_version = 67890, changes = { { path = "basic.debug", old = false, new = true }, { path = "basic.maxPlayers", old = 32, new = 64 }, }, }
#Version Conflict Detection
The system uses content-based hashing for optimistic concurrency:
- When settings are saved, a 31-bit hash is computed from the canonical JSON
- The admin panel sends
expectedVersionwith every update - If the server's current version doesn't match, the update is rejected
- The server returns the latest data so the admin can review and re-save
This prevents two admins from silently overwriting each other's changes.
#Watcher Patterns
#Common server-side patterns
lua-- Re-register useables when equipment config changes lib.scriptConfig.on('equipment', function() registerEquipmentUseables() end) -- Conditionally register debug commands lib.scriptConfig.on('basic', function(basic) if not basic.debug then return end RegisterCommand('debugInfo', function(src) -- debug logic end, true) end) -- React to ALL changes (e.g. regenerate config files) lib.scriptConfig.on('*', function() regenerateExportFiles() end)
#Common client-side patterns
lua-- Register keybinds from settings lib.scriptConfig.on('basic', function(basic) local controls = basic.defaultControls or {} RegisterKeyMapping('+myAction', 'My Action', controls.main?._type or 'keyboard', controls.main?._key or 'E') end)
#Shared-side patterns
lua-- Recalculate derived values used on both sides lib.scriptConfig.on('basic', function(basic) local skillSettings = basic.skillSettings or {} MySkill.baseLevel = skillSettings.baseLevel or 1 MySkill.maxLevel = skillSettings.maxLevel or 99 generateLevelMap() end)
#Real-World Example (dirk_fishing)
The fishing resource demonstrates the full system:
Server watchers (src/server/init.lua):
lualib.scriptConfig.on('equipment', function() registerRodUseables() end) lib.scriptConfig.on('basic', function() registerGuidebookUseable() end) lib.scriptConfig.on('stores', function() registerStoresAndPrices() end) lib.scriptConfig.on('fish', function() registerStoresAndPrices() end) lib.scriptConfig.on('baitDig', function() registerBaitDigUseables() end)
Wildcard watcher (src/server/install.lua):
lualib.scriptConfig.on('*', function() -- Regenerate ox.lua, qb.lua, esx.sql item definition files -- whenever ANY setting changes local basic = scriptConfig?.basic or {} local fish = scriptConfig?.fish or {} local equipment = scriptConfig?.equipment or {} -- ... generate and save files end)
Client keybind registration (src/client/permit.lua):
lualib.scriptConfig.on('basic', function(basic) if permitControlsRegistered then return end permitControlsRegistered = true local controls = basic?.defaultControls or {} RegisterKeyMapping('+flipFishingPermit', 'Flip Fishing Permit', controls.flipCard?.main?._type or 'keyboard', controls.flipCard?.main?._key or 'r') end)
Last updated on 30 September 2026
