Documentation · Développeurs

Architecture

Ressources, arborescence du socle, chargement, cycle de démarrage, règles de performance et de sécurité.

SommaireArchitecture

À qui s'adresse cette section

Cette section est pour le développeur de ton serveur : la personne qui écrit du Lua, ajoute un module ou branche une ressource sur Socle. Si tu cherches à régler le serveur sans toucher au code, tout se fait depuis le panel : commence plutôt par Réglages en direct.

Les pages développeurs :

Les ressources du serveur

La liste exacte et l'ordre de démarrage sont dans server_resources.cfg. Les ressources principales :

RessourceRôleTu peux la modifier ?
oxmysqlAccès à la base de données (MariaDB / MySQL)Non, livrée telle quelle
ox_libBibliothèque utilitaire. Retouchée : ses écrans (saisie, confirmation, skillcheck, menus, texte d'aide) sont affichés par l'interface SocleNon
ox_inventoryInventaire. Retouché : couleur du serveur, habillages (hud.inventorySkin), points d'interaction des boutiquesNon
ox_targetCiblage (œil). Retouché : suit la couleur du serveurNon
es_extendedFramework ESX. ESX.ShowNotification est redirigée vers les notifications SocleNon
socleLe cœur : variables du panel, interface NUI unique, permissions, logs, licence, et la plupart des modules de jeuOui, dans tes propres dossiers de module
coreContenu historique : métiers (police, EMS, mécano…), activités, monde. Utilise les menus RageUI fournis par socleAvec prudence
ltd, radio, banking, cardealer, order-system, interactions, territory…Ressources de contenu livrées avec la baseAvec prudence

Les retouches faites dans ox_lib, ox_inventory et ox_target sont marquées [Socle] dans leur code : elles ne servent qu'à s'afficher dans le style du serveur. Ne les modifie pas toi-même : une mise à jour de Socle les remplace, et ces bibliothèques sont sous licence libre copyleft : on n'y ajoute pas de code de serveur. Ton code se branche sur elles par leurs exports, depuis ton module.

socle démarre après oxmysql, ox_lib, es_extended et ox_inventory (ses dependencies), et avant core, qui charge @socle/bridges/rageui/client/RageUI.lua.

L'arborescence de socle

resources/socle/
  fxmanifest.lua
  init.lua              ← démarre Core quand ESX est prêt
  core/                 ← l'objet Core, la console, les exports de base
    main.lua            ← Core, registerClass, resolveClass, onReady
    api.lua             ← exports getCoreObject, getVariable, getAccent…
    console.lua         ← Console:write(niveau, catégorie, message)
    client/  server/    ← Core.nui, Core.Player, Core.clamp, Core.isFinite…
  handlers/             ← Callback, Event (signés), Hook, Keybind
  system/               ← Variable (réglages du panel), Command, Menu, Focus,
                          Blip, entités
  modules/<nom>/        ← un dossier par fonctionnalité de jeu
    shared/  server/  client/
  bridges/              ← pont avec le panel (HTTP local), RageUI
  addons/               ← carburant, kevlar, écran de chargement
  utils/                ← Utils.Math, Utils.Table, Utils.Zone…
  configs/              ← Config.lang, Config.debug, réglages fixes historiques
  data/                 ← JSON livrés (emotes, blips, tatouages…)
  i18n/                 ← Translate.lua + langs/fr.json, en.json, de.json, es.json
  ui/                   ← l'interface NUI unique (React + Vite) ; dist/ = build
  migrations/           ← création et évolution des tables SQL
  assets/               ← images, sons, logos servis en nui://socle/assets/…

Ton code va dans `modules/<ton_module>/` : ce dossier n'existe pas dans les paquets de mise à jour, il n'est donc jamais touché (voir Mises à jour).

L'ordre de chargement

Le manifeste charge les fichiers par motifs (globs). Côté serveur :

server_scripts {
    'core/api.lua',
    '@oxmysql/lib/MySQL.lua',
    'core/server/**',
    'utils/**/server/**',
    'esx/server.lua',
    'handlers/**/server/**',
    -- le catalogue des descriptions AVANT les variables
    'system/variables/server/Descriptions.lua',
    'system/**/server/**',
    'modules/**/server/**',
    'addons/**/server/**',
    'bridges/**/server/**',
    'migrations/*.lua',      -- en dernier
}

Côté client, même logique : core/client/**, utils, esx/client.lua, handlers, system, bridges, puis modules/**/client/** et addons. Les fichiers partagés (shared_scripts) passent avant tout le reste : core/console.lua, core/main.lua, i18n/Translate.lua, configs/**, puis les dossiers shared ; init.lua est chargé en tout dernier.

Ce que ça implique pour toi :

  • n'importe quel fichier de `modules/<nom>/server/` ou `client/` est chargé automatiquement, sans toucher au manifeste ;
  • quand ton fichier s'exécute, Core, Core.Event, Core.Callback, Core.Hook, Core.Variable, Core.Command existent déjà ;
  • ne compte pas sur l'ordre entre deux fichiers d'un même motif. Si tu as besoin d'une valeur, lis-la au moment où tu t'en sers, pas au chargement du fichier ;
  • les migrations s'exécutent après les modules : une table créée par une migration n'existe pas encore au chargement de ton module (attends Core.migrationsDone, voir plus bas).

Core, l'objet global

Core est une table globale partagée par tous les fichiers de la ressource socle.

MembreCôtéRôle
Core.serverles deuxtrue côté serveur
Core.resourceNameles deux"socle"
Core.readyles deuxtrue une fois le démarrage terminé
Core.onReady(fn)les deuxappelle fn dès que Core est prêt (tout de suite s'il l'est déjà)
Core.registerClass(nom, classe)les deuxenregistre une classe ; appelle classe.onInit() tout de suite, classe.onStop() à l'arrêt de la ressource
Core.resolveClass(nom)les deuxattend qu'une classe soit enregistrée et renvoie Core[nom]
Core.t(clé, vars)les deuxtexte traduit (voir Traductions)
Core.log(niveau, tag, message, ...)serveurécrit dans la console (string.format si arguments)
Core.isFinite(v), Core.clamp(v, min, max), Core.round(v, déc)serveurvalidation des nombres reçus d'un client
Core.jsonDecode(texte, défaut), Core.jsonEncode(v)serveurJSON sans erreur fatale
Core.getPlayer(src), Core.getIdentifier(src), Core.getPlayerName(src)serveurjoueur ESX, identifiant, nom du personnage
Core.ESXserveurl'objet ESX partagé
Core.nui(action, data), Core.rnui(nom, fn)clientmessages vers l'interface, retours de l'interface
Core.Player.ped, .coords, .vehicle, .serverIdclientinfos du joueur local (ped rafraîchi chaque seconde)

Un module qui expose des fonctions à ses autres fichiers les range dans une table de Core :

-- modules/monmodule/server/Etat.lua
Core.MonModule = Core.MonModule or {}

function Core.MonModule.estOccupe(src)
    return false
end

-- facultatif : onInit / onStop
Core.registerClass("MonModule", {
    onStop = function()
        -- nettoyage à l'arrêt de la ressource
    end,
})

Et un autre fichier qui en dépend attend qu'elle existe :

-- bloque (avec Wait) jusqu'à l'enregistrement de la classe, puis renvoie Core.Hook
local Hook = Core.resolveClass("Hook")

Depuis une autre ressource, passe par les exports (exports.socle:getVariable(...), exports.socle:Notify(...)…). exports.socle:getCoreObject() (alias getSharedObject) renvoie bien Core, mais chaque appel de fonction à travers un export coûte cher : réserve-le aux appels ponctuels.

Le cycle de démarrage

Côté serveur

  1. migrations/schema.lua crée les tables manquantes et ajoute les colonnes manquantes, puis met Core.migrationsDone = true. Les autres fichiers de migrations/ attendent ce drapeau pour créer leurs propres tables.
  2. Les variables du panel sont chargées depuis la table socle_variables (les valeurs par défaut du code servent tant qu'aucune valeur n'est enregistrée).
  3. init.lua attend qu'ESX soit disponible, puis appelle Core.Ready(). Côté serveur, Core.Ready attend en plus la fin des migrations (60 s au plus, puis démarrage forcé avec une erreur en console).
  4. Les fonctions passées à Core.onReady s'exécutent, Core.ready passe à true.
  5. Cinq secondes après les migrations, le schéma des variables est publié en base pour que le panel génère ses formulaires.

Si des tables ou des colonnes ont été créées, la console affiche un grand avertissement « SERVER RESTART STRONGLY RECOMMENDED » : redémarre une fois, proprement.

Côté client

  1. init.lua attend ESX, puis Core.Ready().
  2. Le client demande ses variables au serveur (core:variables:server:request) ; seules les valeurs non privées lui sont envoyées.
  3. L'interface NUI se charge ; Core.nui attend son accusé (ui:load) avant d'envoyer quoi que ce soit.

Les convars lues par socle

ConvarOùRôle
setr network_token <entier>server.cfgSignature des events client vers serveur (voir plus bas). setr : le client doit la lire
set panel_token "…"server.cfgProtège le pont HTTP entre le panel et le jeu. set, jamais setr
set socle_license_key "SOCLE-…"server.cfgClé de licence Socle (voir Licence)
maintenance_backup_keepserver.cfgNombre de sauvegardes bak_ gardées par table lors d'une maintenance (5 par défaut)
panel_preview_secondsserver.cfgDurée de l'aperçu des positions en jeu (60 s par défaut)

configs/_main.lua porte encore Config.lang (langue des textes : fr, en, de ou es) et Config.debug (affiche les messages debug de la console).

Les règles de performance

Objectif : chaque ressource sous 0,02 ms au repos dans resmon. Les règles qui suivent ne sont pas du style : ce sont elles qui gardent le serveur fluide à 100 joueurs.

Pas de boucle par image permanente

Mauvais : une boucle qui tourne à chaque image pour tout le monde, tout le temps.

-- À NE PAS FAIRE : 0,1 ms en permanence, même loin du point
CreateThread(function()
    while true do
        local dist = #(GetEntityCoords(PlayerPedId()) - vector3(215.0, -810.0, 30.7))
        if dist < 2.0 and IsControlJustPressed(0, 38) then
            ouvrirMenu()
        end
        Wait(0)
    end
end)

Bon : un point d'interaction. Le Socle n'a qu'un seul thread de proximité pour tous les points de la base ; il ne passe par image que quand le joueur est à portée de touche.

-- 0,00 ms loin du point : c'est le thread unique de modules/interact qui gère tout
local pointId = exports.socle:addInteractionPoint({
    coords = vector3(215.0, -810.0, 30.7),
    dist = 2.0,
    key = "E",
    message = Core.t("monmodule.point"),
    onPress = function() ouvrirMenu() end,
})

Quand une boucle par image est vraiment indispensable (une animation qui bloque des contrôles, un marqueur pendant un placement), elle n'existe que pendant l'état et s'arrête seule : c'est ce que fait le portage (modules/carry), dont la boucle ne vit que tant que le joueur est porté.

Attente adaptée à la distance

Si tu dois surveiller quelque chose de loin, adapte l'attente :

CreateThread(function()
    while actif do
        local dist = #(GetEntityCoords(PlayerPedId()) - cible)
        -- loin : on regarde rarement ; près : plus souvent ; jamais Wait(0) ici
        Wait(dist > 100.0 and 2000 or dist > 20.0 and 500 or 100)
    end
end)

Lire les réglages sans les recopier

Dans une boucle chaude côté client, Core.Variable:watch met la valeur en cache et ne la relit qu'après un changement du panel :

local vitesseMax = Core.Variable:watch("monmodule", "vitesseMax", 120)
CreateThread(function()
    while actif do
        if GetEntitySpeed(veh) * 3.6 > vitesseMax() then --[[ ... ]] end
        Wait(250)
    end
end)

Le reste

  • State bags pour un état partagé et persistant d'un joueur ou d'une entité (Player(src).state:set("carry", role, true) côté serveur) plutôt qu'un event diffusé à tous.
  • Events ciblés : Core.Event:send(src, ...) vers le joueur concerné. Core.Event:broadcast est réservé à ce qui concerne vraiment tout le monde.
  • Écritures SQL groupées : regroupe les insertions (les logs du Socle écrivent par lots toutes les 5 s).
  • Une seule interface NUI (voir L'interface NUI) : n'ajoute jamais de ui_page à une nouvelle ressource.

Les règles de sécurité

Le client n'est jamais source de vérité. Tout ce qui touche à l'argent, aux objets, aux positions ou aux droits est décidé par le serveur.

Ne jamais croire un montant venu du client

Mauvais :

-- À NE PAS FAIRE : le client choisit le prix, un tricheur enverra -100000
Core.Event:register("monmodule:server:acheter", function(src, item, prix)
    local xPlayer = Core.getPlayer(src)
    xPlayer.removeAccountMoney("money", prix)
    exports.ox_inventory:AddItem(src, item, 1)
end)

Bon : le client envoie un identifiant, le serveur retrouve le prix dans les réglages, vérifie la distance, puis encaisse.

Core.Callback:register("monmodule:server:acheter", function(src, cb, index)
    index = math.floor(tonumber(index) or 0)
    local catalogue = Core.Variable:getOr("monmodule", "catalogue", {})
    local article = catalogue[index]
    if type(article) ~= "table" then return cb(false, "inconnu") end

    -- distance vérifiée par le serveur, position lue par le serveur
    local ped = GetPlayerPed(src)
    local comptoir = Core.Variable:getOr("monmodule", "comptoir", { 0, 0, 0 })
    local pos = vector3(comptoir[1] + 0.0, comptoir[2] + 0.0, comptoir[3] + 0.0)
    if ped == 0 or #(GetEntityCoords(ped) - pos) > 3.0 then return cb(false, "trop_loin") end

    local prix = math.floor(tonumber(article.prix) or 0)
    local xPlayer = Core.getPlayer(src)
    if prix <= 0 or not xPlayer or xPlayer.getAccount("money").money < prix then
        return cb(false, "pas_assez")
    end
    if not exports.ox_inventory:CanCarryItem(src, article.item, 1) then return cb(false, "plein") end

    xPlayer.removeAccountMoney("money", prix, "Achat " .. article.item)
    exports.ox_inventory:AddItem(src, article.item, 1)
    cb(true)
end)

Les autres règles

  • Tout event serveur valide ses paramètres : type, bornes (Core.clamp), nombre fini (Core.isFinite), existence du joueur visé.
  • Distance vérifiée par le serveur pour toute action dans l'espace (GetEntityCoords(GetPlayerPed(src))).
  • Permission vérifiée par le serveur pour toute action de staff : Core.Permission:can(src, "admin.xxx"). La vérification côté client ne sert qu'à masquer un bouton.
  • Requêtes SQL préparées (?), jamais de concaténation de texte venu du joueur.
  • Transaction dès qu'une opération de valeur a plusieurs étapes (débiter puis livrer doit réussir ou échouer ensemble) : MySQL.transaction.await({ ... }).
  • Valeurs sensibles privées : une adresse de webhook, un mot de passe, une réponse d'examen se déclarent avec private = true et ne partent jamais chez les joueurs (voir Les réglages côté code).

La signature des events

Les events envoyés par Event:send (client) et reçus par Core.Event:register (serveur) portent une signature calculée à partir de network_token, de l'identifiant du joueur et d'un compteur. Un TriggerServerEvent « à la main » (exécuteur de triche, autre ressource) vers un event enregistré ainsi est refusé avec un avertissement Rejected '…' (invalid token).