diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 22c1def2752bd3b50aea361684b8707d5c078c30..7987e94e89c7b9eae91f50c136596eb5d6dd5da0 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -68,6 +68,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 6ed1f3b4c03119f44c877e5ca1013e7bba581605..a37b63b1ac8ee6dbbf15dfb6dc2c22a552a00cbf 100644
--- a/src/Sourcefile
+++ b/src/Sourcefile
@@ -62,6 +62,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/doomdef.h b/src/doomdef.h
index b382d0ecb4bbaa09a0da1180e481e2f979014e32..f36c6f286b4b7bd50a1a1c3759c3c8f5084f6b53 100644
--- a/src/doomdef.h
+++ b/src/doomdef.h
@@ -540,6 +540,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/m_misc.c b/src/m_misc.c
index f547f5c41ac8fb0fea405f8ed760ffe4070fdeb2..9232737c7edb4bf2dbed7640dcaa769a0a85cfbb 100644
--- a/src/m_misc.c
+++ b/src/m_misc.c
@@ -2129,6 +2129,100 @@ const char *M_TokenizerRead(UINT32 i)
 	return tokenizerToken[i];
 }
 
+const char *M_TokenizerReadZDoom(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'
+			|| 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;
+		return NULL;
+	}
+	// Else, if it's one of these three symbols, capture only this one character
+	else if (tokenizerInput[tokenizerStartPos] == ','
+			|| tokenizerInput[tokenizerStartPos] == '{'
+			|| tokenizerInput[tokenizerStartPos] == '}'
+			|| tokenizerInput[tokenizerStartPos] == '['
+			|| tokenizerInput[tokenizerStartPos] == ']'
+			|| 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] != ']'
+			&& tokenizerInput[tokenizerEndPos] != '='
+			&& tokenizerInput[tokenizerEndPos] != ':'
+			&& tokenizerInput[tokenizerEndPos] != '%'
+			&& 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];
+}
+
 UINT32 M_TokenizerGetEndPos(void)
 {
 	return tokenizerEndPos;
diff --git a/src/p_setup.c b/src/p_setup.c
index 0390761b61a5e064b0b9b55daead7ebd1488ac49..e0b6e902d3498cea629cb55840a0622ebd34d909 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"
@@ -8164,6 +8165,8 @@ static boolean P_LoadAddon(UINT16 numlumps)
 		HWR_ClearAllTextures();
 #endif
 
+	R_LoadTrnslateLumps();
+
 	//
 	// search for sprite replacements
 	//
diff --git a/src/r_data.c b/src/r_data.c
index 4b7492f908e51412f211f2816ac85904efd047b4..8a7a4dd6fa4e441cd1e31c94bb3102573d77f483 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"
@@ -1208,6 +1209,12 @@ void R_InitData(void)
 		R_Init8to16();
 	}
 
+	CONS_Printf("PaletteRemap_Init()...\n");
+	PaletteRemap_Init();
+
+	CONS_Printf("R_LoadTrnslateLumps()...\n");
+	R_LoadTrnslateLumps();
+
 	CONS_Printf("R_LoadTextures()...\n");
 	R_LoadTextures();
 
diff --git a/src/r_translation.c b/src/r_translation.c
new file mode 100644
index 0000000000000000000000000000000000000000..7e98fd084c23e7187f0dade5d7bae077ebb96e8d
--- /dev/null
+++ b/src/r_translation.c
@@ -0,0 +1,697 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// 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 Translations
+
+#include "r_translation.h"
+#include "r_data.h"
+#include "v_video.h" // pMasterPalette
+#include "z_zone.h"
+#include "w_wad.h"
+
+#include <errno.h>
+
+remaptable_t **paletteremaps = NULL;
+unsigned numpaletteremaps = 0;
+
+void PaletteRemap_Init(void)
+{
+	remaptable_t *base = PaletteRemap_New();
+	PaletteRemap_SetIdentity(base);
+	PaletteRemap_Add(base);
+}
+
+remaptable_t *PaletteRemap_New(void)
+{
+	remaptable_t *tr = Z_Calloc(sizeof(remaptable_t), PU_STATIC, NULL);
+	tr->num_entries = 256;
+	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 < 256; 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;
+}
+
+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; \
+}
+
+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;
+}
+
+boolean PaletteRemap_AddColorRange(remaptable_t *tr, int start, int end, int _r1,int _g1, int _b1, int _r2, int _g2, int _b2)
+{
+	if (IndicesOutOfRange(start, end))
+		return false;
+
+	double r1 = _r1;
+	double g1 = _g1;
+	double b1 = _b1;
+	double r2 = _r2;
+	double g2 = _g2;
+	double b2 = _b2;
+	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)
+
+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, (int)(r1 + intensity*r2)),
+		    min(255, (int)(g1 + intensity*g2)),
+		    min(255, (int)(b1 + intensity*b2))
+		);
+	}
+
+	return true;
+}
+
+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;
+}
+
+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 ExpectToken(const char *expect)
+{
+	return strcmp(M_TokenizerReadZDoom(0), expect) == 0;
+}
+
+static boolean StringToNumber(const char *tkn, int *out)
+{
+	char *endPos = NULL;
+
+#ifndef AVOID_ERRNO
+	errno = 0;
+#endif
+
+	int result = strtol(tkn, &endPos, 10);
+	if (endPos == tkn || *endPos != '\0')
+		return false;
+
+#ifndef AVOID_ERRNO
+	if (errno == ERANGE)
+		return false;
+#endif
+
+	*out = result;
+
+	return true;
+}
+
+static boolean ParseNumber(int *out)
+{
+	return StringToNumber(M_TokenizerReadZDoom(0), out);
+}
+
+static boolean ParseDecimal(double *out)
+{
+	const char *tkn = M_TokenizerReadZDoom(0);
+
+	char *endPos = NULL;
+
+#ifndef AVOID_ERRNO
+	errno = 0;
+#endif
+
+	double result = strtod(tkn, &endPos);
+	if (endPos == tkn || *endPos != '\0')
+		return false;
+
+#ifndef AVOID_ERRNO
+	if (errno == ERANGE)
+		return false;
+#endif
+
+	*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);
+	vsprintf(err->error, format, argptr);
+	va_end(argptr);
+
+	return err;
+}
+
+struct PaletteRemapParseResult *PaletteRemap_ParseString(remaptable_t *tr, char *translation)
+{
+	int start, end;
+
+	M_TokenizerOpen(translation);
+
+	if (!ParseNumber(&start))
+		return ThrowError("expected a number for start range");
+	if (!ExpectToken(":"))
+		return ThrowError("expected ':'");
+	if (!ParseNumber(&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("="))
+		return ThrowError("expected '='");
+
+	const char *tkn = M_TokenizerReadZDoom(0);
+	if (strcmp(tkn, "[") == 0)
+	{
+		// translation using RGB values
+		int r1, g1, b1;
+		int r2, g2, b2;
+
+		// start
+		if (!ParseNumber(&r1))
+			return ThrowError("expected a number for starting red");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+
+		if (!ParseNumber(&g1))
+			return ThrowError("expected a number for starting green");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+
+		if (!ParseNumber(&b1))
+			return ThrowError("expected a number for starting blue");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+
+		if (!ExpectToken("]"))
+			return ThrowError("expected ']'");
+		if (!ExpectToken(":"))
+			return ThrowError("expected ':'");
+		if (!ExpectToken("["))
+			return ThrowError("expected '[");
+
+		// end
+		if (!ParseNumber(&r2))
+			return ThrowError("expected a number for ending red");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+
+		if (!ParseNumber(&g2))
+			return ThrowError("expected a number for ending green");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+
+		if (!ParseNumber(&b2))
+			return ThrowError("expected a number for ending blue");
+		if (!ExpectToken("]"))
+			return ThrowError("expected ']'");
+
+		PaletteRemap_AddColorRange(tr, start, end, r1, g1, b1, r2, g2, b2);
+	}
+	else if (strcmp(tkn, "%") == 0)
+	{
+		// translation using RGB values (desaturation)
+		double r1, g1, b1;
+		double r2, g2, b2;
+
+		if (!ExpectToken("["))
+			return ThrowError("expected '[");
+
+		// start
+		if (!ParseDecimal(&r1))
+			return ThrowError("expected a number for starting red");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+
+		if (!ParseDecimal(&g1))
+			return ThrowError("expected a number for starting green");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+
+		if (!ParseDecimal(&b1))
+			return ThrowError("expected a number for starting blue");
+		if (!ExpectToken("]"))
+			return ThrowError("expected ']'");
+
+		if (!ExpectToken(":"))
+			return ThrowError("expected ':'");
+
+		if (!ExpectToken("["))
+			return ThrowError("expected '[");
+
+		// end
+		if (!ParseDecimal(&r2))
+			return ThrowError("expected a number for ending red");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+
+		if (!ParseDecimal(&g2))
+			return ThrowError("expected a number for ending green");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+
+		if (!ParseDecimal(&b2))
+			return ThrowError("expected a number for ending blue");
+		if (!ExpectToken("]"))
+			return ThrowError("expected ']'");
+
+		PaletteRemap_AddDesaturation(tr, start, end, r1, g1, b1, r2, g2, b2);
+	}
+	else if (strcmp(tkn, "#") == 0)
+	{
+		// Colourise translation
+		int r, g, b;
+
+		if (!ExpectToken("["))
+			return ThrowError("expected '[");
+		if (!ParseNumber(&r))
+			return ThrowError("expected a number for red");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+		if (!ParseNumber(&g))
+			return ThrowError("expected a number for green");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+		if (!ParseNumber(&b))
+			return ThrowError("expected a number for blue");
+		if (!ExpectToken("]"))
+			return ThrowError("expected ']'");
+
+		PaletteRemap_AddColourisation(tr, start, end, r, g, b);
+	}
+	else if (strcmp(tkn, "@") == 0)
+	{
+		// Tint translation
+		int a, r, g, b;
+
+		if (!ExpectToken("["))
+			return ThrowError("expected '[");
+		if (!ParseNumber(&a))
+			return ThrowError("expected a number for amount");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+		if (!ParseNumber(&r))
+			return ThrowError("expected a number for red");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+		if (!ParseNumber(&g))
+			return ThrowError("expected a number for green");
+		if (!ExpectToken(","))
+			return ThrowError("expected ','");
+		if (!ParseNumber(&b))
+			return ThrowError("expected a number for blue");
+		if (!ExpectToken("]"))
+			return ThrowError("expected ']'");
+
+		PaletteRemap_AddTint(tr, start, end, r, g, b, a);
+	}
+	else
+	{
+		int pal1, pal2;
+
+		if (!StringToNumber(tkn, &pal1))
+			return ThrowError("expected a number for starting index");
+		if (!ExpectToken(":"))
+			return ThrowError("expected ':'");
+		if (!ParseNumber(&pal2))
+			return ThrowError("expected a number for ending index");
+
+		PaletteRemap_AddIndexRange(tr, start, end, pal1, pal2);
+	}
+
+	M_TokenizerClose();
+
+	return NULL;
+}
+
+static void P_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);
+
+	char *p = text;
+	char *tkn = M_GetToken(p);
+	while (tkn != NULL)
+	{
+		remaptable_t *tr = NULL;
+
+		char *name = tkn;
+
+		tkn = M_GetToken(NULL);
+		if (strcmp(tkn, ":") == 0)
+		{
+			Z_Free(tkn);
+			tkn = M_GetToken(NULL);
+
+			remaptable_t *tbl = R_GetTranslationByID(R_FindCustomTranslation(tkn));
+			if (tbl)
+				tr = PaletteRemap_Copy(tbl);
+			else
+			{
+				CONS_Alert(CONS_ERROR, "Error parsing translation '%s': No translation named '%s'\n", name, tkn);
+				goto fail;
+			}
+
+			Z_Free(tkn);
+			tkn = M_GetToken(NULL);
+		}
+		else
+		{
+			tr = PaletteRemap_New();
+			PaletteRemap_SetIdentity(tr);
+		}
+
+#if 0
+		tkn = M_GetToken(NULL);
+		if (strcmp(tkn, "=") != 0)
+		{
+			CONS_Alert(CONS_ERROR, "Error parsing translation '%s': Expected '=', got '%s'\n", name, tkn);
+			goto fail;
+		}
+		Z_Free(tkn);
+#endif
+
+		do {
+			struct PaletteRemapParseResult *error = PaletteRemap_ParseString(tr, tkn);
+			if (error)
+			{
+				CONS_Alert(CONS_ERROR, "Error parsing translation '%s': %s\n", name, error->error);
+				Z_Free(error);
+				goto fail;
+			}
+
+			Z_Free(tkn);
+			tkn = M_GetToken(NULL);
+			if (!tkn)
+				break;
+
+			if (strcmp(tkn, ",") != 0)
+				break;
+
+			Z_Free(tkn);
+			tkn = M_GetToken(NULL);
+		} while (true);
+
+		// add it
+		unsigned id = PaletteRemap_Add(tr);
+		R_AddCustomTranslation(name, id);
+		Z_Free(name);
+	}
+
+fail:
+	Z_Free(tkn);
+	Z_Free((void *)text);
+}
+
+void R_LoadTrnslateLumps(void)
+{
+	for (INT32 w = numwadfiles-1; w >= 0; w--)
+	{
+		UINT16 lump = W_CheckNumForNamePwad("TRNSLATE", w, 0);
+
+		while (lump != INT16_MAX)
+		{
+			P_ParseTrnslate(w, lump);
+			lump = W_CheckNumForNamePwad("TRNSLATE", (UINT16)w, lump + 1);
+		}
+	}
+}
+
+struct CustomTranslation
+{
+	char *name;
+	unsigned id;
+	UINT32 hash;
+};
+
+static struct CustomTranslation *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;
+}
+
+void R_AddCustomTranslation(const char *name, int trnum)
+{
+	struct CustomTranslation *tr = NULL;
+	UINT32 hash = quickncasehash(name, strlen(name));
+
+	for (unsigned i = 0; i < numcustomtranslations; i++)
+	{
+		tr = &customtranslations[i];
+		if (hash == tr->hash
+		&& strcmp(name, tr->name) == 0)
+		{
+			break;
+		}
+	}
+
+	if (tr == NULL)
+	{
+		numcustomtranslations++;
+		customtranslations = Z_Realloc(customtranslations, sizeof(struct CustomTranslation) * numcustomtranslations, PU_STATIC, NULL);
+		tr = &customtranslations[numcustomtranslations - 1];
+	}
+
+	tr->id = trnum;
+	tr->name = Z_StrDup(name);
+	tr->hash = quickncasehash(name, strlen(name));
+}
+
+remaptable_t *R_GetTranslationByID(int id)
+{
+	if (id < 0 || id >= (signed)numpaletteremaps)
+		return NULL;
+
+	return paletteremaps[id];
+}
diff --git a/src/r_translation.h b/src/r_translation.h
new file mode 100644
index 0000000000000000000000000000000000000000..2a02a0c503f320057c1d6e42e0acf77a71b316d9
--- /dev/null
+++ b/src/r_translation.h
@@ -0,0 +1,48 @@
+// SONIC ROBO BLAST 2
+//-----------------------------------------------------------------------------
+// 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 Translations
+
+#include "doomdef.h"
+
+typedef struct
+{
+	UINT8 remap[256];
+	unsigned num_entries;
+} remaptable_t;
+
+extern remaptable_t **paletteremaps;
+extern unsigned numpaletteremaps;
+
+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);
+
+boolean PaletteRemap_AddIndexRange(remaptable_t *tr, int start, int end, int pal1, int pal2);
+boolean PaletteRemap_AddColorRange(remaptable_t *tr, int start, int end, int _r1,int _g1, int _b1, int _r2, int _g2, int _b2);
+boolean PaletteRemap_AddDesaturation(remaptable_t *tr, int start, int end, double r1, double g1, double b1, double r2, double g2, double b2);
+boolean PaletteRemap_AddColourisation(remaptable_t *tr, int start, int end, int r, int g, int b);
+boolean PaletteRemap_AddTint(remaptable_t *tr, int start, int end, int r, int g, int b, int amount);
+
+struct PaletteRemapParseResult
+{
+	char error[4096];
+};
+
+struct PaletteRemapParseResult *PaletteRemap_ParseString(remaptable_t *tr, char *translation);
+
+int R_FindCustomTranslation(const char *name);
+void R_AddCustomTranslation(const char *name, int trnum);
+remaptable_t *R_GetTranslationByID(int id);
+
+void R_LoadTrnslateLumps(void);
diff --git a/src/sdl/Srb2SDL-vc10.vcxproj b/src/sdl/Srb2SDL-vc10.vcxproj
index 9b51cfb8094a1d6897e0685e9531ccd21df0fbdb..a397e16e974f1f8c353d03151a3e6c39b4307f62 100644
--- a/src/sdl/Srb2SDL-vc10.vcxproj
+++ b/src/sdl/Srb2SDL-vc10.vcxproj
@@ -342,6 +342,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" />
@@ -530,6 +531,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..231f03497d7a34d6ac8a62309375cb78b3ed34e4 100644
--- a/src/sdl/Srb2SDL-vc10.vcxproj.filters
+++ b/src/sdl/Srb2SDL-vc10.vcxproj.filters
@@ -537,6 +537,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>
@@ -1081,6 +1084,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>