From 2c0f85f81799b2ef2057e809c8e5527e3e0bc9aa Mon Sep 17 00:00:00 2001 From: Qiu Date: Thu, 11 Jun 2026 12:34:58 +0800 Subject: [PATCH 01/57] =?UTF-8?q?=E4=BD=BF=E7=94=A8=E6=94=B9=E8=BD=A6?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=97=B6=E8=AF=A5=E8=AF=AD=E5=8F=A5=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=8A=B6=E6=80=81=E6=97=A0=E6=B3=95=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=20=E7=94=A8=E6=9B=B4=E5=A5=BD=E7=9A=84=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E5=A4=84=E7=90=86=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- [core]/es_extended/server/classes/vehicle.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/[core]/es_extended/server/classes/vehicle.lua b/[core]/es_extended/server/classes/vehicle.lua index b85c8f65..6d1e4a81 100644 --- a/[core]/es_extended/server/classes/vehicle.lua +++ b/[core]/es_extended/server/classes/vehicle.lua @@ -167,7 +167,7 @@ Core.vehicleClass = { assert(type(newProps) == "table", "Expected 'props' to be a table") local vehicleData = Core.vehicles[self.plate] - local affectedRows = MySQL.update.await("UPDATE `owned_vehicles` SET `vehicle` = ? WHERE `plate` = ? AND `owner` = ?", json.encode(newProps), vehicleData.plate, vehicleData.owner) + local affectedRows = MySQL.update.await("UPDATE owned_vehicles SET vehicle = ? WHERE plate = ? AND owner = ?",{ json.encode(newProps), vehicleData.plate, vehicleData.owner }) if affectedRows <= 0 then self:delete() return false From 16e33d0d4ca6ba20d68403b5c347d67d8044cbd5 Mon Sep 17 00:00:00 2001 From: rockDEV Date: Fri, 26 Jun 2026 00:55:58 +0800 Subject: [PATCH 02/57] =?UTF-8?q?=E6=8F=90=E4=BE=9B=E6=9B=B4=E5=A5=BD?= =?UTF-8?q?=E7=9A=84SQL=E5=A4=84=E7=90=86=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- [core]/es_extended/server/classes/vehicle.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/[core]/es_extended/server/classes/vehicle.lua b/[core]/es_extended/server/classes/vehicle.lua index 6d1e4a81..3f58b802 100644 --- a/[core]/es_extended/server/classes/vehicle.lua +++ b/[core]/es_extended/server/classes/vehicle.lua @@ -167,7 +167,7 @@ Core.vehicleClass = { assert(type(newProps) == "table", "Expected 'props' to be a table") local vehicleData = Core.vehicles[self.plate] - local affectedRows = MySQL.update.await("UPDATE owned_vehicles SET vehicle = ? WHERE plate = ? AND owner = ?",{ json.encode(newProps), vehicleData.plate, vehicleData.owner }) + local affectedRows = MySQL.update.await("UPDATE `owned_vehicles` SET `vehicle` = ? WHERE `plate` = ? AND `owner` = ?", { json.encode(newProps), vehicleData.plate, vehicleData.owner }) if affectedRows <= 0 then self:delete() return false From e7788b2bb447886d2f5fc599fb0fab6b606f1aa8 Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:42:02 +0530 Subject: [PATCH 03/57] feat: integrate Medal.tv globally --- [core]/esx_lib/config.lua | 24 ++++++++++++++++++++++++ [core]/esx_lib/fxmanifest.lua | 5 +++++ [core]/esx_lib/html/medal.html | 10 ++++++++++ [core]/esx_lib/html/medal.js | 13 +++++++++++++ [core]/esx_lib/imports/medal/client.lua | 18 ++++++++++++++++++ [core]/esx_lib/resource/medal/client.lua | 22 ++++++++++++++++++++++ 6 files changed, 92 insertions(+) create mode 100644 [core]/esx_lib/config.lua create mode 100644 [core]/esx_lib/html/medal.html create mode 100644 [core]/esx_lib/html/medal.js create mode 100644 [core]/esx_lib/imports/medal/client.lua create mode 100644 [core]/esx_lib/resource/medal/client.lua diff --git a/[core]/esx_lib/config.lua b/[core]/esx_lib/config.lua new file mode 100644 index 00000000..da94e619 --- /dev/null +++ b/[core]/esx_lib/config.lua @@ -0,0 +1,24 @@ +Config = {} + +---@class MedalClipOptions +---@field duration integer +---@field captureDelayMs integer +---@field alertType 'Default'|'Disabled'|'SoundOnly'|'OverlayOnly' + +---@class MedalConfig +---@field enabled boolean +---@field publicKey string +---@field eventName string +---@field clipOptions MedalClipOptions + +---@type MedalConfig +Config.Medal = { + enabled = true, + publicKey = 'pub_82qkpMKV77AkpqLSgWsxLlDyfzpPI7Vw', + eventName = 'Death', + clipOptions = { + duration = 30, + captureDelayMs = 0, + alertType = 'Default' + } +} \ No newline at end of file diff --git a/[core]/esx_lib/fxmanifest.lua b/[core]/esx_lib/fxmanifest.lua index d0a98222..5ed51ec2 100644 --- a/[core]/esx_lib/fxmanifest.lua +++ b/[core]/esx_lib/fxmanifest.lua @@ -8,13 +8,18 @@ author 'ESX Team' version '0.01' description 'Official ESX library' +ui_page 'html/medal.html' + files { 'imports.lua', 'imports/**/client.lua', 'imports/**/shared.lua', + 'html/medal.html', + 'html/medal.js', } shared_scripts { + 'config.lua', 'resource/init.lua', 'resource/**/shared.lua', } diff --git a/[core]/esx_lib/html/medal.html b/[core]/esx_lib/html/medal.html new file mode 100644 index 00000000..f1722bd0 --- /dev/null +++ b/[core]/esx_lib/html/medal.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/[core]/esx_lib/html/medal.js b/[core]/esx_lib/html/medal.js new file mode 100644 index 00000000..8c291074 --- /dev/null +++ b/[core]/esx_lib/html/medal.js @@ -0,0 +1,13 @@ +window.addEventListener('message', function (event) { + var data = event.data; + if (!data || data.action !== 'medalClip') return; + + fetch('http://localhost:12665/api/v1/event/invoke', { + method: 'POST', + headers: { + 'publicKey': data.publicKey, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data.payload) + }).catch(function () {}); +}); diff --git a/[core]/esx_lib/imports/medal/client.lua b/[core]/esx_lib/imports/medal/client.lua new file mode 100644 index 00000000..ff10c292 --- /dev/null +++ b/[core]/esx_lib/imports/medal/client.lua @@ -0,0 +1,18 @@ +local medal = {} + +function medal.getConfig() + return exports['esx_lib']:getMedalConfig() +end + +function medal.triggerClip(publicKey, eventName, clipOptions) + local cfg = medal.getConfig() + if not cfg or not cfg.enabled then return end + + exports['esx_lib']:triggerMedalClip( + publicKey or cfg.publicKey, + eventName or cfg.eventName, + clipOptions or cfg.clipOptions + ) +end + +return medal \ No newline at end of file diff --git a/[core]/esx_lib/resource/medal/client.lua b/[core]/esx_lib/resource/medal/client.lua new file mode 100644 index 00000000..8053def2 --- /dev/null +++ b/[core]/esx_lib/resource/medal/client.lua @@ -0,0 +1,22 @@ +xLib.getMedalConfig = function() + return Config.Medal +end + +xLib.triggerMedalClip = function(publicKey, eventName, clipOptions) + if not publicKey or publicKey == '' then return end + + SendNUIMessage({ + action = 'medalClip', + publicKey = publicKey, + payload = { + eventId = ('esx-clip-%s-%s'):format(GetPlayerServerId(PlayerId()), GetGameTimer()), + eventName = eventName or 'Event', + triggerActions = { 'SaveClip' }, + clipOptions = clipOptions or { + duration = 30, + captureDelayMs = 0, + alertType = 'Default' + } + } + }) +end \ No newline at end of file From d80a38c4850d00ae4c27b21c7442197d632a3942 Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:37:12 +0530 Subject: [PATCH 04/57] feat(server.cfg): add esx_lib to default resources --- server.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/server.cfg b/server.cfg index afac7a91..d7902e85 100644 --- a/server.cfg +++ b/server.cfg @@ -53,6 +53,7 @@ add_ace resource.es_extended command.stop allow ensure oxmysql ## ESX Legacy +ensure esx_lib ensure es_extended ensure [core] From 94002da05be9e087e6c66d2c4c5152c62e928ce3 Mon Sep 17 00:00:00 2001 From: ASTROWwwW Date: Thu, 23 Jul 2026 20:51:21 +0200 Subject: [PATCH 05/57] feat(esx_lib): add isEnhanced game edition detection xLib.isEnhanced() returns whether the game runs GTA V Enhanced (server: gamename convar, client: IsGameEnhancedVersion native). es_extended reads it once and requires OneSync Infinity on Legacy only, since Enhanced runs OneSync natively. --- [core]/es_extended/server/main.lua | 3 ++- [core]/esx_lib/imports/isEnhanced/shared.lua | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 [core]/esx_lib/imports/isEnhanced/shared.lua diff --git a/[core]/es_extended/server/main.lua b/[core]/es_extended/server/main.lua index 96c5cc64..21d2f9fa 100644 --- a/[core]/es_extended/server/main.lua +++ b/[core]/es_extended/server/main.lua @@ -2,6 +2,7 @@ SetMapName("San Andreas") SetGameType("ESX Legacy") local oneSyncState = GetConvar("onesync", "off") +local isEnhanced = xLib.isEnhanced() local newPlayer = "INSERT INTO `users` SET `accounts` = ?, `identifier` = ?, `ssn` = ?, `group` = ?" local loadPlayer = "SELECT `accounts`, `ssn`, `job`, `job_grade`, `group`, `position`, `inventory`, `skin`, `loadout`, `metadata`" @@ -151,7 +152,7 @@ if not Config.Multichar then return deferrals.done(("[ESX] ESX Requires a minimum Artifact version of 10188, Please update your server.")) end - if oneSyncState == "off" or oneSyncState == "legacy" then + if not isEnhanced and (oneSyncState == "off" or oneSyncState == "legacy") then return deferrals.done(("[ESX] ESX Requires Onesync Infinity to work. This server currently has Onesync set to: %s"):format(oneSyncState)) end diff --git a/[core]/esx_lib/imports/isEnhanced/shared.lua b/[core]/esx_lib/imports/isEnhanced/shared.lua new file mode 100644 index 00000000..8adafe29 --- /dev/null +++ b/[core]/esx_lib/imports/isEnhanced/shared.lua @@ -0,0 +1,17 @@ +local ENHANCED_GAME_NAMES = { + ["gta5enhanced"] = true, + ["gta5_enhanced"] = true +} + +local isEnhanced + +if IsDuplicityVersion() then + isEnhanced = ENHANCED_GAME_NAMES[GetConvar("gamename", "gta5")] == true +else + isEnhanced = type(IsGameEnhancedVersion) == "function" and IsGameEnhancedVersion() == true +end + +---@return boolean +return function() + return isEnhanced +end From 477b71384fbf8430e73baf32129dd3239d4b98a9 Mon Sep 17 00:00:00 2001 From: ASTROWwwW Date: Mon, 27 Jul 2026 00:25:14 +0200 Subject: [PATCH 06/57] fix(esx_lib): load imports from the correct resource name xLib.name was "xLib", which is not a resource, so the import metatable fed it to LoadResourceFile and resolved nothing. It failed silently on Legacy and hard-errored on Enhanced (Resource 'xLib' not found). Point it at the real resource name. --- [core]/esx_lib/resource/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/[core]/esx_lib/resource/init.lua b/[core]/esx_lib/resource/init.lua index b53f05d2..d40c8ee7 100644 --- a/[core]/esx_lib/resource/init.lua +++ b/[core]/esx_lib/resource/init.lua @@ -1,6 +1,6 @@ ---@diagnostic disable: lowercase-global xLib = setmetatable({ - name = 'xLib', + name = 'esx_lib', side = IsDuplicityVersion() and 'server' or 'client' }, { __newindex = function(self, key, fn) From c30225f223df8353319e6d171b53fc1a41647538 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:05:17 +0200 Subject: [PATCH 07/57] fix(es_extended/server/functions): persist items added via AddItems MySQL.prepare.await batch mode expects an array of positional parameter arrays, but toInsert holds keyed tables, so the placeholders received no values and the INSERT silently did nothing. Items still showed up in-game because ESX.Items was populated from the keyed tables, but nothing was written to the items table. Build a positional parameters array for the prepared insert. Closes #1768 --- [core]/es_extended/server/functions.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/[core]/es_extended/server/functions.lua b/[core]/es_extended/server/functions.lua index 7ab5f9b4..089f016a 100644 --- a/[core]/es_extended/server/functions.lua +++ b/[core]/es_extended/server/functions.lua @@ -799,9 +799,15 @@ if not Config.CustomInventory then end if #toInsert > 0 then + local parameters = {} + for i = 1, #toInsert do + local row = toInsert[i] + parameters[i] = { row.name, row.label, row.weight, row.rare, row.canRemove } + end + MySQL.prepare.await( "INSERT IGNORE INTO `items` (`name`, `label`, `weight`, `rare`, `can_remove`) VALUES (?, ?, ?, ?, ?)", - toInsert) + parameters) for i = 1, #toInsert do local row = toInsert[i] From 49c31bcb6163acf1d5e578449a1e7d88b3d89cb7 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:07:15 +0200 Subject: [PATCH 08/57] fix(es_extended/server/main): skip unknown weapons when loading a loadout ESX.GetWeaponLabel asserts on a name that is not in Config.Weapons, so it never returns a falsy value and the `if label then` check below it can never skip anything. The assert propagates out of loadESXPlayer, which means the player object is never created and esx:playerLoaded never fires - the player is stuck on the loading screen and hits the same error on every reconnect. This happens whenever a saved loadout holds a weapon the config no longer knows, for example after changing sv_enforceGameBuild or after removing a weapon from shared/weapons.lua. Wrapping the lookup keeps GetWeaponLabel itself untouched and lets the existing check do what it was written for. --- [core]/es_extended/server/main.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/[core]/es_extended/server/main.lua b/[core]/es_extended/server/main.lua index 96c5cc64..21bf80b9 100644 --- a/[core]/es_extended/server/main.lua +++ b/[core]/es_extended/server/main.lua @@ -288,9 +288,9 @@ function loadESXPlayer(identifier, playerId, isNew) local loadout = json.decode(result.loadout) for name, weapon in pairs(loadout) do - local label = ESX.GetWeaponLabel(name) + local found, label = pcall(ESX.GetWeaponLabel, name) - if label then + if found and label then userData.loadout[#userData.loadout + 1] = { name = name, ammo = weapon.ammo, From 22ddbadd6ebc057aa3c30cd26e62ba42c7a7040c Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:52:26 +0200 Subject: [PATCH 09/57] fix(es_extended/server/main): use the identity sex for the default skin The fallback skin is built from userData.sex, but that field does not exist yet at this point - it is only assigned further down when the identity data is read from the result row. The comparison therefore always ran against nil and every character without a saved skin fell back to the male model. result.sex holds the value already and is used a few lines later, so read it directly. Tested locally on artifact 25770 with a female character whose skin column is empty: the client used to receive skin.sex 0 and spawned as the male model, it now receives 1 and spawns female. A male character with an empty skin column still receives 0. --- [core]/es_extended/server/main.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/[core]/es_extended/server/main.lua b/[core]/es_extended/server/main.lua index 96c5cc64..6b3ea70e 100644 --- a/[core]/es_extended/server/main.lua +++ b/[core]/es_extended/server/main.lua @@ -307,7 +307,7 @@ function loadESXPlayer(identifier, playerId, isNew) userData.coords = json.decode(result.position) or Config.DefaultSpawns[ESX.Math.Random(1,#Config.DefaultSpawns)] -- Skin - userData.skin = (result.skin and result.skin ~= "") and json.decode(result.skin) or { sex = userData.sex == "f" and 1 or 0 } + userData.skin = (result.skin and result.skin ~= "") and json.decode(result.skin) or { sex = result.sex == "f" and 1 or 0 } -- Metadata userData.metadata = (result.metadata and result.metadata ~= "") and json.decode(result.metadata) or {} From d80274187c240ed1bb4e8cb74a323a502ddd1e40 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:51:21 +0200 Subject: [PATCH 10/57] fix(es_extended/server/functions): drop leading space from merge command arguments The merge argument type computes the length of the preceding arguments plus their separators, then passes that value straight to string.sub. Lua strings are 1-indexed, so the merged text actually starts one character later and the separating space ends up at the front of the value. Tested with a command whose merge argument sits at position 1, 2 and 3: positions 2 and 3 returned " def ghi" and " ccc ddd", now they return "def ghi" and "ccc ddd". Position 1 is unaffected because string.sub clamps index 0 to 1. --- [core]/es_extended/server/functions.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/[core]/es_extended/server/functions.lua b/[core]/es_extended/server/functions.lua index 7ab5f9b4..c3b5ee05 100644 --- a/[core]/es_extended/server/functions.lua +++ b/[core]/es_extended/server/functions.lua @@ -136,7 +136,7 @@ function ESX.RegisterCommand(name, group, cb, allowConsole, suggestion) end local merge = table.concat(args, " ") - newArgs[v.name] = string.sub(merge, length) + newArgs[v.name] = string.sub(merge, length + 1) elseif v.type == "coordinate" then local coord = tonumber(args[k]:match("(-?%d+%.?%d*)")) if not coord then From 7538debb6ffcb8dc13ce518fc3bba53ccc886632 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:19:51 +0200 Subject: [PATCH 11/57] fix(es_extended/server/main): add the weapon before restoring its tint and components esx:giveInventoryItem applies the tint and the components to the receiving player before calling addWeapon. Both setWeaponTint and addWeaponComponent start with getWeapon and only proceed when the player already owns the weapon, and the handler returns early a few lines above if the target does own it - so at that point the target provably does not, and both calls do nothing. The result is that handing a weapon to another player silently strips its attachments and its paint. The pickup path in the same file already adds the weapon first and then restores tint and components. The values are snapshotted from the source weapon before any of this, and removeWeapon already ran before addWeapon, so only the position of the two restore blocks changes. Verified on artifact 25770 by running both orders against a loadout: tint and component first -> both return false, weapon ends up tint 0 with no components addWeapon first -> both return true, weapon ends up tint 1 with the component attached --- [core]/es_extended/server/main.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/[core]/es_extended/server/main.lua b/[core]/es_extended/server/main.lua index 96c5cc64..3bed50ec 100644 --- a/[core]/es_extended/server/main.lua +++ b/[core]/es_extended/server/main.lua @@ -474,6 +474,9 @@ if not Config.CustomInventory then local weaponComponents = ESX.Table.Clone(weapon.components) local weaponTint = weapon.tintIndex + sourceXPlayer.removeWeapon(itemName) + targetXPlayer.addWeapon(itemName, itemCount) + if weaponTint then targetXPlayer.setWeaponTint(itemName, weaponTint) end @@ -484,9 +487,6 @@ if not Config.CustomInventory then end end - sourceXPlayer.removeWeapon(itemName) - targetXPlayer.addWeapon(itemName, itemCount) - if weaponObject.ammo and itemCount > 0 then local ammoLabel = weaponObject.ammo.label sourceXPlayer.showNotification(TranslateCap("gave_weapon_withammo", weaponLabel, itemCount, ammoLabel, targetXPlayer.name)) From eaf0db6c5e1d560408faa7eeca17e0d0b1e47ac8 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:24:53 +0200 Subject: [PATCH 12/57] fix(es_extended/modules/callback): compare the promise state against its numeric value promise.state is a number, not a string - the runtime defines PENDING as 0 and Citizen.Await tests against 0 as well. Comparing it to "pending" is therefore always false and the documented 15 second timeout never rejects anything. When the other side never answers, Citizen.Await blocks the calling coroutine for good. Measured on artifact 25770 with the real 15 second delay: never answers, current guard -> state 0, not rejected, await never returns never answers, fixed guard -> state 0, rejected, await returns the timeout answers after 1s, fixed guard-> state 3, untouched, await returns normally Nothing inside esx_core calls AwaitServerCallback or AwaitClientCallback, so no core behaviour changes. --- [core]/es_extended/client/modules/callback.lua | 2 +- [core]/es_extended/server/modules/callback.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/[core]/es_extended/client/modules/callback.lua b/[core]/es_extended/client/modules/callback.lua index 30211b9a..b0dca393 100644 --- a/[core]/es_extended/client/modules/callback.lua +++ b/[core]/es_extended/client/modules/callback.lua @@ -102,7 +102,7 @@ function ESX.AwaitServerCallback(eventName, ...) -- if the server callback takes longer than 15 seconds to respond, reject the promise SetTimeout(15000, function() - if p.state == "pending" then + if p.state == 0 then p:reject("Server Callback Timed Out") end end) diff --git a/[core]/es_extended/server/modules/callback.lua b/[core]/es_extended/server/modules/callback.lua index c0fa8f73..b29347ea 100644 --- a/[core]/es_extended/server/modules/callback.lua +++ b/[core]/es_extended/server/modules/callback.lua @@ -107,7 +107,7 @@ function ESX.AwaitClientCallback(player, eventName, ...) if not p then return end SetTimeout(15000, function() - if p.state == "pending" then + if p.state == 0 then p:reject("Server Callback Timed Out") end end) From d26455349024de05551967d699705010da5e9a7e Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:09:24 +0200 Subject: [PATCH 13/57] fix(es_extended/shared): return false instead of raising on invalid utf8 ESX.IsValidLocaleString only checks that the argument is a string, but a Lua string is a byte array and need not be valid utf8. utf8.codes raises on a malformed sequence rather than stopping, so the function throws where its annotation promises a boolean. esx_identity feeds raw client input into it through checkNameFormat, so a modified client sending a lone 0xFF byte as a first name aborts the registerIdentity callback before cb() runs. The client is left waiting on a promise that never resolves and cannot get past the identity screen, and it can be repeated at will. utf8.len returns nil instead of raising, so it is enough to reject the string up front. Verified on artifact 25770: "Jo\255hn" used to raise "invalid UTF-8 code" and now returns false. "John" still returns true, "Jo!hn" still returns false, and "Jo3hn" with allowDigits still returns true. --- [core]/es_extended/shared/functions.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/[core]/es_extended/shared/functions.lua b/[core]/es_extended/shared/functions.lua index 71e1390e..a5da44c8 100644 --- a/[core]/es_extended/shared/functions.lua +++ b/[core]/es_extended/shared/functions.lua @@ -186,6 +186,10 @@ function ESX.IsValidLocaleString(str, allowDigits) return false end + if not utf8.len(str) then + return false + end + local locale = string.lower(Config.Locale) local defaultRanges ={ From a507ec6a3195e6182848cf375d0fdc384eca5011 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:20:19 +0200 Subject: [PATCH 14/57] fix(esx_inventory/client): stop overloading the menu usable flag esx_menu_default treats usable == false as "this row cannot be submitted". esx_inventory was filling the same field with its own meaning - whether an item has a use callback - and set it to false for accounts, for weapons and for every item without a registered callback. The result was that pressing enter on cash, bank, dirty money, any weapon or any non consumable item did nothing at all. buildItemActionMenu was never reached, even though those entries carry canRemove and the submenu would have offered give and throw. The action menu needs the value for its own "use" entry, so it moves to a separate canUse field. The weight row keeps usable = false, since not being submittable is correct there. Verified on artifact 25770. Before: enter on cash and on bread did nothing. After: cash offers throw and return, bank offers only return because it is not droppable, bread offers throw and return without a use entry, and the weight row still cannot be selected. --- [core]/esx_inventory/client/main.lua | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/[core]/esx_inventory/client/main.lua b/[core]/esx_inventory/client/main.lua index 3fd84785..5ea57564 100644 --- a/[core]/esx_inventory/client/main.lua +++ b/[core]/esx_inventory/client/main.lua @@ -4,7 +4,8 @@ ---@field icon string ---@field count number ---@field value string ----@field usable boolean +---@field canUse boolean? +---@field usable boolean? ---@field rare boolean ---@field canRemove boolean ---@field unselectable boolean? @@ -34,7 +35,6 @@ local function appendAccounts(elements) icon = "fas fa-money-bill-wave", count = account.money, value = account.name, - usable = false, rare = false, canRemove = canDrop } @@ -56,7 +56,7 @@ local function appendItems(elements) icon = "fas fa-box", count = item.count, value = item.name, - usable = item.usable, + canUse = item.usable, rare = item.rare, canRemove = item.canRemove } @@ -83,7 +83,6 @@ local function appendLoadout(elements) icon = "fas fa-gun", count = 1, value = weapon.name, - usable = false, rare = false, ammo = ammo, canGiveAmmo = (weapon.ammo ~= nil), @@ -119,7 +118,7 @@ end ---@return table local function buildItemActionMenu(selected, playerNearby) local elements2 = {} - if selected.usable then + if selected.canUse then elements2[#elements2 + 1] = { action = "use", label = TranslateCap("use"), icon = "fas fa-utensils", type = selected.type, value = selected.value } end if selected.canRemove then From 2a6ec4c7d4fc175eeaf0da996f8d4344ec15dd5d Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:03:05 +0200 Subject: [PATCH 15/57] fix(esx_multicharacter/client): restore the stored height of hidden hud components HideComponents stores the value GetHudComponentSize returns and reads back size.x and size.z when restoring, but the meaningful fields are x and y - the hide branch a few lines above tests exactly those. size.z is 0.0, so the components are restored at height zero and stay invisible for the rest of the session. Measured on artifact 25770 after character selection, components 11 and 12: before: x=0.218 y=0.000 (width restored, height lost) after: x=0.218 y=0.389 --- [core]/esx_multicharacter/client/modules/multicharacter.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/[core]/esx_multicharacter/client/modules/multicharacter.lua b/[core]/esx_multicharacter/client/modules/multicharacter.lua index 1c6014fc..ce256639 100644 --- a/[core]/esx_multicharacter/client/modules/multicharacter.lua +++ b/[core]/esx_multicharacter/client/modules/multicharacter.lua @@ -50,7 +50,7 @@ local function HideComponents(hide) else if HiddenCompents[components[i]] then local size = HiddenCompents[components[i]] - SetHudComponentSize(components[i], size.x, size.z) + SetHudComponentSize(components[i], size.x, size.y) HiddenCompents[components[i]] = nil end end From 48154495f80a0763cae48e26fdcf061b14f058d5 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:53:26 +0200 Subject: [PATCH 16/57] fix(esx_multicharacter/client): act on the local player in HideHud ESX.PlayerId does not exist - the shared object only defines ESX.playerId, and these were the only two places in the repo using the capitalised name. The nil is passed to the natives as player index 0, so neither call touched the local player. SetPlayerControl at line 266 already used the correct spelling, which is why controls were re-enabled after spawning but never disabled during character selection. Measured on artifact 25770, local player index 128: SetPlayerControl(ESX.PlayerId, false, 0) -> IsPlayerControlOn stays 1 SetPlayerControl(ESX.playerId, false, 0) -> IsPlayerControlOn becomes false HideHud also ignored its argument: hidePlayers was hardcoded to true and the volume override was set to 0.0 for both hiding and unhiding. Correcting only the name would have made the mute apply for the first time and never lift it, so the reset value and the argument have to be handled together. --- [core]/esx_multicharacter/client/modules/multicharacter.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/[core]/esx_multicharacter/client/modules/multicharacter.lua b/[core]/esx_multicharacter/client/modules/multicharacter.lua index 1c6014fc..2a83a969 100644 --- a/[core]/esx_multicharacter/client/modules/multicharacter.lua +++ b/[core]/esx_multicharacter/client/modules/multicharacter.lua @@ -59,9 +59,9 @@ local function HideComponents(hide) end function Multicharacter:HideHud(hide) - self.hidePlayers = true + self.hidePlayers = hide - MumbleSetVolumeOverride(ESX.PlayerId, 0.0) + MumbleSetVolumeOverride(ESX.playerId, hide and 0.0 or -1.0) HideComponents(hide) end @@ -77,7 +77,7 @@ function Multicharacter:SetupCharacters() SetEntityCoords(self.playerPed, self.spawnCoords.x, self.spawnCoords.y, self.spawnCoords.z, true, false, false, false) SetEntityHeading(self.playerPed, self.spawnCoords.w) - SetPlayerControl(ESX.PlayerId, false, 0) + SetPlayerControl(ESX.playerId, false, 0) self:SetupCamera() self:HideHud(true) From 9a248406b04d0e8942eaba38ff2828eae248e1ab Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:18:57 +0200 Subject: [PATCH 17/57] fix(esx_lib/points): keep the points loop alive when a callback errors Every point from every resource is served by one shared thread, and the enter, leave and inside callbacks are all invoked bare. A Lua error in any one of them terminates that coroutine, and nothing restarts it - startLoop only runs once, from the spawn handler in es_extended. So a single faulty third party resource takes down point detection for the whole client. Shops, garages and job markers stop reacting, with no hint beyond one stack trace, until the player reconnects. inside is the worst of the three because it runs every frame while the player stands in the point, so it is also dropped from insidePoints once it errors. Otherwise the guard would turn a dead thread into a console flood at framerate. enter and leave only fire on a transition, so they stay registered. Measured in game on artifact 25770, with a healthy point, a point whose enter raises and a point whose inside raises, all created while standing on them: before: the healthy point fired enter, then the broken one raised at points/client.lua and after that nothing happened at all - no leave when walking away, and points created afterwards never fired enter either, the thread was gone after: both failures are logged against the owning point and resource, all three points fired leave when walking away, and points created afterwards worked normally. The inside failure printed once rather than every frame. The runtime already prints the error and its stack, so the added line only names the point and the resource it came from. --- [core]/esx_lib/imports/points/client.lua | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/[core]/esx_lib/imports/points/client.lua b/[core]/esx_lib/imports/points/client.lua index 7fd04eb1..9c3790ca 100644 --- a/[core]/esx_lib/imports/points/client.lua +++ b/[core]/esx_lib/imports/points/client.lua @@ -50,8 +50,11 @@ function xLib.points.startLoop() while true do local coords = GetEntityCoords(PlayerPedId()) - for _, point in pairs(insidePoints) do - point.inside(#(coords - point.coords)) + for handle, point in pairs(insidePoints) do + if not pcall(point.inside, #(coords - point.coords)) then + insidePoints[handle] = nil + print(("[^1ERROR^7] point ^5%s^7 from ^5%s^7 errored on inside"):format(handle, point.resource)) + end end local now = GetGameTimer() @@ -63,8 +66,8 @@ function xLib.points.startLoop() if not point.nearby then point.nearby = true - if point.enter then - point.enter() + if point.enter and not pcall(point.enter) then + print(("[^1ERROR^7] point ^5%s^7 from ^5%s^7 errored on enter"):format(handle, point.resource)) end if point.inside then @@ -74,8 +77,8 @@ function xLib.points.startLoop() elseif point.nearby then point.nearby = false - if point.leave then - point.leave() + if point.leave and not pcall(point.leave) then + print(("[^1ERROR^7] point ^5%s^7 from ^5%s^7 errored on leave"):format(handle, point.resource)) end insidePoints[handle] = nil From 4fc2d72941706a2ec738baa9338f104c49a46ec8 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:35:31 +0200 Subject: [PATCH 18/57] perf(es_extended/client): only look up the closest player when the pickup prompt is used The pickup loop calls ESX.Game.GetClosestPlayer at the top of every iteration, but closestDistance is only read inside the nested check that runs when the player presses the pickup control right next to a drop. As soon as any pickup is within five metres the loop sets Sleep to 0, so the lookup runs every frame while standing near a drop. GetClosestPlayer builds two tables and calls GetPlayers plus GetPlayerPed, DoesEntityExist and GetEntityCoords per player, so the cost grows with the number of players in scope - worst exactly when the server is busy. Measured in game on artifact 25770 with a single player standing on a dropped pickup, reading es_extended in the resource monitor over about 20 seconds each: before: 0.16 - 0.18 ms, sitting around 0.17 after: 0.13 - 0.15 ms, sitting around 0.14 The two ranges do not overlap. Idle, with no pickup within five metres, is 0.04 ms in both cases, as expected, since the loop sleeps 1500 ms then. One player is the smallest saving this can ever show, because GetClosestPlayer scales with the number of players in scope. The larger saving on a populated server follows from that per player work and was not measured, testing it would need several clients. Behaviour is unchanged, the value is computed in the same frame it is used. --- [core]/es_extended/client/modules/events.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/[core]/es_extended/client/modules/events.lua b/[core]/es_extended/client/modules/events.lua index 5cffaeb6..566457ed 100644 --- a/[core]/es_extended/client/modules/events.lua +++ b/[core]/es_extended/client/modules/events.lua @@ -372,7 +372,6 @@ if not Config.CustomInventory then while true do local Sleep = 1500 local playerCoords = GetEntityCoords(ESX.PlayerData.ped) - local _, closestDistance = ESX.Game.GetClosestPlayer(playerCoords) for pickupId, pickup in pairs(pickups) do local distance = #(playerCoords - pickup.coords) @@ -383,6 +382,8 @@ if not Config.CustomInventory then if distance < 1 then if IsControlJustReleased(0, 38) then + local _, closestDistance = ESX.Game.GetClosestPlayer(playerCoords) + if IsPedOnFoot(ESX.PlayerData.ped) and (closestDistance == -1 or closestDistance > 3) and not pickup.inRange then pickup.inRange = true From e9d5cc9f8d0bf656e6463f31bf2160ff978e24f0 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:34:10 +0200 Subject: [PATCH 19/57] perf(esx_multicharacter): look up characters by exact identifier instead of LIKE The character selection query used `WHERE identifier LIKE 'char%:'`. Every multicharacter identifier starts with `char`, so the primary key range covers the whole table and it degrades to a full scan on every join. Build the exact identifiers (`char1:` .. `charN:` for the player's slots) and query with `IN`, which hits the primary key directly. Measured on a 50k row users table (MariaDB 12.3.2): rows examined 45648 -> 3, query time 52.7 ms -> 3.2 ms. In-game the selection screen shows the same characters in the same slots. --- .../esx_multicharacter/server/modules/database.lua | 12 ++++++++++-- .../server/modules/multicharacter.lua | 1 - 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/[core]/esx_multicharacter/server/modules/database.lua b/[core]/esx_multicharacter/server/modules/database.lua index b7f2768f..a71bda7d 100644 --- a/[core]/esx_multicharacter/server/modules/database.lua +++ b/[core]/esx_multicharacter/server/modules/database.lua @@ -101,9 +101,17 @@ function Database:GetPlayerSlots(identifier) end function Database:GetPlayerInfo(identifier, slots) + local placeholders = {} + local identifiers = {} + + for i = 1, slots do + placeholders[i] = "?" + identifiers[i] = ("%s%s:%s"):format(Server.prefix, i, identifier) + end + return MySQL.query.await( - "SELECT identifier, accounts, job, job_grade, firstname, lastname, dateofbirth, sex, skin, disabled FROM users WHERE identifier LIKE ? LIMIT ?", - { identifier, slots }) + ("SELECT identifier, accounts, job, job_grade, firstname, lastname, dateofbirth, sex, skin, disabled FROM users WHERE identifier IN (%s)"):format(table.concat(placeholders, ", ")), + identifiers) end function Database:SetSlots(identifier, slots) diff --git a/[core]/esx_multicharacter/server/modules/multicharacter.lua b/[core]/esx_multicharacter/server/modules/multicharacter.lua index 91d8d839..b4fa7f37 100644 --- a/[core]/esx_multicharacter/server/modules/multicharacter.lua +++ b/[core]/esx_multicharacter/server/modules/multicharacter.lua @@ -14,7 +14,6 @@ function Multicharacter:SetupCharacters(source) ESX.Players[identifier] = source local slots = Database:GetPlayerSlots(identifier) - identifier = Server.prefix .. "%:" .. identifier local rawCharacters = Database:GetPlayerInfo(identifier, slots) local characters From 12f92e33a4c1cf564682723da7b637331fb2e31a Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:14:25 +0200 Subject: [PATCH 20/57] fix(es_extended/server): skip unspawned players in the bulk save Core.SavePlayer already refuses to save a player whose spawned flag is not set, because an unspawned player has no server-side ped and getCoords/health return 0,0,0 / 0. Core.SavePlayers, the bulk autosave that runs every few minutes and on restart/shutdown, had no such guard and saved every player in ESX.Players. A player still in the load or character-selection window when the autosave fires gets their row overwritten with position 0,0,0 and health 0. On the next load the default-spawn fallback does not kick in, since a decoded {"x":0.0,...} is truthy, so they spawn at the world origin, and with SaveDeathStatus on, dead. Guard the loop with the same spawned check, and skip the query entirely when no spawned player remains. Tested on artifact 25770: forced a connected player to spawned=false and ran saveall. Before, the player's row was overwritten (the save ran); after, the row was left untouched and no save was issued. --- [core]/es_extended/server/functions.lua | 30 +++++++++++++++---------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/[core]/es_extended/server/functions.lua b/[core]/es_extended/server/functions.lua index 7ab5f9b4..c8020cc4 100644 --- a/[core]/es_extended/server/functions.lua +++ b/[core]/es_extended/server/functions.lua @@ -251,18 +251,24 @@ function Core.SavePlayers(cb) local parameters = {} for _, xPlayer in pairs(ESX.Players) do - updateHealthAndArmorInMetadata(xPlayer) - parameters[#parameters + 1] = { - json.encode(xPlayer.getAccounts(true)), - xPlayer.job.name, - xPlayer.job.grade, - xPlayer.group, - json.encode(xPlayer.getCoords(false, true)), - json.encode(xPlayer.getInventory(true)), - json.encode(xPlayer.getLoadout(true)), - json.encode(xPlayer.getMeta()), - xPlayer.identifier, - } + if xPlayer.spawned then + updateHealthAndArmorInMetadata(xPlayer) + parameters[#parameters + 1] = { + json.encode(xPlayer.getAccounts(true)), + xPlayer.job.name, + xPlayer.job.grade, + xPlayer.group, + json.encode(xPlayer.getCoords(false, true)), + json.encode(xPlayer.getInventory(true)), + json.encode(xPlayer.getLoadout(true)), + json.encode(xPlayer.getMeta()), + xPlayer.identifier, + } + end + end + + if not parameters[1] then + return cb and cb() end MySQL.prepare( From d3aaab0692b7b7f44f659385f027035d9d2335d4 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:21:31 +0200 Subject: [PATCH 21/57] perf(esx_multicharacter): index the columns used by character deletion Deleting a character runs one DELETE per table that has an identifier/owner column, all in a single transaction. Only users.identifier is indexed; the other core tables (billing, owned_vehicles, user_licenses, society_moneywash, addon_account_data, addon_inventory_items, datastore_data) have no usable index on the delete column, so each DELETE is a full table scan and they all hold locks at once. Measured on 200k rows per table (MariaDB 12.3.2): the transaction went from about 1573 ms to about 22 ms once the seven columns are indexed, EXPLAIN going from type=ALL scanning ~200k rows to a single-row index lookup per table. The addon and datastore tables were the worst because their composite indexes do not lead with the delete column, so they cannot serve WHERE owner = ?. Adds the indexes to legacy.sql for fresh installs and a migration for existing databases. The migration checks INFORMATION_SCHEMA for an index that leads with the column (SEQ_IN_INDEX = 1) and only creates one where it is missing, so it is a no-op on databases that already have it and re-running is guarded by the migration key. --- [SQL]/legacy.sql | 21 +++++--- .../v1.14.2/multicharDeleteIndexes/main.lua | 53 +++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 [core]/es_extended/server/migration/v1.14.2/multicharDeleteIndexes/main.lua diff --git a/[SQL]/legacy.sql b/[SQL]/legacy.sql index 05d5d10b..23f1e9e8 100644 --- a/[SQL]/legacy.sql +++ b/[SQL]/legacy.sql @@ -749,7 +749,8 @@ ALTER TABLE `addon_account` ALTER TABLE `addon_account_data` ADD PRIMARY KEY (`id`), ADD UNIQUE KEY `index_addon_account_data_account_name_owner` (`account_name`,`owner`), - ADD KEY `index_addon_account_data_account_name` (`account_name`); + ADD KEY `index_addon_account_data_account_name` (`account_name`), + ADD KEY `esx_addon_account_data_owner` (`owner`); -- -- Indexes for table `addon_inventory` @@ -764,13 +765,15 @@ ALTER TABLE `addon_inventory_items` ADD PRIMARY KEY (`id`), ADD KEY `index_addon_inventory_items_inventory_name_name` (`inventory_name`,`name`), ADD KEY `index_addon_inventory_items_inventory_name_name_owner` (`inventory_name`,`name`,`owner`), - ADD KEY `index_addon_inventory_inventory_name` (`inventory_name`); + ADD KEY `index_addon_inventory_inventory_name` (`inventory_name`), + ADD KEY `esx_addon_inventory_items_owner` (`owner`); -- -- Indexes for table `billing` -- ALTER TABLE `billing` - ADD PRIMARY KEY (`id`); + ADD PRIMARY KEY (`id`), + ADD KEY `esx_billing_identifier` (`identifier`); -- -- Indexes for table `cardealer_vehicles` @@ -790,7 +793,8 @@ ALTER TABLE `datastore` ALTER TABLE `datastore_data` ADD PRIMARY KEY (`id`), ADD UNIQUE KEY `index_datastore_data_name_owner` (`name`,`owner`), - ADD KEY `index_datastore_data_name` (`name`); + ADD KEY `index_datastore_data_name` (`name`), + ADD KEY `esx_datastore_data_owner` (`owner`); -- -- Indexes for table `items` @@ -821,7 +825,8 @@ ALTER TABLE `licenses` -- Indexes for table `owned_vehicles` -- ALTER TABLE `owned_vehicles` - ADD PRIMARY KEY (`plate`); + ADD PRIMARY KEY (`plate`), + ADD KEY `esx_owned_vehicles_owner` (`owner`); -- -- @@ -840,7 +845,8 @@ ALTER TABLE `rented_vehicles` -- Indexes for table `society_moneywash` -- ALTER TABLE `society_moneywash` - ADD PRIMARY KEY (`id`); + ADD PRIMARY KEY (`id`), + ADD KEY `esx_society_moneywash_identifier` (`identifier`); -- -- Indexes for table `users` @@ -856,7 +862,8 @@ ALTER TABLE `users` -- Indexes for table `user_licenses` -- ALTER TABLE `user_licenses` - ADD PRIMARY KEY (`id`); + ADD PRIMARY KEY (`id`), + ADD KEY `esx_user_licenses_owner` (`owner`); -- -- Indexes for table `vehicle_categories` diff --git a/[core]/es_extended/server/migration/v1.14.2/multicharDeleteIndexes/main.lua b/[core]/es_extended/server/migration/v1.14.2/multicharDeleteIndexes/main.lua new file mode 100644 index 00000000..5915bd77 --- /dev/null +++ b/[core]/es_extended/server/migration/v1.14.2/multicharDeleteIndexes/main.lua @@ -0,0 +1,53 @@ +local esxVersion = "v1.14.2" + +Core.Migrations = Core.Migrations or {} +Core.Migrations[esxVersion] = Core.Migrations[esxVersion] or {} + +if GetResourceKvpInt(("esx_migration:%s"):format(esxVersion)) == 1 then + return +end + +local targets = { + { table = "billing", column = "identifier" }, + { table = "owned_vehicles", column = "owner" }, + { table = "user_licenses", column = "owner" }, + { table = "society_moneywash", column = "identifier" }, + { table = "addon_account_data", column = "owner" }, + { table = "addon_inventory_items", column = "owner" }, + { table = "datastore_data", column = "owner" }, +} + +---@return boolean restartRequired +Core.Migrations[esxVersion].multicharDeleteIndexes = function() + print("^4[esx_migration:v1.14.2:multicharDeleteIndexes]^7 Indexing character deletion columns.") + + for i = 1, #targets do + local target = targets[i] + + local tableExists = MySQL.scalar.await([[ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + ]], { target.table }) + + if tableExists ~= 0 then + local leadingIndex = MySQL.scalar.await([[ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ? + AND SEQ_IN_INDEX = 1 + ]], { target.table, target.column }) + + if leadingIndex == 0 then + MySQL.update.await(("CREATE INDEX `esx_%s_%s` ON `%s` (`%s`)"):format(target.table, target.column, target.table, target.column)) + print(("^4[esx_migration:v1.14.2:multicharDeleteIndexes]^7 Indexed ^5%s.%s^7."):format(target.table, target.column)) + end + end + end + + print("^4[esx_migration:v1.14.2:multicharDeleteIndexes]^7 Migration complete.") + return false +end From 0fb74a74ef82f677ba8b45a3222c32ca43eb969e Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:12:50 +0200 Subject: [PATCH 22/57] fix(esx_inventory): let the quantity dialog accept a corrected amount The quantity dialog resolved its promise with nil on an invalid amount, so the give/remove had already aborted by the time the player typed a valid value. The dialog stayed open and a corrected amount did nothing. Dropping the early resolve leaves the promise pending, so the same open dialog accepts the retry and drops/gives the item. Cancel still resolves nil. Tested on artifact 25770: entered an over-count amount, got "invalid amount", corrected to a valid amount. Before, nothing happened; after, the item was dropped. --- [core]/esx_inventory/client/main.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/[core]/esx_inventory/client/main.lua b/[core]/esx_inventory/client/main.lua index 3fd84785..c5cf65ff 100644 --- a/[core]/esx_inventory/client/main.lua +++ b/[core]/esx_inventory/client/main.lua @@ -146,7 +146,6 @@ local function openQuantityDialog(title, maxCount) local qty = tonumber(data.value) if not qty or qty <= 0 or qty > maxCount then ESX.ShowNotification(TranslateCap("amount_invalid")) - p:resolve(nil) else menu.close() p:resolve(qty) From 6cbc408102b2278d04ca8aab110c95452e90bbaa Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:22:11 +0200 Subject: [PATCH 23/57] fix(esx_lib): correct isArray, toPascal and replace helpers Three small logic bugs in the shared helpers, each verified with a standalone Lua run. isArray returned true for sparse tables like {[1]=1,[3]=3}: the in-loop `count > maxIndex` check can never fire (with positive integer keys count is always <= maxIndex), so it was dead code. Dropped it and return `count == maxIndex`, which is false for gaps and still true for a dense sequence or an empty table. toPascal lowercased the letters after an underscore: `[%w_]*` swallowed the underscore and the next word into the part that gets lowercased, so "hello_world" became "Helloworld". Excluding the underscore from that class leaves each word to be capitalised, then the trailing gsub removes the underscores -> "HelloWorld". replace escaped the pattern but not the replacement, so a "%" in the replacement raised "invalid use of '%' in replacement string" and "%1" style sequences expanded captures. Escape "%" in a string replacement; a function or table replacement is left untouched. --- [core]/esx_lib/imports/string/shared.lua | 6 +++++- [core]/esx_lib/imports/table/shared.lua | 6 +----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/[core]/esx_lib/imports/string/shared.lua b/[core]/esx_lib/imports/string/shared.lua index b6c00867..ba6bc456 100644 --- a/[core]/esx_lib/imports/string/shared.lua +++ b/[core]/esx_lib/imports/string/shared.lua @@ -47,7 +47,7 @@ end function xLib.string.toPascal(s) xLib.verify(s, 'string', true) - local res = s:gsub("(%a)([%w_]*)", function(first, rest) + local res = s:gsub("(%a)([%w]*)", function(first, rest) return first:upper() .. rest:lower() end):gsub("_", "") @@ -114,6 +114,10 @@ end function xLib.string.replace(s, old, new) xLib.verify(s, 'string', true) + if type(new) == "string" then + new = new:gsub("%%", "%%%%") + end + local result = s:gsub(xLib.string.escapePattern(old), new) return result diff --git a/[core]/esx_lib/imports/table/shared.lua b/[core]/esx_lib/imports/table/shared.lua index 1a590f34..37d51ee6 100644 --- a/[core]/esx_lib/imports/table/shared.lua +++ b/[core]/esx_lib/imports/table/shared.lua @@ -14,13 +14,9 @@ function xLib.table.isArray(tbl) end count, maxIndex = count + 1, math.max(maxIndex, k) - - if count > maxIndex then - return false - end end - return true + return count == maxIndex end ---@param tbl table From 19272bc3e8c18b04e5bf13d4e576a7184bbe9f89 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:14:26 +0200 Subject: [PATCH 24/57] fix(es_extended/locale): return a single value from TranslateCap TranslateCap returned the result of gsub directly, and gsub returns the string plus the number of substitutions. Whenever TranslateCap is the last argument of a call, that count is passed along as an extra argument. The common case is showNotification(msg, notifyType, ...), which is called with just the message in about 49 places in the core. Those all pass notifyType = 1 instead of nil, so the notification is rendered with a numeric type rather than the intended default. 71 call sites in total have TranslateCap in trailing position. Wrapping the call in parentheses truncates it to one value. The string itself is unchanged, and nothing reads the second value. --- [core]/es_extended/locale.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/[core]/es_extended/locale.lua b/[core]/es_extended/locale.lua index 3f059b62..f721f1b9 100644 --- a/[core]/es_extended/locale.lua +++ b/[core]/es_extended/locale.lua @@ -33,7 +33,7 @@ function Translate(str, ...) -- Translate string end function TranslateCap(str, ...) -- Translate string first char uppercase - return _(str, ...):gsub("^%l", string.upper) + return (_(str, ...):gsub("^%l", string.upper)) end _ = Translate From f98a6e22450ad92ada62df7cbeb34dec16d92fa7 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:17:12 +0200 Subject: [PATCH 25/57] fix(cron): keep the scheduler alive when a job errors Registered jobs are third party callbacks and were invoked bare. An error inside one propagates out of OnTime into Tick, so Tick never reaches its SetTimeout(60000, Tick) at the end and nothing ever reschedules it. From that moment cron is dead for every resource on the server - paychecks, cleanups, anything registered through cron:runAt - until a restart. The operator sees one stack trace and then silence. Wrapping the callback keeps the loop and the reschedule intact, and names the job that failed. Simulated the scheduler chain in standalone Lua with three jobs where the first raises: before, the tick died before SetTimeout and the two later jobs never ran again; after, the failure is reported and both later jobs fire. --- [core]/cron/server/main.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/[core]/cron/server/main.lua b/[core]/cron/server/main.lua index 51da085d..7044069d 100644 --- a/[core]/cron/server/main.lua +++ b/[core]/cron/server/main.lua @@ -38,7 +38,10 @@ function OnTime(timestamp) if timestamp >= scheduledTimestamp and (not lastTimestamp or lastTimestamp < scheduledTimestamp) then local d = os.date('*t', scheduledTimestamp).wday - cronJobs[i].cb(d, cronJobs[i].h, cronJobs[i].m) + + if not pcall(cronJobs[i].cb, d, cronJobs[i].h, cronJobs[i].m) then + print(("[^1ERROR^7] cron job at ^5%02d:%02d^7 errored, skipping it"):format(cronJobs[i].h, cronJobs[i].m)) + end end end end From e801856d1a2e2fab3183d4bd08d66bfbad7229c7 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:18:26 +0200 Subject: [PATCH 26/57] fix(esx_menu_dialog, esx_menu_list): do not grab NUI focus after the menu closed Opening a menu schedules SetNuiFocus(true, true) 200 ms later so the NUI has time to render. Closing sets the focus off but does not cancel that timer, so a menu closed within those 200 ms leaves the timer to fire afterwards and turn the focus back on with nothing open: the player gets a cursor and loses movement and input until another menu is opened and closed. esx_menu_list never stored the timer id at all, so it could not be cancelled in any case. Checking inside the callback whether a menu is still open fixes both, and also covers a quick open-close-open where cancelling by id would drop a focus that is still wanted. esx_menu_default is unaffected, it has no timer. Tested on artifact 25770 with a dialog closed 50 ms after opening: before, IsNuiFocused() was still true 600 ms later; after, it stays false. --- [core]/esx_menu_dialog/client/main.lua | 4 +++- [core]/esx_menu_list/client/main.lua | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/[core]/esx_menu_dialog/client/main.lua b/[core]/esx_menu_dialog/client/main.lua index 82588182..cce26666 100644 --- a/[core]/esx_menu_dialog/client/main.lua +++ b/[core]/esx_menu_dialog/client/main.lua @@ -15,7 +15,9 @@ local function openMenu(namespace, name, data) }) local timeoutId = ESX.SetTimeout(200, function() - SetNuiFocus(true, true) + if next(OpenedMenus) then + SetNuiFocus(true, true) + end end) table.insert(Timeouts, timeoutId) diff --git a/[core]/esx_menu_list/client/main.lua b/[core]/esx_menu_list/client/main.lua index 690c8ef6..6bab13a3 100644 --- a/[core]/esx_menu_list/client/main.lua +++ b/[core]/esx_menu_list/client/main.lua @@ -12,7 +12,9 @@ CreateThread(function() data = data, }) SetTimeout(200, function() - SetNuiFocus(true, true) + if next(OpenedMenus) then + SetNuiFocus(true, true) + end end) end From 07b92534019dc9bb711cdb4d6796ce65969449d0 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:51:15 +0200 Subject: [PATCH 27/57] fix(esx_progressbar): honour the requested duration and ignore cancelled runs The processing thread counted the remaining time down in 1000 ms steps and read the global CurrentProgress on every wake, which caused two problems. Any duration that is not a whole number of seconds finished late, since the thread only checked after another full second. The NUI bar runs on wall clock time, so the bar completed and the player then stood frozen waiting for onFinish. Cancelling did not stop the thread either. CancelProgressbar clears CurrentProgress, but the sleeping thread only notices up to a second later, and if a new bar was started in that window it saw a non-nil CurrentProgress and kept decrementing the new one alongside its own thread. The new bar then finished at roughly half its length, firing onFinish early. The run now carries an id, so a thread stops as soon as its run is no longer the current one, and the end is an absolute timestamp rather than a countdown. Measured in game on artifact 25770, requested versus actual onFinish: before: 500 -> 1011, 4200 -> 5042, cancel then 3000 -> 1427 after: 500 -> 519, 4200 -> 4222, cancel then 3000 -> 3058 --- [core]/esx_progressbar/Progress.lua | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/[core]/esx_progressbar/Progress.lua b/[core]/esx_progressbar/Progress.lua index d412f581..8dc53cfd 100644 --- a/[core]/esx_progressbar/Progress.lua +++ b/[core]/esx_progressbar/Progress.lua @@ -9,11 +9,12 @@ ---@field public onFinish? function local CurrentProgress = nil +local progressCount = 0 -local function startProcessing() - while (CurrentProgress ~= nil) do - if CurrentProgress.length > 0 then - CurrentProgress.length = CurrentProgress.length - 1000 +local function startProcessing(id) + while CurrentProgress ~= nil and CurrentProgress.id == id do + if GetGameTimer() < CurrentProgress.finishAt then + Wait(50) else ClearPedTasks(ESX.PlayerData.ped) if CurrentProgress.FreezePlayer then @@ -24,7 +25,6 @@ local function startProcessing() end CurrentProgress = nil end - Wait(1000) end end @@ -55,8 +55,15 @@ local function Progressbar(message, length, Options) length = length or 3000, message = message or "ESX-Framework", }) + progressCount = progressCount + 1 + CurrentProgress.id = progressCount CurrentProgress.length = length or 3000 - CreateThread(startProcessing); + CurrentProgress.finishAt = GetGameTimer() + CurrentProgress.length + + local id = progressCount + CreateThread(function() + startProcessing(id) + end) return true; end From d41b0f1feb0b13ad7a090f7930d8f6ca72d3c0a1 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:36:56 +0200 Subject: [PATCH 28/57] fix(es_extended/client): stop stacking adjustment threads on every relog Adjustments:Load() runs from the esx:playerLoaded handler, so it runs again on every character switch. AmmoAndVehicleRewards, Multipliers and DiscordPresence each start a `while true` thread that never exits, and SeatShuffle and DisableRadio register another esx:enteredVehicle handler. Nothing tears any of it down, so a player who switches characters a few times ends up with several per-frame threads all writing the same values, and client performance degrades until they reconnect. The loops now run `while ESX.PlayerLoaded`, the same way StartServerSyncLoops and Actions:SlowLoop already do, so they end on logout and a fresh one starts on the next load. The two event handlers are registered once. Actions:Init already documents this exact concern in a comment; Adjustments never got the same treatment. Measured in game on artifact 25770 by counting thread ticks per frame: before: 2.01 -> 3.01 -> 4.02 across two relogs after: 1.00 -> 1.00 -> 1.00 --- [core]/es_extended/client/modules/adjustments.lua | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/[core]/es_extended/client/modules/adjustments.lua b/[core]/es_extended/client/modules/adjustments.lua index 1c83b148..bf80b880 100644 --- a/[core]/es_extended/client/modules/adjustments.lua +++ b/[core]/es_extended/client/modules/adjustments.lua @@ -25,7 +25,9 @@ function Adjustments:DisableNPCDrops() end function Adjustments:SeatShuffle() - if Config.DisableVehicleSeatShuff then + if Config.DisableVehicleSeatShuff and not self.seatShuffleRegistered then + self.seatShuffleRegistered = true + AddEventHandler("esx:enteredVehicle", function(vehicle, _, seat) if seat > -1 then SetPedIntoVehicle(ESX.PlayerData.ped, vehicle, seat) @@ -43,7 +45,7 @@ end function Adjustments:AmmoAndVehicleRewards() CreateThread(function() - while true do + while ESX.PlayerLoaded do if Config.DisableDisplayAmmo then DisplayAmmoThisFrame(false) end @@ -187,7 +189,7 @@ end function Adjustments:DiscordPresence() if Config.DiscordActivity.appId ~= 0 then CreateThread(function() - while true do + while ESX.PlayerLoaded do SetDiscordAppId(Config.DiscordActivity.appId) SetRichPresence(self:ReplacePlaceholders(Config.DiscordActivity.presence)) SetDiscordRichPresenceAsset(Config.DiscordActivity.assetName) @@ -213,7 +215,9 @@ function Adjustments:WantedLevel() end function Adjustments:DisableRadio() - if Config.RemoveHudComponents[16] then + if Config.RemoveHudComponents[16] and not self.disableRadioRegistered then + self.disableRadioRegistered = true + AddEventHandler("esx:enteredVehicle", function(vehicle, plate, seat, displayName, netId) SetVehRadioStation(vehicle,"OFF") SetUserRadioControlEnabled(false) @@ -223,7 +227,7 @@ end function Adjustments:Multipliers() CreateThread(function() - while true do + while ESX.PlayerLoaded do SetPedDensityMultiplierThisFrame(Config.Multipliers.pedDensity) SetScenarioPedDensityMultiplierThisFrame(Config.Multipliers.scenarioPedDensityInterior, Config.Multipliers.scenarioPedDensityExterior) SetAmbientVehicleRangeMultiplierThisFrame(Config.Multipliers.ambientVehicleRange) From 443953b24b533870e981edc3b510b01003e02c76 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:50:24 +0200 Subject: [PATCH 29/57] fix(esx_lib/points): start the points loop only once startLoop is called from the esx:playerLoaded handler in es_extended, which fires again on every character switch, and the loop it creates runs `while true` with no exit. Each relog therefore leaves another points thread running: they all scan the same table, fire the same enter and leave callbacks, and none of them ever stop. A guard makes a second call a no-op. The loop is meant to live for the resource lifetime, so starting it twice is never right. Measured in game on artifact 25770, counting scans over four seconds: before: 1.0 -> 2.0 -> 3.0 loops across two relogs after: 1.0 -> 1.0 -> 1.0 Points keep working, the surviving loop is unaffected. Note this touches the same file as #1831, which guards the callbacks inside the loop. The two changes are in different places and independent of each other. --- [core]/esx_lib/imports/points/client.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/[core]/esx_lib/imports/points/client.lua b/[core]/esx_lib/imports/points/client.lua index 7fd04eb1..4f140438 100644 --- a/[core]/esx_lib/imports/points/client.lua +++ b/[core]/esx_lib/imports/points/client.lua @@ -4,6 +4,7 @@ xLib.points = {} local points = {} local insidePoints = {} local handleCount = 0 +local loopStarted = false ---@param coords vector3 ---@param distance number @@ -44,6 +45,12 @@ function xLib.points.hide(handle, hidden) end function xLib.points.startLoop() + if loopStarted then + return + end + + loopStarted = true + CreateThread(function() local lastScan = 0 From eb58d4674b26f222a60f7da04987d5c57e49cb17 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:04:19 +0200 Subject: [PATCH 30/57] fix(esx_lib/game): keep the secondary colour when the primary is custom setVehicleProperties passes props.color1 straight into SetVehicleColours when the secondary colour comes from the palette. If the primary is a custom RGB colour, the getter stored it as a table, so a table reaches a native that wants a paint index and the call does nothing. The secondary colour is silently lost. Any car sprayed with a custom primary and a palette secondary comes back from storage with the wrong secondary colour. Fall back to the vehicle's current primary when props.color1 is not a paint index. The custom primary was already applied by the block above, so nothing is lost. Measured in game on artifact 25770, reading properties off a car with a custom red primary and palette secondary 12, then applying them to a second car: before: color2 came back 0 after: color2 came back 12 The custom primary survives either way, mods and wheels are untouched. --- [core]/esx_lib/imports/game/client.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/[core]/esx_lib/imports/game/client.lua b/[core]/esx_lib/imports/game/client.lua index c7bdd1e8..b8c49954 100644 --- a/[core]/esx_lib/imports/game/client.lua +++ b/[core]/esx_lib/imports/game/client.lua @@ -462,7 +462,7 @@ function xLib.game.setVehicleProperties(vehicle, props) if type(props.color2) == "table" then SetVehicleCustomSecondaryColour(vehicle, props.color2[1], props.color2[2], props.color2[3]) else - SetVehicleColours(vehicle, props.color1 or colorPrimary, props.color2) + SetVehicleColours(vehicle, type(props.color1) == "number" and props.color1 or colorPrimary, props.color2) end end if props.pearlescentColor ~= nil then From 0bf541905cc52fdf3f0ebd20958bad76a7c817ab Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:13:58 +0200 Subject: [PATCH 31/57] fix(es_extended/server/classes/vehicle): update self.plate in setPlate setPlate re-keys Core.vehicles but never updates self.plate, so the object it was called on keeps pointing at the removed key. isValid() fails from then on and every later call on that handle is a no-op, including delete(), which leaves the vehicle spawned and owned_vehicles.stored at false. --- [core]/es_extended/server/classes/vehicle.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/[core]/es_extended/server/classes/vehicle.lua b/[core]/es_extended/server/classes/vehicle.lua index 3f58b802..8c7d1e22 100644 --- a/[core]/es_extended/server/classes/vehicle.lua +++ b/[core]/es_extended/server/classes/vehicle.lua @@ -154,6 +154,7 @@ Core.vehicleClass = { vehicleData.plate = newPlate Core.vehicles[newPlate] = table.clone(vehicleData) Core.vehicles[oldPlate] = nil + self.plate = newPlate TriggerEvent("esx:changedExtendedVehiclePlate", vehicleData.plate, oldPlate) Wait(0) From 1b5e29c62b449c0a1a5cb3d7954fd8307821b0e1 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:44:30 +0200 Subject: [PATCH 32/57] fix(cron): build the scheduled time from the day the job belongs to OnTime always built scheduledTimestamp from the date of the current tick, so a tick that skipped the 23:59 minute compared it against 23:59 of the next day. The run was dropped without a trace. Fall back to the previous day when the scheduled time lands in the future. --- [core]/cron/server/main.lua | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/[core]/cron/server/main.lua b/[core]/cron/server/main.lua index 51da085d..1b613aa7 100644 --- a/[core]/cron/server/main.lua +++ b/[core]/cron/server/main.lua @@ -26,17 +26,25 @@ end ---@param timestamp number function OnTime(timestamp) - for i = 1, #cronJobs, 1 do - local scheduledTimestamp = os.time({ - hour = cronJobs[i].h, - min = cronJobs[i].m, + local function scheduledAt(job, dayOffset) + return os.time({ + hour = job.h, + min = job.m, sec = 0, -- Assuming tasks run at the start of the minute - day = os.date("%d", timestamp), + day = os.date("%d", timestamp) + dayOffset, month = os.date("%m", timestamp), year = os.date("%Y", timestamp), }) + end - if timestamp >= scheduledTimestamp and (not lastTimestamp or lastTimestamp < scheduledTimestamp) then + for i = 1, #cronJobs, 1 do + local scheduledTimestamp = scheduledAt(cronJobs[i], 0) + + if scheduledTimestamp > timestamp then + scheduledTimestamp = scheduledAt(cronJobs[i], -1) + end + + if not lastTimestamp or lastTimestamp < scheduledTimestamp then local d = os.date('*t', scheduledTimestamp).wday cronJobs[i].cb(d, cronJobs[i].h, cronJobs[i].m) end From bac379118ded65ab53aac0a5141537bde2d47047 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:53:00 +0200 Subject: [PATCH 33/57] fix(esx_lib): match uint against integer and drop the unread interaction list math.type only returns 'integer', 'float' or nil, so the 'uint' branch comparing against 'int' never matched and threw whenever throw_error was set. pressedInteractions was written on every interaction and read nowhere, so it grew for the whole session and kept removed interactions reachable. --- [core]/esx_lib/imports/interactions/client.lua | 2 -- [core]/esx_lib/imports/verify/shared.lua | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/[core]/esx_lib/imports/interactions/client.lua b/[core]/esx_lib/imports/interactions/client.lua index 01b88c09..a620b2ef 100644 --- a/[core]/esx_lib/imports/interactions/client.lua +++ b/[core]/esx_lib/imports/interactions/client.lua @@ -2,7 +2,6 @@ xLib.interactions = {} local interactions = {} -local pressedInteractions = {} ---@param name string function xLib.interactions.remove(name) @@ -36,7 +35,6 @@ xLib.addKeybind({ for _, interaction in pairs(interactions) do local success, result = pcall(interaction.condition) if success and result then - pressedInteractions[#pressedInteractions + 1] = interaction interaction.onPress() end end diff --git a/[core]/esx_lib/imports/verify/shared.lua b/[core]/esx_lib/imports/verify/shared.lua index 8de48d22..5698b56f 100644 --- a/[core]/esx_lib/imports/verify/shared.lua +++ b/[core]/esx_lib/imports/verify/shared.lua @@ -81,7 +81,7 @@ local function verifyType(value, valid_type) elseif valid_type == 'float' then return math.type(value) == 'float' elseif valid_type == 'uint' then - return math.type(value) == 'int' and value >= 0 + return math.type(value) == 'integer' and value >= 0 elseif valid_type == 'char' then return type(value) == 'string' and #value == 1 elseif valid_type == 'ped' then From 65d6d32c6edb5d9103a782768862b562aa3bcf7f Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:16:36 +0200 Subject: [PATCH 34/57] fix(es_extended/server/modules/commands): survive console use and fix notifications RegisterCommand hands the callback xPlayer or false, so group, refreshitems and fix threw from the server console instead of doing their work. The same three lines still used the old showNotification argument list, which put a number into title and made esx_notify throw, so players got no confirmation either. --- [core]/es_extended/server/modules/commands.lua | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/[core]/es_extended/server/modules/commands.lua b/[core]/es_extended/server/modules/commands.lua index 2320e82f..6e85da79 100644 --- a/[core]/es_extended/server/modules/commands.lua +++ b/[core]/es_extended/server/modules/commands.lua @@ -178,9 +178,11 @@ ESX.RegisterCommand( return end xTarget.triggerEvent("esx:repairPedVehicle") - xPlayer.showNotification(TranslateCap("command_repair_success"), true, false, 140) - if xPlayer.source ~= xTarget.source then - xTarget.showNotification(TranslateCap("command_repair_success_target"), true, false, 140) + if xPlayer then + xPlayer.showNotification(TranslateCap("command_repair_success")) + end + if not xPlayer or xPlayer.source ~= xTarget.source then + xTarget.showNotification(TranslateCap("command_repair_success_target")) end if Config.AdminLogging then ESX.DiscordLogFields("UserActions", "Fix Vehicle /fix Triggered!", "pink", { @@ -445,7 +447,11 @@ if not Config.CustomInventory then ESX.RegisterCommand("refreshitems", "admin", function(xPlayer) local itemCount = ESX.RefreshItems() - xPlayer.showNotification(Translate("command_refreshitems_success", itemCount), true, false, 140) + if xPlayer then + xPlayer.showNotification(Translate("command_refreshitems_success", itemCount)) + else + print(("[^2INFO^7] %s^7"):format(Translate("command_refreshitems_success", itemCount))) + end end, true, { help = TranslateCap("command_refreshitems") }) ESX.RegisterCommand( @@ -558,7 +564,7 @@ end, true, { help = TranslateCap("command_saveall") }) ESX.RegisterCommand("group", { "user", "admin" }, function(xPlayer, _, _) print(("%s, you are currently: ^5%s^0"):format(xPlayer.getName(), xPlayer.getGroup())) -end, true) +end, false) ESX.RegisterCommand("job", { "user", "admin" }, function(xPlayer, _, _) local job = xPlayer.getJob() From e60674cb56349ef8a7340c93ad0d65c6bd8d7eb2 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:46:58 +0200 Subject: [PATCH 35/57] fix(es_extended/client/modules/adjustments): escape placeholder values Placeholder results go straight into gsub as the replacement string, so a % in a player name either throws and kills the presence thread or silently mangles the output. The "Unknown" fallback above it was unreachable because the error() on the previous line always propagates. --- [core]/es_extended/client/modules/adjustments.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/[core]/es_extended/client/modules/adjustments.lua b/[core]/es_extended/client/modules/adjustments.lua index 1c83b148..12a0aac4 100644 --- a/[core]/es_extended/client/modules/adjustments.lua +++ b/[core]/es_extended/client/modules/adjustments.lua @@ -175,11 +175,11 @@ function Adjustments:ReplacePlaceholders(text) local success, result = pcall(cb) if not success then - error(("Failed to execute placeholder: ^5%s^7\n%s"):format(placeholder, result)) + print(("[^1ERROR^7] Failed to execute placeholder: ^5%s^7\n%s"):format(placeholder, result)) result = "Unknown" end - text = text:gsub(("{%s}"):format(placeholder), tostring(result)) + text = text:gsub(("{%s}"):format(placeholder), (tostring(result):gsub("%%", "%%%%"))) end return text end From e6b6d1f40a2d139a33808b8c4af5196b557facb4 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:54:04 +0200 Subject: [PATCH 36/57] fix(es_extended/server/classes/player): make setInventoryItem a no-op on no change Setting an item to the count it already has sent a zero difference to removeInventoryItem, which treats zero as an error and calls error(). That unwinds out of the caller, so anything after the call was skipped. --- [core]/es_extended/server/classes/player.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/[core]/es_extended/server/classes/player.lua b/[core]/es_extended/server/classes/player.lua index 367c9838..231d86a9 100644 --- a/[core]/es_extended/server/classes/player.lua +++ b/[core]/es_extended/server/classes/player.lua @@ -505,6 +505,10 @@ function CreateExtendedPlayer(playerId, identifier, ssn, group, accounts, invent if item and count >= 0 then count = ESX.Math.Round(count) + if count == item.count then + return + end + if count > item.count then self.addInventoryItem(item.name, count - item.count) else From 6c50d2261506fef5cd1a3aafb61808477a0370e6 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:06:20 +0200 Subject: [PATCH 37/57] fix(es_extended): widen billing.target to hold an identifier The column is varchar(40) but esx_billing writes an identifier into it, and those are 46 characters once multicharacter is on, so the insert fails with 1406 and the handler dies before notifying either player. The other two identifier columns in the same table are already varchar(60). --- [SQL]/legacy.sql | 2 +- .../v1.14.2/billingTargetLength/main.lua | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 [core]/es_extended/server/migration/v1.14.2/billingTargetLength/main.lua diff --git a/[SQL]/legacy.sql b/[SQL]/legacy.sql index 05d5d10b..4e38f100 100644 --- a/[SQL]/legacy.sql +++ b/[SQL]/legacy.sql @@ -89,7 +89,7 @@ CREATE TABLE `billing` ( `identifier` varchar(60) NOT NULL, `sender` varchar(60) NOT NULL, `target_type` varchar(50) NOT NULL, - `target` varchar(40) NOT NULL, + `target` varchar(60) NOT NULL, `label` varchar(255) NOT NULL, `amount` int(11) NOT NULL ) ENGINE=InnoDB; diff --git a/[core]/es_extended/server/migration/v1.14.2/billingTargetLength/main.lua b/[core]/es_extended/server/migration/v1.14.2/billingTargetLength/main.lua new file mode 100644 index 00000000..f7699a60 --- /dev/null +++ b/[core]/es_extended/server/migration/v1.14.2/billingTargetLength/main.lua @@ -0,0 +1,41 @@ +local esxVersion = "v1.14.2" + +Core.Migrations = Core.Migrations or {} +Core.Migrations[esxVersion] = Core.Migrations[esxVersion] or {} + +if GetResourceKvpInt(("esx_migration:%s"):format(esxVersion)) == 1 then + return +end + +---@return boolean restartRequired +Core.Migrations[esxVersion].billingTargetLength = function() + print("^4[esx_migration:v1.14.2:billingTargetLength]^7 Checking the length of billing.target.") + + local length = MySQL.scalar.await([[ + SELECT CHARACTER_MAXIMUM_LENGTH + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'billing' + AND COLUMN_NAME = 'target' + ]]) + + if not length then + print("^4[esx_migration:v1.14.2:billingTargetLength]^7 Column not found, migration not needed.") + return false + end + + if length >= 60 then + print(("^4[esx_migration:v1.14.2:billingTargetLength]^7 Column is already varchar(%s), migration not needed."):format(length)) + return false + end + + print(("^4[esx_migration:v1.14.2:billingTargetLength]^7 Column is varchar(%s), widening it to 60."):format(length)) + + MySQL.update.await([[ + ALTER TABLE `billing` + MODIFY `target` VARCHAR(60) NOT NULL + ]]) + + print("^4[esx_migration:v1.14.2:billingTargetLength]^7 Migration complete.") + return true +end From ad195a65165f8e040f24967f7a1543f1ca348217 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:51:15 +0200 Subject: [PATCH 38/57] fix(es_extended): widen rented_vehicles.owner to hold an identifier The column is varchar(22), which is a leftover from Steam identifiers. esx_vehicleshop writes a player identifier into it, and on a multicharacter server those are 46 characters long, so renting a vehicle out fails. The stock vehicle is deleted from cardealer_vehicles before the insert runs, so the dealer loses the car and the customer never gets a rental contract. Neither side is told anything, because the handler dies on the insert. esx_vehicleshop's own esx_vehicleshop.sql already declares varchar(60). --- [SQL]/legacy.sql | 2 +- .../rentedVehiclesOwnerLength/main.lua | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 [core]/es_extended/server/migration/v1.14.2/rentedVehiclesOwnerLength/main.lua diff --git a/[SQL]/legacy.sql b/[SQL]/legacy.sql index 4e38f100..51ad2f2e 100644 --- a/[SQL]/legacy.sql +++ b/[SQL]/legacy.sql @@ -364,7 +364,7 @@ CREATE TABLE `rented_vehicles` ( `player_name` varchar(255) NOT NULL, `base_price` int(11) NOT NULL, `rent_price` int(11) NOT NULL, - `owner` varchar(22) NOT NULL + `owner` varchar(60) NOT NULL ) ENGINE=InnoDB; -- diff --git a/[core]/es_extended/server/migration/v1.14.2/rentedVehiclesOwnerLength/main.lua b/[core]/es_extended/server/migration/v1.14.2/rentedVehiclesOwnerLength/main.lua new file mode 100644 index 00000000..74518e11 --- /dev/null +++ b/[core]/es_extended/server/migration/v1.14.2/rentedVehiclesOwnerLength/main.lua @@ -0,0 +1,41 @@ +local esxVersion = "v1.14.2" + +Core.Migrations = Core.Migrations or {} +Core.Migrations[esxVersion] = Core.Migrations[esxVersion] or {} + +if GetResourceKvpInt(("esx_migration:%s"):format(esxVersion)) == 1 then + return +end + +---@return boolean restartRequired +Core.Migrations[esxVersion].rentedVehiclesOwnerLength = function() + print("^4[esx_migration:v1.14.2:rentedVehiclesOwnerLength]^7 Checking the length of rented_vehicles.owner.") + + local length = MySQL.scalar.await([[ + SELECT CHARACTER_MAXIMUM_LENGTH + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'rented_vehicles' + AND COLUMN_NAME = 'owner' + ]]) + + if not length then + print("^4[esx_migration:v1.14.2:rentedVehiclesOwnerLength]^7 Column not found, migration not needed.") + return false + end + + if length >= 60 then + print(("^4[esx_migration:v1.14.2:rentedVehiclesOwnerLength]^7 Column is already varchar(%s), migration not needed."):format(length)) + return false + end + + print(("^4[esx_migration:v1.14.2:rentedVehiclesOwnerLength]^7 Column is varchar(%s), widening it to 60."):format(length)) + + MySQL.update.await([[ + ALTER TABLE `rented_vehicles` + MODIFY `owner` VARCHAR(60) NOT NULL + ]]) + + print("^4[esx_migration:v1.14.2:rentedVehiclesOwnerLength]^7 Migration complete.") + return true +end From a890abd11008ccb5ec8055685d6b5d8c1070d1f9 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:45:05 +0200 Subject: [PATCH 39/57] fix(esx_lib): make table.dump work and drop the shadowed contains table.dump called its own table argument as a function, so it threw "attempt to call a table value" for every table that had at least one entry. An empty table returned "{ } " and a non-table returned tostring, which is why it looks fine until you actually dump something. table.contains was defined twice in the same file. Lua keeps the second one, so the annotated version further up was unreachable. Removed the unreachable one; behaviour is unchanged. --- [core]/esx_lib/imports/table/shared.lua | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/[core]/esx_lib/imports/table/shared.lua b/[core]/esx_lib/imports/table/shared.lua index 37d51ee6..57940f09 100644 --- a/[core]/esx_lib/imports/table/shared.lua +++ b/[core]/esx_lib/imports/table/shared.lua @@ -34,13 +34,6 @@ function xLib.table.searchForKey(tbl, item) return nil end ----@param tbl table ----@param item any ----@return boolean -function xLib.table.contains(tbl, item) - return xLib.table.searchForKey(tbl, item) ~= nil -end - ---@param tbl table ---@param filter fun(value:any, key:any):boolean ---@return table @@ -118,7 +111,7 @@ function xLib.table.dump(tbl) local s = '{ ' for k,v in pairs(tbl) do if type(k) ~= 'number' then k = '"'..k..'"' end - s = s .. '['..k..'] = ' .. tbl(v) .. ',' + s = s .. '['..k..'] = ' .. xLib.table.dump(v) .. ',' end return s .. '} ' else From 760f31eb6cffd3acc957e2ccc921e9b8bb0f34b7 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:50:20 +0200 Subject: [PATCH 40/57] fix(esx_multicharacter): correct the chin_3 typo in the default skins Both default skins spell the chin width key chin_13. skinchanger only knows chin_1 through chin_4, so the value never reaches the ped and the key is stored as junk in users.skin. It also breaks the ped. SetFace reads every feature through Normalise(weight, 10), which divides, so a missing chin_3 raises "attempt to perform arithmetic on a nil value" at feature index 17. Everything after that in ApplySkin is skipped: the remaining features, the eye colour, the head overlays, the components and the props. --- [core]/esx_multicharacter/config.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/[core]/esx_multicharacter/config.lua b/[core]/esx_multicharacter/config.lua index aa2aa241..bfd66377 100644 --- a/[core]/esx_multicharacter/config.lua +++ b/[core]/esx_multicharacter/config.lua @@ -48,7 +48,7 @@ else jaw_2 = 0, chin_1 = 0, chin_2 = 0, - chin_13 = 0, + chin_3 = 0, chin_4 = 0, neck_thickness = 0, hair_1 = 76, @@ -147,7 +147,7 @@ else jaw_2 = 0, chin_1 = -10, chin_2 = 10, - chin_13 = -10, + chin_3 = -10, chin_4 = 0, neck_thickness = -5, hair_1 = 43, From f5ed52d03b4cddb8940afe9ba8e73c9e36c5f262 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:38:25 +0200 Subject: [PATCH 41/57] fix(esx_menu_dialog): drop the timeout id bookkeeping With the callback checking whether a menu is still open, cancelling the previous timers by id is no longer needed. The table it kept them in was never emptied, so every open iterated over every id ever created and called ClearTimeout on all of them. That is quadratic, and clearTimeout only marks an id in xLib's CancelledTimeouts, which is cleared when the timer fires. Ids that had already fired stayed in there for good, in a table shared by every resource. --- [core]/esx_menu_dialog/client/main.lua | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/[core]/esx_menu_dialog/client/main.lua b/[core]/esx_menu_dialog/client/main.lua index cce26666..24253846 100644 --- a/[core]/esx_menu_dialog/client/main.lua +++ b/[core]/esx_menu_dialog/client/main.lua @@ -1,10 +1,6 @@ -local Timeouts, OpenedMenus, MenuType = {}, {}, "dialog" +local OpenedMenus, MenuType = {}, "dialog" local function openMenu(namespace, name, data) - for i = 1, #Timeouts, 1 do - ESX.ClearTimeout(Timeouts[i]) - end - OpenedMenus[namespace .. "_" .. name] = true SendNUIMessage({ @@ -14,13 +10,11 @@ local function openMenu(namespace, name, data) data = data, }) - local timeoutId = ESX.SetTimeout(200, function() + ESX.SetTimeout(200, function() if next(OpenedMenus) then SetNuiFocus(true, true) end end) - - table.insert(Timeouts, timeoutId) end local function closeMenu(namespace, name) From 9128ad58579d511df979d8285dd4018c634d8554 Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:10:58 +0530 Subject: [PATCH 42/57] feat(commands): centralize admin permissions via Config.AdminGroups --- .../es_extended/server/modules/commands.lua | 84 +++++++++++-------- 1 file changed, 50 insertions(+), 34 deletions(-) diff --git a/[core]/es_extended/server/modules/commands.lua b/[core]/es_extended/server/modules/commands.lua index 2320e82f..1796b8c1 100644 --- a/[core]/es_extended/server/modules/commands.lua +++ b/[core]/es_extended/server/modules/commands.lua @@ -1,6 +1,22 @@ +local adminGroups = {} +if Config.AdminGroups then + for groupName, enabled in pairs(Config.AdminGroups) do + if enabled then + adminGroups[#adminGroups + 1] = groupName + end + end +else + adminGroups = { "admin" } +end + +local userAndAdminGroups = { "user" } +for _, v in ipairs(adminGroups) do + userAndAdminGroups[#userAndAdminGroups + 1] = v +end + ESX.RegisterCommand( { "setcoords", "tp" }, - "admin", + adminGroups, function(xPlayer, args) xPlayer.setCoords({ x = args.x, y = args.y, z = args.z }) if Config.AdminLogging then @@ -27,7 +43,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "setjob", - "admin", + adminGroups, function(xPlayer, args, showError) if not ESX.DoesJobExist(args.job, args.grade) then return showError(TranslateCap("command_setjob_invalid")) @@ -68,7 +84,7 @@ local upgrades = Config.SpawnVehMaxUpgrades and { ESX.RegisterCommand( "car", - "admin", + adminGroups, function(xPlayer, args, showError) if not xPlayer then return showError("[^1ERROR^7] The xPlayer value is nil") @@ -132,7 +148,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( { "cardel", "dv" }, - "admin", + adminGroups, function(xPlayer, args) local ped = GetPlayerPed(xPlayer.source) local pedVehicle = GetVehiclePedIsIn(ped, false) @@ -168,7 +184,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( { "fix", "repair" }, - "admin", + adminGroups, function(xPlayer, args, showError) local xTarget = args.playerId local ped = GetPlayerPed(xTarget.source) @@ -202,7 +218,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "setaccountmoney", - "admin", + adminGroups, function(xPlayer, args, showError) if not args.playerId.getAccount(args.account) then return showError(TranslateCap("command_giveaccountmoney_invalid")) @@ -232,7 +248,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "giveaccountmoney", - "admin", + adminGroups, function(xPlayer, args, showError) if not args.playerId.getAccount(args.account) then return showError(TranslateCap("command_giveaccountmoney_invalid")) @@ -262,7 +278,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "removeaccountmoney", - "admin", + adminGroups, function(xPlayer, args, showError) if not args.playerId.getAccount(args.account) then return showError(TranslateCap("command_removeaccountmoney_invalid")) @@ -293,7 +309,7 @@ ESX.RegisterCommand( if not Config.CustomInventory then ESX.RegisterCommand( "giveitem", - "admin", + adminGroups, function(xPlayer, args) args.playerId.addInventoryItem(args.item, args.count) if Config.AdminLogging then @@ -323,7 +339,7 @@ if not Config.CustomInventory then ESX.RegisterCommand( "giveweapon", - "admin", + adminGroups, function(xPlayer, args, showError) if args.playerId.hasWeapon(args.weapon) then return showError(TranslateCap("command_giveweapon_hasalready")) @@ -353,7 +369,7 @@ if not Config.CustomInventory then ESX.RegisterCommand( "giveammo", - "admin", + adminGroups, function(xPlayer, args, showError) if not args.playerId.hasWeapon(args.weapon) then return showError(TranslateCap("command_giveammo_noweapon_found")) @@ -383,7 +399,7 @@ if not Config.CustomInventory then ESX.RegisterCommand( "giveweaponcomponent", - "admin", + adminGroups, function(xPlayer, args, showError) if args.playerId.hasWeapon(args.weaponName) then local component = ESX.GetWeaponComponent(args.weaponName, args.componentName) @@ -427,7 +443,7 @@ ESX.RegisterCommand({ "clear", "cls" }, "user", function(xPlayer) xPlayer.triggerEvent("chat:clear") end, false, { help = TranslateCap("command_clear") }) -ESX.RegisterCommand({ "clearall", "clsall" }, "admin", function(xPlayer) +ESX.RegisterCommand({ "clearall", "clsall" }, adminGroups, function(xPlayer) TriggerClientEvent("chat:clear", -1) if Config.AdminLogging then ESX.DiscordLogFields("UserActions", "Clear Chat /clearall Triggered!", "pink", { @@ -437,12 +453,12 @@ ESX.RegisterCommand({ "clearall", "clsall" }, "admin", function(xPlayer) end end, true, { help = TranslateCap("command_clearall") }) -ESX.RegisterCommand("refreshjobs", "admin", function() +ESX.RegisterCommand("refreshjobs", adminGroups, function() ESX.RefreshJobs() end, true, { help = TranslateCap("command_clearall") }) if not Config.CustomInventory then - ESX.RegisterCommand("refreshitems", "admin", function(xPlayer) + ESX.RegisterCommand("refreshitems", adminGroups, function(xPlayer) local itemCount = ESX.RefreshItems() xPlayer.showNotification(Translate("command_refreshitems_success", itemCount), true, false, 140) @@ -450,7 +466,7 @@ if not Config.CustomInventory then ESX.RegisterCommand( "clearinventory", - "admin", + adminGroups, function(xPlayer, args) for _, v in ipairs(args.playerId.inventory) do if v.count > 0 then @@ -478,7 +494,7 @@ if not Config.CustomInventory then ESX.RegisterCommand( "clearloadout", - "admin", + adminGroups, function(xPlayer, args) for i = #args.playerId.loadout, 1, -1 do args.playerId.removeWeapon(args.playerId.loadout[i].name) @@ -505,7 +521,7 @@ end ESX.RegisterCommand( "setgroup", - "admin", + adminGroups, function(xPlayer, args) if not args.playerId then args.playerId = xPlayer.source @@ -537,7 +553,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "save", - "admin", + adminGroups, function(_, args) Core.SavePlayer(args.playerId) print(("[^2Info^0] Saved Player - ^5%s^0"):format(args.playerId.source)) @@ -552,26 +568,26 @@ ESX.RegisterCommand( } ) -ESX.RegisterCommand("saveall", "admin", function() +ESX.RegisterCommand("saveall", adminGroups, function() Core.SavePlayers() end, true, { help = TranslateCap("command_saveall") }) -ESX.RegisterCommand("group", { "user", "admin" }, function(xPlayer, _, _) +ESX.RegisterCommand("group", userAndAdminGroups, function(xPlayer, _, _) print(("%s, you are currently: ^5%s^0"):format(xPlayer.getName(), xPlayer.getGroup())) end, true) -ESX.RegisterCommand("job", { "user", "admin" }, function(xPlayer, _, _) +ESX.RegisterCommand("job", userAndAdminGroups, function(xPlayer, _, _) local job = xPlayer.getJob() print(("%s, your job is: ^5%s^0 - ^5%s^0 - ^5%s^0"):format(xPlayer.getName(), job.name, job.grade_label, job.onDuty and "On Duty" or "Off Duty")) end, false) -ESX.RegisterCommand("info", { "user", "admin" }, function(xPlayer) +ESX.RegisterCommand("info", userAndAdminGroups, function(xPlayer) local job = xPlayer.getJob().name print(("^2ID: ^5%s^0 | ^2Name: ^5%s^0 | ^2Group: ^5%s^0 | ^2Job: ^5%s^0"):format(xPlayer.source, xPlayer.getName(), xPlayer.getGroup(), job)) end, false) -ESX.RegisterCommand("playtime", { "user", "admin" }, function(xPlayer) +ESX.RegisterCommand("playtime", userAndAdminGroups, function(xPlayer) local playtime = xPlayer.getPlayTime() local days = math.floor(playtime / 86400) local hours = math.floor((playtime % 86400) / 3600) @@ -579,7 +595,7 @@ ESX.RegisterCommand("playtime", { "user", "admin" }, function(xPlayer) print(("Playtime: ^5%s^0 Days | ^5%s^0 Hours | ^5%s^0 Minutes"):format(days, hours, minutes)) end, false) -ESX.RegisterCommand("coords", "admin", function(xPlayer) +ESX.RegisterCommand("coords", adminGroups, function(xPlayer) local ped = GetPlayerPed(xPlayer.source) local coords = GetEntityCoords(ped, false) local heading = GetEntityHeading(ped) @@ -587,7 +603,7 @@ ESX.RegisterCommand("coords", "admin", function(xPlayer) print(("Coords - Vector4: ^5%s^0"):format(vector4(coords.x, coords.y, coords.z, heading))) end, false) -ESX.RegisterCommand("tpm", "admin", function(xPlayer) +ESX.RegisterCommand("tpm", adminGroups, function(xPlayer) xPlayer.triggerEvent("esx:tpm") if Config.AdminLogging then ESX.DiscordLogFields("UserActions", "Admin Teleport /tpm Triggered!", "pink", { @@ -599,7 +615,7 @@ end, false) ESX.RegisterCommand( "goto", - "admin", + adminGroups, function(xPlayer, args) local targetCoords = args.playerId.getCoords() local srcDim = GetPlayerRoutingBucket(xPlayer.source) @@ -630,7 +646,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "bring", - "admin", + adminGroups, function(xPlayer, args) local targetCoords = args.playerId.getCoords() local playerCoords = xPlayer.getCoords() @@ -662,7 +678,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "kill", - "admin", + adminGroups, function(xPlayer, args) args.playerId.triggerEvent("esx:killPlayer") if Config.AdminLogging then @@ -685,7 +701,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "freeze", - "admin", + adminGroups, function(xPlayer, args) args.playerId.triggerEvent("esx:freezePlayer", "freeze") if Config.AdminLogging then @@ -708,7 +724,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "unfreeze", - "admin", + adminGroups, function(xPlayer, args) args.playerId.triggerEvent("esx:freezePlayer", "unfreeze") if Config.AdminLogging then @@ -729,7 +745,7 @@ ESX.RegisterCommand( } ) -ESX.RegisterCommand("noclip", "admin", function(xPlayer) +ESX.RegisterCommand("noclip", adminGroups, function(xPlayer) xPlayer.triggerEvent("esx:noclip") if Config.AdminLogging then ESX.DiscordLogFields("UserActions", "Admin NoClip /noclip Triggered!", "pink", { @@ -739,7 +755,7 @@ ESX.RegisterCommand("noclip", "admin", function(xPlayer) end end, false) -ESX.RegisterCommand("players", "admin", function() +ESX.RegisterCommand("players", adminGroups, function() local xPlayers = ESX.GetExtendedPlayers() -- Returns all xPlayers print(("^5%s^2 online player(s)^0"):format(#xPlayers)) for i = 1, #xPlayers do @@ -750,7 +766,7 @@ end, true) ESX.RegisterCommand( {"setdim", "setbucket"}, - "admin", + adminGroups, function(xPlayer, args) SetPlayerRoutingBucket(args.playerId.source, args.dimension) if Config.AdminLogging then From 3aa3bf27d6a5e0a6b438b43c6a2a166c448b117d Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:52:52 +0530 Subject: [PATCH 43/57] fix(legacy.sql): add missing bank_savings entry to addon_account and remove redundant banker job inserts --- [SQL]/legacy.sql | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/[SQL]/legacy.sql b/[SQL]/legacy.sql index 05d5d10b..f8b40e70 100644 --- a/[SQL]/legacy.sql +++ b/[SQL]/legacy.sql @@ -16,7 +16,8 @@ INSERT INTO `addon_account` (`name`, `label`, `shared`) VALUES ('society_cardealer', 'Cardealer', 1), ('society_mechanic', 'Mechanic', 1), ('society_police', 'Police', 1), -('society_taxi', 'Taxi', 1); +('society_taxi', 'Taxi', 1), +('bank_savings','Savings account',0); -- -------------------------------------------------------- @@ -991,27 +992,6 @@ INSERT INTO `fine_types` (label, amount, category) VALUES ('Fraud', 2000, 2); --- --- ESX Bankerjob --- - -INSERT INTO `addon_account` (name, label, shared) VALUES - ('society_banker','Bank',1), - ('bank_savings','Savings account',0) -; - -INSERT INTO `jobs` (name, label) VALUES - ('banker','Banker') -; - -INSERT INTO `job_grades` (job_name, grade, name, label, salary, skin_male, skin_female) VALUES - ('banker',0,'advisor','Consultant',10,'{}','{}'), - ('banker',1,'banker','Banker',20,'{}','{}'), - ('banker',2,'business_banker',"Investment banker",30,'{}','{}'), - ('banker',3,'trader','Broker',40,'{}','{}'), - ('banker',4,'boss','Boss',0,'{}','{}') -; - -- -- ESX Banking -- From e85b05558c79572895b0c6f49b11de92a78345b0 Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:54:44 +0530 Subject: [PATCH 44/57] feat: per-command group permissions for admin commands --- .../es_extended/server/modules/commands.lua | 90 +++++++++---------- [core]/es_extended/shared/config/main.lua | 38 ++++++++ 2 files changed, 81 insertions(+), 47 deletions(-) diff --git a/[core]/es_extended/server/modules/commands.lua b/[core]/es_extended/server/modules/commands.lua index 1796b8c1..0a800f7a 100644 --- a/[core]/es_extended/server/modules/commands.lua +++ b/[core]/es_extended/server/modules/commands.lua @@ -1,22 +1,18 @@ -local adminGroups = {} -if Config.AdminGroups then - for groupName, enabled in pairs(Config.AdminGroups) do - if enabled then - adminGroups[#adminGroups + 1] = groupName +local CommandPermissions = Config.CommandPermissions or {} + +local function FilterGroups(groups) + local filtered = {} + for _, group in ipairs(groups or {}) do + if Config.AdminGroups[group] ~= false then + filtered[#filtered + 1] = group end end -else - adminGroups = { "admin" } -end - -local userAndAdminGroups = { "user" } -for _, v in ipairs(adminGroups) do - userAndAdminGroups[#userAndAdminGroups + 1] = v + return #filtered > 0 and filtered or { "admin" } end ESX.RegisterCommand( { "setcoords", "tp" }, - adminGroups, + FilterGroups(CommandPermissions.setcoords), function(xPlayer, args) xPlayer.setCoords({ x = args.x, y = args.y, z = args.z }) if Config.AdminLogging then @@ -43,7 +39,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "setjob", - adminGroups, + FilterGroups(CommandPermissions.setjob), function(xPlayer, args, showError) if not ESX.DoesJobExist(args.job, args.grade) then return showError(TranslateCap("command_setjob_invalid")) @@ -84,7 +80,7 @@ local upgrades = Config.SpawnVehMaxUpgrades and { ESX.RegisterCommand( "car", - adminGroups, + FilterGroups(CommandPermissions.car), function(xPlayer, args, showError) if not xPlayer then return showError("[^1ERROR^7] The xPlayer value is nil") @@ -148,7 +144,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( { "cardel", "dv" }, - adminGroups, + FilterGroups(CommandPermissions.cardel), function(xPlayer, args) local ped = GetPlayerPed(xPlayer.source) local pedVehicle = GetVehiclePedIsIn(ped, false) @@ -184,7 +180,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( { "fix", "repair" }, - adminGroups, + FilterGroups(CommandPermissions.fix), function(xPlayer, args, showError) local xTarget = args.playerId local ped = GetPlayerPed(xTarget.source) @@ -218,7 +214,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "setaccountmoney", - adminGroups, + FilterGroups(CommandPermissions.setaccountmoney), function(xPlayer, args, showError) if not args.playerId.getAccount(args.account) then return showError(TranslateCap("command_giveaccountmoney_invalid")) @@ -248,7 +244,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "giveaccountmoney", - adminGroups, + FilterGroups(CommandPermissions.giveaccountmoney), function(xPlayer, args, showError) if not args.playerId.getAccount(args.account) then return showError(TranslateCap("command_giveaccountmoney_invalid")) @@ -278,7 +274,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "removeaccountmoney", - adminGroups, + FilterGroups(CommandPermissions.removeaccountmoney), function(xPlayer, args, showError) if not args.playerId.getAccount(args.account) then return showError(TranslateCap("command_removeaccountmoney_invalid")) @@ -309,7 +305,7 @@ ESX.RegisterCommand( if not Config.CustomInventory then ESX.RegisterCommand( "giveitem", - adminGroups, + FilterGroups(CommandPermissions.giveitem), function(xPlayer, args) args.playerId.addInventoryItem(args.item, args.count) if Config.AdminLogging then @@ -339,7 +335,7 @@ if not Config.CustomInventory then ESX.RegisterCommand( "giveweapon", - adminGroups, + FilterGroups(CommandPermissions.giveweapon), function(xPlayer, args, showError) if args.playerId.hasWeapon(args.weapon) then return showError(TranslateCap("command_giveweapon_hasalready")) @@ -369,7 +365,7 @@ if not Config.CustomInventory then ESX.RegisterCommand( "giveammo", - adminGroups, + FilterGroups(CommandPermissions.giveammo), function(xPlayer, args, showError) if not args.playerId.hasWeapon(args.weapon) then return showError(TranslateCap("command_giveammo_noweapon_found")) @@ -399,7 +395,7 @@ if not Config.CustomInventory then ESX.RegisterCommand( "giveweaponcomponent", - adminGroups, + FilterGroups(CommandPermissions.giveweaponcomponent), function(xPlayer, args, showError) if args.playerId.hasWeapon(args.weaponName) then local component = ESX.GetWeaponComponent(args.weaponName, args.componentName) @@ -439,11 +435,11 @@ if not Config.CustomInventory then ) end -ESX.RegisterCommand({ "clear", "cls" }, "user", function(xPlayer) +ESX.RegisterCommand({ "clear", "cls" }, FilterGroups(CommandPermissions.clear), function(xPlayer) xPlayer.triggerEvent("chat:clear") end, false, { help = TranslateCap("command_clear") }) -ESX.RegisterCommand({ "clearall", "clsall" }, adminGroups, function(xPlayer) +ESX.RegisterCommand({ "clearall", "clsall" }, FilterGroups(CommandPermissions.clearall), function(xPlayer) TriggerClientEvent("chat:clear", -1) if Config.AdminLogging then ESX.DiscordLogFields("UserActions", "Clear Chat /clearall Triggered!", "pink", { @@ -453,12 +449,12 @@ ESX.RegisterCommand({ "clearall", "clsall" }, adminGroups, function(xPlayer) end end, true, { help = TranslateCap("command_clearall") }) -ESX.RegisterCommand("refreshjobs", adminGroups, function() +ESX.RegisterCommand("refreshjobs", FilterGroups(CommandPermissions.refreshjobs), function() ESX.RefreshJobs() end, true, { help = TranslateCap("command_clearall") }) if not Config.CustomInventory then - ESX.RegisterCommand("refreshitems", adminGroups, function(xPlayer) + ESX.RegisterCommand("refreshitems", FilterGroups(CommandPermissions.refreshitems), function(xPlayer) local itemCount = ESX.RefreshItems() xPlayer.showNotification(Translate("command_refreshitems_success", itemCount), true, false, 140) @@ -466,7 +462,7 @@ if not Config.CustomInventory then ESX.RegisterCommand( "clearinventory", - adminGroups, + FilterGroups(CommandPermissions.clearinventory), function(xPlayer, args) for _, v in ipairs(args.playerId.inventory) do if v.count > 0 then @@ -494,7 +490,7 @@ if not Config.CustomInventory then ESX.RegisterCommand( "clearloadout", - adminGroups, + FilterGroups(CommandPermissions.clearloadout), function(xPlayer, args) for i = #args.playerId.loadout, 1, -1 do args.playerId.removeWeapon(args.playerId.loadout[i].name) @@ -521,7 +517,7 @@ end ESX.RegisterCommand( "setgroup", - adminGroups, + FilterGroups(CommandPermissions.setgroup), function(xPlayer, args) if not args.playerId then args.playerId = xPlayer.source @@ -553,7 +549,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "save", - adminGroups, + FilterGroups(CommandPermissions.save), function(_, args) Core.SavePlayer(args.playerId) print(("[^2Info^0] Saved Player - ^5%s^0"):format(args.playerId.source)) @@ -568,26 +564,26 @@ ESX.RegisterCommand( } ) -ESX.RegisterCommand("saveall", adminGroups, function() +ESX.RegisterCommand("saveall", FilterGroups(CommandPermissions.saveall), function() Core.SavePlayers() end, true, { help = TranslateCap("command_saveall") }) -ESX.RegisterCommand("group", userAndAdminGroups, function(xPlayer, _, _) +ESX.RegisterCommand("group", FilterGroups(CommandPermissions.group), function(xPlayer, _, _) print(("%s, you are currently: ^5%s^0"):format(xPlayer.getName(), xPlayer.getGroup())) end, true) -ESX.RegisterCommand("job", userAndAdminGroups, function(xPlayer, _, _) +ESX.RegisterCommand("job", FilterGroups(CommandPermissions.job), function(xPlayer, _, _) local job = xPlayer.getJob() print(("%s, your job is: ^5%s^0 - ^5%s^0 - ^5%s^0"):format(xPlayer.getName(), job.name, job.grade_label, job.onDuty and "On Duty" or "Off Duty")) end, false) -ESX.RegisterCommand("info", userAndAdminGroups, function(xPlayer) +ESX.RegisterCommand("info", FilterGroups(CommandPermissions.info), function(xPlayer) local job = xPlayer.getJob().name print(("^2ID: ^5%s^0 | ^2Name: ^5%s^0 | ^2Group: ^5%s^0 | ^2Job: ^5%s^0"):format(xPlayer.source, xPlayer.getName(), xPlayer.getGroup(), job)) end, false) -ESX.RegisterCommand("playtime", userAndAdminGroups, function(xPlayer) +ESX.RegisterCommand("playtime", FilterGroups(CommandPermissions.playtime), function(xPlayer) local playtime = xPlayer.getPlayTime() local days = math.floor(playtime / 86400) local hours = math.floor((playtime % 86400) / 3600) @@ -595,7 +591,7 @@ ESX.RegisterCommand("playtime", userAndAdminGroups, function(xPlayer) print(("Playtime: ^5%s^0 Days | ^5%s^0 Hours | ^5%s^0 Minutes"):format(days, hours, minutes)) end, false) -ESX.RegisterCommand("coords", adminGroups, function(xPlayer) +ESX.RegisterCommand("coords", FilterGroups(CommandPermissions.coords), function(xPlayer) local ped = GetPlayerPed(xPlayer.source) local coords = GetEntityCoords(ped, false) local heading = GetEntityHeading(ped) @@ -603,7 +599,7 @@ ESX.RegisterCommand("coords", adminGroups, function(xPlayer) print(("Coords - Vector4: ^5%s^0"):format(vector4(coords.x, coords.y, coords.z, heading))) end, false) -ESX.RegisterCommand("tpm", adminGroups, function(xPlayer) +ESX.RegisterCommand("tpm", FilterGroups(CommandPermissions.tpm), function(xPlayer) xPlayer.triggerEvent("esx:tpm") if Config.AdminLogging then ESX.DiscordLogFields("UserActions", "Admin Teleport /tpm Triggered!", "pink", { @@ -615,7 +611,7 @@ end, false) ESX.RegisterCommand( "goto", - adminGroups, + FilterGroups(CommandPermissions["goto"]), function(xPlayer, args) local targetCoords = args.playerId.getCoords() local srcDim = GetPlayerRoutingBucket(xPlayer.source) @@ -646,7 +642,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "bring", - adminGroups, + FilterGroups(CommandPermissions.bring), function(xPlayer, args) local targetCoords = args.playerId.getCoords() local playerCoords = xPlayer.getCoords() @@ -678,7 +674,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "kill", - adminGroups, + FilterGroups(CommandPermissions.kill), function(xPlayer, args) args.playerId.triggerEvent("esx:killPlayer") if Config.AdminLogging then @@ -701,7 +697,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "freeze", - adminGroups, + FilterGroups(CommandPermissions.freeze), function(xPlayer, args) args.playerId.triggerEvent("esx:freezePlayer", "freeze") if Config.AdminLogging then @@ -724,7 +720,7 @@ ESX.RegisterCommand( ESX.RegisterCommand( "unfreeze", - adminGroups, + FilterGroups(CommandPermissions.unfreeze), function(xPlayer, args) args.playerId.triggerEvent("esx:freezePlayer", "unfreeze") if Config.AdminLogging then @@ -745,7 +741,7 @@ ESX.RegisterCommand( } ) -ESX.RegisterCommand("noclip", adminGroups, function(xPlayer) +ESX.RegisterCommand("noclip", FilterGroups(CommandPermissions.noclip), function(xPlayer) xPlayer.triggerEvent("esx:noclip") if Config.AdminLogging then ESX.DiscordLogFields("UserActions", "Admin NoClip /noclip Triggered!", "pink", { @@ -755,7 +751,7 @@ ESX.RegisterCommand("noclip", adminGroups, function(xPlayer) end end, false) -ESX.RegisterCommand("players", adminGroups, function() +ESX.RegisterCommand("players", FilterGroups(CommandPermissions.players), function() local xPlayers = ESX.GetExtendedPlayers() -- Returns all xPlayers print(("^5%s^2 online player(s)^0"):format(#xPlayers)) for i = 1, #xPlayers do @@ -766,7 +762,7 @@ end, true) ESX.RegisterCommand( {"setdim", "setbucket"}, - adminGroups, + FilterGroups(CommandPermissions.setdim), function(xPlayer, args) SetPlayerRoutingBucket(args.playerId.source, args.dimension) if Config.AdminLogging then diff --git a/[core]/es_extended/shared/config/main.lua b/[core]/es_extended/shared/config/main.lua index 0bb55117..2c32180b 100644 --- a/[core]/es_extended/shared/config/main.lua +++ b/[core]/es_extended/shared/config/main.lua @@ -39,6 +39,44 @@ Config.AdminGroups = { ["admin"] = true, } +Config.CommandPermissions = { + ["setcoords"] = { "owner", "admin" }, -- /setcoords, /tp + ["setjob"] = { "owner", "admin" }, + ["car"] = { "owner", "admin" }, + ["cardel"] = { "owner", "admin" }, -- /cardel, /dv + ["fix"] = { "owner", "admin" }, -- /fix, /repair + ["setaccountmoney"] = { "owner", "admin" }, + ["giveaccountmoney"] = { "owner", "admin" }, + ["removeaccountmoney"] = { "owner", "admin" }, + ["giveitem"] = { "owner", "admin" }, + ["giveweapon"] = { "owner", "admin" }, + ["giveammo"] = { "owner", "admin" }, + ["giveweaponcomponent"] = { "owner", "admin" }, + ["clearall"] = { "owner", "admin" }, -- /clearall, /clsall + ["refreshjobs"] = { "owner", "admin" }, + ["refreshitems"] = { "owner", "admin" }, + ["clearinventory"] = { "owner", "admin" }, + ["clearloadout"] = { "owner", "admin" }, + ["setgroup"] = { "owner", "admin" }, + ["save"] = { "owner", "admin" }, + ["saveall"] = { "owner", "admin" }, + ["goto"] = { "owner", "admin" }, + ["bring"] = { "owner", "admin" }, + ["kill"] = { "owner", "admin" }, + ["freeze"] = { "owner", "admin" }, + ["unfreeze"] = { "owner", "admin" }, + ["setdim"] = { "owner", "admin" }, -- /setdim, /setbucket + ["players"] = { "owner", "admin" }, + ["noclip"] = { "owner", "admin" }, + ["tpm"] = { "owner", "admin" }, + ["coords"] = { "owner", "admin" }, + ["clear"] = { "user", "admin", "owner" }, -- /clear, /cls + ["group"] = { "user", "admin", "owner" }, + ["job"] = { "user", "admin", "owner" }, + ["info"] = { "user", "admin", "owner" }, + ["playtime"] = { "user", "admin", "owner" }, +} + Config.ValidCharacterSets = { -- Only enable additional charsets if your server is multilingual. By default everything is false. ['el'] = false, -- Greek ['sr'] = false, -- Cyrillic From 87003d41aab520e4fa37ee6c1a7ba2d4681acadf Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:17:52 +0530 Subject: [PATCH 45/57] refactor(oxinventory): improve error handling and streamline inventory functions --- .../server/classes/overrides/oxinventory.lua | 319 +++++++++++++----- 1 file changed, 228 insertions(+), 91 deletions(-) diff --git a/[core]/es_extended/server/classes/overrides/oxinventory.lua b/[core]/es_extended/server/classes/overrides/oxinventory.lua index c3be26a8..cdfeaff3 100644 --- a/[core]/es_extended/server/classes/overrides/oxinventory.lua +++ b/[core]/es_extended/server/classes/overrides/oxinventory.lua @@ -1,11 +1,85 @@ -local Inventory +local OxInventory -if Config.CustomInventory ~= "ox" then return end +if Config.CustomInventory ~= "ox" then + return +end +---Stores the reference to the internal ox_inventory module. +---@param module table +---@return boolean +local function setOxInventory(module) + if type(module) ~= "table" then + return false + end + + OxInventory = module + return true +end + +---Returns the internal ox_inventory module. +---If es_extended missed the load event, it will attempt to retrieve it via the export. +---@return table +local function getOxInventory() + if OxInventory then + return OxInventory + end + + local state = GetResourceState("ox_inventory") + + if state ~= "started" then + error( + ("[es_extended] ox_inventory not started; current status: %s") + :format(tostring(state)), + 2 + ) + end + + local success, module = pcall(function() + return exports.ox_inventory:Inventory() + end) + + if not success then + error( + ("[es_extended] Failed to execute exports.ox_inventory:Inventory(): %s") + :format(tostring(module)), + 2 + ) + end + + if not setOxInventory(module) then + error( + "[es_extended] The Inventory export from ox_inventory returned an invalid module.", + 2 + ) + end + + return OxInventory +end + +-- Standard method used when ox_inventory finishes loading.. AddEventHandler("ox_inventory:loadInventory", function(module) - Inventory = module + if not setOxInventory(module) then + print("^1[es_extended] ox_inventory:loadInventory returned an invalid module.^7") + end end) +-- Standard method used when ox_inventory is stopped. +AddEventHandler("onResourceStop", function(resourceName) + if resourceName == "ox_inventory" then + OxInventory = nil + end +end) + +local function emptyMethod() + return function() end +end + +local function falseMethod() + return function() + return false + end +end + Core.PlayerFunctionOverrides.OxInventory = { getInventory = function(self) return function(minimal) @@ -13,26 +87,26 @@ Core.PlayerFunctionOverrides.OxInventory = { return self.inventory end - local minimalInventory = {} + local result = {} - for k, v in pairs(self.inventory) do - if v.count and v.count > 0 then - local metadata = v.metadata + for slot, item in pairs(self.inventory) do + if item.count and item.count > 0 then + local metadata = item.metadata - if v.metadata and next(v.metadata) == nil then + if type(metadata) == "table" and next(metadata) == nil then metadata = nil end - minimalInventory[#minimalInventory + 1] = { - name = v.name, - count = v.count, - slot = k, + result[#result + 1] = { + name = item.name, + count = item.count, + slot = slot, metadata = metadata, } end end - return minimalInventory + return result end end, @@ -45,18 +119,33 @@ Core.PlayerFunctionOverrides.OxInventory = { setAccountMoney = function(self) return function(accountName, money, reason) reason = reason or "unknown" - if money < 0 then return end + + if money < 0 then + return + end + local account = self.getAccount(accountName) - if not account then return end + if not account then + return + end money = account.round and ESX.Math.Round(money) or money self.accounts[account.index].money = money self.triggerEvent("esx:setAccountMoney", account) - TriggerEvent("esx:setAccountMoney", self.source, accountName, money, reason) - if Inventory.accounts[accountName] then - Inventory.SetItem(self.source, accountName, money) + TriggerEvent( + "esx:setAccountMoney", + self.source, + accountName, + money, + reason + ) + + local inventory = getOxInventory() + + if inventory.accounts[accountName] then + inventory.SetItem(self.source, accountName, money) end end end, @@ -64,17 +153,34 @@ Core.PlayerFunctionOverrides.OxInventory = { addAccountMoney = function(self) return function(accountName, money, reason) reason = reason or "unknown" - if money < 1 then return end + + if money < 1 then + return + end local account = self.getAccount(accountName) - if not account then return end + + if not account then + return + end money = account.round and ESX.Math.Round(money) or money - self.accounts[account.index].money = self.accounts[account.index].money + money + self.accounts[account.index].money = + self.accounts[account.index].money + money + self.triggerEvent("esx:setAccountMoney", account) - TriggerEvent("esx:addAccountMoney", self.source, accountName, money, reason) - if Inventory.accounts[accountName] then - Inventory.AddItem(self.source, accountName, money) + TriggerEvent( + "esx:addAccountMoney", + self.source, + accountName, + money, + reason + ) + + local inventory = getOxInventory() + + if inventory.accounts[accountName] then + inventory.AddItem(self.source, accountName, money) end end end, @@ -82,54 +188,103 @@ Core.PlayerFunctionOverrides.OxInventory = { removeAccountMoney = function(self) return function(accountName, money, reason) reason = reason or "unknown" - if money < 1 then return end + + if money < 1 then + return + end local account = self.getAccount(accountName) - if not account then return end + + if not account then + return + end money = account.round and ESX.Math.Round(money) or money - self.accounts[account.index].money = self.accounts[account.index].money - money + self.accounts[account.index].money = + self.accounts[account.index].money - money + self.triggerEvent("esx:setAccountMoney", account) - TriggerEvent("esx:removeAccountMoney", self.source, accountName, money, reason) - if Inventory.accounts[accountName] then - Inventory.RemoveItem(self.source, accountName, money) + TriggerEvent( + "esx:removeAccountMoney", + self.source, + accountName, + money, + reason + ) + + local inventory = getOxInventory() + + if inventory.accounts[accountName] then + inventory.RemoveItem(self.source, accountName, money) end end end, getInventoryItem = function(self) return function(name, metadata) - return Inventory.GetItem(self.source, name, metadata) + return getOxInventory().GetItem( + self.source, + name, + metadata + ) end end, addInventoryItem = function(self) return function(name, count, metadata, slot) - return Inventory.AddItem(self.source, name, count or 1, metadata, slot) + return getOxInventory().AddItem( + self.source, + name, + count or 1, + metadata, + slot + ) end end, removeInventoryItem = function(self) return function(name, count, metadata, slot) - return Inventory.RemoveItem(self.source, name, count or 1, metadata, slot) + return getOxInventory().RemoveItem( + self.source, + name, + count or 1, + metadata, + slot + ) end end, setInventoryItem = function(self) return function(name, count, metadata) - return Inventory.SetItem(self.source, name, count, metadata) + return getOxInventory().SetItem( + self.source, + name, + count, + metadata + ) end end, canCarryItem = function(self) return function(name, count, metadata) - return Inventory.CanCarryItem(self.source, name, count, metadata) + return getOxInventory().CanCarryItem( + self.source, + name, + count, + metadata + ) end end, canSwapItem = function(self) return function(firstItem, firstItemCount, testItem, testItemCount) - return Inventory.CanSwapItem(self.source, firstItem, firstItemCount, testItem, testItemCount) + return getOxInventory().CanSwapItem( + self.source, + firstItem, + firstItemCount, + testItem, + testItemCount + ) end end, @@ -137,83 +292,65 @@ Core.PlayerFunctionOverrides.OxInventory = { return function(newWeight) self.maxWeight = newWeight self.triggerEvent("esx:setMaxWeight", self.maxWeight) - return Inventory.SetMaxWeight(self.source, newWeight) + + return getOxInventory().SetMaxWeight( + self.source, + newWeight + ) end end, - addWeapon = function() - return function() end - end, + addWeapon = emptyMethod, + addWeaponComponent = emptyMethod, + addWeaponAmmo = emptyMethod, + updateWeaponAmmo = emptyMethod, + setWeaponTint = emptyMethod, + getWeaponTint = emptyMethod, + removeWeapon = emptyMethod, + removeWeaponComponent = emptyMethod, + removeWeaponAmmo = emptyMethod, - addWeaponComponent = function() - return function() end - end, - - addWeaponAmmo = function() - return function() end - end, - - updateWeaponAmmo = function() - return function() end - end, - - setWeaponTint = function() - return function() end - end, - - getWeaponTint = function() - return function() end - end, - - removeWeapon = function() - return function() end - end, - - removeWeaponComponent = function() - return function() end - end, - - removeWeaponAmmo = function() - return function() end - end, - - hasWeaponComponent = function() - return function() - return false - end - end, - - hasWeapon = function() - return function() - return false - end - end, + hasWeaponComponent = falseMethod, + hasWeapon = falseMethod, hasItem = function(self) return function(name, metadata) - return Inventory.GetItem(self.source, name, metadata) + return getOxInventory().GetItem( + self.source, + name, + metadata + ) end end, - getWeapon = function() - return function() end - end, + getWeapon = emptyMethod, syncInventory = function(self) return function(weight, maxWeight, items, money) - self.weight, self.maxWeight = weight, maxWeight + self.weight = weight + self.maxWeight = maxWeight self.inventory = items - if not money then return end + if not money then + return + end + for accountName, amount in pairs(money) do local account = self.getAccount(accountName) if account and ESX.Math.Round(account.money) ~= amount then account.money = amount + self.triggerEvent("esx:setAccountMoney", account) - TriggerEvent("esx:setAccountMoney", self.source, accountName, amount, "Sync account with item") + TriggerEvent( + "esx:setAccountMoney", + self.source, + accountName, + amount, + "Sync account with item" + ) end end end end, -} +} \ No newline at end of file From 1a430fe6042f3a5dd87abd5a6713280485437222 Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:43:48 +0530 Subject: [PATCH 46/57] refactor(ox_inventory): validate module methods and fix hasItem return contract --- .../server/classes/overrides/oxinventory.lua | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/[core]/es_extended/server/classes/overrides/oxinventory.lua b/[core]/es_extended/server/classes/overrides/oxinventory.lua index cdfeaff3..328eaefd 100644 --- a/[core]/es_extended/server/classes/overrides/oxinventory.lua +++ b/[core]/es_extended/server/classes/overrides/oxinventory.lua @@ -4,12 +4,31 @@ if Config.CustomInventory ~= "ox" then return end +local requiredMethods = { + "GetItem", + "AddItem", + "RemoveItem", + "SetItem", + "CanCarryItem", + "CanSwapItem", + "SetMaxWeight", +} + ---Stores the reference to the internal ox_inventory module. ---@param module table ---@return boolean +---@return string? error local function setOxInventory(module) if type(module) ~= "table" then - return false + return false, "module is not a table" + end + + for i = 1, #requiredMethods do + local method = requiredMethods[i] + + if type(module[method]) ~= "function" then + return false, ("missing method %s"):format(method) + end end OxInventory = module @@ -46,9 +65,11 @@ local function getOxInventory() ) end - if not setOxInventory(module) then + local ok, err = setOxInventory(module) + if not ok then error( - "[es_extended] The Inventory export from ox_inventory returned an invalid module.", + ("[es_extended] The Inventory export from ox_inventory returned an invalid module: %s") + :format(err), 2 ) end @@ -58,8 +79,9 @@ end -- Standard method used when ox_inventory finishes loading.. AddEventHandler("ox_inventory:loadInventory", function(module) - if not setOxInventory(module) then - print("^1[es_extended] ox_inventory:loadInventory returned an invalid module.^7") + local ok, err = setOxInventory(module) + if not ok then + print(("^1[es_extended] ox_inventory:loadInventory returned an invalid module: %s^7"):format(err)) end end) @@ -315,11 +337,17 @@ Core.PlayerFunctionOverrides.OxInventory = { hasItem = function(self) return function(name, metadata) - return getOxInventory().GetItem( + local item = getOxInventory().GetItem( self.source, name, metadata ) + + if not item or not item.count or item.count < 1 then + return false + end + + return item, item.count end end, From 4a22b68511796c8252b4d70bd142c747f6739e9a Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:16:03 +0530 Subject: [PATCH 47/57] refactor(commands): simplify group filtering and update deprecation notice for AdminGroups --- [core]/es_extended/server/modules/commands.lua | 8 +------- [core]/es_extended/shared/config/main.lua | 3 +++ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/[core]/es_extended/server/modules/commands.lua b/[core]/es_extended/server/modules/commands.lua index 0a800f7a..0542d3f9 100644 --- a/[core]/es_extended/server/modules/commands.lua +++ b/[core]/es_extended/server/modules/commands.lua @@ -1,13 +1,7 @@ local CommandPermissions = Config.CommandPermissions or {} local function FilterGroups(groups) - local filtered = {} - for _, group in ipairs(groups or {}) do - if Config.AdminGroups[group] ~= false then - filtered[#filtered + 1] = group - end - end - return #filtered > 0 and filtered or { "admin" } + return (groups and #groups > 0) and groups or { "user" } end ESX.RegisterCommand( diff --git a/[core]/es_extended/shared/config/main.lua b/[core]/es_extended/shared/config/main.lua index 2c32180b..f61ff88c 100644 --- a/[core]/es_extended/shared/config/main.lua +++ b/[core]/es_extended/shared/config/main.lua @@ -34,6 +34,9 @@ Config.DefaultSpawns = { -- If you want to have more spawn positions and select --{x = 233.5459, y = -868.2626, z = 30.2922, heading = 1.0} } +---@deprecated Use `Config.CommandPermissions` as the single source of truth for command ACLs. +--- Kept temporarily for backwards compatibility with external resources that still read `Config.AdminGroups`. +--- Will be removed in ESX 1.15. Config.AdminGroups = { ["owner"] = true, ["admin"] = true, From 4b0789906177b28aa420df36172df127a533cb75 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:51:54 +0200 Subject: [PATCH 48/57] fix(es_extended/server/modules/createJob): only insert what is missing The jobs row was inserted unconditionally, so adding a grade to an existing job hit a duplicate primary key and rolled the whole transaction back. The guard above it only caught the case where there was nothing to add, and it read ESX.Jobs, which does not list jobs that have no grades yet. --- .../es_extended/server/modules/createJob.lua | 54 ++++++++++--------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/[core]/es_extended/server/modules/createJob.lua b/[core]/es_extended/server/modules/createJob.lua index a5712cf4..48f55540 100644 --- a/[core]/es_extended/server/modules/createJob.lua +++ b/[core]/es_extended/server/modules/createJob.lua @@ -5,22 +5,8 @@ local NOTIFY_TYPES = { ERROR = "^5[%s]^7-^1[ERROR]^7 %s" } -local function doesJobAndGradesExist(name, grades) - if not ESX.Jobs[name] then - return false - end - - for _, grade in ipairs(grades) do - if not ESX.DoesJobExist(name, grade.grade) then - return false - end - end - - return true -end - local function generateNewJobTable(name, label, grades, jobType) - local job = { name = name, label = label, type = jobType, grades = {} } + local job = ESX.Jobs[name] or { name = name, label = label, type = jobType, grades = {} } for _, v in pairs(grades) do job.grades[tostring(v.grade)] = { job_name = name, grade = v.grade, name = v.name, label = v.label, salary = v.salary, skin_male = v.skin_male or '{}', skin_female = v.skin_female or '{}' } end @@ -66,22 +52,38 @@ function ESX.CreateJob(name, label, grades, jobType) jobType = "civ" end - local currentJobExist = doesJobAndGradesExist(name, grades) + local jobExists = MySQL.scalar.await('SELECT 1 FROM `jobs` WHERE `name` = ?', { name }) ~= nil + local existingGrades = {} - if currentJobExist then - notify("ERROR",currentResourceName, 'Job or grades already exists: `%s`', name) - return success + if jobExists then + local rows = MySQL.query.await('SELECT `grade` FROM `job_grades` WHERE `job_name` = ?', { name }) + + for i = 1, #(rows or {}) do + existingGrades[rows[i].grade] = true + end end - local queries = { - { query = 'INSERT INTO `jobs` (`name`, `label`, `type`) VALUES (?, ?, ?)', values = { name, label, jobType } } - } + local queries = {} + + if not jobExists then + queries[#queries + 1] = { + query = 'INSERT INTO `jobs` (`name`, `label`, `type`) VALUES (?, ?, ?)', + values = { name, label, jobType } + } + end for _, grade in pairs(grades) do - queries[#queries + 1] = { - query = 'INSERT INTO job_grades (job_name, grade, name, label, salary, skin_male, skin_female) VALUES (?, ?, ?, ?, ?, ?, ?)', - values = { name, grade.grade, grade.name, grade.label, grade.salary, grade.skin_male and json.encode(grade.skin_male) or '{}', grade.skin_female and json.encode(grade.skin_female) or '{}' } - } + if not existingGrades[grade.grade] then + queries[#queries + 1] = { + query = 'INSERT INTO job_grades (job_name, grade, name, label, salary, skin_male, skin_female) VALUES (?, ?, ?, ?, ?, ?, ?)', + values = { name, grade.grade, grade.name, grade.label, grade.salary, grade.skin_male and json.encode(grade.skin_male) or '{}', grade.skin_female and json.encode(grade.skin_female) or '{}' } + } + end + end + + if not queries[1] then + notify("ERROR",currentResourceName, 'Job or grades already exists: `%s`', name) + return success end success = exports.oxmysql:transaction_async(queries) From a5623a203974032bcadbb2d73dd9492fd5e0f8d5 Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:50:53 +0200 Subject: [PATCH 49/57] fix(es_extended/server/modules/createJob): cache only the grades that were inserted A grade that already exists in the database is skipped for the insert, but it was still written to ESX.Jobs. A second CreateJob call therefore replaced the stored name, label and salary in memory while the row itself kept the old values, and the two only agreed again after a restart. CreateJob now caches just the grades it inserted. Label and type of an existing job come from the database for the same reason. The grade lookup compares strings, so passing grade "1" for an existing grade 1 no longer inserts a second row. --- [core]/es_extended/server/modules/createJob.lua | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/[core]/es_extended/server/modules/createJob.lua b/[core]/es_extended/server/modules/createJob.lua index 48f55540..f27172e5 100644 --- a/[core]/es_extended/server/modules/createJob.lua +++ b/[core]/es_extended/server/modules/createJob.lua @@ -52,14 +52,17 @@ function ESX.CreateJob(name, label, grades, jobType) jobType = "civ" end - local jobExists = MySQL.scalar.await('SELECT 1 FROM `jobs` WHERE `name` = ?', { name }) ~= nil + local existingJob = MySQL.single.await('SELECT `label`, `type` FROM `jobs` WHERE `name` = ?', { name }) + local jobExists = existingJob ~= nil local existingGrades = {} if jobExists then + label, jobType = existingJob.label, existingJob.type + local rows = MySQL.query.await('SELECT `grade` FROM `job_grades` WHERE `job_name` = ?', { name }) for i = 1, #(rows or {}) do - existingGrades[rows[i].grade] = true + existingGrades[tostring(rows[i].grade)] = true end end @@ -72,8 +75,11 @@ function ESX.CreateJob(name, label, grades, jobType) } end + local newGrades = {} + for _, grade in pairs(grades) do - if not existingGrades[grade.grade] then + if not existingGrades[tostring(grade.grade)] then + newGrades[#newGrades + 1] = grade queries[#queries + 1] = { query = 'INSERT INTO job_grades (job_name, grade, name, label, salary, skin_male, skin_female) VALUES (?, ?, ?, ?, ?, ?, ?)', values = { name, grade.grade, grade.name, grade.label, grade.salary, grade.skin_male and json.encode(grade.skin_male) or '{}', grade.skin_female and json.encode(grade.skin_female) or '{}' } @@ -93,7 +99,7 @@ function ESX.CreateJob(name, label, grades, jobType) return success end - ESX.Jobs[name] = generateNewJobTable(name, label, grades, jobType) + ESX.Jobs[name] = generateNewJobTable(name, label, newGrades, jobType) notify("SUCCESS", currentResourceName, 'Job created successfully: `%s`', name) From 06ac4293538b4155348584bfc651440ddb0c9b3d Mon Sep 17 00:00:00 2001 From: Selt <83926739+seltonmt012@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:55:35 +0200 Subject: [PATCH 50/57] fix(es_extended/server/modules/createJob): store the encoded skin in ESX.Jobs CreateJob wrote the caller's skin table straight into ESX.Jobs while the row got the encoded string. setJob then ran json.decode over a table and died with "bad argument #1 to 'json.decode' (string expected, got table)". A restart appeared to repair the job, because the grade came back from the database as a string. Cache the same value that goes into the row. --- [core]/es_extended/server/modules/createJob.lua | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/[core]/es_extended/server/modules/createJob.lua b/[core]/es_extended/server/modules/createJob.lua index f27172e5..b16ade82 100644 --- a/[core]/es_extended/server/modules/createJob.lua +++ b/[core]/es_extended/server/modules/createJob.lua @@ -8,7 +8,7 @@ local NOTIFY_TYPES = { local function generateNewJobTable(name, label, grades, jobType) local job = ESX.Jobs[name] or { name = name, label = label, type = jobType, grades = {} } for _, v in pairs(grades) do - job.grades[tostring(v.grade)] = { job_name = name, grade = v.grade, name = v.name, label = v.label, salary = v.salary, skin_male = v.skin_male or '{}', skin_female = v.skin_female or '{}' } + job.grades[tostring(v.grade)] = { job_name = name, grade = v.grade, name = v.name, label = v.label, salary = v.salary, skin_male = v.skin_male, skin_female = v.skin_female } end return job @@ -79,10 +79,13 @@ function ESX.CreateJob(name, label, grades, jobType) for _, grade in pairs(grades) do if not existingGrades[tostring(grade.grade)] then - newGrades[#newGrades + 1] = grade + local skinMale = grade.skin_male and json.encode(grade.skin_male) or '{}' + local skinFemale = grade.skin_female and json.encode(grade.skin_female) or '{}' + + newGrades[#newGrades + 1] = { grade = grade.grade, name = grade.name, label = grade.label, salary = grade.salary, skin_male = skinMale, skin_female = skinFemale } queries[#queries + 1] = { query = 'INSERT INTO job_grades (job_name, grade, name, label, salary, skin_male, skin_female) VALUES (?, ?, ?, ?, ?, ?, ?)', - values = { name, grade.grade, grade.name, grade.label, grade.salary, grade.skin_male and json.encode(grade.skin_male) or '{}', grade.skin_female and json.encode(grade.skin_female) or '{}' } + values = { name, grade.grade, grade.name, grade.label, grade.salary, skinMale, skinFemale } } end end From d0bcb3674b3ffc649fa087fb1db23142d0e1354d Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:17:52 +0530 Subject: [PATCH 51/57] refactor(oxinventory): improve error handling and streamline inventory functions --- .../server/classes/overrides/oxinventory.lua | 319 +++++++++++++----- 1 file changed, 228 insertions(+), 91 deletions(-) diff --git a/[core]/es_extended/server/classes/overrides/oxinventory.lua b/[core]/es_extended/server/classes/overrides/oxinventory.lua index c3be26a8..cdfeaff3 100644 --- a/[core]/es_extended/server/classes/overrides/oxinventory.lua +++ b/[core]/es_extended/server/classes/overrides/oxinventory.lua @@ -1,11 +1,85 @@ -local Inventory +local OxInventory -if Config.CustomInventory ~= "ox" then return end +if Config.CustomInventory ~= "ox" then + return +end +---Stores the reference to the internal ox_inventory module. +---@param module table +---@return boolean +local function setOxInventory(module) + if type(module) ~= "table" then + return false + end + + OxInventory = module + return true +end + +---Returns the internal ox_inventory module. +---If es_extended missed the load event, it will attempt to retrieve it via the export. +---@return table +local function getOxInventory() + if OxInventory then + return OxInventory + end + + local state = GetResourceState("ox_inventory") + + if state ~= "started" then + error( + ("[es_extended] ox_inventory not started; current status: %s") + :format(tostring(state)), + 2 + ) + end + + local success, module = pcall(function() + return exports.ox_inventory:Inventory() + end) + + if not success then + error( + ("[es_extended] Failed to execute exports.ox_inventory:Inventory(): %s") + :format(tostring(module)), + 2 + ) + end + + if not setOxInventory(module) then + error( + "[es_extended] The Inventory export from ox_inventory returned an invalid module.", + 2 + ) + end + + return OxInventory +end + +-- Standard method used when ox_inventory finishes loading.. AddEventHandler("ox_inventory:loadInventory", function(module) - Inventory = module + if not setOxInventory(module) then + print("^1[es_extended] ox_inventory:loadInventory returned an invalid module.^7") + end end) +-- Standard method used when ox_inventory is stopped. +AddEventHandler("onResourceStop", function(resourceName) + if resourceName == "ox_inventory" then + OxInventory = nil + end +end) + +local function emptyMethod() + return function() end +end + +local function falseMethod() + return function() + return false + end +end + Core.PlayerFunctionOverrides.OxInventory = { getInventory = function(self) return function(minimal) @@ -13,26 +87,26 @@ Core.PlayerFunctionOverrides.OxInventory = { return self.inventory end - local minimalInventory = {} + local result = {} - for k, v in pairs(self.inventory) do - if v.count and v.count > 0 then - local metadata = v.metadata + for slot, item in pairs(self.inventory) do + if item.count and item.count > 0 then + local metadata = item.metadata - if v.metadata and next(v.metadata) == nil then + if type(metadata) == "table" and next(metadata) == nil then metadata = nil end - minimalInventory[#minimalInventory + 1] = { - name = v.name, - count = v.count, - slot = k, + result[#result + 1] = { + name = item.name, + count = item.count, + slot = slot, metadata = metadata, } end end - return minimalInventory + return result end end, @@ -45,18 +119,33 @@ Core.PlayerFunctionOverrides.OxInventory = { setAccountMoney = function(self) return function(accountName, money, reason) reason = reason or "unknown" - if money < 0 then return end + + if money < 0 then + return + end + local account = self.getAccount(accountName) - if not account then return end + if not account then + return + end money = account.round and ESX.Math.Round(money) or money self.accounts[account.index].money = money self.triggerEvent("esx:setAccountMoney", account) - TriggerEvent("esx:setAccountMoney", self.source, accountName, money, reason) - if Inventory.accounts[accountName] then - Inventory.SetItem(self.source, accountName, money) + TriggerEvent( + "esx:setAccountMoney", + self.source, + accountName, + money, + reason + ) + + local inventory = getOxInventory() + + if inventory.accounts[accountName] then + inventory.SetItem(self.source, accountName, money) end end end, @@ -64,17 +153,34 @@ Core.PlayerFunctionOverrides.OxInventory = { addAccountMoney = function(self) return function(accountName, money, reason) reason = reason or "unknown" - if money < 1 then return end + + if money < 1 then + return + end local account = self.getAccount(accountName) - if not account then return end + + if not account then + return + end money = account.round and ESX.Math.Round(money) or money - self.accounts[account.index].money = self.accounts[account.index].money + money + self.accounts[account.index].money = + self.accounts[account.index].money + money + self.triggerEvent("esx:setAccountMoney", account) - TriggerEvent("esx:addAccountMoney", self.source, accountName, money, reason) - if Inventory.accounts[accountName] then - Inventory.AddItem(self.source, accountName, money) + TriggerEvent( + "esx:addAccountMoney", + self.source, + accountName, + money, + reason + ) + + local inventory = getOxInventory() + + if inventory.accounts[accountName] then + inventory.AddItem(self.source, accountName, money) end end end, @@ -82,54 +188,103 @@ Core.PlayerFunctionOverrides.OxInventory = { removeAccountMoney = function(self) return function(accountName, money, reason) reason = reason or "unknown" - if money < 1 then return end + + if money < 1 then + return + end local account = self.getAccount(accountName) - if not account then return end + + if not account then + return + end money = account.round and ESX.Math.Round(money) or money - self.accounts[account.index].money = self.accounts[account.index].money - money + self.accounts[account.index].money = + self.accounts[account.index].money - money + self.triggerEvent("esx:setAccountMoney", account) - TriggerEvent("esx:removeAccountMoney", self.source, accountName, money, reason) - if Inventory.accounts[accountName] then - Inventory.RemoveItem(self.source, accountName, money) + TriggerEvent( + "esx:removeAccountMoney", + self.source, + accountName, + money, + reason + ) + + local inventory = getOxInventory() + + if inventory.accounts[accountName] then + inventory.RemoveItem(self.source, accountName, money) end end end, getInventoryItem = function(self) return function(name, metadata) - return Inventory.GetItem(self.source, name, metadata) + return getOxInventory().GetItem( + self.source, + name, + metadata + ) end end, addInventoryItem = function(self) return function(name, count, metadata, slot) - return Inventory.AddItem(self.source, name, count or 1, metadata, slot) + return getOxInventory().AddItem( + self.source, + name, + count or 1, + metadata, + slot + ) end end, removeInventoryItem = function(self) return function(name, count, metadata, slot) - return Inventory.RemoveItem(self.source, name, count or 1, metadata, slot) + return getOxInventory().RemoveItem( + self.source, + name, + count or 1, + metadata, + slot + ) end end, setInventoryItem = function(self) return function(name, count, metadata) - return Inventory.SetItem(self.source, name, count, metadata) + return getOxInventory().SetItem( + self.source, + name, + count, + metadata + ) end end, canCarryItem = function(self) return function(name, count, metadata) - return Inventory.CanCarryItem(self.source, name, count, metadata) + return getOxInventory().CanCarryItem( + self.source, + name, + count, + metadata + ) end end, canSwapItem = function(self) return function(firstItem, firstItemCount, testItem, testItemCount) - return Inventory.CanSwapItem(self.source, firstItem, firstItemCount, testItem, testItemCount) + return getOxInventory().CanSwapItem( + self.source, + firstItem, + firstItemCount, + testItem, + testItemCount + ) end end, @@ -137,83 +292,65 @@ Core.PlayerFunctionOverrides.OxInventory = { return function(newWeight) self.maxWeight = newWeight self.triggerEvent("esx:setMaxWeight", self.maxWeight) - return Inventory.SetMaxWeight(self.source, newWeight) + + return getOxInventory().SetMaxWeight( + self.source, + newWeight + ) end end, - addWeapon = function() - return function() end - end, + addWeapon = emptyMethod, + addWeaponComponent = emptyMethod, + addWeaponAmmo = emptyMethod, + updateWeaponAmmo = emptyMethod, + setWeaponTint = emptyMethod, + getWeaponTint = emptyMethod, + removeWeapon = emptyMethod, + removeWeaponComponent = emptyMethod, + removeWeaponAmmo = emptyMethod, - addWeaponComponent = function() - return function() end - end, - - addWeaponAmmo = function() - return function() end - end, - - updateWeaponAmmo = function() - return function() end - end, - - setWeaponTint = function() - return function() end - end, - - getWeaponTint = function() - return function() end - end, - - removeWeapon = function() - return function() end - end, - - removeWeaponComponent = function() - return function() end - end, - - removeWeaponAmmo = function() - return function() end - end, - - hasWeaponComponent = function() - return function() - return false - end - end, - - hasWeapon = function() - return function() - return false - end - end, + hasWeaponComponent = falseMethod, + hasWeapon = falseMethod, hasItem = function(self) return function(name, metadata) - return Inventory.GetItem(self.source, name, metadata) + return getOxInventory().GetItem( + self.source, + name, + metadata + ) end end, - getWeapon = function() - return function() end - end, + getWeapon = emptyMethod, syncInventory = function(self) return function(weight, maxWeight, items, money) - self.weight, self.maxWeight = weight, maxWeight + self.weight = weight + self.maxWeight = maxWeight self.inventory = items - if not money then return end + if not money then + return + end + for accountName, amount in pairs(money) do local account = self.getAccount(accountName) if account and ESX.Math.Round(account.money) ~= amount then account.money = amount + self.triggerEvent("esx:setAccountMoney", account) - TriggerEvent("esx:setAccountMoney", self.source, accountName, amount, "Sync account with item") + TriggerEvent( + "esx:setAccountMoney", + self.source, + accountName, + amount, + "Sync account with item" + ) end end end end, -} +} \ No newline at end of file From e08eb5079d539d84b39aa2e494954121de3f3095 Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:43:48 +0530 Subject: [PATCH 52/57] refactor(ox_inventory): validate module methods and fix hasItem return contract --- .../server/classes/overrides/oxinventory.lua | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/[core]/es_extended/server/classes/overrides/oxinventory.lua b/[core]/es_extended/server/classes/overrides/oxinventory.lua index cdfeaff3..328eaefd 100644 --- a/[core]/es_extended/server/classes/overrides/oxinventory.lua +++ b/[core]/es_extended/server/classes/overrides/oxinventory.lua @@ -4,12 +4,31 @@ if Config.CustomInventory ~= "ox" then return end +local requiredMethods = { + "GetItem", + "AddItem", + "RemoveItem", + "SetItem", + "CanCarryItem", + "CanSwapItem", + "SetMaxWeight", +} + ---Stores the reference to the internal ox_inventory module. ---@param module table ---@return boolean +---@return string? error local function setOxInventory(module) if type(module) ~= "table" then - return false + return false, "module is not a table" + end + + for i = 1, #requiredMethods do + local method = requiredMethods[i] + + if type(module[method]) ~= "function" then + return false, ("missing method %s"):format(method) + end end OxInventory = module @@ -46,9 +65,11 @@ local function getOxInventory() ) end - if not setOxInventory(module) then + local ok, err = setOxInventory(module) + if not ok then error( - "[es_extended] The Inventory export from ox_inventory returned an invalid module.", + ("[es_extended] The Inventory export from ox_inventory returned an invalid module: %s") + :format(err), 2 ) end @@ -58,8 +79,9 @@ end -- Standard method used when ox_inventory finishes loading.. AddEventHandler("ox_inventory:loadInventory", function(module) - if not setOxInventory(module) then - print("^1[es_extended] ox_inventory:loadInventory returned an invalid module.^7") + local ok, err = setOxInventory(module) + if not ok then + print(("^1[es_extended] ox_inventory:loadInventory returned an invalid module: %s^7"):format(err)) end end) @@ -315,11 +337,17 @@ Core.PlayerFunctionOverrides.OxInventory = { hasItem = function(self) return function(name, metadata) - return getOxInventory().GetItem( + local item = getOxInventory().GetItem( self.source, name, metadata ) + + if not item or not item.count or item.count < 1 then + return false + end + + return item, item.count end end, From e1164b487bbb28f25c89d8ff5620e61f83dc1273 Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:42:35 +0530 Subject: [PATCH 53/57] refactor(ox_inventory): enhance account list parsing and improve proxy method validation --- .../server/classes/overrides/oxinventory.lua | 107 ++++++++++++------ 1 file changed, 72 insertions(+), 35 deletions(-) diff --git a/[core]/es_extended/server/classes/overrides/oxinventory.lua b/[core]/es_extended/server/classes/overrides/oxinventory.lua index 328eaefd..757fb015 100644 --- a/[core]/es_extended/server/classes/overrides/oxinventory.lua +++ b/[core]/es_extended/server/classes/overrides/oxinventory.lua @@ -14,29 +14,76 @@ local requiredMethods = { "SetMaxWeight", } ----Stores the reference to the internal ox_inventory module. ----@param module table ----@return boolean ----@return string? error -local function setOxInventory(module) - if type(module) ~= "table" then - return false, "module is not a table" - end +---Parses the inventory:accounts convar to build a lookup table. +---Handles both JSON array format (["money"]) and plain text. +---@return table +local function getAccountList() + local accounts = {} + local convar = GetConvar("inventory:accounts", "[]") + local ok, list = pcall(json.decode, convar) - for i = 1, #requiredMethods do - local method = requiredMethods[i] - - if type(module[method]) ~= "function" then - return false, ("missing method %s"):format(method) + if ok and type(list) == "table" then + for i = 1, #list do + accounts[list[i]] = true + end + else + -- Fallback for non-JSON formats (e.g. "money" or "money black_money") + for account in convar:gmatch("[%w_]+") do + if account ~= "[]" then + accounts[account] = true + end + end + end + + return accounts +end + +---Creates a proxy table that routes method calls to ox_inventory exports. +---@return table +local function createOxInventoryProxy() + local proxy = {} + local accounts = getAccountList() + + -- Validate that the exports actually exist before wrapping them + for i = 1, #requiredMethods do + local method = requiredMethods[i] + if exports.ox_inventory[method] == nil then + print(("^3[es_extended] WARNING: exports.ox_inventory.%s is missing — ox_inventory integration may be broken^7"):format(method)) + end + + proxy[method] = function(...) + return exports.ox_inventory[method](exports.ox_inventory, ...) + end + end + + if not OxInventory then + print("^2[es_extended] ox_inventory: using direct export proxy (Inventory() module unavailable)^7") + end + + proxy.accounts = accounts + return proxy +end + +---Checks if a module table contains all required methods. +---@param module table +---@return boolean +local function isValidModule(module) + if type(module) ~= "table" then + return false + end + + for i = 1, #requiredMethods do + if type(module[requiredMethods[i]]) ~= "function" then + return false end end - OxInventory = module return true end ----Returns the internal ox_inventory module. ----If es_extended missed the load event, it will attempt to retrieve it via the export. +---Returns the ox_inventory interface. +---Tries the legacy Inventory() export first for backward compatibility, +---then falls back to direct export calls. ---@return table local function getOxInventory() if OxInventory then @@ -57,31 +104,21 @@ local function getOxInventory() return exports.ox_inventory:Inventory() end) - if not success then - error( - ("[es_extended] Failed to execute exports.ox_inventory:Inventory(): %s") - :format(tostring(module)), - 2 - ) - end - - local ok, err = setOxInventory(module) - if not ok then - error( - ("[es_extended] The Inventory export from ox_inventory returned an invalid module: %s") - :format(err), - 2 - ) + if success and isValidModule(module) then + OxInventory = module + else + OxInventory = createOxInventoryProxy() end return OxInventory end --- Standard method used when ox_inventory finishes loading.. +-- Standard method used when ox_inventory finishes loading. AddEventHandler("ox_inventory:loadInventory", function(module) - local ok, err = setOxInventory(module) - if not ok then - print(("^1[es_extended] ox_inventory:loadInventory returned an invalid module: %s^7"):format(err)) + if isValidModule(module) then + OxInventory = module + else + OxInventory = createOxInventoryProxy() end end) From 5bcdf4a6fb782953bd1fc33e067e7f0cd26028ef Mon Sep 17 00:00:00 2001 From: Ihsan <122683406+rwixy@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:01:30 +0530 Subject: [PATCH 54/57] refactor(ox_inventory): update account list parsing and enhance export validation --- .../server/classes/overrides/oxinventory.lua | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/[core]/es_extended/server/classes/overrides/oxinventory.lua b/[core]/es_extended/server/classes/overrides/oxinventory.lua index 757fb015..38df0dcb 100644 --- a/[core]/es_extended/server/classes/overrides/oxinventory.lua +++ b/[core]/es_extended/server/classes/overrides/oxinventory.lua @@ -15,11 +15,11 @@ local requiredMethods = { } ---Parses the inventory:accounts convar to build a lookup table. ----Handles both JSON array format (["money"]) and plain text. +---Uses the same default as ox_inventory: '["money"]' ---@return table local function getAccountList() local accounts = {} - local convar = GetConvar("inventory:accounts", "[]") + local convar = GetConvar("inventory:accounts", '["money"]') local ok, list = pcall(json.decode, convar) if ok and type(list) == "table" then @@ -44,22 +44,23 @@ local function createOxInventoryProxy() local proxy = {} local accounts = getAccountList() - -- Validate that the exports actually exist before wrapping them + print("^3[es_extended] ox_inventory: using direct export proxy (Inventory() module unavailable)^7") + for i = 1, #requiredMethods do local method = requiredMethods[i] - if exports.ox_inventory[method] == nil then - print(("^3[es_extended] WARNING: exports.ox_inventory.%s is missing — ox_inventory integration may be broken^7"):format(method)) + local ok, err = pcall(function() + exports.ox_inventory[method](exports.ox_inventory, 0, "test", 0) + end) + + if not ok and tostring(err):find("No such export") then + print(("^1[es_extended] CRITICAL: exports.ox_inventory.%s is missing^7"):format(method)) end - + proxy[method] = function(...) return exports.ox_inventory[method](exports.ox_inventory, ...) end end - if not OxInventory then - print("^2[es_extended] ox_inventory: using direct export proxy (Inventory() module unavailable)^7") - end - proxy.accounts = accounts return proxy end From a1e9d144d2e3e1f3b5d50e3b8a0592060cce84df Mon Sep 17 00:00:00 2001 From: _Not_ <160342111+N0tNvll@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:01:11 -0500 Subject: [PATCH 55/57] change: rename folder from 1.14.2 to 1.14.1 --- .../migration/{v1.14.2 => v1.14.1}/billingTargetLength/main.lua | 0 .../{v1.14.2 => v1.14.1}/multicharDeleteIndexes/main.lua | 0 .../{v1.14.2 => v1.14.1}/rentedVehiclesOwnerLength/main.lua | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename [core]/es_extended/server/migration/{v1.14.2 => v1.14.1}/billingTargetLength/main.lua (100%) rename [core]/es_extended/server/migration/{v1.14.2 => v1.14.1}/multicharDeleteIndexes/main.lua (100%) rename [core]/es_extended/server/migration/{v1.14.2 => v1.14.1}/rentedVehiclesOwnerLength/main.lua (100%) diff --git a/[core]/es_extended/server/migration/v1.14.2/billingTargetLength/main.lua b/[core]/es_extended/server/migration/v1.14.1/billingTargetLength/main.lua similarity index 100% rename from [core]/es_extended/server/migration/v1.14.2/billingTargetLength/main.lua rename to [core]/es_extended/server/migration/v1.14.1/billingTargetLength/main.lua diff --git a/[core]/es_extended/server/migration/v1.14.2/multicharDeleteIndexes/main.lua b/[core]/es_extended/server/migration/v1.14.1/multicharDeleteIndexes/main.lua similarity index 100% rename from [core]/es_extended/server/migration/v1.14.2/multicharDeleteIndexes/main.lua rename to [core]/es_extended/server/migration/v1.14.1/multicharDeleteIndexes/main.lua diff --git a/[core]/es_extended/server/migration/v1.14.2/rentedVehiclesOwnerLength/main.lua b/[core]/es_extended/server/migration/v1.14.1/rentedVehiclesOwnerLength/main.lua similarity index 100% rename from [core]/es_extended/server/migration/v1.14.2/rentedVehiclesOwnerLength/main.lua rename to [core]/es_extended/server/migration/v1.14.1/rentedVehiclesOwnerLength/main.lua From b7efa23ebba19539f850c47940be6181a7d17b7a Mon Sep 17 00:00:00 2001 From: _Not_ <160342111+N0tNvll@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:37:43 -0500 Subject: [PATCH 56/57] Bump version from 1.14.0 to 1.14.1 --- [core]/es_extended/fxmanifest.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/[core]/es_extended/fxmanifest.lua b/[core]/es_extended/fxmanifest.lua index f7f9789e..5ad53897 100644 --- a/[core]/es_extended/fxmanifest.lua +++ b/[core]/es_extended/fxmanifest.lua @@ -3,7 +3,7 @@ fx_version 'cerulean' game 'gta5' description 'The Core resource that provides the functionalities for all other resources.' lua54 'yes' -version '1.14.0' +version '1.14.1' shared_scripts { '@esx_lib/imports.lua', From 8888c6ce1422a0979ebf642a9fa405a31290db30 Mon Sep 17 00:00:00 2001 From: _Not_ Date: Sat, 15 Aug 2026 14:40:34 -0500 Subject: [PATCH 57/57] chore: update version to 1.14.1 across all fxmanifest files --- [core]/esx_chat_theme/fxmanifest.lua | 2 +- [core]/esx_context/fxmanifest.lua | 2 +- [core]/esx_identity/fxmanifest.lua | 2 +- [core]/esx_inventory/fxmanifest.lua | 2 +- [core]/esx_loadingscreen/fxmanifest.lua | 2 +- [core]/esx_menu_default/fxmanifest.lua | 2 +- [core]/esx_menu_dialog/fxmanifest.lua | 2 +- [core]/esx_menu_list/fxmanifest.lua | 2 +- [core]/esx_multicharacter/fxmanifest.lua | 2 +- [core]/esx_notify/fxmanifest.lua | 2 +- [core]/esx_progressbar/fxmanifest.lua | 2 +- [core]/esx_skin/fxmanifest.lua | 2 +- [core]/esx_textui/fxmanifest.lua | 2 +- [core]/skinchanger/fxmanifest.lua | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/[core]/esx_chat_theme/fxmanifest.lua b/[core]/esx_chat_theme/fxmanifest.lua index 2b5d6a7c..d0d95b3f 100644 --- a/[core]/esx_chat_theme/fxmanifest.lua +++ b/[core]/esx_chat_theme/fxmanifest.lua @@ -1,4 +1,4 @@ -version '1.14.0' +version '1.14.1' author 'ESX-Framework' description 'A ESX Stylised theme for the chat resource.' diff --git a/[core]/esx_context/fxmanifest.lua b/[core]/esx_context/fxmanifest.lua index 9f0eab07..0f11904a 100644 --- a/[core]/esx_context/fxmanifest.lua +++ b/[core]/esx_context/fxmanifest.lua @@ -4,7 +4,7 @@ game 'gta5' author 'ESX-Framework & Brayden' description 'A simplistic context menu for ESX.' lua54 'yes' -version '1.14.0' +version '1.14.1' ui_page 'index.html' diff --git a/[core]/esx_identity/fxmanifest.lua b/[core]/esx_identity/fxmanifest.lua index 8b18becc..7964d7d3 100644 --- a/[core]/esx_identity/fxmanifest.lua +++ b/[core]/esx_identity/fxmanifest.lua @@ -3,7 +3,7 @@ fx_version 'adamant' game 'gta5' description 'Allows the player to Pick their characters: Name, Gender, Height and Date-of-birth.' lua54 'yes' -version '1.14.0' +version '1.14.1' shared_scripts { '@es_extended/imports.lua', diff --git a/[core]/esx_inventory/fxmanifest.lua b/[core]/esx_inventory/fxmanifest.lua index 2ddda016..11c5a107 100644 --- a/[core]/esx_inventory/fxmanifest.lua +++ b/[core]/esx_inventory/fxmanifest.lua @@ -4,7 +4,7 @@ game "gta5" description "Inventory for the ESX framework" lua54 "yes" use_fxv2_oal "yes" -version '1.14.0' +version '1.14.1' shared_scripts { "/config/main.lua", diff --git a/[core]/esx_loadingscreen/fxmanifest.lua b/[core]/esx_loadingscreen/fxmanifest.lua index 5a4e9825..ecc665be 100644 --- a/[core]/esx_loadingscreen/fxmanifest.lua +++ b/[core]/esx_loadingscreen/fxmanifest.lua @@ -3,7 +3,7 @@ game 'common' fx_version 'cerulean' author 'ESX-Framework' description 'Allows resources to Run tasks at specific intervals.' -version '1.14.0' +version '1.14.1' lua54 'yes' loadscreen 'index.html' diff --git a/[core]/esx_menu_default/fxmanifest.lua b/[core]/esx_menu_default/fxmanifest.lua index 237a11f5..0da5b6ab 100644 --- a/[core]/esx_menu_default/fxmanifest.lua +++ b/[core]/esx_menu_default/fxmanifest.lua @@ -3,7 +3,7 @@ fx_version 'cerulean' game 'gta5' description 'A basic menu system for ESX Legacy.' lua54 'yes' -version '1.14.0' +version '1.14.1' client_scripts { '@es_extended/imports.lua', 'client/main.lua' } diff --git a/[core]/esx_menu_dialog/fxmanifest.lua b/[core]/esx_menu_dialog/fxmanifest.lua index 85d984b4..a4a82690 100644 --- a/[core]/esx_menu_dialog/fxmanifest.lua +++ b/[core]/esx_menu_dialog/fxmanifest.lua @@ -3,7 +3,7 @@ fx_version 'adamant' game 'gta5' description 'A basic input dialog for ESX Legacy.' lua54 'yes' -version '1.14.0' +version '1.14.1' client_scripts { '@es_extended/imports.lua', diff --git a/[core]/esx_menu_list/fxmanifest.lua b/[core]/esx_menu_list/fxmanifest.lua index e1dd57da..7bd0a482 100644 --- a/[core]/esx_menu_list/fxmanifest.lua +++ b/[core]/esx_menu_list/fxmanifest.lua @@ -3,7 +3,7 @@ fx_version 'adamant' game 'gta5' description 'A basic table-based menu system for ESX Legacy.' lua54 'yes' -version '1.14.0' +version '1.14.1' client_scripts { diff --git a/[core]/esx_multicharacter/fxmanifest.lua b/[core]/esx_multicharacter/fxmanifest.lua index 5e30bd03..cbb32c2b 100644 --- a/[core]/esx_multicharacter/fxmanifest.lua +++ b/[core]/esx_multicharacter/fxmanifest.lua @@ -2,7 +2,7 @@ fx_version 'cerulean' game 'gta5' author 'ESX-Framework - Linden - KASH' description 'Allows players to have multiple characters on the same account.' -version '1.14.0' +version '1.14.1' lua54 'yes' dependencies { 'es_extended', 'esx_context', 'esx_identity', 'esx_skin' } diff --git a/[core]/esx_notify/fxmanifest.lua b/[core]/esx_notify/fxmanifest.lua index 15d93406..d6ee2d63 100644 --- a/[core]/esx_notify/fxmanifest.lua +++ b/[core]/esx_notify/fxmanifest.lua @@ -2,7 +2,7 @@ fx_version 'adamant' lua54 'yes' game 'gta5' -version '1.14.0' +version '1.14.1' author 'ESX-Framework' description 'A beautiful and simple NUI notification system for ESX' diff --git a/[core]/esx_progressbar/fxmanifest.lua b/[core]/esx_progressbar/fxmanifest.lua index b0397dfd..4dcebddb 100644 --- a/[core]/esx_progressbar/fxmanifest.lua +++ b/[core]/esx_progressbar/fxmanifest.lua @@ -3,7 +3,7 @@ fx_version 'adamant' game 'gta5' author 'ESX-Framework' description 'A beautiful and simple NUI progress bar for ESX' -version '1.14.0' +version '1.14.1' lua54 'yes' client_scripts { 'Progress.lua' } diff --git a/[core]/esx_skin/fxmanifest.lua b/[core]/esx_skin/fxmanifest.lua index f06f34c4..16c44ad5 100644 --- a/[core]/esx_skin/fxmanifest.lua +++ b/[core]/esx_skin/fxmanifest.lua @@ -2,7 +2,7 @@ fx_version 'adamant' game 'gta5' description 'Allows players to customise their character\'s appearance' -version '1.14.0' +version '1.14.1' lua54 'yes' shared_scripts { diff --git a/[core]/esx_textui/fxmanifest.lua b/[core]/esx_textui/fxmanifest.lua index e6e2a93c..6ff9dd51 100644 --- a/[core]/esx_textui/fxmanifest.lua +++ b/[core]/esx_textui/fxmanifest.lua @@ -3,7 +3,7 @@ fx_version 'adamant' game 'gta5' author 'ESX-Framework' description 'A beautiful and simple Persistent Notification system for ESX.' -version '1.14.0' +version '1.14.1' lua54 'yes' client_scripts { 'TextUI.lua' } diff --git a/[core]/skinchanger/fxmanifest.lua b/[core]/skinchanger/fxmanifest.lua index b27502e9..0954c9b9 100644 --- a/[core]/skinchanger/fxmanifest.lua +++ b/[core]/skinchanger/fxmanifest.lua @@ -2,7 +2,7 @@ fx_version 'adamant' game 'gta5' description 'Saves/loads character appearances for ESX Legacy.' -version '1.14.0' +version '1.14.1' lua54 'yes' client_scripts {