Nomina

Table of Contents

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

Features:

Nomina is a part of DoomToolbox.

1. Source code

version 4.14
#include "zscript/nat_Actors.zs"

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.
version 4.14

1.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;

    {
      let[found, name] = findNameInConfig(thing.getClassName());
      if (found)
      {
        thing.setTag(name);
        return;
      }
    }
    {
      let[found, name] = findNameInData(thing.getClassName());
      if (found)
      {
        thing.setTag(name);
        return;
      }
    }

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

    string name = makeAutoName(thing.getClassName());
    thing.setTag(name);
  }
class nat_Imp : DoomImp {}
mExpectedNames.insert("DoomImp", "Imp");
mExpectedNames.insert("nat_Imp", "Imp");

1.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");

1.3. In-game enemy tag correction

1.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");

1.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 : Actor { Default { Monster; } }
mExpectedNames.insert("nat_NamelessToRename", "Renamed To Several Words");

1.4. Tag databases

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

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

  // Initializes the event handler.
  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());
      }
    }
  }

  private Dictionary mData;
}
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");

2. Tests

GameInfo { EventHandlers = "nat_EventHandler" }
class nat_Monster : Actor { Default { Monster; } }
#include "zscript/nat_Actors.zs"

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.
    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;
}

3. Run tests

(setq wait-delay 0)
""
(setq wait-delay (+ new-delay wait-delay))
(format "wait %d" wait-delay)
wait 2; map map01
wait 4; na_rename nat_NamelessToRename Renamed To Several Words
wait 6; summon nat_NamelessToRename
wait 15; quit

Created: 2026-01-04 Sun 07:06