Skip to content

RedM Emotes Admin Guide

Granting access

There are exactly two routes, checked in this order. Either one alone is enough.

RouteChecked withConfigured inTakes effect
ACE permissionIsPlayerAceAllowed, default object rm_emotes.adminserver.cfgOn the next library push — once the ACE is registered
Identifier listEvery string from GetPlayerIdentifiers compared against RM.Adminsconfig.luaOnly after a resource restart

There are no permission tiers and no per-action permissions. One boolean unlocks everything.

cfg
add_ace group.admin rm_emotes.admin allow

server.cfg is parsed only when the server starts, so saving the file is not enough on a running server. Either restart the whole server, or paste the same line into the live server console (txAdmin's), where it takes effect immediately. A console line is not written back to server.cfg, so add it to the file as well or the grant is lost at the next server restart.

No admin group yet, or granting one person:

cfg
add_ace group.admin rm_emotes.admin allow
add_principal identifier.license:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx group.admin

Per-identifier, without a group:

cfg
add_ace identifier.license:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx rm_emotes.admin allow

steam:, fivem: and discord: principals work the same way. Rename the object if rm_emotes.admin clashes with something in your permissions file:

lua
RM.AdminAce = 'myserver.emoteadmin'

The identifier list route

Useful when you do not manage permissions through server.cfg at all. Any identifier type is matched verbatim, including the prefix:

lua
RM.Admins = {
    'steam:110000112345678',
    'license:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
    'discord:123456789012345678',
}

The list is cached on first use and never rebuilt

Editing it and running refresh is not enough:

cfg
restart rm_emotes

Finding your own identifier

RedM Emotes ships no command that prints identifiers. Practical routes: txAdmin's Players page lists every identifier the server saw, already prefixed; or your framework's user table. Failing that, drop this into any server script of your own, use it once, and remove it:

lua
-- temporary helper — remove it once you have your identifier
RegisterCommand('myidentifiers', function(source)
    if source == 0 then return end
    for _, id in ipairs(GetPlayerIdentifiers(source)) do
        print(('[identifier] %s -> %s'):format(GetPlayerName(source), id))
    end
end, false)

When the grant becomes visible

Admin status is computed server-side every time the library is pushed, and the client only learns about it from that push. Granting yourself admin while already in game does not make the Admin tab appear on its own.

EventTriggers a push?
Player connects, or the client resource startsYes
Character loadYes
Any admin save or deleteYes, to everyone
exports.rm_emotes:Rebuild()Yes, to everyone
Editing server.cfg or config.luaNo

How you apply a fresh grant depends on the route. Identifier list: restart rm_emotes — relogging does nothing, because config.lua is not re-read on reconnect. ACE: register the ACE first (live console, or a full server restart if you only edited server.cfg), then relog, call exports.rm_emotes:Rebuild(), or restart rm_emotes to force the library push that carries the new flag.

What admin unlocks

CapabilityDetail
Admin tabThe in-game editor, rendered only when the server-sent flag is true
Hidden emotesSent to you and visible in every tab, dimmed in the editor list
Restricted emotesSent to you regardless of job or ACE
StarringYou may star hidden and restricted emotes; non-admins are refused server-side
Create, edit, deleteBoth re-checked server-side

There is no admin-only command, no moderation over other players, and no way to force an emote on someone from the editor.

The in-game editor

Menu → Admin tab. The list shows every row the server sent you — display name, type, category, /command slug, and a lock chip when a restriction is set. Hidden rows are drawn dimmed. Search filters on display name, and at most 400 rows render at once.

New emote opens an empty form. Clicking a row opens the same form filled in, with a Delete button.

Every field

LabelColumnRules the server enforces
Display namecustom_nameTrimmed to 255. Empty rejects the whole save
CommandcommandLowercased, non-alphanumerics stripped, trimmed to 40. stop, random, c are blanked. Empty stores NULL and derives the slug from the display name
Anim dictionarydictTrimmed to 255. Required unless the type is walk or scenario
Anim clipnameTrimmed to 255. Empty rejects the whole save
CategorycategoryTrimmed to 55. Any string is accepted, but the menu folds unknown values into Misc
Typeemote_typeAnything outside anim, walk, face, scenario is forced to anim
Durationdurationtonumber or -1. No clamp. 0 or less means hold indefinitely
RestrictionrestrictedTrimmed to 64. Empty stores NULL
Loopis_loopSets animation flag bit 1
Upper bodyupper_bodyORs the flags with 48 — upper body plus keep control
HiddenhiddenSee below

Type and category are independent. Category decides which tab the card appears in; type decides how the emote is played. See Managing the library.

Adding an emote

  1. New emote.
  2. Fill in Display name and Anim clip. Fill in Anim dictionary unless the type is walk.
  3. Pick the category and the type.
  4. Leave Command empty unless you want a specific /e slug.
  5. Tick Loop for anything that should keep playing; Upper body for anything the player should be able to walk during.
  6. Save.

The row is inserted with the next AUTO_INCREMENT id, the server re-reads the whole table, re-assigns /e slugs, and pushes to every connected player.

Editing

Click the row, change what you need, Save. A single UPDATE ... WHERE id = ? followed by a reload and a push. The id never changes, so existing favourites and key binds survive an edit.

Hide, don't delete

Ticking Hidden is the safe way to take an emote out of circulation:

  • Hidden rows are stripped from the library push for non-admins. They are never transmitted to those clients, so there is nothing for a modified client to find.
  • A hidden emote a player already starred stops being served — the same filter runs on the collection push, so the quick slot and its bind disappear at their next collection push (relog, character load, or the next time they star, unstar or rebind). Until then their existing bind still plays it, because playback is client-side.
  • The rm_emote_favs row is not deleted, so unhiding restores it.
  • Starring a hidden emote is refused server-side for non-admins.

Delete has no undo and no confirmation

Delete issues DELETE FROM rm_emotes WHERE id = ?. The cascade removes that emote from every player's collection, including their key bind for it. Prefer Hidden unless you are certain.

How changes reach other players

Every successful save and delete reloads the library and pushes it to everyone — no restart, no relog. Two caveats:

  • Collections are not re-pushed. Library changes are live; a player's starred list refreshes only on their own collection events, character load or reconnect.
  • Direct SQL edits are invisible. Run exports.rm_emotes:Rebuild() or restart. See Database.

Known quirks

QuirkWhat happensWhat to do
Prop and shared rows in the listThey come from shared/extras.lua with synthetic ids from 90000. Saving or deleting affects zero database rows, while the webhook still logs itEdit shared/extras.lua
A scenario row with no dictionaryThe server accepts an empty dict for scenario, but the form only waives it for walk, so Save does nothingPut any text in the dictionary field, or add scenario rows through SQL
The save notice always appears"Emote saved" is shown by the client before the server replies, so a rejected save still shows itIf a row does not appear after saving, re-check Display name and Anim clip
New rows land at high idsAfter amount.sql, AUTO_INCREMENT is past 9132, so your creations start thereHarmless. The packs upsert their own ids and never touch rows above their range

Restricted emotes

The restricted column holds one value, which may be a framework job name or an ACE object name. The check runs in this exact order:

  1. Empty or NULL → everyone may see it.
  2. IsPlayerAceAllowed(src, restricted) → if the player holds that ACE, allowed.
  3. Otherwise, allowed only if the player's job name equals the value exactly.

Admins bypass all of it. Restricted rows are filtered out of the library push, the collection push and the star handler, so an unauthorised player never receives the row at all.

Job names by framework

FrameworkJob read from
VORP CoreThe used character's .job
RSG CorePlayerData.job.name — the name, not the label
RedEM:RPGetJob(), else .job
Standalone, or any other coreNot available — returns nil, so restrictions degrade to ACE only

Comparison is exact and case-sensitive. Sheriff does not match sheriff.

A VIP tier

ACE works identically on all four setups, which makes it the right tool for tiers that are not jobs. Set the emote's Restriction field to rm_emotes.vip, then:

cfg
add_ace group.vip rm_emotes.vip allow
add_principal identifier.license:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx group.vip

Use the same value on every VIP emote. Adding or removing a player from group.vip needs no database change — they see the new set on their next library push.

A sheriff-only emote

FrameworkPut this in the Restriction field
VORP Coresheriff — the VORP job name. Nothing goes in server.cfg
RSG Corevallaw — as it appears in PlayerData.job.name
RedEM:RPsheriff — the job returned by GetJob()
Standalonerm_emotes.lawman, plus an add_ace/add_principal pair

Limits worth planning around

LimitConsequence
One value per rowNo lists. Two jobs sharing an emote need two duplicate rows, or an ACE granted to both groups
ACE is tested firstA job name that collides with an ACE your server grants lets those holders through too. Namespaced values like rm_emotes.vip avoid this
Exact matchCase and spelling must match the framework's job name exactly
Change timingA mid-session job change only applies at the next library push
Already-starred rowsSame as hidden — the quick slot survives until the next collection push
Not a playback gateSee below

Discord webhook

Create a webhook in Discord and paste the URL into config.lua:

lua
RM.Webhook = 'https://discord.com/api/webhooks/000000000000000000/xxxxxxxxxxxxxxxxxxxxxxxx'

An empty string disables logging. Restart the resource after changing it.

Failures are completely silent

It is a plain POST with an empty response callback — rate limits, a wrong URL and network failures all pass unnoticed. If nothing arrives in the channel, test the URL from outside the game before looking anywhere else.

What is logged

Three events, all from the editor:

TriggerEmbed titleDescription
Save on an existing emoteEmote updated**<player>** updated emote #<id> (<name>)
Save on a new emoteEmote created**<player>** created emote "<name>"
DeleteEmote deleted**<player>** deleted emote #<id>

The webhook fires before the database write is checked for effect, so an edit aimed at a prop or shared row is logged even though nothing changed.

What is not logged

Emote usage, favourites, key binds, shared-emote requests, rejected attempts by non-admins, and failed saves — a save the server drops at sanitisation is the only write that goes unlogged. A write that matches no row is still logged. There is no join or leave logging. If you need usage analytics, this is not it.

Privacy

  • The embed carries the display name of the admin only — no identifiers, no server id, no IP, no character name, no timestamp field beyond Discord's own.
  • The URL lives in plain text in config.lua. Treat it as a secret, use a staff-only channel, and rotate it if it leaks. Redact it before sending your config to anyone.
  • Posts leave your server and are stored by Discord under their terms.

What the server actually validates

The protection model is data withholding, not playback validation. Understanding the difference saves you from planning a monetisation tier around a guarantee this resource does not make.

EventValidation
pullLibraryServes that player's filtered library from cache; no database hit
pullCollectionRequires an identity; one database query per call, unthrottled
collect (star)Id must exist in the cached library; non-admins refused on hidden or restricted rows
discardDeletes only rows matching the caller's own identity
assignKeyString of 50 characters or less, scoped to the caller. The single letter-or-digit rule is client-side only
adminUpsert / adminDeleteAdmin status re-checked server-side, then every field sanitised
rebuildLibraryNot a net event — clients cannot force full-table reloads

Client-side admin status only decides whether the UI draws the tab. Faking it achieves nothing: both admin events re-check server-side.

Why a player cannot reach a hidden emote by editing their client

Hidden and restricted rows are removed before the library leaves the server. A client never receives the id, the dictionary or the clip name. Every path into playback — cards, /e, the exports, Random, the quick dock — resolves against that already-filtered local library, so there is nothing to select.

What this does not stop

  • Playback itself is entirely client-side. The server is never told what is being played and never authorises it. A player running a modified client can call TaskPlayAnim with any dictionary and clip they already know, exactly as they could with no emote resource installed at all. Restrictions are a content-visibility feature, not anti-cheat.
  • The shared two-player relays are trust-on-first-use — see Props & shared emotes.
  • There is no rate limiting anywhere. pullCollection is the cheapest amplification vector, one SELECT ... JOIN per trigger.

Practical mitigations: keep anything genuinely sensitive out of the animation library rather than merely hidden, run a server-side anti-cheat that watches animation tasks if your server needs one, and treat restricted as a content tier rather than a security boundary.

Documentation for RedMorrow. Scripts are licensed per server — redistribution is not permitted.