diff --git a/src/d_main.cpp b/src/d_main.cpp
index d26ae1d0c..f73049397 100644
--- a/src/d_main.cpp
+++ b/src/d_main.cpp
@@ -2581,7 +2581,8 @@ void D_DoomMain (void)
 			};
 			for (p = 0; p < 5; ++p)
 			{
-				const char *str = GStrings[startupString[p]];
+				// At this point we cannot use the player's gender info yet so force 'male' here.
+				const char *str = GStrings.GetString(startupString[p], nullptr, 0);
 				if (str != NULL && str[0] != '\0')
 				{
 					Printf("%s\n", str);
diff --git a/src/gamedata/d_dehacked.cpp b/src/gamedata/d_dehacked.cpp
index 4643cd492..c7105117d 100644
--- a/src/gamedata/d_dehacked.cpp
+++ b/src/gamedata/d_dehacked.cpp
@@ -2164,13 +2164,14 @@ static int PatchMusic (int dummy)
 
 	while ((result = GetLine()) == 1)
 	{
-		const char *newname = skipwhite (Line2);
+		FString newname = skipwhite (Line2);
 		FString keystring;
 		
 		keystring << "MUSIC_" << Line1;
 
-		DehStrings.Insert(keystring, newname);
-		DPrintf (DMSG_SPAMMY, "Music %s set to:\n%s\n", keystring.GetChars(), newname);
+		TableElement te = { newname, newname, newname, newname };
+		DehStrings.Insert(keystring, te);
+		DPrintf (DMSG_SPAMMY, "Music %s set to:\n%s\n", keystring.GetChars(), newname.GetChars());
 	}
 
 	return result;
@@ -2283,7 +2284,9 @@ static int PatchText (int oldSize)
 		str = EnglishStrings.MatchString(oldStr);
 		if (str != NULL)
 		{
-			DehStrings.Insert(str, newStr);
+			FString newname = newStr;
+			TableElement te = { newname, newname, newname, newname };
+			DehStrings.Insert(str, te);
 			EnglishStrings.Remove(str);	// remove entry so that it won't get found again by the next iteration or  by another replacement later
 			good = true;
 		}
@@ -2337,7 +2340,8 @@ static int PatchStrings (int dummy)
 		// Account for a discrepancy between Boom's and ZDoom's name for the red skull key pickup message
 		const char *ll = Line1;
 		if (!stricmp(ll, "GOTREDSKULL")) ll = "GOTREDSKUL";
-		DehStrings.Insert(ll, holdstring);
+		TableElement te = { holdstring, holdstring, holdstring, holdstring };
+		DehStrings.Insert(ll, te);
 		DPrintf (DMSG_SPAMMY, "%s set to:\n%s\n", Line1, holdstring.GetChars());
 	}
 
diff --git a/src/gamedata/stringtable.cpp b/src/gamedata/stringtable.cpp
index 2fbe12a61..a892992e1 100644
--- a/src/gamedata/stringtable.cpp
+++ b/src/gamedata/stringtable.cpp
@@ -42,9 +42,16 @@
 #include "c_dispatch.h"
 #include "v_text.h"
 #include "gi.h"
+#include "d_player.h"
 #include "xlsxread/xlsxio_read.h"
 
 
+//==========================================================================
+//
+//
+//
+//==========================================================================
+
 void FStringTable::LoadStrings ()
 {
 	int lastlump, lump;
@@ -58,8 +65,14 @@ void FStringTable::LoadStrings ()
 	}
 	SetLanguageIDs();
 	UpdateLanguage();
+	allMacros.Clear();
 }
 
+//==========================================================================
+//
+//
+//
+//==========================================================================
 
 bool FStringTable::LoadLanguageFromSpreadsheet(int lumpnum)
 {
@@ -69,14 +82,60 @@ bool FStringTable::LoadLanguageFromSpreadsheet(int lumpnum)
 	{
 		return false;
 	}
-
-	if (!readSheetIntoTable(xlsxio, "strings")) return false;
-	// readMacros(xlsxio, "macros");
+	readMacros(xlsxio, "macros");
+	readSheetIntoTable(xlsxio, "strings");
 
 	xlsxioread_close(xlsxio);
 	return true;
 }
 
+//==========================================================================
+//
+//
+//
+//==========================================================================
+
+bool FStringTable::readMacros(xlsxioreader reader, const char *sheetname)
+{
+	xlsxioreadersheet sheet = xlsxioread_sheet_open(reader, sheetname, XLSXIOREAD_SKIP_NONE);
+	if (sheet == nullptr) return false;
+
+	while (xlsxioread_sheet_next_row(sheet))
+	{
+		auto macroname = xlsxioread_sheet_next_cell(sheet);
+		auto language = xlsxioread_sheet_next_cell(sheet);
+		if (!macroname || !language) continue;
+		FStringf combined_name("%s/%s", language, macroname);
+		free(language);
+		free(macroname);
+		FName name = combined_name;
+
+		StringMacro macro;
+
+		char *value;
+		for (int i = 0; i < 4; i++)
+		{
+			value = xlsxioread_sheet_next_cell(sheet);
+			macro.Replacements[i] = value;
+			free(value);
+		}
+		// This is needed because the reader code would choke on incompletely read rows.
+		while ((value = xlsxioread_sheet_next_cell(sheet)) != nullptr)
+		{
+			free(value);
+		}
+		allMacros.Insert(name, macro);
+	}
+	xlsxioread_sheet_close(sheet);
+	return true;
+}
+
+//==========================================================================
+//
+//
+//
+//==========================================================================
+
 bool FStringTable::readSheetIntoTable(xlsxioreader reader, const char *sheetname)
 {
 
@@ -94,7 +153,7 @@ bool FStringTable::readSheetIntoTable(xlsxioreader reader, const char *sheetname
 		while ((value = xlsxioread_sheet_next_cell(sheet)) != nullptr)
 		{
 			auto vcopy = value;
-			if (table.Size() <= row) table.Reserve(1);
+			if (table.Size() <= (unsigned)row) table.Reserve(1);
 			while (*vcopy && iswspace((unsigned char)*vcopy)) vcopy++;	// skip over leaading whitespace;
 			auto vend = vcopy + strlen(vcopy);
 			while (vend > vcopy && iswspace((unsigned char)vend[-1])) *--vend = 0;	// skip over trailing whitespace
@@ -105,67 +164,74 @@ bool FStringTable::readSheetIntoTable(xlsxioreader reader, const char *sheetname
 		}
 		row++;
 	}
-	xlsxioread_sheet_close(sheet);
 
 	int labelcol = -1;
 	int filtercol = -1;
 	TArray<std::pair<int, unsigned>> langrows;
 
-	for (unsigned column = 0; column < table[0].Size(); column++)
+	if (table.Size() > 0)
 	{
-		auto &entry = table[0][column];
-		if (entry.CompareNoCase("filter") == 0)
+		for (unsigned column = 0; column < table[0].Size(); column++)
 		{
-			filtercol = column;
-		}
-		else if (entry.CompareNoCase("identifier") == 0)
-		{
-			labelcol = column;;
-		}
-		else
-		{
-			auto languages = entry.Split(" ", FString::TOK_SKIPEMPTY);
-			for (auto &lang : languages)
+			auto &entry = table[0][column];
+			if (entry.CompareNoCase("filter") == 0)
 			{
-				if (lang.CompareNoCase("default") == 0)
+				filtercol = column;
+			}
+			else if (entry.CompareNoCase("identifier") == 0)
+			{
+				labelcol = column;;
+			}
+			else
+			{
+				auto languages = entry.Split(" ", FString::TOK_SKIPEMPTY);
+				for (auto &lang : languages)
 				{
-					langrows.Push(std::make_pair(column, default_table));
+					if (lang.CompareNoCase("default") == 0)
+					{
+						langrows.Push(std::make_pair(column, default_table));
+					}
+					else if (lang.Len() < 4)
+					{
+						lang.ToLower();
+						langrows.Push(std::make_pair(column, MAKE_ID(lang[0], lang[1], lang[2], 0)));
+					}
 				}
-				else if (lang.Len() < 4)
+			}
+		}
+
+		for (unsigned i = 1; i < table.Size(); i++)
+		{
+			auto &row = table[i];
+			if (filtercol > -1)
+			{
+				auto filterstr = row[filtercol];
+				auto filter = filterstr.Split(" ", FString::TOK_SKIPEMPTY);
+				if (filter.Size() > 0 && filter.FindEx([](const auto &str) { return str.CompareNoCase(GameNames[gameinfo.gametype]) == 0; }) == filter.Size())
+					continue;
+			}
+
+			FName strName = row[labelcol];
+			for (auto &langentry : langrows)
+			{
+				auto str = row[langentry.first];
+				if (str.Len() > 0)
 				{
-					lang.ToLower();
-					langrows.Push(std::make_pair(column, MAKE_ID(lang[0], lang[1], lang[2], 0)));
+					InsertString(langentry.second, strName, str);
 				}
 			}
 		}
 	}
-
-	for (unsigned i = 1; i < table.Size(); i++)
-	{
-		auto &row = table[i];
-		if (filtercol > -1)
-		{
-			auto filterstr = row[filtercol];
-			auto filter = filterstr.Split(" ", FString::TOK_SKIPEMPTY);
-			if (filter.Size() > 0 && filter.FindEx([](const auto &str) { return str.CompareNoCase(GameNames[gameinfo.gametype]) == 0; }) == filter.Size())
-				continue;
-		}
-
-		FName strName = row[labelcol];
-		for (auto &langentry : langrows)
-		{
-			auto str = row[langentry.first];
-			if (str.Len() > 0)
-			{
-				allStrings[langentry.second].Insert(strName, str);
-				str.Substitute("\n", "|");
-				Printf(PRINT_LOG, "Setting %s for %s to %.40s\n\n", strName.GetChars(), &langentry.second, str.GetChars());
-			}
-		}
-	}
+	xlsxioread_sheet_close(sheet);
 	return true;
 }
 
+//==========================================================================
+//
+//
+//
+//==========================================================================
+
 void FStringTable::LoadLanguage (int lumpnum)
 {
 	bool errordone = false;
@@ -262,13 +328,52 @@ void FStringTable::LoadLanguage (int lumpnum)
 				// Insert the string into all relevant tables.
 				for (auto map : activeMaps)
 				{
-					allStrings[map].Insert(strName, strText);
+					InsertString(map, strName, strText);
 				}
 			}
 		}
 	}
 }
 
+//==========================================================================
+//
+//
+//
+//==========================================================================
+
+void FStringTable::InsertString(int langid, FName label, const FString &string)
+{
+	const char *strlangid = (const char *)&langid;
+	TableElement te = { string, string, string, string };
+	long index;
+	while ((index = te.strings[0].IndexOf("@[")) >= 0)
+	{
+		auto endindex = te.strings[0].IndexOf(']', index);
+		if (endindex == -1)
+		{
+			Printf("Bad macro in %s : %s\n", strlangid, label.GetChars());
+			break;
+		}
+		FString macroname(string.GetChars() + index + 2, endindex - index - 2);
+ 		FStringf lookupstr("%s/%s", strlangid, macroname.GetChars());
+		FStringf replacee("@[%s]", macroname.GetChars());
+		FName lookupname(lookupstr, true);
+		auto replace = allMacros.CheckKey(lookupname);
+		for (int i = 0; i < 4; i++)
+		{
+			const char *replacement = replace && replace->Replacements[i] ? replace->Replacements[i] : "";
+			te.strings[i].Substitute(replacee, replacement);
+		}
+	}
+	allStrings[langid].Insert(label, te);
+}
+
+//==========================================================================
+//
+//
+//
+//==========================================================================
+
 void FStringTable::UpdateLanguage()
 {
 	currentLanguageSet.Clear();
@@ -290,7 +395,12 @@ void FStringTable::UpdateLanguage()
 	checkone(default_table);
 }
 
+//==========================================================================
+//
 // Replace \ escape sequences in a string with the escaped characters.
+//
+//==========================================================================
+
 size_t FStringTable::ProcessEscapes (char *iptr)
 {
 	char *sptr = iptr, *optr = iptr, c;
@@ -317,10 +427,15 @@ size_t FStringTable::ProcessEscapes (char *iptr)
 	return optr - sptr;
 }
 
+//==========================================================================
+//
+// Checks if the given key exists in any one of the default string tables that are valid for all languages.
+// To replace IWAD content this condition must be true.
+//
+//==========================================================================
+
 bool FStringTable::exists(const char *name)
 {
-	// Checks if the given key exists in any one of the default string tables that are valid for all languages.
-	// To replace IWAD content this condition must be true.
 	if (name == nullptr || *name == 0)
 	{
 		return false;
@@ -343,13 +458,20 @@ bool FStringTable::exists(const char *name)
 	return false;
 }
 
+//==========================================================================
+//
 // Finds a string by name and returns its value
-const char *FStringTable::GetString(const char *name, uint32_t *langtable) const
+//
+//==========================================================================
+
+const char *FStringTable::GetString(const char *name, uint32_t *langtable, int gender) const
 {
 	if (name == nullptr || *name == 0)
 	{
 		return nullptr;
 	}
+	if (gender == -1) gender = players[consoleplayer].userinfo.GetGender();
+	if (gender < 0 || gender > 3) gender = 0;
 	FName nm(name, true);
 	if (nm != NAME_None)
 	{
@@ -359,20 +481,27 @@ const char *FStringTable::GetString(const char *name, uint32_t *langtable) const
 			if (item)
 			{
 				if (langtable) *langtable = map.first;
-				return item->GetChars();
+				return item->strings[gender].GetChars();
 			}
 		}
 	}
 	return nullptr;
 }
 
+//==========================================================================
+//
 // Finds a string by name in a given language
-const char *FStringTable::GetLanguageString(const char *name, uint32_t langtable) const
+//
+//==========================================================================
+
+const char *FStringTable::GetLanguageString(const char *name, uint32_t langtable, int gender) const
 {
 	if (name == nullptr || *name == 0)
 	{
 		return nullptr;
 	}
+	if (gender == -1) gender = players[consoleplayer].userinfo.GetGender();
+	if (gender < 0 || gender > 3) gender = 0;
 	FName nm(name, true);
 	if (nm != NAME_None)
 	{
@@ -381,14 +510,19 @@ const char *FStringTable::GetLanguageString(const char *name, uint32_t langtable
 		auto item = map->CheckKey(nm);
 		if (item)
 		{
-			return item->GetChars();
+			return item->strings[gender].GetChars();
 		}
 	}
 	return nullptr;
 }
 
+//==========================================================================
+//
 // Finds a string by name and returns its value. If the string does
 // not exist, returns the passed name instead.
+//
+//==========================================================================
+
 const char *FStringTable::operator() (const char *name) const
 {
 	const char *str = operator[] (name);
@@ -396,7 +530,14 @@ const char *FStringTable::operator() (const char *name) const
 }
 
 
+//==========================================================================
+//
 // Find a string with the same exact text. Returns its name.
+// This does not need to check genders, it is only used by
+// Dehacked on the English table for finding stock strings.
+//
+//==========================================================================
+
 const char *StringMap::MatchString (const char *string) const
 {
 	StringMap::ConstIterator it(*this);
@@ -404,7 +545,7 @@ const char *StringMap::MatchString (const char *string) const
 
 	while (it.NextPair(pair))
 	{
-		if (pair->Value.CompareNoCase(string) == 0)
+		if (pair->Value.strings[0].CompareNoCase(string) == 0)
 		{
 			return pair->Key.GetChars();
 		}
diff --git a/src/gamedata/stringtable.h b/src/gamedata/stringtable.h
index d0d7b0347..c341d97b8 100644
--- a/src/gamedata/stringtable.h
+++ b/src/gamedata/stringtable.h
@@ -47,14 +47,25 @@
 #include "doomdef.h"
 #include "doomtype.h"
 
+struct TableElement
+{
+	FString strings[4];
+};
+
 // This public interface is for Dehacked
-class StringMap : public TMap<FName, FString>
+class StringMap : public TMap<FName, TableElement>
 {
 public:
 	const char *MatchString(const char *string) const;
 };
 
 
+struct StringMacro
+{
+	FString Replacements[4];
+};
+
+
 class FStringTable
 {
 public:
@@ -66,6 +77,7 @@ public:
 	};
 
 	using LangMap = TMap<uint32_t, StringMap>;
+	using StringMacroMap = TMap<FName, StringMacro>;
 
 	void LoadStrings ();
 	void UpdateLanguage();
@@ -76,8 +88,8 @@ public:
 		UpdateLanguage();
 	}
 	
-	const char *GetLanguageString(const char *name, uint32_t langtable) const;
-	const char *GetString(const char *name, uint32_t *langtable) const;
+	const char *GetLanguageString(const char *name, uint32_t langtable, int gender = -1) const;
+	const char *GetString(const char *name, uint32_t *langtable, int gender = -1) const;
 	const char *operator() (const char *name) const;	// Never returns NULL
 	const char *operator[] (const char *name) const
 	{
@@ -87,12 +99,15 @@ public:
 
 private:
 
+	StringMacroMap allMacros;
 	LangMap allStrings;
 	TArray<std::pair<uint32_t, StringMap*>> currentLanguageSet;
 
 	void LoadLanguage (int lumpnum);
 	bool LoadLanguageFromSpreadsheet(int lumpnum);
+	bool readMacros(struct xlsxio_read_struct *reader, const char *sheet);
 	bool readSheetIntoTable(struct xlsxio_read_struct *reader, const char *sheet);
+	void InsertString(int langid, FName label, const FString &string);
 
 	static size_t ProcessEscapes (char *str);
 };
diff --git a/src/p_interaction.cpp b/src/p_interaction.cpp
index 6cd290c1b..121fb1cad 100644
--- a/src/p_interaction.cpp
+++ b/src/p_interaction.cpp
@@ -261,12 +261,12 @@ void ClientObituary (AActor *self, AActor *inflictor, AActor *attacker, int dmgf
 
 	if (message != NULL && message[0] == '$') 
 	{
-		message = GStrings[message+1];
+		message = GStrings.GetString(message+1, nullptr, self->player->userinfo.GetGender());
 	}
 
 	if (message == NULL)
 	{
-		message = GStrings("OB_DEFAULT");
+		message = GStrings.GetString("OB_DEFAULT", nullptr, self->player->userinfo.GetGender());
 	}
 
 	// [CK] Don't display empty strings
diff --git a/src/utility/xlsxread/xlsxio_read.cpp b/src/utility/xlsxread/xlsxio_read.cpp
index 738268707..48f6de5d8 100644
--- a/src/utility/xlsxread/xlsxio_read.cpp
+++ b/src/utility/xlsxread/xlsxio_read.cpp
@@ -63,7 +63,7 @@ void zip_close(zip_t *zipfile)
 
 zip_file_t *zip_fopen(zip_t *zipfile, const char *filename)
 {
-    if (!zipfile) return NULL;
+    if (!zipfile || !filename) return NULL;
     auto lump = zipfile->FindLump(filename);
     if (!lump) return NULL;
     return new FileReader(std::move(lump->NewReader()));