Nomina

Table of Contents

1. About

Nomina is an add-on that helps with enemy names (tags).

Features:

  • 4.2: if an enemy doesn't have a tag, Nomina assigns to beautify its class name and use it is a tag.
  • 4.3: use na_rename <Class> <NewName> console command to assign a tag to a class manually.
  • 4.4: load na_data.json JSON lump (file) that contains class-tag pairs. See examples in tests below.
    • TODO: add command to export current in-game enemy tag corrections to nadata.json.
  • If an enemy without a tag has a name in a sequence definitions, Nomina uses it.

Nomina is a part of DoomToolbox.

2. Changelog

v2.1.0:

  • new: read names from cast sequence definitions

v2.0.0:

  • rewritten from scratch.

3. License

GPL-3.0-only

SPDX-FileCopyrightText: © 2025 Alexander Kromm <mmaulwurff@gmail.com>
SPDX-License-Identifier: GPL-3.0-only

CC0-1.0

SPDX-FileCopyrightText: © 2025 Alexander Kromm <mmaulwurff@gmail.com>
SPDX-License-Identifier: CC0-1.0

4. Source code

version 4.14.3
version 4.14.3

4.1. Event handler

GameInfo { EventHandlers = "na_EventHandler" }
class na_EventHandler : StaticEventHandler
{
  // Renames the spawned thing, if needed.
  override void worldThingSpawned(WorldEvent event)
  {
    Actor thing = event.thing;
    if (thing == NULL || !thing.bIsMonster || thing is "Weapon") return;

    thing.setTag(makeTag(thing));
  }

  private string makeTag(Actor anActor)
  {
    string className = anActor.getClassName();

    {
      let[found, name] = findNameInConfig(className);
      if (found) return name;
    }
    {
      let[found, name] = findNameInData(className);
      if (found) return name;
    }

    bool hasTag = anActor.getTag(".") != ".";
    if (hasTag) return anActor.getTag();

    {
      let[found, name] = findNameInCast(className);
      if (found) return name;
    }

    string name = makeAutoName(className);
    return name;
  }


  // Turns '_' to spaces, capitalizes words, trims and removes duplicate spaces,
  // splits camelCase.
  private static string makeAutoName(string className)
  {
    className.replace("_", " ");

    Array<string> words;
    className.split(words, " ", TOK_SkipEmpty);

    string result;

    foreach (word : words)
    {
      // It seems that non-latin characters are not allowed in class names. Overkill?
      let[firstChar, firstLength] = word.getNextCodepoint(0);

      string split = string.format("%c", string.charUpper(firstChar));
      for (uint i = firstLength; i < word.length();)
      {
        let[letter, next] = word.getNextCodepoint(i);

        bool mustSplit = isUpper(letter)
          && uint(next) < word.length()
          && isLower(word.getNextCodepoint(next));

        split.appendFormat(mustSplit ? " %c" : "%c", letter);

        i = next;
      }

      bool isFirst = result.length() == 0;
      result.appendFormat(isFirst ? "%s" : " %s", split);
    }

    return result;
  }

  private static bool isUpper(int letter) { return string.charLower(letter) != letter; }
  private static bool isLower(int letter) { return string.charUpper(letter) != letter; }

  private static bool, string findNameInConfig(string className)
  {
    let config    = Dictionary.fromString(na_config);
    string newTag = config.at(className);

    return newTag.length() != 0, newTag;
  }

  // Handles `na_rename` command.
  override void networkProcess(ConsoleEvent event)
  {
    Array<string> parts;
    event.name.split(parts, ":");

    if (parts.size() == 0 || parts[0] != "na_rename") return;

    string className = parts[1];
    string newTag    = parts[2];
    for (int i = 3; i < parts.size(); ++i)
      if (parts[i].length() != 0) newTag.appendFormat(" %s", parts[i]);

    if (parts.size() < 3 || className.length() == 0 || newTag.length() == 0)
    {
      Console.printf("%s:\nna_rename ClassName NewTag",
                     StringTable.localize("$NA_USAGE"));
      return;
    }

    let config = Dictionary.fromString(na_config);
    config.insert(className, newTag);
    Cvar.getCvar("na_config").setString(config.toString());

    let i = ThinkerIterator.create(className);
    for (Actor anActor = Actor(i.next()); anActor != NULL; anActor = Actor(i.next()))
      anActor.setTag(newTag);
  }

  private bool, string findNameInData(string className)
  {
    string newTag = mData.at(className);

    return newTag.length() != 0, newTag;
  }

  private Dictionary mData;

  private bool, string findNameInCast(string className)
  {
    string newTag = mCastData.at(className.makeLower());
    return newTag.length() != 0, newTag;
  }

  private Dictionary collectCastData()
  {
    let result = Dictionary.create();

    string mapinfoLumps[] = { "mapinfo", "zmapinfo" };
    foreach (mapinfoLump : mapinfoLumps)
    {
      for (int i = Wads.findLump(mapinfoLump, 0, Wads.AnyNamespace);
          i != -1;
          i = Wads.findLump(mapinfoLump, i + 1, Wads.AnyNamespace))
      {
        string contents = Wads.readLump(i);
        addCastFrom(contents, result);
      }
    }

    return result;
  }

  // Not bothering with writing a proper parser. This will suffice for now.
  // Note: this doesn't understand comments.
  private void addCastFrom(string contents, out Dictionary result)
  {
    // make a lowercase copy so token search is case-independent.
    string lowerContents = contents.makeLower();

    // iterate over all CastClass.
    int contentsLength = contents.length();
    for (int castClassStart = lowerContents.indexOf("castclass");
         castClassStart < contentsLength && castClassStart != -1;
         castClassStart = lowerContents.indexOf("castclass", castClassStart + 1))
    {
      int classStart = lowerContents.indexOf("\"", castClassStart) + 1;
      int classEnd   = lowerContents.indexOf("\"", classStart + 1);

      if (classStart == -1 || classEnd == -1) continue;

      int blockStart = lowerContents.rightIndexOf("{", castClassStart);
      int blockEnd   = lowerContents.indexOf("}", classEnd);

      if (blockStart == -1 || blockEnd == -1) continue;
      if (lowerContents.rightIndexOf("{", blockEnd) != blockStart) continue;
      if (lowerContents.indexOf("}", blockStart) != blockEnd) continue;

      // CastName is either before or after CastClass.
      int castNameStart = lowerContents.rightIndexOf("castname", castClassStart);
      if (castNameStart == -1 || castNameStart < blockStart)
        castNameStart = lowerContents.indexOf("castname", castClassStart);

      if (castNameStart == -1) continue;

      int nameStart = lowerContents.indexOf("\"", castNameStart) + 1;
      int nameEnd   = lowerContents.indexOf("\"", nameStart + 1);

      string className = lowerContents.mid(classStart, classEnd - classStart);
      string name = contents.mid(nameStart, nameEnd - nameStart);
      result.insert(className, name);
    }
  }

  private Dictionary mCastData;

  override void onEngineInitialize()
  {

    mData = Dictionary.create();

    string dataLump = "na_data";
    for (int i = Wads.findLump(dataLump, 0, Wads.AnyNamespace);
         i != -1;
         i = Wads.findLump(dataLump, i + 1, Wads.AnyNamespace))
    {
      let data = Dictionary.fromString(Wads.readLump(i));
      for (let i = DictionaryIterator.create(data); i.next();)
      {
        mData.insert(i.key(), i.value());
      }
    }

    mCastData = collectCastData();

  }
}
class nat_Imp : DoomImp {}
mExpectedNames.insert("DoomImp", "Imp");
mExpectedNames.insert("nat_Imp", "Imp");

4.2. Automatic tags

// Turns '_' to spaces, capitalizes words, trims and removes duplicate spaces,
// splits camelCase.
private static string makeAutoName(string className)
{
  className.replace("_", " ");

  Array<string> words;
  className.split(words, " ", TOK_SkipEmpty);

  string result;

  foreach (word : words)
  {
    // It seems that non-latin characters are not allowed in class names. Overkill?
    let[firstChar, firstLength] = word.getNextCodepoint(0);

    string split = string.format("%c", string.charUpper(firstChar));
    for (uint i = firstLength; i < word.length();)
    {
      let[letter, next] = word.getNextCodepoint(i);

      bool mustSplit = isUpper(letter)
        && uint(next) < word.length()
        && isLower(word.getNextCodepoint(next));

      split.appendFormat(mustSplit ? " %c" : "%c", letter);

      i = next;
    }

    bool isFirst = result.length() == 0;
    result.appendFormat(isFirst ? "%s" : " %s", split);
  }

  return result;
}

private static bool isUpper(int letter) { return string.charLower(letter) != letter; }
private static bool isLower(int letter) { return string.charUpper(letter) != letter; }
class _nat__nameless1_   : nat_Monster {}
class nat_CamelCaseEnemy : nat_Monster {}
class nat_BFGZombie      : nat_Monster {}
class NAT_BFG9000        : nat_Monster {}
mExpectedNames.insert("_nat__nameless1_", "Nat Nameless1");
mExpectedNames.insert("nat_CamelCaseEnemy", "Nat Camel Case Enemy");
mExpectedNames.insert("nat_BFGZombie", "Nat BFG Zombie");
mExpectedNames.insert("NAT_BFG9000", "NAT BFG9000");

4.3. In-game enemy tag correction

4.3.1. na_config Cvar

server nosave string na_config = "";
private static bool, string findNameInConfig(string className)
{
  let config    = Dictionary.fromString(na_config);
  string newTag = config.at(className);

  return newTag.length() != 0, newTag;
}
class nat_NamelessByConfig : nat_Monster {}
// Cannot set a string value with " in the console, have to do it programmatically.
Cvar.getCvar("na_config").setString(
  "{\"nat_NamelessByConfig\":\"TestName\", \"Zombieman\":\"TestZombie\"}");
mExpectedNames.insert("nat_NamelessByConfig", "TestName");
mExpectedNames.insert("Zombieman", "TestZombie");

4.3.2. na_rename console command

// Limited to 10 words. For more words, use the external name data (na_data.json).
Alias na_rename "netevent na_rename:%1:%2:%3:%4:%5:%6:%7:%8:%9:%10"
[enu default]
NA_USAGE = "Usage";

[ru]
NA_USAGE = "Использование";
// Handles `na_rename` command.
override void networkProcess(ConsoleEvent event)
{
  Array<string> parts;
  event.name.split(parts, ":");

  if (parts.size() == 0 || parts[0] != "na_rename") return;

  string className = parts[1];
  string newTag    = parts[2];
  for (int i = 3; i < parts.size(); ++i)
    if (parts[i].length() != 0) newTag.appendFormat(" %s", parts[i]);

  if (parts.size() < 3 || className.length() == 0 || newTag.length() == 0)
  {
    Console.printf("%s:\nna_rename ClassName NewTag",
                   StringTable.localize("$NA_USAGE"));
    return;
  }

  let config = Dictionary.fromString(na_config);
  config.insert(className, newTag);
  Cvar.getCvar("na_config").setString(config.toString());

  let i = ThinkerIterator.create(className);
  for (Actor anActor = Actor(i.next()); anActor != NULL; anActor = Actor(i.next()))
    anActor.setTag(newTag);
}
class nat_NamelessToRename : nat_Monster {}
mExpectedNames.insert("nat_NamelessToRename", "Renamed To Several Words");

4.4. Tag databases

private bool, string findNameInData(string className)
{
  string newTag = mData.at(className);

  return newTag.length() != 0, newTag;
}

private Dictionary mData;
mData = Dictionary.create();

string dataLump = "na_data";
for (int i = Wads.findLump(dataLump, 0, Wads.AnyNamespace);
     i != -1;
     i = Wads.findLump(dataLump, i + 1, Wads.AnyNamespace))
{
  let data = Dictionary.fromString(Wads.readLump(i));
  for (let i = DictionaryIterator.create(data); i.next();)
  {
    mData.insert(i.key(), i.value());
  }
}
class nat_NamelessByData1 : nat_Monster {}
class nat_NamelessByData2 : nat_Monster {}
class nat_NamelessByData3 : nat_Monster {}
{
  "nat_NamelessByData1": "TestData1",
  "nat_NamelessByData3": "TestData3"
}
{
  "nat_NamelessByData2": "TestData2",
  "nat_NamelessByData3": "TestData3-2",
  "nat_NamelessByData4": "TestData4"
}
mExpectedNames.insert("nat_NamelessByData1", "TestData1");
mExpectedNames.insert("nat_NamelessByData2", "TestData2");
mExpectedNames.insert("nat_NamelessByData3", "TestData3-2");

4.5. Cast

private bool, string findNameInCast(string className)
{
  string newTag = mCastData.at(className.makeLower());
  return newTag.length() != 0, newTag;
}

private Dictionary collectCastData()
{
  let result = Dictionary.create();

  string mapinfoLumps[] = { "mapinfo", "zmapinfo" };
  foreach (mapinfoLump : mapinfoLumps)
  {
    for (int i = Wads.findLump(mapinfoLump, 0, Wads.AnyNamespace);
        i != -1;
        i = Wads.findLump(mapinfoLump, i + 1, Wads.AnyNamespace))
    {
      string contents = Wads.readLump(i);
      addCastFrom(contents, result);
    }
  }

  return result;
}

// Not bothering with writing a proper parser. This will suffice for now.
// Note: this doesn't understand comments.
private void addCastFrom(string contents, out Dictionary result)
{
  // make a lowercase copy so token search is case-independent.
  string lowerContents = contents.makeLower();

  // iterate over all CastClass.
  int contentsLength = contents.length();
  for (int castClassStart = lowerContents.indexOf("castclass");
       castClassStart < contentsLength && castClassStart != -1;
       castClassStart = lowerContents.indexOf("castclass", castClassStart + 1))
  {
    int classStart = lowerContents.indexOf("\"", castClassStart) + 1;
    int classEnd   = lowerContents.indexOf("\"", classStart + 1);

    if (classStart == -1 || classEnd == -1) continue;

    int blockStart = lowerContents.rightIndexOf("{", castClassStart);
    int blockEnd   = lowerContents.indexOf("}", classEnd);

    if (blockStart == -1 || blockEnd == -1) continue;
    if (lowerContents.rightIndexOf("{", blockEnd) != blockStart) continue;
    if (lowerContents.indexOf("}", blockStart) != blockEnd) continue;

    // CastName is either before or after CastClass.
    int castNameStart = lowerContents.rightIndexOf("castname", castClassStart);
    if (castNameStart == -1 || castNameStart < blockStart)
      castNameStart = lowerContents.indexOf("castname", castClassStart);

    if (castNameStart == -1) continue;

    int nameStart = lowerContents.indexOf("\"", castNameStart) + 1;
    int nameEnd   = lowerContents.indexOf("\"", nameStart + 1);

    string className = lowerContents.mid(classStart, classEnd - classStart);
    string name = contents.mid(nameStart, nameEnd - nameStart);
    result.insert(className, name);
  }
}

private Dictionary mCastData;
mCastData = collectCastData();

class nat_NamelessInCast1 : nat_Monster {}
class nat_NamelessInCast2 : nat_Monster {}
class nat_NamelessInCast3 : nat_Monster {}
Intermission Doom2Cast
{

  // Out of block:
  // CastClass = "nat_NamelessInCast3"
  // CastName  = "Cast Enemy 3"

  Cast
  {
    CastClass = "nat_NamelessInCast1"
    CastName  = "Cast Enemy 1"
  }
  cast
  {
    castname  = "Cast Enemy 2"
    castclass = "nat_NamelessInCast2"
  }

  // Out of block:
  // CastClass = "nat_NamelessInCast3"
  // CastName  = "Cast Enemy 3"

  Link = Doom2Cast // restart cast call
}
mExpectedNames.insert("nat_NamelessInCast1", "Cast Enemy 1");
mExpectedNames.insert("nat_NamelessInCast2", "Cast Enemy 2");
mExpectedNames.insert("nat_NamelessInCast3", "Nat Nameless In Cast3");

5. Tests

GameInfo { EventHandlers = "nat_EventHandler" }
class nat_Monster : Actor { Default { Monster; } }

class nat_Imp : DoomImp {}

class _nat__nameless1_   : nat_Monster {}
class nat_CamelCaseEnemy : nat_Monster {}
class nat_BFGZombie      : nat_Monster {}
class NAT_BFG9000        : nat_Monster {}

class nat_NamelessByConfig : nat_Monster {}

class nat_NamelessToRename : nat_Monster {}

class nat_NamelessByData1 : nat_Monster {}
class nat_NamelessByData2 : nat_Monster {}
class nat_NamelessByData3 : nat_Monster {}

class nat_NamelessInCast1 : nat_Monster {}
class nat_NamelessInCast2 : nat_Monster {}
class nat_NamelessInCast3 : nat_Monster {}

class nat_Test : Clematis {}

class nat_EventHandler : StaticEventHandler
{
  override void worldLoaded(WorldEvent event)
  {
    mTest = new ("nat_Test");
    mTest.Describe("Nomina tests");

    mExpectedNames = Dictionary.create();

    // Setting expectations goes here.

    mExpectedNames.insert("DoomImp", "Imp");
    mExpectedNames.insert("nat_Imp", "Imp");

    mExpectedNames.insert("_nat__nameless1_", "Nat Nameless1");
    mExpectedNames.insert("nat_CamelCaseEnemy", "Nat Camel Case Enemy");
    mExpectedNames.insert("nat_BFGZombie", "Nat BFG Zombie");
    mExpectedNames.insert("NAT_BFG9000", "NAT BFG9000");

    // Cannot set a string value with " in the console, have to do it programmatically.
    Cvar.getCvar("na_config").setString(
      "{\"nat_NamelessByConfig\":\"TestName\", \"Zombieman\":\"TestZombie\"}");
    mExpectedNames.insert("nat_NamelessByConfig", "TestName");
    mExpectedNames.insert("Zombieman", "TestZombie");

    mExpectedNames.insert("nat_NamelessByData1", "TestData1");
    mExpectedNames.insert("nat_NamelessByData2", "TestData2");
    mExpectedNames.insert("nat_NamelessByData3", "TestData3-2");

    mExpectedNames.insert("nat_NamelessInCast1", "Cast Enemy 1");
    mExpectedNames.insert("nat_NamelessInCast2", "Cast Enemy 2");
    mExpectedNames.insert("nat_NamelessInCast3", "Nat Nameless In Cast3");

    vector3 spawnPoint = players[consolePlayer].mo.pos + (100, 0, 0);

    for (let i = DictionaryIterator.create(mExpectedNames); i.next();)
      Actor.Spawn(i.key(), spawnPoint);


    mExpectedNames.insert("nat_NamelessToRename", "Renamed To Several Words");
  }

  override void worldThingSpawned(WorldEvent event)
  {
    Actor thing = event.thing;

    if (thing == NULL || !(thing.bIsMonster || thing is "Weapon")) return;

    string className = thing.getClassName();
    if (mExpectedNames.at(className).length() == 0) return;

    string actual   = thing.getTag();
    string expected = mExpectedNames.at(className);
    bool isExpected = actual == expected;
    mTest.it(className, mTest.assert(isExpected));
    if (!isExpected)
      Console.printf("Actual: %s, expected: %s", actual, expected);
  }

  override void OnUnregister() { mTest.EndDescribe(); }

  private Clematis mTest;
  private Dictionary mExpectedNames;
}
wait  2; map map01
wait  4; na_rename nat_NamelessToRename Renamed To Several Words
wait  6; summon nat_NamelessToRename
wait 15; quit

Created: 2026-06-20 Sat 02:58