RedM Emotes Database & SQL
RedM Emotes talks to MySQL/MariaDB through oxmysql only. There is no other server-side store — player recents, walk style and preview toggle live in client-side KVP, not in the database.
The whole sql/ folder is escrow_ignore'd, so every file here is readable and editable in both editions.
The two tables
Exactly two tables are created. There is no migration table and no schema inspection at boot.
rm_emotes — the library
| Column | Type | Default | What it does |
|---|---|---|---|
id | int AUTO_INCREMENT | auto | Favourites, recents, preview and playback all key on this. Changing it orphans everything pointing at it |
dict | text | none | Animation dictionary. Unused for walk rows (stores the literal walkstyle) and scenario rows (stores '') |
name | text | none | Clip name. For walk this is the MP_Style_* string; for scenario the WORLD_HUMAN_* name |
custom_name | varchar(255) | NULL | The label on the card, and the fallback source for the /e slug. An empty one rejects the save |
duration | int | 0 | Milliseconds. Anything <= 0 becomes 99999999 ms — hold until stopped. Every shipped row is -1 |
category | varchar(55) | NULL | Which tab the card lands in. Unknown values fold into Misc |
is_loop | int | NULL | 1 sets animation flag bit 1 |
command | varchar(64) | NULL | Explicit /e slug override. NULL or empty derives it from custom_name |
emote_type | varchar(16) | 'anim' | Playback router. Only anim, walk, face, scenario are accepted; anything else is coerced to anim |
restricted | varchar(64) | NULL | One ACE name or one job name. Empty means everyone |
hidden | tinyint | 0 | 1 strips the row from every non-admin push entirely |
upper_body | tinyint | 0 | 1 ORs the flags with 48 — upper body plus keep control, so the player can keep moving |
Indexes: PRIMARY KEY (id) and nothing else. None is needed — the server reads the whole table once per rebuild and filters in Lua. Note there is no unique constraint on command.
rm_emote_favs — saved emotes and key binds
| Column | Type | What it does |
|---|---|---|
id | int AUTO_INCREMENT | Surrogate key, never sent to the client |
emote_id | int | Foreign key to rm_emotes.id |
steam_id | varchar(255) | Misnamed. Holds the steam identifier if present, otherwise license:. A server without Steam stores license:... here |
key_bind | varchar(50) | The quick-slot key. Written as '' on create; updates longer than 50 characters are rejected |
Three constraints and why each exists:
UNIQUE KEY (steam_id, emote_id)— one row per identity/emote pair. This is what makes theINSERT IGNOREused when starring idempotent, so double-starring cannot duplicate.KEY (emote_id)— the index InnoDB requires to back the foreign key. The unique key leads withsteam_id, so it cannot serve that purpose.FOREIGN KEY ... ON DELETE CASCADE— deleting an emote removes it from every player's collection automatically.
Deleting an emote is destructive for your players
The cascade means a DELETE FROM rm_emotes, whether from the Admin tab or a SQL client, also deletes that emote from every player's saved list and takes their key bind with it. Prefer hidden = 1.
"Specified key was too long" on import
uq_rm_favs is 1024 bytes wide — 255 utf8mb4 characters plus a 4-byte int. Fine under InnoDB's DYNAMIC row format, but over the 767-byte limit on a pre-5.7 MySQL still defaulting to COMPACT or REDUNDANT.
How /e slugs are derived
command is not the slug the player types — it is an optional override. After every library reload the server walks the rows in id order and assigns:
command, if set and not one of the reserved wordsstop,random,c.- Otherwise
custom_namelowercased with every non-alphanumeric character removed. - If that is empty or reserved,
emote<id>. - Truncated to 40 characters.
- If already taken,
2,3… is appended until it is free.
Because the pass is ordered by id, slugs are stable across reloads. The raw stored value is kept separately and shown in the editor, so a derived slug is never written back into the table.
A 24-character dead zone
The column is varchar(64) but writes are truncated to 40 characters, so the last 24 are unreachable through the editor.
The ten files in sql/
| File | Rows | Id range | Safe to re-run? |
|---|---|---|---|
english.sql and the five other language seeds | 208 | 4–220 | Harmless, but pointless — it changes nothing |
walkstyles.sql | 12 | 1000–1011 | Yes |
scenarios.sql | 6 | 4900–4905 | Yes |
amount.sql | 4133 | 5000–9132 | Yes |
upgrade_2.1.sql | 0 | — | No — fails with #1060 |
Every pack upserts with ON DUPLICATE KEY UPDATE. No file in sql/ contains a DELETE or a DROP, which is the deliberate design: rm_emote_favs cascades on delete, so clearing a range would take players' saved emotes with it.
The six language seeds
Functionally identical — the schema statements are byte-identical and only custom_name differs. Each one creates rm_emotes (the only file that does), inserts 208 rows in a single statement, and creates rm_emote_favs with CREATE TABLE IF NOT EXISTS.
No seed drops your favourites
Every seed carries the comment -- NOTE: this file deliberately does NOT drop rm_emote_favs. There is no DROP statement in any file in sql/, so re-running a seed cannot cost a player their saved emotes whatever your SQL client does with the failed insert.
Category breakdown, the same in every language: action 89, other 46, dance 34, position 34, sit 3, photo 2. 127 rows loop, 81 do not, and every row has duration = -1.
The insert column list is (id, dict, name, custom_name, duration, category, is_loop) — it never mentions the five 2.1 columns, so every seeded row takes the defaults: a plain, visible, unrestricted, full-body anim.
The id gaps are intentional
Ids are not contiguous. The file starts at 4, and 1, 2, 3, 5, 6, 7, 8, 10, 57, 76, 77 and 78 are absent. AUTO_INCREMENT is set to 221. There is nothing to fix.
The seed also contains deliberate duplicates — six rows share a dict+name pair with another row, and Look into the Distance appears three times under three slightly different clips. Both are legal, and the slug allocator quietly appends 2 and 3.
walkstyles.sql
Twelve styles at ids 1000–1011, all emote_type = 'walk', category = 'walk', dict = 'walkstyle' (never read for walk rows).
Id 1000 is name = 'default' and the client special-cases it: instead of applying a style it removes the active one and deletes the stored KVP. That row is the only way a player returns to a normal walk — do not delete it.
It upserts, so re-running it restores the twelve shipped rows in place and leaves any walk style an admin created in the editor alone.
scenarios.sql
Six world scenarios at ids 4900–4905, emote_type = 'scenario', dict = ''. Five are category = 'sit'; WORLD_HUMAN_CAMP_FIRE_STAND ("Warm by the Fire") is other.
It has no DELETE at all — the six rows are upserted, so re-running it updates its own rows in place and touches nothing else. Nothing cascades, so favourites pointing at a scenario survive a re-import, and emotes an admin created above the range are never at risk.
amount.sql
4,133 animations at ids 5000–9132, generated from the community rdr3_discoveries in-game animation listing. Categories: other 1744, sit 1029, action 938, position 422. Split into nine INSERT statements — eight of 500 rows plus one of 133 — because the pack is machine-generated and emitted in fixed 500-row chunks.
This is a large, unsorted, machine-generated pack. Import it for breadth; the cost is that the All tab becomes very long and the menu caps rendering at 400 rows per view, so players rely on search rather than browsing.
Safe to re-run
It has no DELETE — all nine statements end with ON DUPLICATE KEY UPDATE, so re-running it rewrites its own 5000–9132 rows in place and touches nothing else.
That matters because rm_emote_favs has ON DELETE CASCADE: clearing the range with a DELETE would silently take every player's saved emote and key bind with it. The pack's own header says so.
Admin-created emotes land at 9133 and above, outside the range entirely, so they survive a re-import and so do the favourites pointing at them. The one thing a re-import does overwrite is an edit you made to a pack row in the editor — the upsert puts the shipped values back.
Neither amount.sql nor scenarios.sql is mentioned in the shipped per-framework install guides. They are optional extras, not part of the documented base install.
upgrade_2.1.sql
ALTER TABLE `rm_emotes`
ADD COLUMN `command` VARCHAR(64) NULL DEFAULT NULL,
ADD COLUMN `emote_type` VARCHAR(16) NOT NULL DEFAULT 'anim',
ADD COLUMN `restricted` VARCHAR(64) NULL DEFAULT NULL,
ADD COLUMN `hidden` TINYINT NOT NULL DEFAULT 0,
ADD COLUMN `upper_body` TINYINT NOT NULL DEFAULT 0;Exactly the five columns that distinguish a 2.1 table from a 2.0 one. It does not touch rm_emote_favs, add indexes or insert data.
Who needs it: only a server that already ran a 2.0 seed and wants to keep its table, and therefore its players' favourites. A fresh install must not run it. Being a single multi-clause ALTER, running it twice fails atomically and changes nothing.
Which files to import
| Your situation | Import, in this order | Never |
|---|---|---|
| Fresh install, base library | One seed, then walkstyles.sql | upgrade_2.1.sql |
| Fresh install, everything | One seed, walkstyles.sql, scenarios.sql, amount.sql | upgrade_2.1.sql |
| Upgrading 2.0, keeping favourites | upgrade_2.1.sql, then the packs | Any language seed |
| Upgrading 2.0, happy to start clean | Drop both tables, then treat as fresh | upgrade_2.1.sql |
| Already on 2.1, adding the bulk pack | amount.sql alone | Everything else |
| Already on 2.1, switching language | Full reset — see below | A second seed on top |
Error reference
| Error | When | What it means |
|---|---|---|
#1060 - Duplicate column name 'command' | upgrade_2.1.sql on a 2.1 table | The ALTER is atomic. Nothing changed. Skip the file |
#1062 - Duplicate entry '4' for key 'PRIMARY' | A seed on a database that already has one | The whole insert rolled back. See the warning below |
#1054 - Unknown column 'emote_type' | A pack on a 2.0 table | Run upgrade_2.1.sql first |
#3730 / #1217 | DROP TABLE rm_emotes | The foreign key blocks it. Drop the child first |
Specified key was too long | Creating rm_emote_favs | Old MySQL row format. Use DYNAMIC |
What a second language seed actually does
Nothing, which surprises people who expect it to translate the library. The exact sequence inside the file:
CREATE TABLE IF NOT EXISTS rm_emotes— a no-op with a warning. No columns are added, which is why a 2.0 table stays a 2.0 table.- The 208-row
INSERThas noIGNORE, noON DUPLICATE KEY UPDATEand is not aREPLACE. It collides on id 4 and the entire statement rolls back. Zero rows inserted. The installed language is left completely intact — you never get a mixed-language table and never get 416 rows. CREATE TABLE IF NOT EXISTS rm_emote_favs— another no-op on an install that already has the table.
Net effect: nothing changes. Favourites are untouched whatever your client does with the error, because no file in sql/ contains a DROP. There is simply no path by which a second seed replaces the first language — for that, see switching language.
Backing up just these tables
Both tables are small — even with the bulk pack, rm_emotes is well under a megabyte.
mysqldump --single-transaction DBNAME rm_emotes rm_emote_favs -u DBUSER -p > rm_emotes_backup.sqlRestore both together. Restoring rm_emotes alone over a live rm_emote_favs leaves favourite rows pointing at ids that no longer exist — and the dump disables FOREIGN_KEY_CHECKS, so nothing stops you: the restore succeeds and the orphans survive silently. If you have already done it, clear them with:
DELETE f FROM `rm_emote_favs` f
LEFT JOIN `rm_emotes` e ON e.id = f.emote_id
WHERE e.id IS NULL;Favourites only — worth doing before any operation involving a DELETE or DROP:
mysqldump --single-transaction DBNAME rm_emote_favs -u DBUSER -p > rm_emote_favs_backup.sqlNo shell access? Make an in-database copy, which has no constraints and is exactly right for a holding area:
DROP TABLE IF EXISTS `rm_emote_favs_backup`;
CREATE TABLE `rm_emote_favs_backup` AS SELECT * FROM `rm_emote_favs`;Restore it filtered, so favourites for emotes that no longer exist are dropped rather than failing the foreign key:
INSERT IGNORE INTO `rm_emote_favs` (steam_id, emote_id, key_bind)
SELECT b.steam_id, b.emote_id, b.key_bind
FROM `rm_emote_favs_backup` b
JOIN `rm_emotes` e ON e.id = b.emote_id;
DROP TABLE `rm_emote_favs_backup`;Useful queries
Library health at a glance:
SELECT COUNT(*) AS total,
SUM(hidden = 1) AS hidden_rows,
SUM(restricted IS NOT NULL AND restricted <> '') AS restricted_rows,
SUM(emote_type = 'walk') AS walk_rows,
MAX(id) AS highest_id
FROM `rm_emotes`;Which emotes players actually save — the most useful number for deciding what to hide:
SELECT e.id, e.custom_name, e.category, COUNT(*) AS saves
FROM `rm_emote_favs` f
JOIN `rm_emotes` e ON e.id = f.emote_id
GROUP BY e.id, e.custom_name, e.category
ORDER BY saves DESC
LIMIT 25;Diagnose "my saved emotes disappeared". Favourites key on the Steam identifier when one exists and license otherwise, so a player connecting without Steam gets a different key and appears to have lost everything. A healthy database has one identifier type:
SELECT SUBSTRING_INDEX(steam_id, ':', 1) AS identifier_type,
COUNT(DISTINCT steam_id) AS players,
COUNT(*) AS saved_rows
FROM `rm_emote_favs`
GROUP BY identifier_type;If that returns both steam and license, your server changed its identifier situation at some point. There is no supported way to merge the two keys — the resource offers no identifier override.
Conflicting explicit commands. There is no unique constraint on command, so collisions resolve at runtime in id order, meaning the lower id silently keeps the slug you wanted:
SELECT command, COUNT(*) AS copies, GROUP_CONCAT(id ORDER BY id) AS ids
FROM `rm_emotes`
WHERE command IS NOT NULL AND command <> ''
GROUP BY command
HAVING copies > 1;Bulk hiding
Hiding is reversible and non-destructive; deleting cascades to favourites. Prefer hiding.
-- take one category out of circulation
UPDATE `rm_emotes` SET hidden = 1 WHERE category = 'photo';
-- tame the bulk pack's noisiest category, keep the rest
UPDATE `rm_emotes` SET hidden = 1 WHERE id >= 5000 AND category = 'other';
-- shelve the entire bulk pack, re-enable with one statement
UPDATE `rm_emotes` SET hidden = 1 WHERE id BETWEEN 5000 AND 9132;
-- restrict rather than hide (ACE tried first, then the job name)
UPDATE `rm_emotes` SET restricted = 'rm_emotes.vip' WHERE category = 'dance';Resetting and switching language
DROP TABLE rm_emotes alone fails while rm_emote_favs exists. Drop the child first:
DROP TABLE IF EXISTS `rm_emote_favs`;
DROP TABLE IF EXISTS `rm_emotes`;Switching display language has no in-place route — the seeds cannot overwrite each other. Back up favourites, drop both tables, run the new seed, re-run the packs, then restore favourites with the filtered INSERT ... SELECT ... JOIN above. Because the seeds use fixed ids, favourites pointing at seeded emotes survive the switch intact — only the display language changes.
To clear just the optional packs and keep the base seed, use their own ranges:
DELETE FROM `rm_emotes` WHERE id >= 5000;
DELETE FROM `rm_emotes` WHERE id BETWEEN 4900 AND 4905;
DELETE FROM `rm_emotes` WHERE `emote_type` = 'walk';Each cascades into rm_emote_favs.
Making the server notice your changes
The server reads the library into memory once and serves every client from that cache. Direct SQL edits are invisible until it is rebuilt. There is no polling and no in-game command for it.
SELECT * FROM rm_emotes runs only when:
| Trigger | Effect |
|---|---|
onResourceStart | Reloads. Restarting also restarts the client script, so everyone re-pulls. The simplest option |
| An admin saves or deletes in the editor | Reloads and pushes to every connected player |
exports.rm_emotes:Rebuild() from a server script | Same |
TriggerEvent('rm_emotes:server:rebuildLibrary') | Identical handler |
-- server side, after editing rm_emotes directly
exports.rm_emotes:Rebuild()Rebuild re-pushes the library, not collections. A player whose favourite you just hid sees the library change immediately, but their quick dock updates on their next character load or resource restart.
Clients cannot force a full-table read
rm_emotes:server:rebuildLibrary is registered with AddEventHandler, not RegisterNetEvent. That is deliberate.
Every statement the resource itself runs
| Statement | When |
|---|---|
SELECT * FROM rm_emotes | Library reload — start, admin save/delete, Rebuild() |
SELECT ... FROM rm_emote_favs f JOIN rm_emotes e ... WHERE f.steam_id = ? | Every collection push |
INSERT IGNORE INTO rm_emote_favs ... | Player stars an emote |
DELETE FROM rm_emote_favs WHERE steam_id = ? AND emote_id = ? | Player unstars |
UPDATE rm_emote_favs SET key_bind = ? ... | Player assigns a quick key |
UPDATE rm_emotes SET ... WHERE id = ? | Admin edits |
INSERT INTO rm_emotes (...) | Admin creates. Id omitted, taken from AUTO_INCREMENT |
DELETE FROM rm_emotes WHERE id = ? | Admin deletes. Cascades |
There is nothing else. The library query is cached, so it costs one read per rebuild. The collection query is not cached and not rate limited — it runs once per star, unstar, key assignment and character load.