Skip to content

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

ColumnTypeDefaultWhat it does
idint AUTO_INCREMENTautoFavourites, recents, preview and playback all key on this. Changing it orphans everything pointing at it
dicttextnoneAnimation dictionary. Unused for walk rows (stores the literal walkstyle) and scenario rows (stores '')
nametextnoneClip name. For walk this is the MP_Style_* string; for scenario the WORLD_HUMAN_* name
custom_namevarchar(255)NULLThe label on the card, and the fallback source for the /e slug. An empty one rejects the save
durationint0Milliseconds. Anything <= 0 becomes 99999999 ms — hold until stopped. Every shipped row is -1
categoryvarchar(55)NULLWhich tab the card lands in. Unknown values fold into Misc
is_loopintNULL1 sets animation flag bit 1
commandvarchar(64)NULLExplicit /e slug override. NULL or empty derives it from custom_name
emote_typevarchar(16)'anim'Playback router. Only anim, walk, face, scenario are accepted; anything else is coerced to anim
restrictedvarchar(64)NULLOne ACE name or one job name. Empty means everyone
hiddentinyint01 strips the row from every non-admin push entirely
upper_bodytinyint01 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

ColumnTypeWhat it does
idint AUTO_INCREMENTSurrogate key, never sent to the client
emote_idintForeign key to rm_emotes.id
steam_idvarchar(255)Misnamed. Holds the steam identifier if present, otherwise license:. A server without Steam stores license:... here
key_bindvarchar(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 the INSERT IGNORE used when starring idempotent, so double-starring cannot duplicate.
  • KEY (emote_id) — the index InnoDB requires to back the foreign key. The unique key leads with steam_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:

  1. command, if set and not one of the reserved words stop, random, c.
  2. Otherwise custom_name lowercased with every non-alphanumeric character removed.
  3. If that is empty or reserved, emote<id>.
  4. Truncated to 40 characters.
  5. 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/

FileRowsId rangeSafe to re-run?
english.sql and the five other language seeds2084–220Harmless, but pointless — it changes nothing
walkstyles.sql121000–1011Yes
scenarios.sql64900–4905Yes
amount.sql41335000–9132Yes
upgrade_2.1.sql0No — 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

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 situationImport, in this orderNever
Fresh install, base libraryOne seed, then walkstyles.sqlupgrade_2.1.sql
Fresh install, everythingOne seed, walkstyles.sql, scenarios.sql, amount.sqlupgrade_2.1.sql
Upgrading 2.0, keeping favouritesupgrade_2.1.sql, then the packsAny language seed
Upgrading 2.0, happy to start cleanDrop both tables, then treat as freshupgrade_2.1.sql
Already on 2.1, adding the bulk packamount.sql aloneEverything else
Already on 2.1, switching languageFull reset — see belowA second seed on top

Error reference

ErrorWhenWhat it means
#1060 - Duplicate column name 'command'upgrade_2.1.sql on a 2.1 tableThe ALTER is atomic. Nothing changed. Skip the file
#1062 - Duplicate entry '4' for key 'PRIMARY'A seed on a database that already has oneThe whole insert rolled back. See the warning below
#1054 - Unknown column 'emote_type'A pack on a 2.0 tableRun upgrade_2.1.sql first
#3730 / #1217DROP TABLE rm_emotesThe foreign key blocks it. Drop the child first
Specified key was too longCreating rm_emote_favsOld 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:

  1. 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.
  2. The 208-row INSERT has no IGNORE, no ON DUPLICATE KEY UPDATE and is not a REPLACE. 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.
  3. 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.

bash
mysqldump --single-transaction DBNAME rm_emotes rm_emote_favs -u DBUSER -p > rm_emotes_backup.sql

Restore 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:

sql
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:

bash
mysqldump --single-transaction DBNAME rm_emote_favs -u DBUSER -p > rm_emote_favs_backup.sql

No shell access? Make an in-database copy, which has no constraints and is exactly right for a holding area:

sql
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:

sql
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:

sql
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:

sql
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:

sql
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:

sql
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.

sql
-- 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:

sql
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:

sql
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:

TriggerEffect
onResourceStartReloads. Restarting also restarts the client script, so everyone re-pulls. The simplest option
An admin saves or deletes in the editorReloads and pushes to every connected player
exports.rm_emotes:Rebuild() from a server scriptSame
TriggerEvent('rm_emotes:server:rebuildLibrary')Identical handler
lua
-- 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

StatementWhen
SELECT * FROM rm_emotesLibrary 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.

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