Guides › Development

Wrapping an AzerothCore module

The idea

Most AzerothCore modules build as plugins unchanged. Instead of forking a module, you write a small repository, <module>-plugin, that says how to build it, the way a distribution's package spec builds a program from its original sources:

mod-transmog-plugin/
  plugin.json          manifest; "source" pins the module's repository and commit
  CMakeLists.txt       fetches the module, applies patches/, calls AddPlugin, lays out conf/ and data/
  plugin/plugin.cpp    the entry point: the module's own script loader
  settings.json        the module's main options in the launcher's Settings
  patches/*.patch      changes the module needs as a plugin, if any
  icon.png             64x64, shown by the launcher and the catalog
  LICENSE              the module's license
  README.md

The module's own files are never copied into your repository. The wrappers LonelyIce ships (mod-transmog-plugin, mod-ah-bot-plugin, mod-npc-beastmaster-plugin and others) all follow this layout, each with a settings.json; copy one as a starting point. The format reference is Modules from their own repositories.

1. Write the manifest

{
  "format": 1,
  "id": "mod-transmog",
  "version": "1.0.2",
  "name": { "en": "Transmogrification", "ru": "Трансмогрификация" },
  "description": { "en": "Changes the look of equipped items to that of other items." },
  "authors": [ "AzerothCore" ],
  "license": "AGPL-3.0",
  "homepage": "https://github.com/azerothcore/mod-transmog",
  "locales": [ "en", "de", "es", "fr", "ko", "ru", "zh-CN", "zh-TW" ],
  "source": {
    "repo": "https://github.com/azerothcore/mod-transmog.git",
    "commit": "0d85cbc53d63ce2df8527169ce6ae47f5f6f6ba8"
  },
  "core": { "abi": "lonelyice-ac-2" },
  "platforms": [ "windows-x64" ],
  "server": { "library": "mod_transmog" },
  "databases": { "auth": "data/sql/db-auth", "characters": "data/sql/db-characters", "world": "data/sql/db-world" },
  "config": "conf/transmog.conf.dist",
  "settings": "settings.json"
}
  • source.repo and source.commit pin the module. Always a full commit hash, never a branch.
  • authors, license and homepage are the module's, not yours.
  • databases and config name the module's own files as they are in its repository (data/sql/db-world, conf/transmog.conf.dist); the build lays them out at the same paths inside the plugin.
  • settings names your wrapper's settings.json (section 4). locales lists the languages the module's texts are translated into.
  • version is the plugin's version. Raise it whenever the pinned commit or a patch changes.
  • The shipped wrappers use the module's name as the id (mod-transmog). The catalog accepts such ids; it warns only because third-party ids are usually author.feature.

2. Write CMakeLists.txt

This is CMakeLists.txt of mod-transmog-plugin. For another module, replace every mod-transmog with the target name you choose:

include(FetchContent)
find_package(Git REQUIRED)

file(READ "${CMAKE_CURRENT_SOURCE_DIR}/plugin.json" manifest)
string(JSON id GET "${manifest}" id)
string(JSON repo GET "${manifest}" source repo)
string(JSON commit GET "${manifest}" source commit)
file(GLOB patches "${CMAKE_CURRENT_SOURCE_DIR}/patches/*.patch")
set(patch_command "")
if (patches)
  # Runs again whenever the module is fetched anew or reconfigured, so it starts from the pinned commit every time.
  set(patch_command PATCH_COMMAND "${GIT_EXECUTABLE}" reset --quiet --hard COMMAND "${GIT_EXECUTABLE}" clean --quiet -fd
    COMMAND "${GIT_EXECUTABLE}" apply --whitespace=nowarn ${patches})
endif()
# SOURCE_SUBDIR names no folder: the module is only fetched, its own build files are not used.
FetchContent_Declare(mod-transmog GIT_REPOSITORY "${repo}" GIT_TAG "${commit}" SOURCE_SUBDIR none ${patch_command})
FetchContent_MakeAvailable(mod-transmog)
set(module "${mod-transmog_SOURCE_DIR}")

file(GLOB_RECURSE sources "${module}/src/*.cpp" "${module}/src/*.h")
AddPlugin(mod-transmog SOURCES ${sources} plugin/plugin.cpp)
if (NOT TARGET mod-transmog)
  return()
endif()
target_include_directories(mod-transmog PRIVATE "${module}/src")

# The module's conf and sql folders, laid out after the plugin's own files.
set(copy "")
foreach(sub conf data)
  if (EXISTS "${module}/${sub}")
    list(APPEND copy COMMAND ${CMAKE_COMMAND} -E copy_directory "${module}/${sub}" "${AC_PLUGINS_OUTPUT_DIR}/${id}/${sub}")
  endif()
endforeach()
add_custom_target(mod-transmog-module-files ALL ${copy} VERBATIM)
set_target_properties(mod-transmog-module-files PROPERTIES FOLDER "plugins")
add_dependencies(mod-transmog-module-files mod-transmog-files)
add_dependencies(mod-transmog mod-transmog-module-files)

What each part does:

Part Purpose
FetchContent_Declare(... SOURCE_SUBDIR none ...) Clones the module at source.commit into build/_deps/<name>-src without running its CMake files.
PATCH_COMMAND Resets the checkout to the pinned commit, then applies every patches/*.patch with git apply. A patch that no longer applies stops the configure step.
AddPlugin(...) Builds the module's sources plus your entry point as the plugin library. It returns without a target when the build has no game library, hence the if (NOT TARGET ...) guard.
target_include_directories The module's src/ on the include path (AddPlugin adds only your wrapper's src/).
*-module-files target Copies the module's conf/ and data/ into the laid-out plugin. It depends on <target>-files, the target AddPlugin creates to lay out your own files, because that target first deletes the conf and data folders of the output.

If the module needs more than game, add LINK (another plugin's target, for example mod-playerbots) and list that plugin in depends.

3. Write the entry point

plugin/plugin.cpp:

#include "PluginApi.h"

void Addmod_transmogScripts();

AC_PLUGIN(Addmod_transmogScripts)

The function is the module's own script loader, the one the core's module system calls: Add + the module's folder name with - replaced by _ + Scripts. Look it up in the module's src/*_loader.cpp (or wherever it is defined).

4. Describe the settings

The module's .conf.dist already declares its options; settings.json in the wrapper's root picks the ones players change and gives them a group in the launcher's Settings. AddPlugin lays it out with the plugin. An excerpt of mod-transmog-plugin's:

{
  "group": { "en": "Transmogrification", "ru": "Трансмогрификация" },
  "hint": {
    "en": "Changes take effect after a restart, or at once with .reload config followed by .transmog reload",
    "ru": "Изменения вступают в силу после перезапуска или сразу после .reload config и затем .transmog reload"
  },
  "fields": [
    { "key": "Transmogrification.Enable", "type": "bool", "default": 1, "apply": "restart",
      "label": { "en": "Transmogrification enabled", "ru": "Трансмогрификация включена" },
      "hint": { "en": "Off: transmogrified looks are hidden; the saved data stays", "ru": "Выкл.: изменённый облик не виден; сохранённые данные остаются" } },
    { "key": "Transmogrification.CopperCost", "type": "int", "min": 0, "default": 0, "apply": "restart",
      "label": { "en": "Extra price, copper", "ru": "Доплата, в меди" } },
    { "key": "Transmogrification.AllowMixedWeaponTypes", "type": "choice", "default": 0, "apply": "restart",
      "label": { "en": "Weapon types", "ru": "Типы оружия" },
      "options": [
        { "value": "0", "label": { "en": "Strict: same weapon type", "ru": "Строго: тот же тип оружия" } },
        { "value": "1", "label": { "en": "Modern: e.g. swords to axes", "ru": "Как в поздних версиях: например, мечи в топоры" } },
        { "value": "2", "label": { "en": "Full: any weapon to any weapon", "ru": "Полностью: любое оружие в любое" } }
      ] }
  ]
}
  • Every key must exist in the module's .conf.dist; the launcher writes it into modules/transmog.conf beside the server's config (configs/modules), the file the core reads over the .dist.
  • Choose apply from how the module reads the option, not from what would be convenient. mod-transmog reads its options once, in OnStartup, and again only on its own .transmog reload; the launcher sends reload config at most, so every field is restart, and the group's hint names the manual way. A module that re-reads its options in OnAfterConfigLoad can use reload. Never now for a module's option: the core re-reads configs only on reload config or a restart.
  • Write the labels yourself, in the languages of locales; the module's comments are no labels.

The field reference is Plugin settings.

5. Build and check it

cmake -S . -B build -DBOOST_ROOT=<boost> -DOPENSSL_ROOT_DIR=<openssl> "-DLONELYICE_PLUGIN_DIRS=C:/dev/mod-transmog-plugin"
cmake --build build --config RelWithDebInfo --target mod-transmog

The plugin is laid out in build/bin/RelWithDebInfo/plugins/mod-transmog/. Before publishing, check on a test server:

  1. The server log shows Plugin mod-transmog 1.0.0 (...), and the module's own start-up messages.
  2. The module's SQL applies on the default SQLite storage. Module SQL is written for MySQL; statements the translation does not support are listed in Shipping SQL. A file that fails needs a patch.
  3. The module finds its files. Modules often hard-code modules/<name>/... paths, which do not exist for a plugin.
  4. The config is read: modules/<conf name> beside the server's config (configs/modules/transmog.conf in LonelyIce) overrides the .dist laid out in the plugin key by key.
  5. The Settings group appears, and a changed value takes effect after the apply you chose.

6. Patch only what a plugin needs

Patches stay small. Typical ones:

  • Paths. Files the module reads from modules/<name>/ are in the plugin's folder instead. The plugin loader knows it: sPluginMgr->Find("<id>")->dir (PluginMgr.h). From mod-npc-beastmaster-plugin:

    +#include "PluginMgr.h"
    ...
    -  const std::string path = "modules/mod-npc-beastmaster/conf/profanity.txt";
    +  // Loaded as a plugin: the list is in the plugin's folder.
    +  PluginInfo const *plugin = sPluginMgr->Find("mod-npc-beastmaster");
    +  const std::string path =
    +      plugin ? (plugin->dir / "conf" / "profanity.txt").string()
    +             : "modules/mod-npc-beastmaster/conf/profanity.txt";
    
  • Calls into core code the core does not export from its shared libraries: change the module's call.

  • SQL that does not translate for SQLite.

To make a patch:

  1. Edit the checkout in build/_deps/<name>-src.
  2. Save git diff there as patches/NNNN-what-it-does.patch.
  3. Delete build/_deps/<name>-* and configure again, so the module is fetched and patched from scratch.
  4. List the patch and its reason in the wrapper's README.

A module that needs larger changes is forked instead; source.repo then points at the fork.

Updating the module

  1. Set source.commit to the new commit and raise version.
  2. Delete build/_deps/<name>-* and configure again. A patch that no longer applies stops the configure step; rework it as above.
  3. Test again, including the module's new SQL files.

Licenses

  • The wrapper repository (build files and patches) is under the module's license, with the module's LICENSE file. Patches are derived from the module's code.
  • The manifest's license is the module's license, and the package carries it.
  • A module without a license: the project's wrappers of such modules (mod-instance-reset-plugin, mod-multibot-bridge-plugin) put their own files (build files, patches, manifest) under GPL-2.0-or-later, while the module's code stays under its authors' terms. Without a license nothing grants you the right to redistribute that code, so ask the module's authors to add one (or for permission) before you publish a package built from it.
  • When publishing, the site asks you to confirm that the source is public and that you may publish it under its license.

Publishing

settings.json (section 4), icon.png, LICENSE and README.md in the wrapper root are laid out with the plugin and go into the package.

Pack and publish as for any plugin (Publishing a package). The zip is built from your wrapper, so the publish form never prefills source.repo and source.commit, which are the module's: it shows them as "Wraps". On the first upload the repository stays empty unless homepage is your wrapper repository; later uploads reuse the repository of your previous submission. Give your wrapper repository and the tag you built from, and mention the pinned module commit in "What changed".