RedM Emotes Admin Guide
Granting access
There are exactly two routes, checked in this order. Either one alone is enough.
| Route | Checked with | Configured in | Takes effect |
|---|---|---|---|
| ACE permission | IsPlayerAceAllowed, default object rm_emotes.admin | server.cfg | On the next library push — once the ACE is registered |
| Identifier list | Every string from GetPlayerIdentifiers compared against RM.Admins | config.lua | Only after a resource restart |
There are no permission tiers and no per-action permissions. One boolean unlocks everything.
The ACE route (recommended)
add_ace group.admin rm_emotes.admin allowserver.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:
add_ace group.admin rm_emotes.admin allow
add_principal identifier.license:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx group.adminPer-identifier, without a group:
add_ace identifier.license:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx rm_emotes.admin allowsteam:, fivem: and discord: principals work the same way. Rename the object if rm_emotes.admin clashes with something in your permissions file:
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:
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:
restart rm_emotesFinding 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:
-- 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.
| Event | Triggers a push? |
|---|---|
| Player connects, or the client resource starts | Yes |
| Character load | Yes |
| Any admin save or delete | Yes, to everyone |
exports.rm_emotes:Rebuild() | Yes, to everyone |
Editing server.cfg or config.lua | No |
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
| Capability | Detail |
|---|---|
| Admin tab | The in-game editor, rendered only when the server-sent flag is true |
| Hidden emotes | Sent to you and visible in every tab, dimmed in the editor list |
| Restricted emotes | Sent to you regardless of job or ACE |
| Starring | You may star hidden and restricted emotes; non-admins are refused server-side |
| Create, edit, delete | Both 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
| Label | Column | Rules the server enforces |
|---|---|---|
| Display name | custom_name | Trimmed to 255. Empty rejects the whole save |
| Command | command | Lowercased, non-alphanumerics stripped, trimmed to 40. stop, random, c are blanked. Empty stores NULL and derives the slug from the display name |
| Anim dictionary | dict | Trimmed to 255. Required unless the type is walk or scenario |
| Anim clip | name | Trimmed to 255. Empty rejects the whole save |
| Category | category | Trimmed to 55. Any string is accepted, but the menu folds unknown values into Misc |
| Type | emote_type | Anything outside anim, walk, face, scenario is forced to anim |
| Duration | duration | tonumber or -1. No clamp. 0 or less means hold indefinitely |
| Restriction | restricted | Trimmed to 64. Empty stores NULL |
| Loop | is_loop | Sets animation flag bit 1 |
| Upper body | upper_body | ORs the flags with 48 — upper body plus keep control |
| Hidden | hidden | See 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
- New emote.
- Fill in Display name and Anim clip. Fill in Anim dictionary unless the type is
walk. - Pick the category and the type.
- Leave Command empty unless you want a specific
/eslug. - Tick Loop for anything that should keep playing; Upper body for anything the player should be able to walk during.
- 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_favsrow 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
| Quirk | What happens | What to do |
|---|---|---|
| Prop and shared rows in the list | They come from shared/extras.lua with synthetic ids from 90000. Saving or deleting affects zero database rows, while the webhook still logs it | Edit shared/extras.lua |
A scenario row with no dictionary | The server accepts an empty dict for scenario, but the form only waives it for walk, so Save does nothing | Put 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 it | If a row does not appear after saving, re-check Display name and Anim clip |
| New rows land at high ids | After amount.sql, AUTO_INCREMENT is past 9132, so your creations start there | Harmless. 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:
- Empty or
NULL→ everyone may see it. IsPlayerAceAllowed(src, restricted)→ if the player holds that ACE, allowed.- 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
| Framework | Job read from |
|---|---|
| VORP Core | The used character's .job |
| RSG Core | PlayerData.job.name — the name, not the label |
| RedEM:RP | GetJob(), else .job |
| Standalone, or any other core | Not 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:
add_ace group.vip rm_emotes.vip allow
add_principal identifier.license:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx group.vipUse 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
| Framework | Put this in the Restriction field |
|---|---|
| VORP Core | sheriff — the VORP job name. Nothing goes in server.cfg |
| RSG Core | vallaw — as it appears in PlayerData.job.name |
| RedEM:RP | sheriff — the job returned by GetJob() |
| Standalone | rm_emotes.lawman, plus an add_ace/add_principal pair |
Limits worth planning around
| Limit | Consequence |
|---|---|
| One value per row | No lists. Two jobs sharing an emote need two duplicate rows, or an ACE granted to both groups |
| ACE is tested first | A job name that collides with an ACE your server grants lets those holders through too. Namespaced values like rm_emotes.vip avoid this |
| Exact match | Case and spelling must match the framework's job name exactly |
| Change timing | A mid-session job change only applies at the next library push |
| Already-starred rows | Same as hidden — the quick slot survives until the next collection push |
| Not a playback gate | See below |
Discord webhook
Create a webhook in Discord and paste the URL into config.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:
| Trigger | Embed title | Description |
|---|---|---|
| Save on an existing emote | Emote updated | **<player>** updated emote #<id> (<name>) |
| Save on a new emote | Emote created | **<player>** created emote "<name>" |
| Delete | Emote 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.
| Event | Validation |
|---|---|
pullLibrary | Serves that player's filtered library from cache; no database hit |
pullCollection | Requires 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 |
discard | Deletes only rows matching the caller's own identity |
assignKey | String of 50 characters or less, scoped to the caller. The single letter-or-digit rule is client-side only |
adminUpsert / adminDelete | Admin status re-checked server-side, then every field sanitised |
rebuildLibrary | Not 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
TaskPlayAnimwith 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.
pullCollectionis the cheapest amplification vector, oneSELECT ... JOINper 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.