AzerothCore学习笔记·模组02:Hook 系统(上)——ScriptMgr 与脚本加载机制

你装了一个 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

阶段二:数据库绑定脚本后加载

有些脚本需要在数据库中配置才会生效------比如 CreatureScriptnpc_textgossip_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+ 个 HookUnitScript 类似量级
  • ScriptMgr.cppInitialize() 初始化了 26 个 Hook 分组表
  • Unload() 清理了 50+ 个 ScriptRegistry 实例

这就是 AzerothCore 的「万能胶水」。模块只需要继承脚本类型、重写虚函数、new 出来注册,核心代码完全不感知模块的存在。

做个粗略的对比:Linux 内核的 Netfilter Hook 也有类似的注册表思想------事件发生时遍历已注册的回调函数。AzerothCore 的 ScriptMgr 做的本质上是一样的事,只是把回调函数的注册和触发机制用 C++ 模板实现到了极致。