diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 49f783722ab429986fb45b317a2d00c36a4b5f94..21f7c6c45fd2a26aff061aba3df305a845adcca9 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -35,6 +35,7 @@ add_executable(SRB2SDL2 MACOSX_BUNDLE WIN32
 	m_misc.c
 	m_perfstats.c
 	m_random.c
+	m_tokenizer.c
 	m_queue.c
 	info.c
 	p_ceilng.c
@@ -68,6 +69,7 @@ add_executable(SRB2SDL2 MACOSX_BUNDLE WIN32
 	r_things.c
 	r_bbox.c
 	r_textures.c
+	r_translation.c
 	r_patch.c
 	r_patchrotation.c
 	r_picformats.c
diff --git a/src/Sourcefile b/src/Sourcefile
index 7beb98c9e313286506c55d359bb19b38b013bfb0..ad5eacfeb9e96f75316ce0d4c66ae8d1e31b4fe1 100644
--- a/src/Sourcefile
+++ b/src/Sourcefile
@@ -29,6 +29,7 @@ m_menu.c
 m_misc.c
 m_perfstats.c
 m_random.c
+m_tokenizer.c
 m_queue.c
 info.c
 p_ceilng.c
@@ -62,6 +63,7 @@ r_splats.c
 r_things.c
 r_bbox.c
 r_textures.c
+r_translation.c
 r_patch.c
 r_patchrotation.c
 r_picformats.c
diff --git a/src/command.c b/src/command.c
index fdded341327e688607dc4d2c1aa0a59b495126a1..a065f764cc5d7d7990826ce3e8174e42b7f02563 100644
--- a/src/command.c
+++ b/src/command.c
@@ -1429,8 +1429,8 @@ void CV_RegisterVar(consvar_t *variable)
 #ifdef PARANOIA
 	if ((variable->flags & CV_NOINIT) && !(variable->flags & CV_CALL))
 		I_Error("variable %s has CV_NOINIT without CV_CALL\n", variable->name);
-	if ((variable->flags & CV_CALL) && !variable->func)
-		I_Error("variable %s has CV_CALL without a function\n", variable->name);
+	if ((variable->flags & CV_CALL) && !(variable->func || variable->can_change))
+		I_Error("variable %s has CV_CALL without any callbacks\n", variable->name);
 #endif
 
 	if (variable->flags & CV_NOINIT)
@@ -1496,12 +1496,35 @@ static void Setvalue(consvar_t *var, const char *valstr, boolean stealth)
 	boolean override = false;
 	INT32 overrideval = 0;
 
-	// If we want messages informing us if cheats have been enabled or disabled,
-	// we need to rework the consvars a little bit.  This call crashes the game
-	// on load because not all variables will be registered at that time.
-/*	boolean prevcheats = false;
-	if (var->flags & CV_CHEAT)
-		prevcheats = CV_CheatsEnabled(); */
+	// raise 'can change' code
+	LUA_CVarChanged(var); // let consolelib know what cvar this is.
+	if (var->flags & CV_CALL && var->can_change && !stealth)
+	{
+		if (!var->can_change(valstr))
+		{
+			// The callback refused the default value on register. How naughty...
+			// So we just use some fallback value.
+			if (var->string == NULL)
+			{
+				if (var->PossibleValue)
+				{
+					// Use PossibleValue
+					valstr = var->PossibleValue[0].strvalue;
+				}
+				else
+				{
+					// Else, use an empty string
+					valstr = "";
+				}
+			}
+			else
+			{
+				// Callback returned false, and the game is not registering this variable,
+				// so we can return safely.
+				return;
+			}
+		}
+	}
 
 	if (var->PossibleValue)
 	{
@@ -1663,16 +1686,6 @@ found:
 	}
 
 finish:
-	// See the note above.
-/* 	if (var->flags & CV_CHEAT)
-	{
-		boolean newcheats = CV_CheatsEnabled();
-
-		if (!prevcheats && newcheats)
-			CONS_Printf(M_GetText("Cheats have been enabled.\n"));
-		else if (prevcheats && !newcheats)
-			CONS_Printf(M_GetText("Cheats have been disabled.\n"));
-	} */
 
 	if (var->flags & CV_SHOWMODIFONETIME || var->flags & CV_SHOWMODIF)
 	{
@@ -1685,8 +1698,7 @@ finish:
 	}
 	var->flags |= CV_MODIFIED;
 	// raise 'on change' code
-	LUA_CVarChanged(var); // let consolelib know what cvar this is.
-	if (var->flags & CV_CALL && !stealth)
+	if (var->flags & CV_CALL && var->func && !stealth)
 		var->func();
 
 	return;
diff --git a/src/command.h b/src/command.h
index 619d8c1dcab07a7ab5dfbe20e98cae409c77fb9d..f0dc62418a8815abfa9e7c9b84d9357aac406b73 100644
--- a/src/command.h
+++ b/src/command.h
@@ -136,6 +136,7 @@ typedef struct consvar_s //NULL, NULL, 0, NULL, NULL |, 0, NULL, NULL, 0, 0, NUL
 	INT32 flags;            // flags see cvflags_t above
 	CV_PossibleValue_t *PossibleValue; // table of possible values
 	void (*func)(void);   // called on change, if CV_CALL set
+	boolean (*can_change)(const char*);   // called before change, if CV_CALL set
 	INT32 value;            // for INT32 and fixed_t
 	const char *string;   // value in string
 	char *zstring;        // Either NULL or same as string.
@@ -158,6 +159,9 @@ typedef struct consvar_s //NULL, NULL, 0, NULL, NULL |, 0, NULL, NULL, 0, 0, NUL
 
 /* name, defaultvalue, flags, PossibleValue, func */
 #define CVAR_INIT( ... ) \
+{ __VA_ARGS__, NULL, 0, NULL, NULL, {0, {NULL}}, 0U, (char)0, NULL }
+
+#define CVAR_INIT_WITH_CALLBACKS( ... ) \
 { __VA_ARGS__, 0, NULL, NULL, {0, {NULL}}, 0U, (char)0, NULL }
 
 #ifdef OLD22DEMOCOMPAT
diff --git a/src/d_main.c b/src/d_main.c
index c9419e93027432a2e7df6599a36aaac35bccfda7..d7ee81689ca40ed789316d6a2b6a687d6122eaba 100644
--- a/src/d_main.c
+++ b/src/d_main.c
@@ -50,6 +50,7 @@
 #include "p_saveg.h"
 #include "r_main.h"
 #include "r_local.h"
+#include "r_translation.h"
 #include "s_sound.h"
 #include "st_stuff.h"
 #include "v_video.h"
@@ -1467,6 +1468,8 @@ void D_SRB2Main(void)
 	// setup loading screen
 	SCR_Startup();
 
+	PaletteRemap_Init();
+
 	HU_Init();
 
 	CON_Init();
diff --git a/src/deh_lua.c b/src/deh_lua.c
index 8552360e0c3fb60514ac3ab5f2b932b3ba75508e..3600c3554da059091e513c559f7a9e80cff72de2 100644
--- a/src/deh_lua.c
+++ b/src/deh_lua.c
@@ -573,7 +573,8 @@ static int ScanConstants(lua_State *L, boolean mathlib, const char *word)
 		if (mathlib) return luaL_error(L, "NiGHTS grade '%s' could not be found.\n", word);
 		return 0;
 	}
-	else if (fastncmp("MN_",word,3)) {
+	else if (fastncmp("MN_",word,3))
+	{
 		p = word+3;
 		for (i = 0; i < NUMMENUTYPES; i++)
 			if (fastcmp(p, MENUTYPES_LIST[i])) {
@@ -583,6 +584,17 @@ static int ScanConstants(lua_State *L, boolean mathlib, const char *word)
 		if (mathlib) return luaL_error(L, "menutype '%s' could not be found.\n", word);
 		return 0;
 	}
+	else if (mathlib && fastncmp("TRANSLATION_",word,12))
+	{
+		p = word+12;
+		int id = R_FindCustomTranslation_CaseInsensitive(p);
+		if (id != -1)
+		{
+			lua_pushinteger(L, id);
+			return 1;
+		}
+		return luaL_error(L, "translation '%s' could not be found.\n", word);
+	}
 
 	// TODO: 2.3: Delete this alias
 	if (fastcmp(word, "BT_USE"))
diff --git a/src/deh_lua.h b/src/deh_lua.h
index 1bec371ccb42d5b76549103d9068c5e471825c98..9c6fb6257413aefa47f87766a72d7ca0402c8264 100644
--- a/src/deh_lua.h
+++ b/src/deh_lua.h
@@ -20,6 +20,7 @@
 #include "m_misc.h"
 #include "p_local.h"
 #include "st_stuff.h"
+#include "r_translation.h"
 #include "fastcmp.h"
 #include "lua_script.h"
 #include "lua_libs.h"
diff --git a/src/deh_tables.c b/src/deh_tables.c
index 6d29fa2c77e51b27649cc253c11c1905fe992382..fa86518e2e0eaf51555d6ee334a97cad22b3afa3 100644
--- a/src/deh_tables.c
+++ b/src/deh_tables.c
@@ -219,6 +219,7 @@ actionpointer_t actionpointers[] =
 	{{A_ChangeColorRelative},    "A_CHANGECOLORRELATIVE"},
 	{{A_ChangeColorAbsolute},    "A_CHANGECOLORABSOLUTE"},
 	{{A_Dye},                    "A_DYE"},
+	{{A_SetTranslation},         "A_SETTRANSLATION"},
 	{{A_MoveRelative},           "A_MOVERELATIVE"},
 	{{A_MoveAbsolute},           "A_MOVEABSOLUTE"},
 	{{A_Thrust},                 "A_THRUST"},
diff --git a/src/doomdef.h b/src/doomdef.h
index f73c242915c0986f3050cb3bf5ad4c1c5dfde28e..c85abe2d68f6cb87610c3f25eb0857da72312036 100644
--- a/src/doomdef.h
+++ b/src/doomdef.h
@@ -570,6 +570,7 @@ void M_UnGetToken(void);
 void M_TokenizerOpen(const char *inputString);
 void M_TokenizerClose(void);
 const char *M_TokenizerRead(UINT32 i);
+const char *M_TokenizerReadZDoom(UINT32 i);
 UINT32 M_TokenizerGetEndPos(void);
 void M_TokenizerSetEndPos(UINT32 newPos);
 char *sizeu1(size_t num);
diff --git a/src/hardware/hw_glob.h b/src/hardware/hw_glob.h
index d391c415670846fad27d4a22e1e33f46ccb367b3..fbb02f46322c614107fc5523884e7081064c5c4a 100644
--- a/src/hardware/hw_glob.h
+++ b/src/hardware/hw_glob.h
@@ -74,8 +74,6 @@ typedef struct gl_vissprite_s
 	float spritexscale, spriteyscale;
 	float spritexoffset, spriteyoffset;
 
-	skincolornum_t color;
-
 	UINT32 renderflags;
 	UINT8 rotateflags;
 
diff --git a/src/hardware/hw_main.c b/src/hardware/hw_main.c
index e0548e50d420dbf59457c89c75a8cd31a233a841..36d206843151b202d485f269660ca4094b1d741a 100644
--- a/src/hardware/hw_main.c
+++ b/src/hardware/hw_main.c
@@ -39,6 +39,7 @@
 #include "../m_cheat.h"
 #include "../f_finale.h"
 #include "../r_things.h" // R_GetShadowZ
+#include "../r_translation.h"
 #include "../d_main.h"
 #include "../p_slopes.h"
 #include "hw_md2.h"
@@ -5064,6 +5065,8 @@ static void HWR_ProjectSprite(mobj_t *thing)
 	boolean vflip = (!(thing->eflags & MFE_VERTICALFLIP) != !R_ThingVerticallyFlipped(thing));
 	boolean mirrored = thing->mirrored;
 	boolean hflip = (!R_ThingHorizontallyFlipped(thing) != !mirrored);
+	skincolornum_t color;
+	UINT16 translation;
 	INT32 dispoffset;
 
 	angle_t ang;
@@ -5495,45 +5498,19 @@ static void HWR_ProjectSprite(mobj_t *thing)
 		vis->gpatch = (patch_t *)W_CachePatchNum(sprframe->lumppat[rot], PU_SPRITE);
 
 	vis->mobj = thing;
+
 	if ((thing->flags2 & MF2_LINKDRAW) && thing->tracer && thing->color == SKINCOLOR_NONE)
-		vis->color = thing->tracer->color;
+		color = thing->tracer->color;
 	else
-		vis->color = thing->color;
+		color = thing->color;
 
-	//Hurdler: 25/04/2000: now support colormap in hardware mode
-	if ((vis->mobj->flags & (MF_ENEMY|MF_BOSS)) && (vis->mobj->flags2 & MF2_FRET) && !(vis->mobj->flags & MF_GRENADEBOUNCE) && (leveltime & 1)) // Bosses "flash"
-	{
-		if (vis->mobj->type == MT_CYBRAKDEMON || vis->mobj->colorized)
-			vis->colormap = R_GetTranslationColormap(TC_ALLWHITE, 0, GTC_CACHE);
-		else if (vis->mobj->type == MT_METALSONIC_BATTLE)
-			vis->colormap = R_GetTranslationColormap(TC_METALSONIC, 0, GTC_CACHE);
-		else
-			vis->colormap = R_GetTranslationColormap(TC_BOSS, vis->color, GTC_CACHE);
-	}
-	else if (vis->color)
-	{
-		// New colormap stuff for skins Tails 06-07-2002
-		if (thing->colorized)
-			vis->colormap = R_GetTranslationColormap(TC_RAINBOW, vis->color, GTC_CACHE);
-		else if (thing->player && thing->player->dashmode >= DASHMODE_THRESHOLD
-			&& (thing->player->charflags & SF_DASHMODE)
-			&& ((leveltime/2) & 1))
-		{
-			if (thing->player->charflags & SF_MACHINE)
-				vis->colormap = R_GetTranslationColormap(TC_DASHMODE, 0, GTC_CACHE);
-			else
-				vis->colormap = R_GetTranslationColormap(TC_RAINBOW, vis->color, GTC_CACHE);
-		}
-		else if (thing->skin && thing->sprite == SPR_PLAY) // This thing is a player!
-		{
-			UINT8 skinnum = ((skin_t*)thing->skin)->skinnum;
-			vis->colormap = R_GetTranslationColormap(skinnum, vis->color, GTC_CACHE);
-		}
-		else
-			vis->colormap = R_GetTranslationColormap(TC_DEFAULT, vis->color ? vis->color : SKINCOLOR_CYAN, GTC_CACHE);
-	}
+	if ((thing->flags2 & MF2_LINKDRAW) && thing->tracer && thing->translation == 0)
+		translation = thing->tracer->translation;
 	else
-		vis->colormap = NULL;
+		translation = thing->translation;
+
+	//Hurdler: 25/04/2000: now support colormap in hardware mode
+	vis->colormap = R_GetTranslationForThing(vis->mobj, color, translation);
 
 	// set top/bottom coords
 	vis->gzt = gzt;
@@ -5659,7 +5636,6 @@ static void HWR_ProjectPrecipitationSprite(precipmobj_t *thing)
 	vis->gpatch = (patch_t *)W_CachePatchNum(sprframe->lumppat[rot], PU_SPRITE);
 	vis->flip = flip;
 	vis->mobj = (mobj_t *)thing;
-	vis->color = SKINCOLOR_NONE;
 
 	vis->colormap = NULL;
 
diff --git a/src/info.h b/src/info.h
index 20a0a4b8a5823114ad99265d72876fdf5e606c1d..2362935f0629cf7304df3ea5a9f673f2aa3b3183 100644
--- a/src/info.h
+++ b/src/info.h
@@ -173,6 +173,7 @@ enum actionnum
 	A_CHANGECOLORRELATIVE,
 	A_CHANGECOLORABSOLUTE,
 	A_DYE,
+	A_SETTRANSLATION,
 	A_MOVERELATIVE,
 	A_MOVEABSOLUTE,
 	A_THRUST,
@@ -445,6 +446,7 @@ void A_SetRandomTics();
 void A_ChangeColorRelative();
 void A_ChangeColorAbsolute();
 void A_Dye();
+void A_SetTranslation();
 void A_MoveRelative();
 void A_MoveAbsolute();
 void A_Thrust();
diff --git a/src/lua_consolelib.c b/src/lua_consolelib.c
index 1fabe2c00ae17bbd505b576635d7ef7c47a7fd94..aaa676526d1eac6adbad4ac5d8ba34e31388d2cd 100644
--- a/src/lua_consolelib.c
+++ b/src/lua_consolelib.c
@@ -300,6 +300,30 @@ static void Lua_OnChange(void)
 	lua_remove(gL, 1); // remove LUA_GetErrorMessage
 }
 
+static boolean Lua_CanChange(const char *valstr)
+{
+	lua_pushcfunction(gL, LUA_GetErrorMessage);
+	lua_insert(gL, 1); // Because LUA_Call wants it at index 1.
+
+	// From CV_CanChange registry field, get the function for this cvar by name.
+	lua_getfield(gL, LUA_REGISTRYINDEX, "CV_CanChange");
+	I_Assert(lua_istable(gL, -1));
+	lua_pushlightuserdata(gL, this_cvar);
+	lua_rawget(gL, -2); // get function
+
+	LUA_RawPushUserdata(gL, this_cvar);
+	lua_pushstring(gL, valstr);
+
+	boolean result;
+
+	LUA_Call(gL, 2, 1, 1); // call function(cvar, valstr)
+	result = lua_toboolean(gL, -1);
+	lua_pop(gL, 1); // pop CV_CanChange table
+	lua_remove(gL, 1); // remove LUA_GetErrorMessage
+
+	return result;
+}
+
 static int lib_cvRegisterVar(lua_State *L)
 {
 	const char *k;
@@ -458,6 +482,20 @@ static int lib_cvRegisterVar(lua_State *L)
 			lua_pop(L, 1);
 			cvar->func = Lua_OnChange;
 		}
+		else if (cvar->flags & CV_CALL && (k && fasticmp(k, "can_change")))
+		{
+			if (!lua_isfunction(L, 4))
+			{
+				TYPEERROR("func", LUA_TFUNCTION)
+			}
+			lua_getfield(L, LUA_REGISTRYINDEX, "CV_CanChange");
+			I_Assert(lua_istable(L, 5));
+			lua_pushlightuserdata(L, cvar);
+			lua_pushvalue(L, 4);
+			lua_rawset(L, 5);
+			lua_pop(L, 1);
+			cvar->can_change = Lua_CanChange;
+		}
 		lua_pop(L, 1);
 	}
 
@@ -479,9 +517,9 @@ static int lib_cvRegisterVar(lua_State *L)
 		return luaL_error(L, M_GetText("Variable %s has CV_NOINIT without CV_CALL"), cvar->name);
 	}
 
-	if ((cvar->flags & CV_CALL) && !cvar->func)
+	if ((cvar->flags & CV_CALL) && !(cvar->func || cvar->can_change))
 	{
-		return luaL_error(L, M_GetText("Variable %s has CV_CALL without a function"), cvar->name);
+		return luaL_error(L, M_GetText("Variable %s has CV_CALL without any callbacks"), cvar->name);
 	}
 
 	cvar->flags |= CV_ALLOWLUA;
@@ -672,6 +710,8 @@ int LUA_ConsoleLib(lua_State *L)
 	lua_setfield(L, LUA_REGISTRYINDEX, "CV_PossibleValue");
 	lua_newtable(L);
 	lua_setfield(L, LUA_REGISTRYINDEX, "CV_OnChange");
+	lua_newtable(L);
+	lua_setfield(L, LUA_REGISTRYINDEX, "CV_CanChange");
 
 	// Push opaque CV_PossibleValue pointers
 	// Because I don't care enough to bother.
diff --git a/src/lua_infolib.c b/src/lua_infolib.c
index 03888f32e0d8244428c6035fc59d1724affcaa1e..3facec82b82da21c92d5041aba81fb1f9f543deb 100644
--- a/src/lua_infolib.c
+++ b/src/lua_infolib.c
@@ -22,6 +22,7 @@
 #include "r_patch.h"
 #include "r_picformats.h"
 #include "r_things.h"
+#include "r_translation.h"
 #include "r_draw.h" // R_GetColorByName
 #include "doomstat.h" // luabanks[]
 
@@ -1899,6 +1900,32 @@ static int colorramp_len(lua_State *L)
 	return 1;
 }
 
+//////////////////////
+// TRANSLATION INFO //
+//////////////////////
+
+// Arbitrary translations[] table index -> colormap_t *
+static int lib_getTranslation(lua_State *L)
+{
+	lua_remove(L, 1);
+
+	const char *name = luaL_checkstring(L, 1);
+	remaptable_t *tr = R_GetTranslationByID(R_FindCustomTranslation(name));
+	if (tr)
+		LUA_PushUserdata(L, &tr->remap, META_COLORMAP);
+	else
+		lua_pushnil(L);
+
+	return 1;
+}
+
+// #translations -> R_NumCustomTranslations()
+static int lib_translationslen(lua_State *L)
+{
+	lua_pushinteger(L, R_NumCustomTranslations());
+	return 1;
+}
+
 //////////////////////////////
 //
 // Now push all these functions into the Lua state!
@@ -1931,6 +1958,7 @@ int LUA_InfoLib(lua_State *L)
 	LUA_RegisterGlobalUserdata(L, "spr2defaults", lib_getSpr2default, lib_setSpr2default, lib_spr2namelen);
 	LUA_RegisterGlobalUserdata(L, "states", lib_getState, lib_setState, lib_statelen);
 	LUA_RegisterGlobalUserdata(L, "mobjinfo", lib_getMobjInfo, lib_setMobjInfo, lib_mobjinfolen);
+	LUA_RegisterGlobalUserdata(L, "translations", lib_getTranslation, NULL, lib_translationslen);
 	LUA_RegisterGlobalUserdata(L, "skincolors", lib_getSkinColor, lib_setSkinColor, lib_skincolorslen);
 	LUA_RegisterGlobalUserdata(L, "spriteinfo", lib_getSpriteInfo, lib_setSpriteInfo, lib_spriteinfolen);
 	LUA_RegisterGlobalUserdata(L, "sfxinfo", lib_getSfxInfo, lib_setSfxInfo, lib_sfxlen);
diff --git a/src/lua_mobjlib.c b/src/lua_mobjlib.c
index b5c6c0329556e3463dc95e1dc3ce6f8b16cd7f50..85e4590c571e23cfa701f6f9fb15b5948350e1f0 100644
--- a/src/lua_mobjlib.c
+++ b/src/lua_mobjlib.c
@@ -14,6 +14,7 @@
 #include "fastcmp.h"
 #include "r_data.h"
 #include "r_skins.h"
+#include "r_translation.h"
 #include "p_local.h"
 #include "g_game.h"
 #include "p_setup.h"
@@ -66,6 +67,7 @@ enum mobj_e {
 	mobj_renderflags,
 	mobj_skin,
 	mobj_color,
+	mobj_translation,
 	mobj_blendmode,
 	mobj_bnext,
 	mobj_bprev,
@@ -146,6 +148,7 @@ static const char *const mobj_opt[] = {
 	"renderflags",
 	"skin",
 	"color",
+	"translation",
 	"blendmode",
 	"bnext",
 	"bprev",
@@ -338,6 +341,16 @@ static int mobj_get(lua_State *L)
 	case mobj_color:
 		lua_pushinteger(L, mo->color);
 		break;
+	case mobj_translation:
+		if (mo->translation)
+		{
+			const char *name = R_GetCustomTranslationName(mo->translation);
+			if (name)
+				lua_pushstring(L, name);
+			break;
+		}
+		lua_pushnil(L);
+		break;
 	case mobj_blendmode:
 		lua_pushinteger(L, mo->blendmode);
 		break;
@@ -689,12 +702,26 @@ static int mobj_set(lua_State *L)
 	}
 	case mobj_color:
 	{
-		UINT16 newcolor = (UINT16)luaL_checkinteger(L,3);
+		UINT16 newcolor = (UINT16)luaL_checkinteger(L, 3);
 		if (newcolor >= numskincolors)
 			return luaL_error(L, "mobj.color %d out of range (0 - %d).", newcolor, numskincolors-1);
 		mo->color = newcolor;
 		break;
 	}
+	case mobj_translation:
+	{
+		if (!lua_isnil(L, 3)) {
+			const char *tr = luaL_checkstring(L, 3);
+			int id = R_FindCustomTranslation(tr);
+			if (id != -1)
+				mo->translation = id;
+			else
+				return luaL_error(L, "invalid translation '%s'.", tr);
+		}
+		else
+			mo->translation = 0;
+		break;
+	}
 	case mobj_blendmode:
 	{
 		INT32 blendmode = (INT32)luaL_checkinteger(L, 3);
diff --git a/src/m_misc.c b/src/m_misc.c
index 084dc2896eb19b2d23eae9d5ea1f3ad2839cba33..2cf260e949ec63c967d3b4982365085d96d97a13 100644
--- a/src/m_misc.c
+++ b/src/m_misc.c
@@ -31,6 +31,7 @@
 #include "doomdef.h"
 #include "g_game.h"
 #include "m_misc.h"
+#include "m_tokenizer.h"
 #include "hu_stuff.h"
 #include "st_stuff.h"
 #include "v_video.h"
@@ -1975,168 +1976,39 @@ void M_UnGetToken(void)
 	endPos = oldendPos;
 }
 
-#define NUMTOKENS 2
-static const char *tokenizerInput = NULL;
-static UINT32 tokenCapacity[NUMTOKENS] = {0};
-static char *tokenizerToken[NUMTOKENS] = {NULL};
-static UINT32 tokenizerStartPos = 0;
-static UINT32 tokenizerEndPos = 0;
-static UINT32 tokenizerInputLength = 0;
-static UINT8 tokenizerInComment = 0; // 0 = not in comment, 1 = // Single-line, 2 = /* Multi-line */
+static tokenizer_t *globalTokenizer = NULL;
 
 void M_TokenizerOpen(const char *inputString)
 {
-	size_t i;
-
-	tokenizerInput = inputString;
-	for (i = 0; i < NUMTOKENS; i++)
-	{
-		tokenCapacity[i] = 1024;
-		tokenizerToken[i] = (char*)Z_Malloc(tokenCapacity[i] * sizeof(char), PU_STATIC, NULL);
-	}
-	tokenizerInputLength = strlen(tokenizerInput);
+	globalTokenizer = Tokenizer_Open(inputString, 2);
 }
 
 void M_TokenizerClose(void)
 {
-	size_t i;
-
-	tokenizerInput = NULL;
-	for (i = 0; i < NUMTOKENS; i++)
-		Z_Free(tokenizerToken[i]);
-	tokenizerStartPos = 0;
-	tokenizerEndPos = 0;
-	tokenizerInComment = 0;
-}
-
-static void M_DetectComment(UINT32 *pos)
-{
-	if (tokenizerInComment)
-		return;
-
-	if (*pos >= tokenizerInputLength - 1)
-		return;
-
-	if (tokenizerInput[*pos] != '/')
-		return;
-
-	//Single-line comment start
-	if (tokenizerInput[*pos + 1] == '/')
-		tokenizerInComment = 1;
-	//Multi-line comment start
-	else if (tokenizerInput[*pos + 1] == '*')
-		tokenizerInComment = 2;
-}
-
-static void M_ReadTokenString(UINT32 i)
-{
-	UINT32 tokenLength = tokenizerEndPos - tokenizerStartPos;
-	if (tokenLength + 1 > tokenCapacity[i])
-	{
-		tokenCapacity[i] = tokenLength + 1;
-		// Assign the memory. Don't forget an extra byte for the end of the string!
-		tokenizerToken[i] = (char *)Z_Malloc(tokenCapacity[i] * sizeof(char), PU_STATIC, NULL);
-	}
-	// Copy the string.
-	M_Memcpy(tokenizerToken[i], tokenizerInput + tokenizerStartPos, (size_t)tokenLength);
-	// Make the final character NUL.
-	tokenizerToken[i][tokenLength] = '\0';
+	Tokenizer_Close(globalTokenizer);
+	globalTokenizer = NULL;
 }
 
 const char *M_TokenizerRead(UINT32 i)
 {
-	if (!tokenizerInput)
-		return NULL;
-
-	tokenizerStartPos = tokenizerEndPos;
-
-	// Try to detect comments now, in case we're pointing right at one
-	M_DetectComment(&tokenizerStartPos);
-
-	// Find the first non-whitespace char, or else the end of the string trying
-	while ((tokenizerInput[tokenizerStartPos] == ' '
-			|| tokenizerInput[tokenizerStartPos] == '\t'
-			|| tokenizerInput[tokenizerStartPos] == '\r'
-			|| tokenizerInput[tokenizerStartPos] == '\n'
-			|| tokenizerInput[tokenizerStartPos] == '\0'
-			|| tokenizerInput[tokenizerStartPos] == '=' || tokenizerInput[tokenizerStartPos] == ';' // UDMF TEXTMAP.
-			|| tokenizerInComment != 0)
-			&& tokenizerStartPos < tokenizerInputLength)
-	{
-		// Try to detect comment endings now
-		if (tokenizerInComment == 1	&& tokenizerInput[tokenizerStartPos] == '\n')
-			tokenizerInComment = 0; // End of line for a single-line comment
-		else if (tokenizerInComment == 2
-			&& tokenizerStartPos < tokenizerInputLength - 1
-			&& tokenizerInput[tokenizerStartPos] == '*'
-			&& tokenizerInput[tokenizerStartPos+1] == '/')
-		{
-			// End of multi-line comment
-			tokenizerInComment = 0;
-			tokenizerStartPos++; // Make damn well sure we're out of the comment ending at the end of it all
-		}
-
-		tokenizerStartPos++;
-		M_DetectComment(&tokenizerStartPos);
-	}
-
-	// If the end of the string is reached, no token is to be read
-	if (tokenizerStartPos == tokenizerInputLength) {
-		tokenizerEndPos = tokenizerInputLength;
+	if (!globalTokenizer)
 		return NULL;
-	}
-	// Else, if it's one of these three symbols, capture only this one character
-	else if (tokenizerInput[tokenizerStartPos] == ','
-			|| tokenizerInput[tokenizerStartPos] == '{'
-			|| tokenizerInput[tokenizerStartPos] == '}')
-	{
-		tokenizerEndPos = tokenizerStartPos + 1;
-		tokenizerToken[i][0] = tokenizerInput[tokenizerStartPos];
-		tokenizerToken[i][1] = '\0';
-		return tokenizerToken[i];
-	}
-	// Return entire string within quotes, except without the quotes.
-	else if (tokenizerInput[tokenizerStartPos] == '"')
-	{
-		tokenizerEndPos = ++tokenizerStartPos;
-		while (tokenizerInput[tokenizerEndPos] != '"' && tokenizerEndPos < tokenizerInputLength)
-			tokenizerEndPos++;
-
-		M_ReadTokenString(i);
-		tokenizerEndPos++;
-		return tokenizerToken[i];
-	}
 
-	// Now find the end of the token. This includes several additional characters that are okay to capture as one character, but not trailing at the end of another token.
-	tokenizerEndPos = tokenizerStartPos + 1;
-	while ((tokenizerInput[tokenizerEndPos] != ' '
-			&& tokenizerInput[tokenizerEndPos] != '\t'
-			&& tokenizerInput[tokenizerEndPos] != '\r'
-			&& tokenizerInput[tokenizerEndPos] != '\n'
-			&& tokenizerInput[tokenizerEndPos] != ','
-			&& tokenizerInput[tokenizerEndPos] != '{'
-			&& tokenizerInput[tokenizerEndPos] != '}'
-			&& tokenizerInput[tokenizerEndPos] != '=' && tokenizerInput[tokenizerEndPos] != ';' // UDMF TEXTMAP.
-			&& tokenizerInComment == 0)
-			&& tokenizerEndPos < tokenizerInputLength)
-	{
-		tokenizerEndPos++;
-		// Try to detect comment starts now; if it's in a comment, we don't want it in this token
-		M_DetectComment(&tokenizerEndPos);
-	}
-
-	M_ReadTokenString(i);
-	return tokenizerToken[i];
+	return Tokenizer_SRB2Read(globalTokenizer, i);
 }
 
 UINT32 M_TokenizerGetEndPos(void)
 {
-	return tokenizerEndPos;
+	if (!globalTokenizer)
+		return 0;
+
+	return Tokenizer_GetEndPos(globalTokenizer);
 }
 
 void M_TokenizerSetEndPos(UINT32 newPos)
 {
-	tokenizerEndPos = newPos;
+	if (globalTokenizer)
+		Tokenizer_SetEndPos(globalTokenizer, newPos);
 }
 
 /** Count bits in a number.
diff --git a/src/m_tokenizer.c b/src/m_tokenizer.c
new file mode 100644
index 0000000000000000000000000000000000000000..26275881d3f81fe8db6c22d2e35226839c75af56
--- /dev/null
+++ b/src/m_tokenizer.c
@@ -0,0 +1,278 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2013-2023 by Sonic Team Junior.
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+/// \file  m_tokenizer.c
+/// \brief Tokenizer
+
+#include "m_tokenizer.h"
+#include "z_zone.h"
+
+tokenizer_t *Tokenizer_Open(const char *inputString, unsigned numTokens)
+{
+	tokenizer_t *tokenizer = Z_Malloc(sizeof(tokenizer_t), PU_STATIC, NULL);
+
+	tokenizer->input = inputString;
+	tokenizer->startPos = 0;
+	tokenizer->endPos = 0;
+	tokenizer->inputLength = 0;
+	tokenizer->inComment = 0;
+	tokenizer->get = Tokenizer_Read;
+
+	if (numTokens < 1)
+		numTokens = 1;
+
+	tokenizer->numTokens = numTokens;
+	tokenizer->capacity = Z_Malloc(sizeof(UINT32) * numTokens, PU_STATIC, NULL);
+	tokenizer->token = Z_Malloc(sizeof(char*) * numTokens, PU_STATIC, NULL);
+
+	for (size_t i = 0; i < numTokens; i++)
+	{
+		tokenizer->capacity[i] = 1024;
+		tokenizer->token[i] = (char*)Z_Malloc(tokenizer->capacity[i] * sizeof(char), PU_STATIC, NULL);
+	}
+
+	tokenizer->inputLength = strlen(tokenizer->input);
+
+	return tokenizer;
+}
+
+void Tokenizer_Close(tokenizer_t *tokenizer)
+{
+	if (!tokenizer)
+		return;
+
+	for (size_t i = 0; i < tokenizer->numTokens; i++)
+		Z_Free(tokenizer->token[i]);
+	Z_Free(tokenizer->capacity);
+	Z_Free(tokenizer->token);
+	Z_Free(tokenizer);
+}
+
+static void Tokenizer_DetectComment(tokenizer_t *tokenizer, UINT32 *pos)
+{
+	if (tokenizer->inComment)
+		return;
+
+	if (*pos >= tokenizer->inputLength - 1)
+		return;
+
+	if (tokenizer->input[*pos] != '/')
+		return;
+
+	// Single-line comment start
+	if (tokenizer->input[*pos + 1] == '/')
+		tokenizer->inComment = 1;
+	// Multi-line comment start
+	else if (tokenizer->input[*pos + 1] == '*')
+		tokenizer->inComment = 2;
+}
+
+static void Tokenizer_ReadTokenString(tokenizer_t *tokenizer, UINT32 i)
+{
+	UINT32 tokenLength = tokenizer->endPos - tokenizer->startPos;
+	if (tokenLength + 1 > tokenizer->capacity[i])
+	{
+		tokenizer->capacity[i] = tokenLength + 1;
+		// Assign the memory. Don't forget an extra byte for the end of the string!
+		tokenizer->token[i] = (char *)Z_Malloc(tokenizer->capacity[i] * sizeof(char), PU_STATIC, NULL);
+	}
+	// Copy the string.
+	M_Memcpy(tokenizer->token[i], tokenizer->input + tokenizer->startPos, (size_t)tokenLength);
+	// Make the final character NUL.
+	tokenizer->token[i][tokenLength] = '\0';
+}
+
+const char *Tokenizer_Read(tokenizer_t *tokenizer, UINT32 i)
+{
+	if (!tokenizer->input)
+		return NULL;
+
+	tokenizer->startPos = tokenizer->endPos;
+
+	// Try to detect comments now, in case we're pointing right at one
+	Tokenizer_DetectComment(tokenizer, &tokenizer->startPos);
+
+	// Find the first non-whitespace char, or else the end of the string trying
+	while ((tokenizer->input[tokenizer->startPos] == ' '
+			|| tokenizer->input[tokenizer->startPos] == '\t'
+			|| tokenizer->input[tokenizer->startPos] == '\r'
+			|| tokenizer->input[tokenizer->startPos] == '\n'
+			|| tokenizer->input[tokenizer->startPos] == '\0'
+			|| tokenizer->inComment != 0)
+			&& tokenizer->startPos < tokenizer->inputLength)
+	{
+		// Try to detect comment endings now
+		if (tokenizer->inComment == 1	&& tokenizer->input[tokenizer->startPos] == '\n')
+			tokenizer->inComment = 0; // End of line for a single-line comment
+		else if (tokenizer->inComment == 2
+			&& tokenizer->startPos < tokenizer->inputLength - 1
+			&& tokenizer->input[tokenizer->startPos] == '*'
+			&& tokenizer->input[tokenizer->startPos+1] == '/')
+		{
+			// End of multi-line comment
+			tokenizer->inComment = 0;
+			tokenizer->startPos++; // Make damn well sure we're out of the comment ending at the end of it all
+		}
+
+		tokenizer->startPos++;
+		Tokenizer_DetectComment(tokenizer, &tokenizer->startPos);
+	}
+
+	// If the end of the string is reached, no token is to be read
+	if (tokenizer->startPos == tokenizer->inputLength) {
+		tokenizer->endPos = tokenizer->inputLength;
+		return NULL;
+	}
+	// Else, if it's one of these three symbols, capture only this one character
+	else if (tokenizer->input[tokenizer->startPos] == ','
+			|| tokenizer->input[tokenizer->startPos] == '{'
+			|| tokenizer->input[tokenizer->startPos] == '}'
+			|| tokenizer->input[tokenizer->startPos] == '['
+			|| tokenizer->input[tokenizer->startPos] == ']'
+			|| tokenizer->input[tokenizer->startPos] == '='
+			|| tokenizer->input[tokenizer->startPos] == ':'
+			|| tokenizer->input[tokenizer->startPos] == '%')
+	{
+		tokenizer->endPos = tokenizer->startPos + 1;
+		tokenizer->token[i][0] = tokenizer->input[tokenizer->startPos];
+		tokenizer->token[i][1] = '\0';
+		return tokenizer->token[i];
+	}
+	// Return entire string within quotes, except without the quotes.
+	else if (tokenizer->input[tokenizer->startPos] == '"')
+	{
+		tokenizer->endPos = ++tokenizer->startPos;
+		while (tokenizer->input[tokenizer->endPos] != '"' && tokenizer->endPos < tokenizer->inputLength)
+			tokenizer->endPos++;
+
+		Tokenizer_ReadTokenString(tokenizer, i);
+		tokenizer->endPos++;
+		return tokenizer->token[i];
+	}
+
+	// Now find the end of the token. This includes several additional characters that are okay to capture as one character, but not trailing at the end of another token.
+	tokenizer->endPos = tokenizer->startPos + 1;
+	while ((tokenizer->input[tokenizer->endPos] != ' '
+			&& tokenizer->input[tokenizer->endPos] != '\t'
+			&& tokenizer->input[tokenizer->endPos] != '\r'
+			&& tokenizer->input[tokenizer->endPos] != '\n'
+			&& tokenizer->input[tokenizer->endPos] != ','
+			&& tokenizer->input[tokenizer->endPos] != '{'
+			&& tokenizer->input[tokenizer->endPos] != '}'
+			&& tokenizer->input[tokenizer->endPos] != '['
+			&& tokenizer->input[tokenizer->endPos] != ']'
+			&& tokenizer->input[tokenizer->endPos] != '='
+			&& tokenizer->input[tokenizer->endPos] != ':'
+			&& tokenizer->input[tokenizer->endPos] != '%'
+			&& tokenizer->inComment == 0)
+			&& tokenizer->endPos < tokenizer->inputLength)
+	{
+		tokenizer->endPos++;
+		// Try to detect comment starts now; if it's in a comment, we don't want it in this token
+		Tokenizer_DetectComment(tokenizer, &tokenizer->endPos);
+	}
+
+	Tokenizer_ReadTokenString(tokenizer, i);
+	return tokenizer->token[i];
+}
+
+const char *Tokenizer_SRB2Read(tokenizer_t *tokenizer, UINT32 i)
+{
+	if (!tokenizer->input)
+		return NULL;
+
+	tokenizer->startPos = tokenizer->endPos;
+
+	// Try to detect comments now, in case we're pointing right at one
+	Tokenizer_DetectComment(tokenizer, &tokenizer->startPos);
+
+	// Find the first non-whitespace char, or else the end of the string trying
+	while ((tokenizer->input[tokenizer->startPos] == ' '
+			|| tokenizer->input[tokenizer->startPos] == '\t'
+			|| tokenizer->input[tokenizer->startPos] == '\r'
+			|| tokenizer->input[tokenizer->startPos] == '\n'
+			|| tokenizer->input[tokenizer->startPos] == '\0'
+			|| tokenizer->input[tokenizer->startPos] == '=' || tokenizer->input[tokenizer->startPos] == ';' // UDMF TEXTMAP.
+			|| tokenizer->inComment != 0)
+			&& tokenizer->startPos < tokenizer->inputLength)
+	{
+		// Try to detect comment endings now
+		if (tokenizer->inComment == 1	&& tokenizer->input[tokenizer->startPos] == '\n')
+			tokenizer->inComment = 0; // End of line for a single-line comment
+		else if (tokenizer->inComment == 2
+			&& tokenizer->startPos < tokenizer->inputLength - 1
+			&& tokenizer->input[tokenizer->startPos] == '*'
+			&& tokenizer->input[tokenizer->startPos+1] == '/')
+		{
+			// End of multi-line comment
+			tokenizer->inComment = 0;
+			tokenizer->startPos++; // Make damn well sure we're out of the comment ending at the end of it all
+		}
+
+		tokenizer->startPos++;
+		Tokenizer_DetectComment(tokenizer, &tokenizer->startPos);
+	}
+
+	// If the end of the string is reached, no token is to be read
+	if (tokenizer->startPos == tokenizer->inputLength) {
+		tokenizer->endPos = tokenizer->inputLength;
+		return NULL;
+	}
+	// Else, if it's one of these three symbols, capture only this one character
+	else if (tokenizer->input[tokenizer->startPos] == ','
+			|| tokenizer->input[tokenizer->startPos] == '{'
+			|| tokenizer->input[tokenizer->startPos] == '}')
+	{
+		tokenizer->endPos = tokenizer->startPos + 1;
+		tokenizer->token[i][0] = tokenizer->input[tokenizer->startPos];
+		tokenizer->token[i][1] = '\0';
+		return tokenizer->token[i];
+	}
+	// Return entire string within quotes, except without the quotes.
+	else if (tokenizer->input[tokenizer->startPos] == '"')
+	{
+		tokenizer->endPos = ++tokenizer->startPos;
+		while (tokenizer->input[tokenizer->endPos] != '"' && tokenizer->endPos < tokenizer->inputLength)
+			tokenizer->endPos++;
+
+		Tokenizer_ReadTokenString(tokenizer, i);
+		tokenizer->endPos++;
+		return tokenizer->token[i];
+	}
+
+	// Now find the end of the token. This includes several additional characters that are okay to capture as one character, but not trailing at the end of another token.
+	tokenizer->endPos = tokenizer->startPos + 1;
+	while ((tokenizer->input[tokenizer->endPos] != ' '
+			&& tokenizer->input[tokenizer->endPos] != '\t'
+			&& tokenizer->input[tokenizer->endPos] != '\r'
+			&& tokenizer->input[tokenizer->endPos] != '\n'
+			&& tokenizer->input[tokenizer->endPos] != ','
+			&& tokenizer->input[tokenizer->endPos] != '{'
+			&& tokenizer->input[tokenizer->endPos] != '}'
+			&& tokenizer->input[tokenizer->endPos] != '=' && tokenizer->input[tokenizer->endPos] != ';' // UDMF TEXTMAP.
+			&& tokenizer->inComment == 0)
+			&& tokenizer->endPos < tokenizer->inputLength)
+	{
+		tokenizer->endPos++;
+		// Try to detect comment starts now; if it's in a comment, we don't want it in this token
+		Tokenizer_DetectComment(tokenizer, &tokenizer->endPos);
+	}
+
+	Tokenizer_ReadTokenString(tokenizer, i);
+	return tokenizer->token[i];
+}
+
+UINT32 Tokenizer_GetEndPos(tokenizer_t *tokenizer)
+{
+	return tokenizer->endPos;
+}
+
+void Tokenizer_SetEndPos(tokenizer_t *tokenizer, UINT32 newPos)
+{
+	tokenizer->endPos = newPos;
+}
diff --git a/src/m_tokenizer.h b/src/m_tokenizer.h
new file mode 100644
index 0000000000000000000000000000000000000000..88cb2a566907d27b0d70508fb20c9ab1dff84fbb
--- /dev/null
+++ b/src/m_tokenizer.h
@@ -0,0 +1,38 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 2013-2023 by Sonic Team Junior.
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+/// \file  m_tokenizer.h
+/// \brief Tokenizer
+
+#ifndef __M_TOKENIZER__
+#define __M_TOKENIZER__
+
+#include "doomdef.h"
+
+typedef struct Tokenizer
+{
+	const char *input;
+	unsigned numTokens;
+	UINT32 *capacity;
+	char **token;
+	UINT32 startPos;
+	UINT32 endPos;
+	UINT32 inputLength;
+	UINT8 inComment; // 0 = not in comment, 1 = // Single-line, 2 = /* Multi-line */
+	const char *(*get)(struct Tokenizer*, UINT32);
+} tokenizer_t;
+
+tokenizer_t *Tokenizer_Open(const char *inputString, unsigned numTokens);
+void Tokenizer_Close(tokenizer_t *tokenizer);
+
+const char *Tokenizer_Read(tokenizer_t *tokenizer, UINT32 i);
+const char *Tokenizer_SRB2Read(tokenizer_t *tokenizer, UINT32 i);
+UINT32 Tokenizer_GetEndPos(tokenizer_t *tokenizer);
+void Tokenizer_SetEndPos(tokenizer_t *tokenizer, UINT32 newPos);
+
+#endif
diff --git a/src/netcode/d_netcmd.c b/src/netcode/d_netcmd.c
index 0c3d519be11c93c12894f52a02d3c3276a38ef20..938a91a0de0c098b036e9980541d13c9b72cbb9b 100644
--- a/src/netcode/d_netcmd.c
+++ b/src/netcode/d_netcmd.c
@@ -109,6 +109,9 @@ static void Color2_OnChange(void);
 static void DummyConsvar_OnChange(void);
 static void SoundTest_OnChange(void);
 
+static boolean Skin_CanChange(const char *valstr);
+static boolean Skin2_CanChange(const char *valstr);
+
 #ifdef NETGAME_DEVMODE
 static void Fishcake_OnChange(void);
 #endif
@@ -228,7 +231,6 @@ consvar_t cv_seenames = CVAR_INIT ("seenames", "Ally/Foe", CV_SAVE|CV_ALLOWLUA,
 consvar_t cv_allowseenames = CVAR_INIT ("allowseenames", "Yes", CV_SAVE|CV_NETVAR|CV_ALLOWLUA, CV_YesNo, NULL);
 
 // names
-static char *lastskinnames[2];
 consvar_t cv_playername = CVAR_INIT ("name", "Sonic", CV_SAVE|CV_CALL|CV_NOINIT, NULL, Name_OnChange);
 consvar_t cv_playername2 = CVAR_INIT ("name2", "Tails", CV_SAVE|CV_CALL|CV_NOINIT, NULL, Name2_OnChange);
 // player colors
@@ -236,8 +238,8 @@ UINT16 lastgoodcolor = SKINCOLOR_BLUE, lastgoodcolor2 = SKINCOLOR_BLUE;
 consvar_t cv_playercolor = CVAR_INIT ("color", "Blue", CV_CALL|CV_NOINIT|CV_ALLOWLUA, Color_cons_t, Color_OnChange);
 consvar_t cv_playercolor2 = CVAR_INIT ("color2", "Orange", CV_CALL|CV_NOINIT|CV_ALLOWLUA, Color_cons_t, Color2_OnChange);
 // player's skin, saved for commodity, when using a favorite skins wad..
-consvar_t cv_skin = CVAR_INIT ("skin", DEFAULTSKIN, CV_CALL|CV_NOINIT|CV_ALLOWLUA, NULL, Skin_OnChange);
-consvar_t cv_skin2 = CVAR_INIT ("skin2", DEFAULTSKIN2, CV_CALL|CV_NOINIT|CV_ALLOWLUA, NULL, Skin2_OnChange);
+consvar_t cv_skin = CVAR_INIT_WITH_CALLBACKS ("skin", DEFAULTSKIN, CV_CALL|CV_NOINIT|CV_ALLOWLUA, NULL, Skin_OnChange, Skin_CanChange);
+consvar_t cv_skin2 = CVAR_INIT_WITH_CALLBACKS ("skin2", DEFAULTSKIN2, CV_CALL|CV_NOINIT|CV_ALLOWLUA, NULL, Skin2_OnChange, Skin2_CanChange);
 
 // saved versions of the above six
 consvar_t cv_defaultplayercolor = CVAR_INIT ("defaultcolor", "Blue", CV_SAVE, Color_cons_t, NULL);
@@ -4790,6 +4792,41 @@ static void Name2_OnChange(void)
 		SendNameAndColor2();
 }
 
+static boolean Skin_CanChange(const char *valstr)
+{
+	(void)valstr;
+
+	if (!Playing())
+		return true; // do whatever you want
+
+	if (!(multiplayer || netgame)) // In single player.
+		return true;
+
+	if (CanChangeSkin(consoleplayer) && !P_PlayerMoving(consoleplayer))
+		return true;
+	else
+	{
+		CONS_Alert(CONS_NOTICE, M_GetText("You can't change your skin at the moment.\n"));
+		return false;
+	}
+}
+
+static boolean Skin2_CanChange(const char *valstr)
+{
+	(void)valstr;
+
+	if (!Playing() || !splitscreen)
+		return true; // do whatever you want
+
+	if (CanChangeSkin(secondarydisplayplayer) && !P_PlayerMoving(secondarydisplayplayer))
+		return true;
+	else
+	{
+		CONS_Alert(CONS_NOTICE, M_GetText("You can't change your skin at the moment.\n"));
+		return false;
+	}
+}
+
 /** Sends a skin change for the console player, unless that player is moving.
   * \sa cv_skin, Skin2_OnChange, Color_OnChange
   * \author Graue <graue@oceanbase.org>
@@ -4797,10 +4834,7 @@ static void Name2_OnChange(void)
 static void Skin_OnChange(void)
 {
 	if (!Playing())
-		return; // do whatever you want
-
-	if (lastskinnames[0] == NULL)
-		lastskinnames[0] = Z_StrDup(cv_skin.string);
+		return;
 
 	if (!(multiplayer || netgame)) // In single player.
 	{
@@ -4816,17 +4850,7 @@ static void Skin_OnChange(void)
 		return;
 	}
 
-	if (CanChangeSkin(consoleplayer) && !P_PlayerMoving(consoleplayer))
-	{
-		SendNameAndColor();
-		Z_Free(lastskinnames[0]);
-		lastskinnames[0] = Z_StrDup(cv_skin.string);
-	}
-	else
-	{
-		CONS_Alert(CONS_NOTICE, M_GetText("You can't change your skin at the moment.\n"));
-		CV_StealthSet(&cv_skin, lastskinnames[0]);
-	}
+	SendNameAndColor();
 }
 
 /** Sends a skin change for the secondary splitscreen player, unless that
@@ -4837,22 +4861,9 @@ static void Skin_OnChange(void)
 static void Skin2_OnChange(void)
 {
 	if (!Playing() || !splitscreen)
-		return; // do whatever you want
-
-	if (lastskinnames[1] == NULL)
-		lastskinnames[1] = Z_StrDup(cv_skin2.string);
+		return;
 
-	if (CanChangeSkin(secondarydisplayplayer) && !P_PlayerMoving(secondarydisplayplayer))
-	{
-		SendNameAndColor2();
-		Z_Free(lastskinnames[1]);
-		lastskinnames[1] = Z_StrDup(cv_skin.string);
-	}
-	else
-	{
-		CONS_Alert(CONS_NOTICE, M_GetText("You can't change your skin at the moment.\n"));
-		CV_StealthSet(&cv_skin2, lastskinnames[1]);
-	}
+	SendNameAndColor2();
 }
 
 /** Sends a color change for the console player, unless that player is moving.
diff --git a/src/p_enemy.c b/src/p_enemy.c
index 23d855a637b3182c908c58336c710e2bb8cc049b..dd259f410bbe9c825e179c47e2e607fe39a554a1 100644
--- a/src/p_enemy.c
+++ b/src/p_enemy.c
@@ -23,6 +23,7 @@
 #include "m_random.h"
 #include "m_misc.h"
 #include "r_skins.h"
+#include "r_translation.h"
 #include "i_video.h"
 #include "z_zone.h"
 #include "lua_hook.h"
@@ -196,6 +197,7 @@ void A_SetRandomTics(mobj_t *actor);
 void A_ChangeColorRelative(mobj_t *actor);
 void A_ChangeColorAbsolute(mobj_t *actor);
 void A_Dye(mobj_t *actor);
+void A_SetTranslation(mobj_t *actor);
 void A_MoveRelative(mobj_t *actor);
 void A_MoveAbsolute(mobj_t *actor);
 void A_Thrust(mobj_t *actor);
@@ -9214,6 +9216,26 @@ void A_Dye(mobj_t *actor)
 	}
 }
 
+// Function: A_SetTranslation
+//
+// Description: Changes the translation of an actor.
+//
+// var1 = translation ID
+// var2 = unused
+//
+void A_SetTranslation(mobj_t *actor)
+{
+	INT32 locvar1 = var1;
+
+	if (LUA_CallAction(A_SETTRANSLATION, actor))
+		return;
+
+	if (R_TranslationIsValid(locvar1))
+		actor->translation = (UINT32)locvar1;
+	else
+		actor->translation = 0;
+}
+
 // Function: A_MoveRelative
 //
 // Description: Moves an object (wrapper for P_Thrust)
diff --git a/src/p_mobj.h b/src/p_mobj.h
index a980691beb8b296b4fd16415141e4f9e5f484af8..f2e4cbf3d63ff1675825078c1e6fc165aa515af8 100644
--- a/src/p_mobj.h
+++ b/src/p_mobj.h
@@ -331,9 +331,14 @@ typedef struct mobj_s
 	UINT16 eflags; // extra flags
 
 	void *skin; // overrides 'sprite' when non-NULL (for player bodies to 'remember' the skin)
+
 	// Player and mobj sprites in multiplayer modes are modified
 	//  using an internal color lookup table for re-indexing.
-	UINT16 color; // This replaces MF_TRANSLATION. Use 0 for default (no translation).
+	UINT16 color;
+
+	// This replaces MF_TRANSLATION. Use 0 for default (no translation).
+	UINT16 translation;
+
 	struct player_s *drawonlyforplayer; // If set, hides the mobj for everyone except this player and their spectators
 	struct mobj_s *dontdrawforviewmobj; // If set, hides the mobj if dontdrawforviewmobj is the current camera (first-person player or awayviewmobj)
 
diff --git a/src/p_saveg.c b/src/p_saveg.c
index 6440ab0e36bf0cf953ce5411146d6a5ead455e99..2c7b9e238fd62a1081a56e3c9894eacf4555cdaf 100644
--- a/src/p_saveg.c
+++ b/src/p_saveg.c
@@ -1741,7 +1741,8 @@ typedef enum
 	MD2_FLOORSPRITESLOPE    = 1<<22,
 	MD2_DISPOFFSET          = 1<<23,
 	MD2_DRAWONLYFORPLAYER   = 1<<24,
-	MD2_DONTDRAWFORVIEWMOBJ = 1<<25
+	MD2_DONTDRAWFORVIEWMOBJ = 1<<25,
+	MD2_TRANSLATION         = 1<<26
 } mobj_diff2_t;
 
 typedef enum
@@ -1930,6 +1931,8 @@ static void SaveMobjThinker(const thinker_t *th, const UINT8 type)
 		diff2 |= MD2_CVMEM;
 	if (mobj->color)
 		diff2 |= MD2_COLOR;
+	if (mobj->translation)
+		diff2 |= MD2_TRANSLATION;
 	if (mobj->skin)
 		diff2 |= MD2_SKIN;
 	if (mobj->extravalue1)
@@ -2163,6 +2166,8 @@ static void SaveMobjThinker(const thinker_t *th, const UINT8 type)
 		WRITEUINT32(save_p, mobj->dontdrawforviewmobj->mobjnum);
 	if (diff2 & MD2_DISPOFFSET)
 		WRITEINT32(save_p, mobj->dispoffset);
+	if (diff2 & MD2_TRANSLATION)
+		WRITEUINT16(save_p, mobj->translation);
 
 	WRITEUINT32(save_p, mobj->mobjnum);
 }
@@ -3225,6 +3230,8 @@ static thinker_t* LoadMobjThinker(actionf_p1 thinker)
 		mobj->dispoffset = READINT32(save_p);
 	else
 		mobj->dispoffset = mobj->info->dispoffset;
+	if (diff2 & MD2_TRANSLATION)
+		mobj->translation = READUINT16(save_p);
 
 	if (diff & MD_REDFLAG)
 	{
diff --git a/src/p_setup.c b/src/p_setup.c
index f3b4b6b8f0c7717fd71f58ffee3aaf3cbc16f323..ced9c3b92a4790e7c1a9a5170063df9c65b57608 100644
--- a/src/p_setup.c
+++ b/src/p_setup.c
@@ -30,6 +30,7 @@
 #include "r_data.h"
 #include "r_things.h" // for R_AddSpriteDefs
 #include "r_textures.h"
+#include "r_translation.h"
 #include "r_patch.h"
 #include "r_picformats.h"
 #include "r_sky.h"
@@ -8319,6 +8320,8 @@ static boolean P_LoadAddon(UINT16 numlumps)
 		HWR_ClearAllTextures();
 #endif
 
+	R_LoadParsedTranslations();
+
 	//
 	// search for sprite replacements
 	//
diff --git a/src/p_user.c b/src/p_user.c
index 35f2e1ebe857b7f8a79e4ebf360553391af83ef0..fae18af67ffd64cee4e043ca07c5a72711d1b31f 100644
--- a/src/p_user.c
+++ b/src/p_user.c
@@ -2042,6 +2042,7 @@ mobj_t *P_SpawnGhostMobj(mobj_t *mobj)
 	}
 
 	ghost->color = mobj->color;
+	ghost->translation = mobj->translation;
 	ghost->colorized = mobj->colorized; // alternatively, "true" for sonic advance style colourisation
 
 	ghost->angle = (mobj->player ? mobj->player->drawangle : mobj->angle);
diff --git a/src/r_data.c b/src/r_data.c
index e2b74da40712014986b3adc495b77a3ce5c9a221..0a13d27dbaf55f9e9092adeb0de4635523c96606 100644
--- a/src/r_data.c
+++ b/src/r_data.c
@@ -20,6 +20,7 @@
 #include "m_misc.h"
 #include "r_data.h"
 #include "r_textures.h"
+#include "r_translation.h"
 #include "r_patch.h"
 #include "r_picformats.h"
 #include "w_wad.h"
@@ -1217,6 +1218,9 @@ void R_InitData(void)
 		R_Init8to16();
 	}
 
+	CONS_Printf("R_LoadParsedTranslations()...\n");
+	R_LoadParsedTranslations();
+
 	CONS_Printf("R_LoadTextures()...\n");
 	R_LoadTextures();
 
diff --git a/src/r_defs.h b/src/r_defs.h
index 2931eb1c8afca3c2b70bcb58c880c86003e8a65e..39e6765cd7af35d6478e690de8c48045d063b505 100644
--- a/src/r_defs.h
+++ b/src/r_defs.h
@@ -24,12 +24,11 @@
 
 #include "screen.h" // MAXVIDWIDTH, MAXVIDHEIGHT
 
-#ifdef HWRENDER
-#include "m_aatree.h"
-#endif
-
 #include "taglist.h"
 
+// Amount of colors in the palette
+#define NUM_PALETTE_ENTRIES 256
+
 //
 // ClipWallSegment
 // Clips the given range of columns
diff --git a/src/r_draw.c b/src/r_draw.c
index 6e7efd004e656121e895ae4169cc5842b8b01932..b87a8404ebc3ea5895425dd81b1c78dc6040fdfa 100644
--- a/src/r_draw.c
+++ b/src/r_draw.c
@@ -18,6 +18,7 @@
 #include "doomdef.h"
 #include "doomstat.h"
 #include "r_local.h"
+#include "r_translation.h"
 #include "st_stuff.h" // need ST_HEIGHT
 #include "i_video.h"
 #include "v_video.h"
@@ -452,8 +453,14 @@ static void R_GenerateTranslationColormap(UINT8 *dest_colormap, INT32 translatio
 		switch (translation)
 		{
 			case TC_ALLWHITE:
-				memset(dest_colormap, 0, NUM_PALETTE_ENTRIES * sizeof(UINT8));
-				return;
+			case TC_DASHMODE:
+				remaptable_t *tr = R_GetBuiltInTranslation((SINT8)translation);
+				if (tr)
+				{
+					memcpy(dest_colormap, tr->remap, NUM_PALETTE_ENTRIES);
+					return;
+				}
+				break;
 			case TC_RAINBOW:
 				if (color >= numskincolors)
 					I_Error("Invalid skin color #%hu", (UINT16)color);
@@ -501,40 +508,6 @@ static void R_GenerateTranslationColormap(UINT8 *dest_colormap, INT32 translatio
 			for (i = 0; i < COLORRAMPSIZE; i++)
 				dest_colormap[96+i] = dest_colormap[skincolors[SKINCOLOR_COBALT].ramp[i]];
 		}
-		else if (translation == TC_DASHMODE) // This is a long one, because MotorRoach basically hand-picked the indices
-		{
-			// greens -> ketchups
-			dest_colormap[96] = dest_colormap[97] = 48;
-			dest_colormap[98] = 49;
-			dest_colormap[99] = 51;
-			dest_colormap[100] = 52;
-			dest_colormap[101] = dest_colormap[102] = 54;
-			dest_colormap[103] = 34;
-			dest_colormap[104] = 37;
-			dest_colormap[105] = 39;
-			dest_colormap[106] = 41;
-			for (i = 0; i < 5; i++)
-				dest_colormap[107 + i] = 43 + i;
-
-			// reds -> steel blues
-			dest_colormap[32] = 146;
-			dest_colormap[33] = 147;
-			dest_colormap[34] = dest_colormap[35] = 170;
-			dest_colormap[36] = 171;
-			dest_colormap[37] = dest_colormap[38] = 172;
-			dest_colormap[39] = dest_colormap[40] = dest_colormap[41] = 173;
-			dest_colormap[42] = dest_colormap[43] = dest_colormap[44] = 174;
-			dest_colormap[45] = dest_colormap[46] = dest_colormap[47] = 175;
-			dest_colormap[71] = 139;
-
-			// steel blues -> oranges
-			dest_colormap[170] = 52;
-			dest_colormap[171] = 54;
-			dest_colormap[172] = 56;
-			dest_colormap[173] = 42;
-			dest_colormap[174] = 45;
-			dest_colormap[175] = 47;
-		}
 		return;
 	}
 	else if (color == SKINCOLOR_NONE)
diff --git a/src/r_splats.c b/src/r_splats.c
index e9665e84a35a4089e72cfa40120157cf418cf9e9..027ccd720aed2902b7c096040548b95e3d3b369d 100644
--- a/src/r_splats.c
+++ b/src/r_splats.c
@@ -404,7 +404,7 @@ static void R_RasterizeFloorSplat(floorsplat_t *pSplat, vector2_t *verts, visspr
 	}
 
 	ds_colormap = vis->colormap;
-	ds_translation = R_GetSpriteTranslation(vis);
+	ds_translation = R_GetTranslationForThing(vis->mobj, vis->color, vis->translation);
 	if (ds_translation == NULL)
 		ds_translation = colormaps;
 
diff --git a/src/r_things.c b/src/r_things.c
index d2163d14a09c7d8e899a435465229a57015dbadd..9da647501e711c4e4e3943d379249763d5441712 100644
--- a/src/r_things.c
+++ b/src/r_things.c
@@ -25,6 +25,7 @@
 #include "i_system.h"
 #include "r_fps.h"
 #include "r_things.h"
+#include "r_translation.h"
 #include "r_patch.h"
 #include "r_patchrotation.h"
 #include "r_picformats.h"
@@ -761,50 +762,46 @@ void R_DrawFlippedMaskedColumn(column_t *column)
 	dc_texturemid = basetexturemid;
 }
 
-boolean R_SpriteIsFlashing(vissprite_t *vis)
+UINT8 *R_GetTranslationForThing(mobj_t *mobj, skincolornum_t color, UINT16 translation)
 {
-	return (!(vis->cut & SC_PRECIP)
-	&& (vis->mobj->flags & (MF_ENEMY|MF_BOSS))
-	&& (vis->mobj->flags2 & MF2_FRET)
-	&& !(vis->mobj->flags & MF_GRENADEBOUNCE)
-	&& (leveltime & 1));
-}
-
-UINT8 *R_GetSpriteTranslation(vissprite_t *vis)
-{
-	if (R_SpriteIsFlashing(vis)) // Bosses "flash"
+	if (R_ThingIsFlashing(mobj)) // Bosses "flash"
 	{
-		if (vis->mobj->type == MT_CYBRAKDEMON || vis->mobj->colorized)
+		if (mobj->type == MT_CYBRAKDEMON || mobj->colorized)
 			return R_GetTranslationColormap(TC_ALLWHITE, 0, GTC_CACHE);
-		else if (vis->mobj->type == MT_METALSONIC_BATTLE)
+		else if (mobj->type == MT_METALSONIC_BATTLE)
 			return R_GetTranslationColormap(TC_METALSONIC, 0, GTC_CACHE);
 		else
-			return R_GetTranslationColormap(TC_BOSS, vis->color, GTC_CACHE);
+			return R_GetTranslationColormap(TC_BOSS, color, GTC_CACHE);
 	}
-	else if (vis->color)
+	else if (translation != 0)
+	{
+		remaptable_t *tr = R_GetTranslationByID(translation);
+		if (tr != NULL)
+			return tr->remap;
+	}
+	else if (color != SKINCOLOR_NONE)
 	{
 		// New colormap stuff for skins Tails 06-07-2002
-		if (!(vis->cut & SC_PRECIP) && vis->mobj->colorized)
-			return R_GetTranslationColormap(TC_RAINBOW, vis->color, GTC_CACHE);
-		else if (!(vis->cut & SC_PRECIP)
-			&& vis->mobj->player && vis->mobj->player->dashmode >= DASHMODE_THRESHOLD
-			&& (vis->mobj->player->charflags & SF_DASHMODE)
+		if (mobj->colorized)
+			return R_GetTranslationColormap(TC_RAINBOW, color, GTC_CACHE);
+		else if (mobj->player && mobj->player->dashmode >= DASHMODE_THRESHOLD
+			&& (mobj->player->charflags & SF_DASHMODE)
 			&& ((leveltime/2) & 1))
 		{
-			if (vis->mobj->player->charflags & SF_MACHINE)
+			if (mobj->player->charflags & SF_MACHINE)
 				return R_GetTranslationColormap(TC_DASHMODE, 0, GTC_CACHE);
 			else
-				return R_GetTranslationColormap(TC_RAINBOW, vis->color, GTC_CACHE);
+				return R_GetTranslationColormap(TC_RAINBOW, color, GTC_CACHE);
 		}
-		else if (!(vis->cut & SC_PRECIP) && vis->mobj->skin && vis->mobj->sprite == SPR_PLAY) // This thing is a player!
+		else if (mobj->skin && mobj->sprite == SPR_PLAY) // This thing is a player!
 		{
-			UINT8 skinnum = ((skin_t*)vis->mobj->skin)->skinnum;
-			return R_GetTranslationColormap(skinnum, vis->color, GTC_CACHE);
+			UINT8 skinnum = ((skin_t*)mobj->skin)->skinnum;
+			return R_GetTranslationColormap(skinnum, color, GTC_CACHE);
 		}
 		else // Use the defaults
-			return R_GetTranslationColormap(TC_DEFAULT, vis->color, GTC_CACHE);
+			return R_GetTranslationColormap(TC_DEFAULT, color, GTC_CACHE);
 	}
-	else if (vis->mobj->sprite == SPR_PLAY) // Looks like a player, but doesn't have a color? Get rid of green sonic syndrome.
+	else if (mobj->sprite == SPR_PLAY) // Looks like a player, but doesn't have a color? Get rid of green sonic syndrome.
 		return R_GetTranslationColormap(TC_DEFAULT, SKINCOLOR_BLUE, GTC_CACHE);
 
 	return NULL;
@@ -852,11 +849,11 @@ static void R_DrawVisSprite(vissprite_t *vis)
 
 	colfunc = colfuncs[BASEDRAWFUNC]; // hack: this isn't resetting properly somewhere.
 	dc_colormap = vis->colormap;
-	dc_translation = R_GetSpriteTranslation(vis);
+	dc_translation = R_GetTranslationForThing(vis->mobj, vis->color, vis->translation);
 
-	if (R_SpriteIsFlashing(vis)) // Bosses "flash"
+	if (R_ThingIsFlashing(vis->mobj)) // Bosses "flash"
 		colfunc = colfuncs[COLDRAWFUNC_TRANS]; // translate certain pixels to white
-	else if (vis->color && vis->transmap) // Color mapping
+	else if (dc_translation && vis->transmap) // Color mapping
 	{
 		colfunc = colfuncs[COLDRAWFUNC_TRANSTRANS];
 		dc_transmap = vis->transmap;
@@ -866,9 +863,7 @@ static void R_DrawVisSprite(vissprite_t *vis)
 		colfunc = colfuncs[COLDRAWFUNC_FUZZY];
 		dc_transmap = vis->transmap;    //Fab : 29-04-98: translucency table
 	}
-	else if (vis->color) // translate green skin to another color
-		colfunc = colfuncs[COLDRAWFUNC_TRANS];
-	else if (vis->mobj->sprite == SPR_PLAY) // Looks like a player, but doesn't have a color? Get rid of green sonic syndrome.
+	else if (dc_translation) // translate green skin to another color
 		colfunc = colfuncs[COLDRAWFUNC_TRANS];
 
 	// Hack: Use a special column function for drop shadows that bypasses
@@ -1403,6 +1398,7 @@ static void R_ProjectDropShadow(mobj_t *thing, vissprite_t *vis, fixed_t scale,
 
 	shadow->mobj = thing; // Easy access! Tails 06-07-2002
 	shadow->color = thing->color;
+	shadow->translation = 0;
 
 	shadow->x1 = x1 < portalclipstart ? portalclipstart : x1;
 	shadow->x2 = x2 >= portalclipend ? portalclipend-1 : x2;
@@ -2200,6 +2196,11 @@ static void R_ProjectSprite(mobj_t *thing)
 	else
 		vis->color = oldthing->color;
 
+	if ((oldthing->flags2 & MF2_LINKDRAW) && oldthing->tracer && oldthing->translation == 0)
+		vis->translation = oldthing->tracer->translation;
+	else
+		vis->translation = oldthing->translation;
+
 	vis->x1 = x1 < portalclipstart ? portalclipstart : x1;
 	vis->x2 = x2 >= portalclipend ? portalclipend-1 : x2;
 
@@ -2470,6 +2471,7 @@ static void R_ProjectPrecipitationSprite(precipmobj_t *thing)
 	vis->extra_colormap = thing->subsector->sector->extra_colormap;
 	vis->heightsec = thing->subsector->sector->heightsec;
 	vis->color = SKINCOLOR_NONE;
+	vis->translation = 0;
 
 	// Fullbright
 	vis->colormap = colormaps;
@@ -3570,6 +3572,14 @@ boolean R_ThingIsFullDark(mobj_t *thing)
 	return ((thing->frame & FF_BRIGHTMASK) == FF_FULLDARK || (thing->renderflags & RF_BRIGHTMASK) == RF_FULLDARK);
 }
 
+boolean R_ThingIsFlashing(mobj_t *thing)
+{
+	if (thing == NULL)
+		return false;
+
+	return (thing->flags & (MF_ENEMY|MF_BOSS)) && (thing->flags2 & MF2_FRET) && !(thing->flags & MF_GRENADEBOUNCE) && (leveltime & 1);
+}
+
 // Offsets MT_OVERLAY towards the camera at render-time - Works in splitscreen!
 // The &x and &y arguments should be pre-interpolated, and will be modified
 void R_ThingOffsetOverlay(mobj_t *thing, fixed_t *x, fixed_t *y)
diff --git a/src/r_things.h b/src/r_things.h
index ed2156baab4b15784360c0664352a1b8b2461d19..bd0a350093453a7798e3d254c6ddb7b3c16cce35 100644
--- a/src/r_things.h
+++ b/src/r_things.h
@@ -88,6 +88,10 @@ boolean R_ThingIsFullBright (mobj_t *thing);
 boolean R_ThingIsSemiBright (mobj_t *thing);
 boolean R_ThingIsFullDark (mobj_t *thing);
 
+boolean R_ThingIsFlashing (mobj_t *thing);
+
+UINT8 *R_GetTranslationForThing(mobj_t *mobj, skincolornum_t color, UINT16 translation);
+
 void R_ThingOffsetOverlay (mobj_t *thing, fixed_t *outx, fixed_t *outy);
 
 // --------------
@@ -216,6 +220,7 @@ typedef struct vissprite_s
 	fixed_t shadowscale;
 
 	skincolornum_t color;
+	UINT16 translation;
 
 	INT16 clipbot[MAXVIDWIDTH], cliptop[MAXVIDWIDTH];
 
@@ -226,12 +231,8 @@ extern UINT32 visspritecount, numvisiblesprites;
 
 void R_ClipSprites(drawseg_t* dsstart, portal_t* portal);
 
-boolean R_SpriteIsFlashing(vissprite_t *vis);
-
 void R_DrawThingBoundingBox(vissprite_t *spr);
 
-UINT8 *R_GetSpriteTranslation(vissprite_t *vis);
-
 // ----------
 // DRAW NODES
 // ----------
diff --git a/src/r_translation.c b/src/r_translation.c
new file mode 100644
index 0000000000000000000000000000000000000000..a4df3cde0c9bdc04702f963e62b3703ca6f42c58
--- /dev/null
+++ b/src/r_translation.c
@@ -0,0 +1,993 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 1998-2006 by Randy Heit.
+// Copyright (C) 2023 by Sonic Team Junior.
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+/// \file  r_translation.c
+/// \brief Translation table handling
+
+#include "r_translation.h"
+#include "r_data.h"
+#include "r_draw.h"
+#include "v_video.h" // pMasterPalette
+#include "z_zone.h"
+#include "w_wad.h"
+#include "m_tokenizer.h"
+
+#include <errno.h>
+
+static remaptable_t **paletteremaps = NULL;
+static unsigned numpaletteremaps = 0;
+
+static int allWhiteRemap = 0;
+static int dashModeRemap = 0;
+
+static void MakeDashModeRemap(void);
+
+static boolean PaletteRemap_AddIndexRange(remaptable_t *tr, int start, int end, int pal1, int pal2);
+static boolean PaletteRemap_AddColorRange(remaptable_t *tr, int start, int end, int r1i, int g1i, int b1i, int r2i, int g2i, int b2i);
+static boolean PaletteRemap_AddDesaturation(remaptable_t *tr, int start, int end, double r1, double g1, double b1, double r2, double g2, double b2);
+static boolean PaletteRemap_AddColourisation(remaptable_t *tr, int start, int end, int r, int g, int b);
+static boolean PaletteRemap_AddTint(remaptable_t *tr, int start, int end, int r, int g, int b, int amount);
+static boolean PaletteRemap_AddInvert(remaptable_t *tr, int start, int end);
+
+enum PaletteRemapType
+{
+	REMAP_ADD_INDEXRANGE,
+	REMAP_ADD_COLORRANGE,
+	REMAP_ADD_COLOURISATION,
+	REMAP_ADD_DESATURATION,
+	REMAP_ADD_TINT
+};
+
+struct PaletteRemapParseResult
+{
+	int start, end;
+	enum PaletteRemapType type;
+	union
+	{
+		struct
+		{
+			int pal1, pal2;
+		} indexRange;
+		struct
+		{
+			int r1, g1, b1;
+			int r2, g2, b2;
+		} colorRange;
+		struct
+		{
+			double r1, g1, b1;
+			double r2, g2, b2;
+		} desaturation;
+		struct
+		{
+			int r, g, b;
+		} colourisation;
+		struct
+		{
+			int r, g, b, amount;
+		} tint;
+	};
+
+	boolean has_error;
+	char error[4096];
+};
+
+void PaletteRemap_Init(void)
+{
+	// First translation must be the identity one.
+	remaptable_t *base = PaletteRemap_New();
+	PaletteRemap_SetIdentity(base);
+	PaletteRemap_Add(base);
+
+	// Grayscale translation
+	remaptable_t *grayscale = PaletteRemap_New();
+	PaletteRemap_SetIdentity(grayscale);
+	PaletteRemap_AddDesaturation(grayscale, 0, 255, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
+	R_AddCustomTranslation("Grayscale", PaletteRemap_Add(grayscale));
+
+	// All white (TC_ALLWHITE)
+	remaptable_t *allWhite = PaletteRemap_New();
+	memset(allWhite->remap, 0, NUM_PALETTE_ENTRIES * sizeof(UINT8));
+	allWhiteRemap = PaletteRemap_Add(allWhite);
+	R_AddCustomTranslation("AllWhite", allWhiteRemap);
+
+	// All black
+	remaptable_t *allBlack = PaletteRemap_New();
+	memset(allBlack->remap, 31, NUM_PALETTE_ENTRIES * sizeof(UINT8));
+	R_AddCustomTranslation("AllBlack", PaletteRemap_Add(allBlack));
+
+	// Invert
+	remaptable_t *invertRemap = PaletteRemap_New();
+	PaletteRemap_SetIdentity(invertRemap);
+	PaletteRemap_AddInvert(invertRemap, 0, 255);
+	R_AddCustomTranslation("Invert", PaletteRemap_Add(invertRemap));
+
+	// Dash mode (TC_DASHMODE)
+	MakeDashModeRemap();
+}
+
+remaptable_t *PaletteRemap_New(void)
+{
+	remaptable_t *tr = Z_Calloc(sizeof(remaptable_t), PU_STATIC, NULL);
+	tr->num_entries = NUM_PALETTE_ENTRIES;
+	return tr;
+}
+
+remaptable_t *PaletteRemap_Copy(remaptable_t *tr)
+{
+	remaptable_t *copy = Z_Malloc(sizeof(remaptable_t), PU_STATIC, NULL);
+	memcpy(copy, tr, sizeof(remaptable_t));
+	return copy;
+}
+
+boolean PaletteRemap_Equal(remaptable_t *a, remaptable_t *b)
+{
+	if (a->num_entries != b->num_entries)
+		return false;
+
+	return memcmp(a->remap, b->remap, a->num_entries) == 0;
+}
+
+void PaletteRemap_SetIdentity(remaptable_t *tr)
+{
+	for (unsigned i = 0; i < tr->num_entries; i++)
+	{
+		tr->remap[i] = i;
+	}
+}
+
+boolean PaletteRemap_IsIdentity(remaptable_t *tr)
+{
+	for (unsigned i = 0; i < NUM_PALETTE_ENTRIES; i++)
+	{
+		if (tr->remap[i] != i)
+			return false;
+	}
+
+	return true;
+}
+
+unsigned PaletteRemap_Add(remaptable_t *tr)
+{
+#if 0
+	for (unsigned i = 0; i < numpaletteremaps; i++)
+	{
+		if (PaletteRemap_Equal(tr, paletteremaps[i]))
+			return i;
+	}
+#endif
+
+	numpaletteremaps++;
+	paletteremaps = Z_Realloc(paletteremaps, sizeof(remaptable_t *) * numpaletteremaps, PU_STATIC, NULL);
+	paletteremaps[numpaletteremaps - 1] = tr;
+
+	return numpaletteremaps - 1;
+}
+
+// This is a long one, because MotorRoach basically hand-picked the indices
+static void MakeDashModeRemap(void)
+{
+	remaptable_t *dashmode = PaletteRemap_New();
+
+	PaletteRemap_SetIdentity(dashmode);
+
+	UINT8 *dest_colormap = dashmode->remap;
+
+	// greens -> ketchups
+	dest_colormap[96] = dest_colormap[97] = 48;
+	dest_colormap[98] = 49;
+	dest_colormap[99] = 51;
+	dest_colormap[100] = 52;
+	dest_colormap[101] = dest_colormap[102] = 54;
+	dest_colormap[103] = 34;
+	dest_colormap[104] = 37;
+	dest_colormap[105] = 39;
+	dest_colormap[106] = 41;
+	for (unsigned i = 0; i < 5; i++)
+		dest_colormap[107 + i] = 43 + i;
+
+	// reds -> steel blues
+	dest_colormap[32] = 146;
+	dest_colormap[33] = 147;
+	dest_colormap[34] = dest_colormap[35] = 170;
+	dest_colormap[36] = 171;
+	dest_colormap[37] = dest_colormap[38] = 172;
+	dest_colormap[39] = dest_colormap[40] = dest_colormap[41] = 173;
+	dest_colormap[42] = dest_colormap[43] = dest_colormap[44] = 174;
+	dest_colormap[45] = dest_colormap[46] = dest_colormap[47] = 175;
+	dest_colormap[71] = 139;
+
+	// steel blues -> oranges
+	dest_colormap[170] = 52;
+	dest_colormap[171] = 54;
+	dest_colormap[172] = 56;
+	dest_colormap[173] = 42;
+	dest_colormap[174] = 45;
+	dest_colormap[175] = 47;
+
+	dashModeRemap = PaletteRemap_Add(dashmode);
+
+	R_AddCustomTranslation("DashMode", dashModeRemap);
+}
+
+static boolean PalIndexOutOfRange(int color)
+{
+	return color < 0 || color > 255;
+}
+
+static boolean IndicesOutOfRange(int start, int end)
+{
+	return PalIndexOutOfRange(start) || PalIndexOutOfRange(end);
+}
+
+static boolean IndicesOutOfRange2(int start1, int end1, int start2, int end2)
+{
+	return IndicesOutOfRange(start1, end1) || IndicesOutOfRange(start2, end2);
+}
+
+#define SWAP(a, b, t) { \
+	t swap = a; \
+	a = b; \
+	b = swap; \
+}
+
+static boolean PaletteRemap_AddIndexRange(remaptable_t *tr, int start, int end, int pal1, int pal2)
+{
+	if (IndicesOutOfRange2(start, end, pal1, pal2))
+		return false;
+
+	if (start > end)
+	{
+		SWAP(start, end, int);
+		SWAP(pal1, pal2, int);
+	}
+	else if (start == end)
+	{
+		tr->remap[start] = pal1;
+		return true;
+	}
+
+	double palcol = pal1;
+	double palstep = (pal2 - palcol) / (end - start);
+
+	for (int i = start; i <= end; palcol += palstep, ++i)
+	{
+		double idx = round(palcol);
+		tr->remap[i] = (int)idx;
+	}
+
+	return true;
+}
+
+static boolean PaletteRemap_AddColorRange(remaptable_t *tr, int start, int end, int r1i, int g1i, int b1i, int r2i, int g2i, int b2i)
+{
+	if (IndicesOutOfRange(start, end))
+		return false;
+
+	double r1 = r1i;
+	double g1 = g1i;
+	double b1 = b1i;
+	double r2 = r2i;
+	double g2 = g2i;
+	double b2 = b2i;
+	double r, g, b;
+	double rs, gs, bs;
+
+	if (start > end)
+	{
+		SWAP(start, end, int);
+
+		r = r2;
+		g = g2;
+		b = b2;
+		rs = r1 - r2;
+		gs = g1 - g2;
+		bs = b1 - b2;
+	}
+	else
+	{
+		r = r1;
+		g = g1;
+		b = b1;
+		rs = r2 - r1;
+		gs = g2 - g1;
+		bs = b2 - b1;
+	}
+
+	if (start == end)
+	{
+		tr->remap[start] = NearestColor(r, g, b);
+	}
+	else
+	{
+		rs /= (end - start);
+		gs /= (end - start);
+		bs /= (end - start);
+
+		for (int i = start; i <= end; ++i)
+		{
+			tr->remap[i] = NearestColor(r, g, b);
+			r += rs;
+			g += gs;
+			b += bs;
+		}
+	}
+
+	return true;
+}
+
+#define CLAMP(val, minval, maxval) max(min(val, maxval), minval)
+
+static boolean PaletteRemap_AddDesaturation(remaptable_t *tr, int start, int end, double r1, double g1, double b1, double r2, double g2, double b2)
+{
+	if (IndicesOutOfRange(start, end))
+		return false;
+
+	r1 = CLAMP(r1, 0.0, 2.0);
+	g1 = CLAMP(g1, 0.0, 2.0);
+	b1 = CLAMP(b1, 0.0, 2.0);
+	r2 = CLAMP(r2, 0.0, 2.0);
+	g2 = CLAMP(g2, 0.0, 2.0);
+	b2 = CLAMP(b2, 0.0, 2.0);
+
+	if (start > end)
+	{
+		SWAP(start, end, int);
+		SWAP(r1, r2, double);
+		SWAP(g1, g2, double);
+		SWAP(b1, b2, double);
+	}
+
+	r2 -= r1;
+	g2 -= g1;
+	b2 -= b1;
+	r1 *= 255;
+	g1 *= 255;
+	b1 *= 255;
+
+	for (int c = start; c <= end; c++)
+	{
+		double intensity = (pMasterPalette[c].s.red * 77 + pMasterPalette[c].s.green * 143 + pMasterPalette[c].s.blue * 37) / 255.0;
+
+		tr->remap[c] = NearestColor(
+		    min(255, max(0, (int)(r1 + intensity*r2))),
+		    min(255, max(0, (int)(g1 + intensity*g2))),
+		    min(255, max(0, (int)(b1 + intensity*b2)))
+		);
+	}
+
+	return true;
+}
+
+#undef CLAMP
+
+#undef SWAP
+
+static boolean PaletteRemap_AddColourisation(remaptable_t *tr, int start, int end, int r, int g, int b)
+{
+	if (IndicesOutOfRange(start, end))
+		return false;
+
+	for (int i = start; i < end; ++i)
+	{
+		double br = pMasterPalette[i].s.red;
+		double bg = pMasterPalette[i].s.green;
+		double bb = pMasterPalette[i].s.blue;
+		double grey = (br * 0.299 + bg * 0.587 + bb * 0.114) / 255.0f;
+		if (grey > 1.0)
+			grey = 1.0;
+
+		br = r * grey;
+		bg = g * grey;
+		bb = b * grey;
+
+		tr->remap[i] = NearestColor(
+		    (int)br,
+		    (int)bg,
+		    (int)bb
+		);
+	}
+
+	return true;
+}
+
+static boolean PaletteRemap_AddTint(remaptable_t *tr, int start, int end, int r, int g, int b, int amount)
+{
+	if (IndicesOutOfRange(start, end))
+		return false;
+
+	for (int i = start; i < end; ++i)
+	{
+		float br = pMasterPalette[i].s.red;
+		float bg = pMasterPalette[i].s.green;
+		float bb = pMasterPalette[i].s.blue;
+		float a = amount * 0.01f;
+		float ia = 1.0f - a;
+
+		br = br * ia + r * a;
+		bg = bg * ia + g * a;
+		bb = bb * ia + b * a;
+
+		tr->remap[i] = NearestColor(
+		    (int)br,
+		    (int)bg,
+		    (int)bb
+		);
+	}
+
+	return true;
+}
+
+static boolean PaletteRemap_AddInvert(remaptable_t *tr, int start, int end)
+{
+	if (IndicesOutOfRange(start, end))
+		return false;
+
+	for (int i = start; i < end; ++i)
+	{
+		tr->remap[i] = NearestColor(
+		    255 - tr->remap[pMasterPalette[i].s.red],
+		    255 - tr->remap[pMasterPalette[i].s.green],
+		    255 - tr->remap[pMasterPalette[i].s.blue]
+		);
+	}
+
+	return true;
+}
+
+struct ParsedTranslation
+{
+	struct ParsedTranslation *next;
+	remaptable_t *remap;
+	remaptable_t *baseTranslation;
+	struct PaletteRemapParseResult *data;
+};
+
+static struct ParsedTranslation *parsedTranslationListHead = NULL;
+static struct ParsedTranslation *parsedTranslationListTail = NULL;
+
+static void AddParsedTranslation(unsigned id, int base_translation, struct PaletteRemapParseResult *data)
+{
+	struct ParsedTranslation *node = Z_Calloc(sizeof(struct ParsedTranslation), PU_STATIC, NULL);
+
+	node->remap = paletteremaps[id];
+	node->data = data;
+
+	if (base_translation != -1)
+		node->baseTranslation = paletteremaps[base_translation];
+
+	if (parsedTranslationListHead == NULL)
+		parsedTranslationListHead = parsedTranslationListTail = node;
+	else
+	{
+		parsedTranslationListTail->next = node;
+		parsedTranslationListTail = node;
+	}
+}
+
+static void PaletteRemap_ApplyResult(remaptable_t *tr, struct PaletteRemapParseResult *data)
+{
+	int start = data->start;
+	int end = data->end;
+
+	switch (data->type)
+	{
+	case REMAP_ADD_INDEXRANGE:
+		PaletteRemap_AddIndexRange(tr, start, end, data->indexRange.pal1, data->indexRange.pal2);
+		break;
+	case REMAP_ADD_COLORRANGE:
+		PaletteRemap_AddColorRange(tr, start, end,
+			data->colorRange.r1, data->colorRange.g1, data->colorRange.b1,
+			data->colorRange.r2, data->colorRange.g2, data->colorRange.b2);
+		break;
+	case REMAP_ADD_COLOURISATION:
+		PaletteRemap_AddColourisation(tr, start, end,
+			data->colourisation.r, data->colourisation.g, data->colourisation.b);
+		break;
+	case REMAP_ADD_DESATURATION:
+		PaletteRemap_AddDesaturation(tr, start, end,
+			data->desaturation.r1, data->desaturation.g1, data->desaturation.b1,
+			data->desaturation.r2, data->desaturation.g2, data->desaturation.b2);
+		break;
+	case REMAP_ADD_TINT:
+		PaletteRemap_AddTint(tr, start, end, data->tint.r, data->tint.g, data->tint.b, data->tint.amount);
+		break;
+	}
+}
+
+void R_LoadParsedTranslations(void)
+{
+	struct ParsedTranslation *node = parsedTranslationListHead;
+	while (node)
+	{
+		struct PaletteRemapParseResult *result = node->data;
+		struct ParsedTranslation *next = node->next;
+
+		remaptable_t *tr = node->remap;
+		PaletteRemap_SetIdentity(tr);
+
+		if (node->baseTranslation)
+			memcpy(tr, node->baseTranslation, sizeof(remaptable_t));
+
+		PaletteRemap_ApplyResult(tr, result);
+
+		Z_Free(result);
+		Z_Free(node);
+
+		node = next;
+	}
+
+	parsedTranslationListHead = parsedTranslationListTail = NULL;
+}
+
+static boolean ExpectToken(tokenizer_t *sc, const char *expect)
+{
+	return strcmp(sc->get(sc, 0), expect) == 0;
+}
+
+static boolean StringToNumber(const char *tkn, int *out)
+{
+	char *endPos = NULL;
+
+	errno = 0;
+
+	int result = strtol(tkn, &endPos, 10);
+	if (endPos == tkn || *endPos != '\0')
+		return false;
+
+	if (errno == ERANGE)
+		return false;
+
+	*out = result;
+
+	return true;
+}
+
+static boolean ParseNumber(tokenizer_t *sc, int *out)
+{
+	return StringToNumber(sc->get(sc, 0), out);
+}
+
+static boolean ParseDecimal(tokenizer_t *sc, double *out)
+{
+	const char *tkn = sc->get(sc, 0);
+
+	char *endPos = NULL;
+
+	errno = 0;
+
+	double result = strtod(tkn, &endPos);
+	if (endPos == tkn || *endPos != '\0')
+		return false;
+
+	if (errno == ERANGE)
+		return false;
+
+	*out = result;
+
+	return true;
+}
+
+static struct PaletteRemapParseResult *ThrowError(const char *format, ...)
+{
+	struct PaletteRemapParseResult *err = Z_Calloc(sizeof(struct PaletteRemapParseResult), PU_STATIC, NULL);
+
+	va_list argptr;
+	va_start(argptr, format);
+	vsnprintf(err->error, sizeof err->error, format, argptr);
+	va_end(argptr);
+
+	err->has_error = true;
+
+	return err;
+}
+
+static struct PaletteRemapParseResult *MakeResult(enum PaletteRemapType type, int start, int end)
+{
+	struct PaletteRemapParseResult *tr = Z_Calloc(sizeof(struct PaletteRemapParseResult), PU_STATIC, NULL);
+	tr->type = type;
+	tr->start = start;
+	tr->end = end;
+	return tr;
+}
+
+static struct PaletteRemapParseResult *PaletteRemap_ParseString(tokenizer_t *sc)
+{
+	int start, end;
+
+	if (!ParseNumber(sc, &start))
+		return ThrowError("expected a number for start range");
+	if (!ExpectToken(sc, ":"))
+		return ThrowError("expected ':'");
+	if (!ParseNumber(sc, &end))
+		return ThrowError("expected a number for end range");
+
+	if (start < 0 || start > 255 || end < 0 || end > 255)
+		return ThrowError("palette indices out of range");
+
+	if (!ExpectToken(sc, "="))
+		return ThrowError("expected '='");
+
+	const char *tkn = sc->get(sc, 0);
+	if (strcmp(tkn, "[") == 0)
+	{
+		// translation using RGB values
+		int r1, g1, b1;
+		int r2, g2, b2;
+
+		// start
+		if (!ParseNumber(sc, &r1))
+			return ThrowError("expected a number for starting red");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+
+		if (!ParseNumber(sc, &g1))
+			return ThrowError("expected a number for starting green");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+
+		if (!ParseNumber(sc, &b1))
+			return ThrowError("expected a number for starting blue");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+
+		if (!ExpectToken(sc, "]"))
+			return ThrowError("expected ']'");
+		if (!ExpectToken(sc, ":"))
+			return ThrowError("expected ':'");
+		if (!ExpectToken(sc, "["))
+			return ThrowError("expected '[");
+
+		// end
+		if (!ParseNumber(sc, &r2))
+			return ThrowError("expected a number for ending red");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+
+		if (!ParseNumber(sc, &g2))
+			return ThrowError("expected a number for ending green");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+
+		if (!ParseNumber(sc, &b2))
+			return ThrowError("expected a number for ending blue");
+		if (!ExpectToken(sc, "]"))
+			return ThrowError("expected ']'");
+
+		struct PaletteRemapParseResult *tr = MakeResult(REMAP_ADD_COLORRANGE, start, end);
+		tr->colorRange.r1 = r1;
+		tr->colorRange.g1 = g1;
+		tr->colorRange.b1 = b1;
+		tr->colorRange.r2 = r2;
+		tr->colorRange.g2 = g2;
+		tr->colorRange.b2 = b2;
+		return tr;
+	}
+	else if (strcmp(tkn, "%") == 0)
+	{
+		// translation using RGB values (desaturation)
+		double r1, g1, b1;
+		double r2, g2, b2;
+
+		if (!ExpectToken(sc, "["))
+			return ThrowError("expected '[");
+
+		// start
+		if (!ParseDecimal(sc, &r1))
+			return ThrowError("expected a number for starting red");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+
+		if (!ParseDecimal(sc, &g1))
+			return ThrowError("expected a number for starting green");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+
+		if (!ParseDecimal(sc, &b1))
+			return ThrowError("expected a number for starting blue");
+		if (!ExpectToken(sc, "]"))
+			return ThrowError("expected ']'");
+
+		if (!ExpectToken(sc, ":"))
+			return ThrowError("expected ':'");
+		if (!ExpectToken(sc, "["))
+			return ThrowError("expected '[");
+
+		// end
+		if (!ParseDecimal(sc, &r2))
+			return ThrowError("expected a number for ending red");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+
+		if (!ParseDecimal(sc, &g2))
+			return ThrowError("expected a number for ending green");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+
+		if (!ParseDecimal(sc, &b2))
+			return ThrowError("expected a number for ending blue");
+		if (!ExpectToken(sc, "]"))
+			return ThrowError("expected ']'");
+
+		struct PaletteRemapParseResult *tr = MakeResult(REMAP_ADD_DESATURATION, start, end);
+		tr->desaturation.r1 = r1;
+		tr->desaturation.g1 = g1;
+		tr->desaturation.b1 = b1;
+		tr->desaturation.r2 = r2;
+		tr->desaturation.g2 = g2;
+		tr->desaturation.b2 = b2;
+		return tr;
+	}
+	else if (strcmp(tkn, "#") == 0)
+	{
+		// Colourise translation
+		int r, g, b;
+
+		if (!ExpectToken(sc, "["))
+			return ThrowError("expected '[");
+		if (!ParseNumber(sc, &r))
+			return ThrowError("expected a number for red");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+		if (!ParseNumber(sc, &g))
+			return ThrowError("expected a number for green");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+		if (!ParseNumber(sc, &b))
+			return ThrowError("expected a number for blue");
+		if (!ExpectToken(sc, "]"))
+			return ThrowError("expected ']'");
+
+		struct PaletteRemapParseResult *tr = MakeResult(REMAP_ADD_COLOURISATION, start, end);
+		tr->colourisation.r = r;
+		tr->colourisation.g = g;
+		tr->colourisation.b = b;
+		return tr;
+	}
+	else if (strcmp(tkn, "@") == 0)
+	{
+		// Tint translation
+		int a, r, g, b;
+
+		if (!ExpectToken(sc, "["))
+			return ThrowError("expected '[");
+		if (!ParseNumber(sc, &a))
+			return ThrowError("expected a number for amount");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+		if (!ParseNumber(sc, &r))
+			return ThrowError("expected a number for red");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+		if (!ParseNumber(sc, &g))
+			return ThrowError("expected a number for green");
+		if (!ExpectToken(sc, ","))
+			return ThrowError("expected ','");
+		if (!ParseNumber(sc, &b))
+			return ThrowError("expected a number for blue");
+		if (!ExpectToken(sc, "]"))
+			return ThrowError("expected ']'");
+
+		struct PaletteRemapParseResult *tr = MakeResult(REMAP_ADD_TINT, start, end);
+		tr->tint.r = r;
+		tr->tint.g = g;
+		tr->tint.b = b;
+		tr->tint.amount = a;
+		return tr;
+	}
+	else
+	{
+		int pal1, pal2;
+
+		if (!StringToNumber(tkn, &pal1))
+			return ThrowError("expected a number for starting index");
+		if (!ExpectToken(sc, ":"))
+			return ThrowError("expected ':'");
+		if (!ParseNumber(sc, &pal2))
+			return ThrowError("expected a number for ending index");
+
+		struct PaletteRemapParseResult *tr = MakeResult(REMAP_ADD_INDEXRANGE, start, end);
+		tr->indexRange.pal1 = pal1;
+		tr->indexRange.pal2 = pal2;
+		return tr;
+	}
+
+	return NULL;
+}
+
+static struct PaletteRemapParseResult *PaletteRemap_ParseTranslation(const char *translation)
+{
+	tokenizer_t *sc = Tokenizer_Open(translation, 1);
+	struct PaletteRemapParseResult *result = PaletteRemap_ParseString(sc);
+	Tokenizer_Close(sc);
+	return result;
+}
+
+void R_ParseTrnslate(INT32 wadNum, UINT16 lumpnum)
+{
+	char *lumpData = (char *)W_CacheLumpNumPwad(wadNum, lumpnum, PU_STATIC);
+	size_t lumpLength = W_LumpLengthPwad(wadNum, lumpnum);
+	char *text = (char *)Z_Malloc((lumpLength + 1), PU_STATIC, NULL);
+	memmove(text, lumpData, lumpLength);
+	text[lumpLength] = '\0';
+	Z_Free(lumpData);
+
+	tokenizer_t *sc = Tokenizer_Open(text, 1);
+	const char *tkn = sc->get(sc, 0);
+	while (tkn != NULL)
+	{
+		int base_translation = -1;
+
+		char *name = Z_StrDup(tkn);
+
+		tkn = sc->get(sc, 0);
+		if (strcmp(tkn, ":") == 0)
+		{
+			tkn = sc->get(sc, 0);
+
+			base_translation = R_FindCustomTranslation(tkn);
+			if (base_translation == -1)
+			{
+				CONS_Alert(CONS_ERROR, "Error parsing translation '%s': No translation named '%s'\n", name, tkn);
+				goto fail;
+			}
+
+			tkn = sc->get(sc, 0);
+		}
+
+		if (strcmp(tkn, "=") != 0)
+		{
+			CONS_Alert(CONS_ERROR, "Error parsing translation '%s': Expected '=', got '%s'\n", name, tkn);
+			goto fail;
+		}
+		tkn = sc->get(sc, 0);
+
+		struct PaletteRemapParseResult *result = NULL;
+		do {
+			result = PaletteRemap_ParseTranslation(tkn);
+			if (result->has_error)
+			{
+				CONS_Alert(CONS_ERROR, "Error parsing translation '%s': %s\n", name, result->error);
+				Z_Free(result);
+				goto fail;
+			}
+
+			tkn = sc->get(sc, 0);
+			if (!tkn)
+				break;
+
+			if (strcmp(tkn, ",") != 0)
+				break;
+
+			tkn = sc->get(sc, 0);
+		} while (true);
+
+		// Allocate it and register it
+		remaptable_t *tr = PaletteRemap_New();
+		unsigned id = PaletteRemap_Add(tr);
+		R_AddCustomTranslation(name, id);
+
+		// Free this, since it's no longer needed
+		Z_Free(name);
+
+		// The translation is not generated until later, because the palette may not have been loaded.
+		// We store the result for when it's needed.
+		AddParsedTranslation(id, base_translation, result);
+	}
+
+fail:
+	Tokenizer_Close(sc);
+	Z_Free(text);
+}
+
+typedef struct CustomTranslation
+{
+	char *name;
+	unsigned id;
+	UINT32 hash;
+} customtranslation_t;
+
+static customtranslation_t *customtranslations = NULL;
+static unsigned numcustomtranslations = 0;
+
+int R_FindCustomTranslation(const char *name)
+{
+	UINT32 hash = quickncasehash(name, strlen(name));
+
+	for (unsigned i = 0; i < numcustomtranslations; i++)
+	{
+		if (hash == customtranslations[i].hash && strcmp(name, customtranslations[i].name) == 0)
+			return (int)customtranslations[i].id;
+	}
+
+	return -1;
+}
+
+// This is needed for SOC (which is case insensitive)
+int R_FindCustomTranslation_CaseInsensitive(const char *name)
+{
+	for (unsigned i = 0; i < numcustomtranslations; i++)
+	{
+		if (stricmp(name, customtranslations[i].name) == 0)
+			return (int)customtranslations[i].id;
+	}
+
+	return -1;
+}
+
+void R_AddCustomTranslation(const char *name, int trnum)
+{
+	customtranslation_t *tr = NULL;
+	UINT32 hash = quickncasehash(name, strlen(name));
+
+	for (unsigned i = 0; i < numcustomtranslations; i++)
+	{
+		customtranslation_t *lookup = &customtranslations[i];
+		if (hash == lookup->hash && strcmp(name, lookup->name) == 0)
+		{
+			tr = lookup;
+			break;
+		}
+	}
+
+	if (tr == NULL)
+	{
+		numcustomtranslations++;
+		customtranslations = Z_Realloc(customtranslations, sizeof(customtranslation_t) * numcustomtranslations, PU_STATIC, NULL);
+		tr = &customtranslations[numcustomtranslations - 1];
+	}
+
+	tr->id = trnum;
+	tr->name = Z_StrDup(name);
+	tr->hash = quickncasehash(name, strlen(name));
+}
+
+const char *R_GetCustomTranslationName(unsigned id)
+{
+	for (unsigned i = 0; i < numcustomtranslations; i++)
+	{
+		if (id == customtranslations[i].id)
+			return customtranslations[i].name;
+	}
+
+	return NULL;
+}
+
+unsigned R_NumCustomTranslations(void)
+{
+	return numcustomtranslations;
+}
+
+remaptable_t *R_GetTranslationByID(int id)
+{
+	if (!R_TranslationIsValid(id))
+		return NULL;
+
+	return paletteremaps[id];
+}
+
+boolean R_TranslationIsValid(int id)
+{
+	if (id < 0 || id >= (signed)numpaletteremaps)
+		return false;
+
+	return true;
+}
+
+remaptable_t *R_GetBuiltInTranslation(SINT8 tc)
+{
+	switch (tc)
+	{
+	case TC_ALLWHITE:
+		return R_GetTranslationByID(allWhiteRemap);
+	case TC_DASHMODE:
+		return R_GetTranslationByID(dashModeRemap);
+	}
+	return NULL;
+}
diff --git a/src/r_translation.h b/src/r_translation.h
new file mode 100644
index 0000000000000000000000000000000000000000..70bc2fd27e4e248301029c93de562c24dab467ac
--- /dev/null
+++ b/src/r_translation.h
@@ -0,0 +1,45 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// Copyright (C) 1998-2006 by Randy Heit.
+// Copyright (C) 2023 by Sonic Team Junior.
+//
+// This program is free software distributed under the
+// terms of the GNU General Public License, version 2.
+// See the 'LICENSE' file for more details.
+//-----------------------------------------------------------------------------
+/// \file  r_translation.h
+/// \brief Translation table handling
+
+#ifndef __R_TRANSLATION__
+#define __R_TRANSLATION__
+
+#include "doomdef.h"
+
+typedef struct
+{
+	UINT8 remap[256];
+	unsigned num_entries;
+} remaptable_t;
+
+void PaletteRemap_Init(void);
+remaptable_t *PaletteRemap_New(void);
+remaptable_t *PaletteRemap_Copy(remaptable_t *tr);
+boolean PaletteRemap_Equal(remaptable_t *a, remaptable_t *b);
+void PaletteRemap_SetIdentity(remaptable_t *tr);
+boolean PaletteRemap_IsIdentity(remaptable_t *tr);
+unsigned PaletteRemap_Add(remaptable_t *tr);
+
+int R_FindCustomTranslation(const char *name);
+int R_FindCustomTranslation_CaseInsensitive(const char *name);
+void R_AddCustomTranslation(const char *name, int trnum);
+const char *R_GetCustomTranslationName(unsigned id);
+unsigned R_NumCustomTranslations(void);
+remaptable_t *R_GetTranslationByID(int id);
+boolean R_TranslationIsValid(int id);
+
+void R_ParseTrnslate(INT32 wadNum, UINT16 lumpnum);
+void R_LoadParsedTranslations(void);
+
+remaptable_t *R_GetBuiltInTranslation(SINT8 tc);
+
+#endif
diff --git a/src/sdl/Srb2SDL-vc10.vcxproj b/src/sdl/Srb2SDL-vc10.vcxproj
index 9b51cfb8094a1d6897e0685e9531ccd21df0fbdb..7142cb64cb571e5b1c84489b1b38c9c48bdbf561 100644
--- a/src/sdl/Srb2SDL-vc10.vcxproj
+++ b/src/sdl/Srb2SDL-vc10.vcxproj
@@ -292,6 +292,7 @@
     <ClInclude Include="..\m_fixed.h" />
     <ClInclude Include="..\m_menu.h" />
     <ClInclude Include="..\m_misc.h" />
+    <ClInclude Include="..\m_tokenizer.h" />
     <ClInclude Include="..\m_perfstats.h" />
     <ClInclude Include="..\m_queue.h" />
     <ClInclude Include="..\m_random.h" />
@@ -342,6 +343,7 @@
     <ClInclude Include="..\r_state.h" />
     <ClInclude Include="..\r_textures.h" />
     <ClInclude Include="..\r_things.h" />
+    <ClInclude Include="..\r_translation.h" />
     <ClInclude Include="..\screen.h" />
     <ClInclude Include="..\snake.h" />
     <ClInclude Include="..\sounds.h" />
@@ -468,6 +470,7 @@
     <ClCompile Include="..\m_fixed.c" />
     <ClCompile Include="..\m_menu.c" />
     <ClCompile Include="..\m_misc.c" />
+    <ClCompile Include="..\m_tokenizer.c" />
     <ClCompile Include="..\m_perfstats.c" />
     <ClCompile Include="..\m_queue.c" />
     <ClCompile Include="..\m_random.c" />
@@ -530,6 +533,7 @@
     <ClCompile Include="..\r_splats.c" />
     <ClCompile Include="..\r_textures.c" />
     <ClCompile Include="..\r_things.c" />
+    <ClCompile Include="..\r_translation.c" />
     <ClCompile Include="..\screen.c" />
     <ClCompile Include="..\snake.c" />
     <ClCompile Include="..\sounds.c" />
diff --git a/src/sdl/Srb2SDL-vc10.vcxproj.filters b/src/sdl/Srb2SDL-vc10.vcxproj.filters
index 96501b2160e587937f5513fa864f3a2f93a6d6ac..44c353ae29899fd561065a64b50d517cff34505b 100644
--- a/src/sdl/Srb2SDL-vc10.vcxproj.filters
+++ b/src/sdl/Srb2SDL-vc10.vcxproj.filters
@@ -339,6 +339,9 @@
     <ClInclude Include="..\m_misc.h">
       <Filter>M_Misc</Filter>
     </ClInclude>
+    <ClInclude Include="..\m_tokenizer.h">
+      <Filter>M_Misc</Filter>
+    </ClInclude>
     <ClInclude Include="..\m_perfstats.h">
       <Filter>M_Misc</Filter>
     </ClInclude>
@@ -537,6 +540,9 @@
     <ClInclude Include="..\r_textures.h">
       <Filter>R_Rend</Filter>
     </ClInclude>
+    <ClInclude Include="..\r_translation.h">
+      <Filter>R_Rend</Filter>
+    </ClInclude>
     <ClInclude Include="..\r_portal.h">
       <Filter>R_Rend</Filter>
     </ClInclude>
@@ -831,6 +837,9 @@
     <ClCompile Include="..\m_misc.c">
       <Filter>M_Misc</Filter>
     </ClCompile>
+    <ClCompile Include="..\m_tokenizer.c">
+      <Filter>M_Misc</Filter>
+    </ClCompile>
     <ClCompile Include="..\m_perfstats.c">
       <Filter>M_Misc</Filter>
     </ClCompile>
@@ -1081,6 +1090,9 @@
     <ClCompile Include="..\r_textures.c">
       <Filter>R_Rend</Filter>
     </ClCompile>
+    <ClCompile Include="..\r_translation.c">
+      <Filter>R_Rend</Filter>
+    </ClCompile>
     <ClCompile Include="..\r_portal.c">
       <Filter>R_Rend</Filter>
     </ClCompile>
diff --git a/src/w_wad.c b/src/w_wad.c
index 10359de22826096ef2cbb5700564436d9fa79a22..06025b6b1ee4547b6cdaccf6b5788dd6453089c4 100644
--- a/src/w_wad.c
+++ b/src/w_wad.c
@@ -59,6 +59,7 @@
 #include "r_textures.h"
 #include "r_patch.h"
 #include "r_picformats.h"
+#include "r_translation.h"
 #include "i_time.h"
 #include "i_system.h"
 #include "i_video.h" // rendermode
@@ -810,6 +811,16 @@ static void W_ReadFileShaders(wadfile_t *wadfile)
 #endif
 }
 
+static void W_LoadTrnslateLumps(UINT16 w)
+{
+	UINT16 lump = W_CheckNumForNamePwad("TRNSLATE", w, 0);
+	while (lump != INT16_MAX)
+	{
+		R_ParseTrnslate(w, lump);
+		lump = W_CheckNumForNamePwad("TRNSLATE", (UINT16)w, lump + 1);
+	}
+}
+
 //  Allocate a wadfile, setup the lumpinfo (directory) and
 //  lumpcache, add the wadfile to the current active wadfiles
 //
@@ -963,6 +974,9 @@ UINT16 W_InitFile(const char *filename, boolean mainfile, boolean startup)
 	// Load maps from file
 	P_LoadMapsFromFile(numwadfiles - 1, !startup);
 
+	// The below hack makes me load this here.
+	W_LoadTrnslateLumps(numwadfiles - 1);
+
 	// TODO: HACK ALERT - Load Lua & SOC stuff right here. I feel like this should be out of this place, but... Let's stick with this for now.
 	switch (wadfile->type)
 	{