DirkScripts Logo

Login With FiveM

Login

๐Ÿ“š Library
โ€บ
Modules
โ€บ

scriptConfig

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

lua
fx_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:

lua
CreateThread(function()
  lib.scriptConfig(schema, function(src)
    return IsPlayerAceAllowed(src, 'admin')
  end)
end)

That single call handles everything:

  1. Waits for MySQL to be ready
  2. Creates the dirk_scriptConfig DB table if needed
  3. Loads stored settings from the database
  4. Smart-merges with schema defaults (schema is always source-of-truth)
  5. Computes a content-hash version for cache invalidation
  6. Fires all registered watchers with source = "initial"
  7. Registers all NUI callbacks for the admin panel

4. Set Up the Client

Create src/client/scriptConfig.lua:

lua
local _ = lib.scriptConfig  -- triggers lazy-load + KVP cache

The client:

  1. Checks the resource KVP cache for a fast-path load
  2. Calls the server with its cached version number
  3. If the server has a newer version, receives the delta and merges locally
  4. Caches updated settings to KVP for next startup
  5. 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.().

lua
local settings = scriptConfig.get()
-- or
local settings = scriptConfig.()

scriptConfig.on

Watch for settings changes. Returns an unsubscribe function.

lua
local unsubscribe = scriptConfig.on('basic', function(newValue, oldValue, meta)
    print('basic settings changed', meta.path)
end, { immediate = true })

-- Stop watching
unsubscribe()
ParameterTypeRequiredDescription
pathstringyesDot-separated path to watch, or '*' for all changes
cbfunctionyesfunction(newValue, oldValue, meta)
optionstableno{ 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:

FieldTypeDescription
pathstringWatched path
changedPathsstring[]Which leaf paths actually changed
sourcestring'load', 'refresh', 'update', or 'initial'
currenttableFull current settings
previoustableFull previous settings

scriptConfig.set

Update settings (admin only). Sends the change to the server.

lua
scriptConfig.set({ basic = { debug = true } }, expectedVersion)
ParameterTypeRequiredDescription
datatableyesPartial settings to merge
expectedVersionnumbernoOptimistic concurrency version

scriptConfig.getAll

Get the full unfiltered settings (admin only).

lua
local 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.

lua
lib.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,
    },
})
ParameterTypeRequiredDescription
schematableyesJSON Schema definition with defaults
canEditFnfunctionnofunction(src) โ†’ boolean โ€” additional permission grant on top of the master ACE + overrides (see Access Control). Purely additive: cannot lock out the master.
rulestableno{ migrations = { ['1.2.0'] = function(data) ... end } } โ€” see below.

Save permission is gated by the master ACE list (dirk_lib_master_group convar), the access block your schema pushes, and the dirk_admins rows first. Your canEditFn only 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_config and 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:

  1. the master group โ€” dirk_lib_master_group convar, default group.admin. Always edit, and can never be locked out.
  2. 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.
  3. rows on the Admins page (dirk_admins), which name either a player identifier or an ACE principal, at edit or view level, optionally scoped to particular scripts. An empty scope means every script.
  4. 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.

lua
if exports.dirk_lib:canEditScriptConfig(source, GetCurrentResourceName()) then
    -- show admin-only menu, run admin command, etc.
end

Rules

lua
rules = {
    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 and 10 on 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

ExtensionTypeDescription
x-serverOnlybooleanMarks 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-renamedFromstringDeclarative key migration โ€” old path is automatically moved to the new path on load.
x-arrayKeystringIdentity 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-autoDefaultstring | arrayConvar 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

ExtensionTypeDescription
x-installItemobjectOn 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-installItemListboolean | objectOn 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 its weightLimits say.

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.

ExtensionWhereDescription
x-sectionsschema rootRe-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-workspaceon a top-level block, or on a section as workspace: trueThis 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-rowTabson an array, at any depthTabs 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-mapPathsschema rootArray 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-labelschema rootWhat this script is called in the panel. Without it the raw resource name is shown.
x-iconschema root, or a blockThe lucide icon name for the script, or for one section.
x-sharedschema rootThis 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-pagesschema rootWhole 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-managedElsewhereschema rootBlock 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.

KeyMeaning
pathWhich setting to read. Omit, or use "self", for the field being tested. Inside a row, "self.otherField" reads a sibling column.
equals / inExact match / one of a set.
gt gte lt lteNumeric comparison against a literal.
gtField gteField ltField lteFieldNumeric comparison against another setting.
isSettrue = present and non-empty, false = empty.
minLength / maxLengthFor strings and arrays.
ascending / strictlyAscendingA [min, max] pair that must not run backwards.
allOf / anyOf / notCombine 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:

ShapeHow 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 objectjust 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.

ExtensionTypeDescription
x-enumarrayConstrains a value to a set, like enum, without JSON Schema validation semantics.
x-enumLabelsobjectDisplay names for enum values: { "zh-TW": "็น้ซ”ไธญๆ–‡ (zh-TW)" }. Without it the raw value is humanised, which is useless for codes.
x-optionsFromobjectValues 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-anyLabelstringThe 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-iconPickerbooleanThis 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-itemPickerbooleanForce the inventory-item picker on a string.
x-itemTitlestringWhich 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-groupPickerbooleanThis 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-enumIconsobjectlucide icon name per enum value: { "fuel": "fuel" }. Any icon lucide ships works.
x-enumColorsobjectHex colour per enum value: { "fuel": "#0A84FF" }.
x-validateWithstringNames 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-vector4booleanForce a world-position picker: x, y, z and a heading. Somewhere to stand and a way to face.
x-vector3booleanThe same picker, writing x, y, z โ€” a place with a height but no facing.
x-vector2booleanThe same picker, writing x, y โ€” a flat point, like a zone corner.
x-bodyHeightbooleanThis 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-secretbooleanThis 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-keybindbooleanThis value is a key to press, when the name does not say so.
x-durationBasestringWhat 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-skillstringThis 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-bandLabelsarrayWhat 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-boolLabelsobjectThe 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-propModelstringWhich 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-goToobject"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-controlstringName 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

NameWhat it is
stringA single-line box. The fallback for anything unrecognised.
textMulti-line, for a description.
secretA credential: masked, never guessed at.
number / integerA number box; integer refuses decimals.
percentA number understood as a percentage.
sliderA bounded number you drag. Inferred automatically for 0..1 and 0..2.
rangeA [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.
timeA time of day.
durationA 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.
hourOfDayAn 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:

NameReads as
chanceNever โ†’ Always. Accepts 0โ€“1 or 0โ€“100.
multiplierFills from the centre: below 1 is a reduction, above 1 a boost.
difficultyEasier โ†’ Harder.
forgivenessStrict โ†’ Forgiving.
rarityCommon โ†’ Legendary.
balanceA centre-out trade-off between two sides.
progressionMuch easier โ†’ Much harder.

Choices

NameWhat it is
booleanA switch.
enumOne of a fixed set. Add x-enumLabels / x-enumIcons / x-enumColors.
enumListSeveral of a fixed set.
pickOne / pickListOne / several, read from another setting โ€” see x-optionsFrom.
boolChoiceA 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.
weekdaysDays of the week, because nobody remembers which end Sunday is.

Things in the game world

NameWhat it is
itemAn inventory item, picked by its picture. Unknown names are allowed but flagged.
modelAny model, searched by name.
ped / pedsOne / several ped models, shown as the peds themselves.
vehicleA vehicle model.
coordsA world position, with Goto and Set.
cameraA 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.
propWhere 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.
positionsSeveral of them.
zonesPolygons, drawn on the shared map.
group / groupsOne / several framework jobs or gangs, from the server's real list.
groupGradesJobs with a minimum rank โ€” "LSPD, rank 2 and above".
account / accountsFramework money accounts.

Blips

NameWhat it is
blipColorThe real colour list, not a shortlist.
blipSpriteBrowsable by artwork โ€” nobody knows a sprite's name before seeing it.
blipDisplayWhere the blip shows.

Appearance

NameWhat it is
colorA hex colour.
mantineColor / shadeA theme colour and its shade.
paletteA full ten-step palette.
iconAn icon, picked by looking at it. Pair with x-iconSet.
anchorWhere 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

NameWhat it is
keybindA key, captured by pressing it.
control / controlsOne / several GTA control IDs, by name.
keybindMapNamed actions mapped to keys.

Structured

NameWhat it is
listAn array of rows, with the schema-built row editor.
tagsLoose values as chips.
keyvalueA free-form map โ€” keys are data, not schema.
weightMapNamed things with a weighting, as bars.
objectMapAn 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.
customThe owning script supplies the editor โ€” see x-component.

Discord โ€” both read the bot configured in dirk_lib's own Discord settings:

NameWhat it is
discordChannelA 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.
redirectKindWhich 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.

ExtensionTypeDescription
x-rowControlsobjectControls 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-rowOrderarrayThe 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-boundsFromobjectLimits 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-iconSetstringWhich 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

ExtensionTypeDescription
x-componentstringPath 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-componentFullbooleanThat 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 }
  }
}
KeyMeaning
controlThe control name, as the bare string form.
optionsFixed choices, as strings or { value, label }.
optionsFromChoices 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.
anyLabelThe label for "no choice", when empty is allowed.
iconSetAs x-iconSet.
boundsFromAs x-boundsFrom.
enabledWhenAs 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 your fxmanifest files{}
  • Define process.env.NODE_ENV away 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 minimal process as a safety net, but relying on it is asking for trouble.
  • window.__dirkStudio.version is the contract version (currently 6); check it if you want to fail loudly on a future React major rather than halfway through a render
  • react-leaflet and leaflet are 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 and useMap() 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 shapeControl
enum / x-enumselect (searchable past 5 options)
array with items.enumconstrained 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 objectsobjectMap โ€” add x-optionsFrom to say what the keys may be
object of unbounded numbers, or key ending revokersgroup + grade pickers
object whose values all carry a {_key} mainkeybind map
object with no declared propertiesfree-form key/value editor
a hex string value, whatever the field is calledcolour picker
a { _type, _key } valuekeybind
an { x, y } value or propertiesworld position
array of {x,y,z}world-position list with goto / set-from-here
a map layer whose rows have an outlinepolygon ยท 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 rowslider. 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:

KeyControl
exactly daysOfWeekSunโ€“Sat toggles
ending accounts, or exactly paymentMethods, on an arrayframework account picker
ending groups, on an arrayjob / gang / ACE group picker
containing control or key, on an array of numbersGTA 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:

lua
lib.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:

ts
const 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:

lua
TriggerEvent('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:

ts
const 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:

  1. the resource's own locale bundle for the active language โ€” settings.<path>.label
  2. the same bundle in English, the fallback the whole chain leans on
  3. 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:

WhatKey
a setting's namesettings.<path>.label
its help textsettings.<path>.description
an enum optionsettings.<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 sectionsections.<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.

lua
local 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.

lua
scriptConfig.set({ basic = { maxPlayers = 64 } })

scriptConfig.on

Same watcher API as the client side.

lua
lib.scriptConfig.on('basic', function(newVal, oldVal, meta)
    print('basic changed, source:', meta.source)
end)

scriptConfig.reset

Reset all settings to schema defaults.

lua
scriptConfig.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:

CallbackPurpose
<resource>:getScriptConfigClient fetch with version check โ€” returns a delta if changed
<resource>:getFullScriptConfigPanel fetch of everything a viewer may see
<resource>:getServerOnlyScriptConfigThe x-serverOnly values, edit level only โ€” never in the client snapshot
<resource>:updateScriptConfigSave, with expectedVersion for conflict detection
<resource>:resetScriptConfigReset to defaults
<resource>:getScriptConfigHistoryPaginated audit log with search / filter
<resource>:getMissingItemsWhich x-installItem names are not in the inventory yet
<resource>:giveScriptConfigItemAdmin give-item action (for testing)

Database

Settings are stored in the dirk_scriptConfig table (created automatically):

ColumnTypeDescription
scriptvarchar (PK)Resource name
datalongtextJSON settings data
client_versionintContent-based hash (31-bit) for cache invalidation
resource_versionvarcharResource version from manifest at last save
change_loglongtextJSON array of audit entries (max 250)
last_editorlongtextJSON { source, name, identifier } of last admin
lastupdatedtimestampAutomatic 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:

  1. When settings are saved, a 31-bit hash is computed from the canonical JSON
  2. The admin panel sends expectedVersion with every update
  3. If the server's current version doesn't match, the update is rejected
  4. 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):

lua
lib.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):

lua
lib.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):

lua
lib.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

Copyright ยฉ 2026 DirkScripts.

Not affiliated with or endorsed by Rockstar North, Take-Two Interactive, or any other rights holders. FiveM is a copyright and registered trademark of Take-Two Interactive Software, Inc.
Our checkout system is provided by Tebex Limited, who manage payment processing, product delivery, and billing support. Prices shown in currencies other than GBP are approximate conversions updated daily. All purchases are processed in GBP, so the final amount charged may vary depending on your bank or payment providerโ€™s exchange rate.