你装了一个 mod,想让玩家在杀怪时自动发一条聊天消息。你不需要改核心代码,不需要重新编译 worldserver------只需要写一个类继承 PlayerScript,重写 OnPlayerCreatureKill 方法,然后 new 出来,服务器重启后它就生效了。
核心不感知你的存在,但你能介入核心。这就是 AzerothCore 的 Hook 系统。核心发生某个事件时,沿着注册表遍历一遍,通知所有订阅了这个事件的模块:"嘿,这事发生了,你要处理吗?"
这套机制的核心就是 ScriptMgr。

ScriptMgr:全局事件分发中心
ScriptMgr 是一个单例类(ScriptMgr::instance(),全局简写 sScriptMgr),定义在 src/server/game/Scripting/ScriptMgr.h。
它做的事很简单:维护一张注册表,核心代码发生事件时查表调用。
但这个"简单"背后有大量的类型定义。打开 AllScriptsObjects.h,你会看到它引入了 48 个脚本类型头文件:
cpp
#include "PlayerScript.h"
#include "CreatureScript.h"
#include "GameObjectScript.h"
#include "SpellScriptLoader.h"
#include "WorldScript.h"
#include "AllSpellScript.h"
#include "AllBattlegroundScript.h"
// ... 41 more
每种脚本类型对应游戏中的一类实体或事件------玩家有 PlayerScript,怪物有 CreatureScript,物品有 ItemScript,成就有 AchievementScript,甚至拍卖行有 AuctionHouseScript。
以 PlayerScript 为例,它定义了 150+ 个虚函数 (对应 PlayerHook 枚举里的 150+ 个 Hook ID),覆盖玩家在游戏中的几乎每一个行为:登录、登出、升级、死亡、杀怪、学技能、装备物品、发送聊天......
cpp
class PlayerScript : public ScriptObject
{
public:
virtual void OnPlayerLogin(Player* player) { }
virtual void OnPlayerLogout(Player* player) { }
virtual void OnPlayerJustDied(Player* player) { }
virtual void OnPlayerCreatureKill(Player* killer, Creature* killed) { }
virtual void OnPlayerLevelChanged(Player* player, uint8 oldLevel) { }
virtual bool OnPlayerCanEquipItem(Player* player, uint8 slot,
uint16& dest, Item* pItem, bool swap, bool not_loading) { return true; }
// ... 140+ more
};
每个虚函数的默认实现要么是空函数(通知型 Hook),要么返回 true(拦截型 Hook)。模块只需要重写自己关心的那几个。
注册表:ScriptRegistry 的两层结构
注册表是模板类 ScriptRegistry<T>,每种脚本类型有独立的注册表实例。每个注册表维护两个数据结构:
cpp
static ScriptMap ScriptPointerList; // 所有已注册脚本,按 ID 索引
static EnabledHooksVector EnabledHooks; // 按 Hook ID 分组的已启用脚本列表
ScriptPointerList 存的是"这个脚本类型下有哪些脚本实例",EnabledHooks 存的是"某个具体 Hook 被哪些脚本实例重写了"。
为什么需要两层?性能。
假设有 50 个 PlayerScript 实例,但大部分只重写了 OnPlayerLogin,只有 2 个重写了 OnPlayerPVPKill。如果每次触发 Hook 都遍历全部 50 个,大量虚函数调用白费。EnabledHooks 让 ScriptMgr 只遍历真正重写了该 Hook 的脚本。
加载流程:两阶段注册
模块的脚本注册分两个阶段,都发生在服务器启动时。
阶段一:编译时注册代码脚本
每个模块提供一个入口函数,命名规则为 Add<模块名>Scripts()。比如 mod-ollama-chat:
cpp
void Addmod_ollama_chatScripts()
{
new OllamaChatConfigWorldScript();
new PlayerBotChatHandler();
new OllamaBotRandomChatter();
new ChatOnKill();
new ChatOnLoot();
new ChatOnDeath();
// ...
}
关键动作就是 new:每个脚本对象的构造函数里调用 ScriptRegistry::AddScript(),把自己注册到对应类型的注册表中。
构造函数怎么知道往哪个注册表注册?靠继承关系。ChatOnKill 继承自 PlayerScript,而 PlayerScript 的构造函数里:
cpp
PlayerScript::PlayerScript(const char* name, std::vector<uint16> enabledHooks)
: ScriptObject(name)
{
ScriptRegistry<PlayerScript>::AddScript(this, enabledHooks);
}
如果脚本是纯代码(不需要数据库绑定),直接进入 ScriptPointerList。
阶段二:数据库绑定脚本后加载
有些脚本需要在数据库中配置才会生效------比如 CreatureScript 的 npc_text、gossip_menu 等表里会指定脚本名称。这类脚本(IsDatabaseBound() == true)进入 ALScripts(After-Load Scripts)暂存区。
等数据库加载完成后,AddALScripts() 被调用,从 ALScripts 里取脚本,查 sObjectMgr->GetScriptId() 获取数据库分配的 ID,再正式注册到 ScriptPointerList。
如果数据库里没有配置这个脚本名称,核心会打一条警告日志------脚本存在但没有被任何实体使用。
分发机制:CALL_ENABLED_HOOKS 宏
核心代码调用 Hook 的方式非常简洁。以玩家死亡为例:
cpp
// ScriptMgr.cpp
void ScriptMgr::OnPlayerJustDied(Player* player)
{
CALL_ENABLED_HOOKS(PlayerScript, PLAYERHOOK_ON_PLAYER_JUST_DIED,
script->OnPlayerJustDied(player));
}
CALL_ENABLED_HOOKS 是一个宏,展开后就是:
cpp
if (!ScriptRegistry<PlayerScript>::EnabledHooks[PLAYERHOOK_ON_PLAYER_JUST_DIED].empty())
for (auto const& script :
ScriptRegistry<PlayerScript>::EnabledHooks[PLAYERHOOK_ON_PLAYER_JUST_DIED])
{
script->OnPlayerJustDied(player);
}
查 EnabledHooks 里有没有人注册了这个 Hook,有就逐个调用。
PlayerScript.cpp 文件有近 930 行,做的全是这件事------把 ScriptMgr.h 里声明的 150+ 个分发函数逐一实现,每个函数体内就一行 CALL_ENABLED_HOOKS。
拦截型 Hook vs 通知型 Hook
不是所有 Hook 都是"通知你一声"的。有些 Hook 允许模块拦截和修改核心行为。
通知型(void 返回):
cpp
virtual void OnPlayerLogin(Player* player) { }
模块接到通知后做自己的事,核心逻辑继续执行不受影响。
拦截型(bool 返回或引用参数):
cpp
virtual bool OnPlayerCanEquipItem(Player* player, uint8 slot,
uint16& dest, Item* pItem, bool swap, bool not_loading) { return true; }
返回 false 可以阻止玩家装备物品。&dest 引用参数允许模块修改装备目标位置。
还有参数修改型:
cpp
virtual void OnPlayerGiveXP(Player* player, uint32& amount,
Unit* victim, uint8 xpSource) { }
模块可以修改 amount,改变玩家实际获得的经验值。
一张图总结
模块编写脚本 (new ChatOnKill)
│
▼
ScriptRegistry<PlayerScript>::AddScript(this)
│
├─ 代码脚本 → ScriptPointerList[id] = script
└─ 数据库绑定脚本 → ALScripts → 等DB加载 → ScriptPointerList[id]
│
▼
ScriptMgr::Initialize()
├─ _script_loader_callback() ← 核心自带脚本
├─ _modules_loader_callback() ← 所有模块入口
└─ InitEnabledHooksIfNeeded() ← 初始化 Hook 分组表
│
▼
运行时:核心代码调用 sScriptMgr->OnPlayerCreatureKill()
│
▼
CALL_ENABLED_HOOKS(PlayerScript, PLAYERHOOK_ON_CREATURE_KILL,
script->OnPlayerCreatureKill(killer, killed))
│
▼
ChatOnKill::OnPlayerCreatureKill() ← mod-ollama-chat 的实现
ScriptMgr 的规模
光看数字就能感受到这套系统的"厚重感":
- 48 种脚本类型,对应游戏中 48 类事件/实体
PlayerScript一种就有 150+ 个 Hook ,UnitScript类似量级ScriptMgr.cpp的Initialize()初始化了 26 个 Hook 分组表Unload()清理了 50+ 个 ScriptRegistry 实例
这就是 AzerothCore 的「万能胶水」。模块只需要继承脚本类型、重写虚函数、new 出来注册,核心代码完全不感知模块的存在。
做个粗略的对比:Linux 内核的 Netfilter Hook 也有类似的注册表思想------事件发生时遍历已注册的回调函数。AzerothCore 的 ScriptMgr 做的本质上是一样的事,只是把回调函数的注册和触发机制用 C++ 模板实现到了极致。