简介:AzerothCore-WotLK 是一套面向 MMORPG 服务端开发与游戏模拟器研究者的开源、模块化解决方案,基于《魔兽世界:巫妖王之怒》版本,可用于搭建、定制和扩展私有游戏服务器。项目强调模块化组件设计,便于按需增删游戏功能,适合具备一定 C++、数据库与服务器运维基础,想研究 TrinityCore、MaNGOS 等生态或开发自定义脚本的开发者。资源以 zip 压缩包分发,约 138.19MB;上游未提供可核验的文件总数与内部类型明细,需以实际解压内容为准。现有 1403 人学习或下载,说明其在开源游戏服务端社区具有一定关注度。读者可从中获取服务端框架源码与配置基础,理解模块划分、脚本扩展和功能定制思路,也可用于研究服务端启动、数据库连接、脚本注册与模块加载等典型流程,为搭建自己的 MMO 环境、复现实验和二次开发提供参考。
1. 一个能在自己机器上跑起来的完整 MMO 世界
把一台笔记本变成能容纳几十人同时在线的 MMO 世界,在 azerothcore-wotlk 这套 MMO 开源服务端上已经是可复现的事:编译出两个可执行文件、写入三个数据库、再把 DataDir 指向一份 3.3.5a 客户端资源,半小时内就能登录进游戏。它并非只能跑到登录界面的演示程序,它的模块化能力已经深入到目录结构层面:在modules/下放一个文件夹,CMake 就会自动把它编进构建流程,不用改动核心代码一行。适合两类人:想搭建私服、自定义玩法的服主,以及想研究大型 C++ 服务端架构的后端开发者。下面按「先看架构、再跑起来、最后改动」的顺序,逐项说明。
2. 技术栈与目录结构:从 authserver 到 modules 的整体视图
2.1 双进程加 Boost.Asio 的架构底层
这套服务端的进程模型非常传统,但边界划分得很清楚。authserver只负责登录认证,监听 3724 端口,用 SRP6 协议校验账号密码,校验通过后下发 realm 列表和会话密钥;worldserver监听 8085 端口,承担地图、AI、法术、掉落、聊天、公会这些玩家可见的全部业务逻辑。两者之间没有 RPC 调用,靠的是共享acore_auth库里的账号数据和一次性 session key 完成交接,所以登录服基本不消耗 CPU,真正吃资源的是世界服。
网络层使用的就是 Boost.Asio 的异步 IO:AuthSocket和WorldSocket各自挂一条 io_context 线程,收到的字节流按 opcode 分发到WorldSession的处理函数上。数据库层没有使用 ORM,而是三个全局对象LoginDatabase、WorldDatabase、CharacterDatabase加连接池,SQL 直接写在业务代码里。地图更新则是另一套机制:每个 Map 实例挂在一个MapUpdater线程池上,worldserver.conf里的MapUpdate.Threads决定开几个线程,这个值调高能明显改善大量玩家分散在不同地图时的卡顿。
| 层次 | 技术选型 | 项目里的落点 |
|---|---|---|
| 语言与构建 | C++17 + CMake | 根目录CMakeLists.txt,-DSCRIPTS、-DMODULES控制编译模式 |
| 网络 | Boost.Asio 异步 IO | src/server/game/Server/下的 Socket 与 Session |
| 存储 | MySQL(三库分离) | acore_auth/acore_characters/acore_world |
| 加密与压缩 | OpenSSL + zlib | 登录握手、封包压缩 |
| 脚本 | SmartAI + C++ Script + Eluna | src/server/scripts/、modules/ |
| 部署 | Docker Compose / 裸机 | 仓库根目录的编排文件与 Dockerfile |
2.2 目录结构:每个目录负责什么
拿到源码,第一件事就是搞清src/下的划分逻辑,不然改一个功能会到处找文件。核心目录我按职责列一下:
| 目录 | 职责 |
|---|---|
src/common/ | 工具库:日志、配置、加密、数据库封装、网络基础类 |
src/server/apps/ | 两个可执行入口:authserver、worldserver |
src/server/game/ | 全部游戏逻辑:实体、法术、AI、战场、副本、聊天、公会 |
src/server/scripts/ | 官方内容脚本,按地区拆成EasternKingdoms、Kalimdor、Northrend等子目录 |
src/server/database/ | 数据库层封装与升级工具 |
modules/ | 第三方与自研模块,每个子目录一个独立 CMake 工程 |
data/sql/ | 建库脚本与增量更新脚本,base/存基础结构,updates/存版本增量 |
conf/ | 配置模板,*.conf.dist复制成*.conf才生效 |
这里有个容易忽略的点:src/server/scripts/和modules/的代码地位是平级的。官方副本脚本同样是通过ScriptMgr注册进去的,你在模块里能做的事,官方脚本里也能做,反过来也一样。所以判断一个改动该放哪,标准很简单——只影响你自己服务器的,放modules/;想提给上游的通用修复,才动src/。
2.3 客户端资源与 DataDir:无法绕开的四个数据目录
worldserver启动后第一件事是去DataDir找资源,找不到就直接退。这四类数据缺一不可,而且必须从客户端提取出来:
dbc/:客户端自带的静态表数据,法术、物品、生物的基础属性都在这里,跟数据库里的表互为补充;maps/:地形高度图,决定角色能不能站上去、技能能不能打到;vmaps/:视线遮挡数据,没有它怪物隔墙就能看见你,远程法术也能穿墙;mmaps/:寻路网格,怪物绕不绕得开障碍全靠它,也是四类里生成最慢的。
提取靠bin/下那几个工具,顺序不能乱:先mapextractor出 maps 和 dbc,再vmap4extractor出 vmaps 的中间文件,vmap4assembler合成为最终 vmaps,最后mmaps_generator生成寻路网格。mmaps 那一步开多线程能省下大半时间,但依然可能跑二三十分钟,这属于一次性成本,跑完把目录备份好,以后重装环境直接复制过去就行。
3. 编译与跑通:从裸机到能登录的完整步骤
3.1 依赖安装与 CMake 参数
Linux 下的依赖其实不多,麻烦的是版本。编译器建议用 GCC 10 以上或 Clang 12 以上,Boost 用发行版自带的就够,MySQL 客户端库注意别装成 MariaDB 的 Connector/C++,那是另一套 API。
# Debian/Ubuntu 系,一次性把编译链和运行库装齐 sudo apt update sudo apt install -y build-essential cmake git ccache \ libboost-all-dev libssl-dev zlib1g-dev libreadline-dev \ default-libmysqlclient-dev # 拉源码(仓库地址按你下载到的资源替换) git clone <仓库地址> azerothcore-wotlk cd azerothcore-wotlkCMake 参数里有几个决定了后面能不能少走弯路:
mkdir -p build && cd build cmake .. \ -DCMAKE_INSTALL_PREFIX="$HOME/acore-server" \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DTOOLS=1 \ -DSCRIPTS=static \ -DMODULES=staticCMAKE_INSTALL_PREFIX是安装根目录,make install后所有产物都收在这一个文件夹里,删的时候直接rm -rf就行,不会污染系统。DTOOLS=1一定要开,地图提取器就是靠它编出来的,漏了这个参数后面生成资源时连可执行文件都找不到。SCRIPTS和MODULES都设成static,意思是把脚本和模块静态链接进worldserver,启动快、部署简单,代价是每改一行脚本都要重编。如果你打算频繁改脚本,可以把SCRIPTS改成dynamic,让核心去加载.so,改完重启世界服即可,不用走完整编译。
RelWithDebInfo是个折中:带调试符号,出错时能拿到函数名和行号,性能又比纯Debug好得多。线上环境再换成Release。
3.2 编译、安装与产物确认
# 并行数别开满,留两个核给系统和内存回收 make -j"$(nproc --ignore=2)" make install编译时间看机器,8 核大概 15 到 25 分钟。如果内存小于 8G,-j开到 4 就够了,开太高会被 OOM Killer 直接杀掉,表现为编译进程无声无息消失,日志里什么都没有,很容易误判成代码问题。
装完之后检查一下目录结构,正常长这样:
~/acore-server/ ├── bin/ authserver worldserver mapextractor vmap4extractor │ vmap4assembler mmaps_generator DBUpdater ├── etc/ worldserver.conf.dist authserver.conf.dist ├── data/ dbc/ maps/ vmaps/ mmaps/ <- 需要你自己填 └── sql/ base/ updates/bin/里如果能同时看到worldserver和mmaps_generator,说明DTOOLS=1生效了,可以继续往下走。看不到提取器就回头重跑 CMake,不要试图拿网上别人的资源包凑合,版本对不上会在启动时报一堆“表结构不一致”。
3.3 数据库初始化与账号创建
三个库各自独立,字符集统一用utf8mb4,否则玩家起中文角色名会变成问号:
mysql -uroot -p -e "CREATE DATABASE acore_auth DEFAULT CHARSET utf8mb4;" mysql -uroot -p -e "CREATE DATABASE acore_characters DEFAULT CHARSET utf8mb4;" mysql -uroot -p -e "CREATE DATABASE acore_world DEFAULT CHARSET utf8mb4;" # 基础结构,路径在安装目录的 sql/base 下,按 auth / characters / world 顺序导入 mysql -uroot -p acore_auth < sql/base/db_auth/auth_database.sql mysql -uroot -p acore_characters < sql/base/db_characters/characters_database.sql mysql -uroot -p acore_world < sql/base/db_world/world_database.sql基础结构只包含建表语句,游戏内容(生物、任务、掉落)以及后续的所有表结构变更都在sql/updates/里。这里别手动一条条执行,bin/DBUpdater就是干这个的:它记录了每个库已经执行到哪个版本,只补缺失的部分,重复跑也不会炸。
cd ~/acore-server/bin ./DBUpdater # 按提示填主机、端口、账号密码和三个库名,它会自动按序执行 updates库齐了之后启动worldserver,第一次会跑一遍内容初始化,耗时几分钟。控制台出现World initialized就可以建号了,直接在worldserver的交互控制台敲:
account create tester tester123 # 账号 密码 account set gmlevel tester 3 -1 # 3 级 GM,-1 表示全服生效3.4 配置、端口与首次登录验证
把etc/下的.dist复制成正式配置再改,这样以后升级覆盖模板时不会把你的改动冲掉:
# etc/worldserver.conf LoginDatabaseInfo = "127.0.0.1;3306;acore;acore;acore_auth" WorldDatabaseInfo = "127.0.0.1;3306;acore;acore;acore_world" CharacterDatabaseInfo = "127.0.0.1;3306;acore;acore;acore_characters" DataDir = "/home/dev/acore-server/data" MapUpdate.Threads = 4连接串是分号分隔的五段:地址、端口、用户名、密码、库名,少一段都会在启动时抛异常。DataDir建议写绝对路径,相对路径的解析基准是进程工作目录,用 systemd 拉起服务时经常因为这个踩坑。
最后一步在acore_auth.realmlist表里把地址改对:
-- 客户端连的是这个地址,不是 127.0.0.1(除非客户端和服务端在同一台机器) UPDATE realmlist SET address = '192.168.1.20', port = 8085 WHERE id = 1;两个进程都用nohup或 systemd 拉起,客户端realmlist指向同一地址,能进到选人界面就算跑通了。如果卡在“正在认证”转圈,八成是 3724 没通;能选人但进不去世界,看 8085 和worldserver的控制台日志,那里一定会有一行明确的错误。
4. 模块系统拆解:一个 mod 从注册到生效的完整链路
4.1 模块目录约定与 CMake 的自动挂载
模块系统是这套服务端最有价值的设计。核心的CMakeLists.txt会扫描modules/下的每个子目录,只要里面有CMakeLists.txt就纳入构建,不需要在任何地方登记模块名。一个标准模块的骨架:
modules/mod_gatekeeper/ ├── CMakeLists.txt ├── conf/ │ └── mod_gatekeeper.conf.dist └── src/ ├── mod_gatekeeper.cpp └── mod_gatekeeper_loader.cppCMakeLists.txt只做两件事:告诉核心有哪些源文件要编译,以及提供一个加载入口。
# modules/mod_gatekeeper/CMakeLists.txt # 把模块的源文件交给核心的脚本收集器,路径必须用绝对路径 AC_ADD_SCRIPT("${CMAKE_CURRENT_LIST_DIR}/src/mod_gatekeeper.cpp") # 注册加载器:核心启动时会调用由这个文件名派生的初始化函数 AC_ADD_SCRIPT_LOADER("ModGatekeeper" "${CMAKE_CURRENT_LIST_DIR}/src/mod_gatekeeper_loader.cpp")加载器文件本身很短,它只负责把模块里的脚本对象new出来。这一步的作用是把初始化时机交给核心控制:所有脚本都在数据库连接建立之后、世界初始化之前构造,这个顺序保证了脚本构造函数里能安全地访问sConfigMgr和WorldDatabase。
// modules/mod_gatekeeper/src/mod_gatekeeper_loader.cpp // 真正登记脚本的函数,实现在 mod_gatekeeper.cpp 里 void AddModGatekeeperScripts(); // 核心通过 AC_ADD_SCRIPT_LOADER 生成的这张表逐项调用 void AddSC_mod_gatekeeper() { AddModGatekeeperScripts(); }4.2 ScriptMgr 与脚本注册链路
所有脚本的基类构造函数都会把自己挂到ScriptMgr的全局链表上,所以「注册」这个动作就是在构造函数里完成的,你不需要写任何Register(...)调用。ScriptMgr在世界初始化阶段按类型把链表拆成生物脚本表、玩家脚本表、世界脚本表等若干张哈希表,之后每个事件触发时按 entry 或全局遍历去查。
这套机制带来一个必须记住的规则:脚本对象一旦构造就不会销毁,生命周期等同进程。所以脚本类里不要放持有Player*的成员变量,玩家下线后指针就悬空了,访问它是最常见的崩溃来源。要保存状态就用Player::GetGUID()做 key,需要时再ObjectAccessor::FindPlayer捞回来。
4.3 一个带配置项的完整模块示例
光说机制太虚,直接看一个能跑的:右键某个 NPC 回血,外加玩家登录时给一句提示。这个例子把三种最常用的脚本类型都用上了。
// modules/mod_gatekeeper/src/mod_gatekeeper.cpp #include "Config.h" #include "Creature.h" #include "CreatureScript.h" #include "Player.h" #include "ScriptMgr.h" #include "ScriptedCreature.h" #include "WorldSession.h" namespace { bool g_Enable = true; // 模块总开关,配置里能一键关掉 uint32 g_HealAmount = 500; // 每次对话恢复的血量 } // 玩家脚本:挂在登录事件上,用来快速验证模块到底加载了没有 class ModGatekeeper_Player : public PlayerScript { public: ModGatekeeper_Player() : PlayerScript("ModGatekeeper_Player") { } void OnPlayerLogin(Player* player) override { if (!g_Enable) return; player->SendSystemMessage("Gatekeeper module online."); } }; // 生物脚本:entry 由 creature_template.ScriptName 匹配 class npc_gatekeeper : public CreatureScript { public: npc_gatekeeper() : CreatureScript("npc_gatekeeper") { } struct npc_gatekeeperAI : public ScriptedAI { npc_gatekeeperAI(Creature* creature) : ScriptedAI(creature) { } bool OnGossipHello(Player* player) override { if (!g_Enable) return false; player->ModifyHealth(g_HealAmount); // 正数治疗,负数扣血 me->Say("Rest, traveler.", LANG_UNIVERSAL); // 附近玩家都能看到 return true; // true 表示不再往下展开菜单 } }; CreatureAI* GetAI(Creature* creature) const override { return new npc_gatekeeperAI(creature); } }; // 世界脚本:读取配置,必须在其他脚本之前构造 class ModGatekeeper_World : public WorldScript { public: ModGatekeeper_World() : WorldScript("ModGatekeeper_World") { } void OnAfterConfigLoad(bool /*reload*/) override { g_Enable = sConfigMgr->GetOption<bool>("ModGatekeeper.Enable", true); g_HealAmount = sConfigMgr->GetOption<uint32>("ModGatekeeper.HealAmount", 500); } }; void AddModGatekeeperScripts() { new ModGatekeeper_World(); // 先读配置,再构造用配置的脚本 new ModGatekeeper_Player(); new npc_gatekeeper(); }几个关键点解释一下。CreatureScript("npc_gatekeeper")里那个字符串就是脚本名,必须和数据库里creature_template.ScriptName字段完全一致,差一个字母 AI 就不会挂载,表现是 NPC 站着不动、右键没反应,日志里什么都不报,这是最难查的一类问题。OnGossipHello的返回值语义是「是否继续展开默认菜单」,返回true就只走你的逻辑,返回false则会接着弹默认的对话选项。OnAfterConfigLoad同时承担首次加载和reload config命令的重载,参数reload为true时可以做资源重新分配,别在里面做耗时操作,它会卡住整个世界线程。
配套的配置模板和一条挂载用的 SQL:
# modules/mod_gatekeeper/conf/mod_gatekeeper.conf.dist ModGatekeeper.Enable = 1 ModGatekeeper.HealAmount = 500-- 把已有的某个生物改造成门卫,entry 换成你服务器里实际存在的编号 UPDATE creature_template SET ScriptName = 'npc_gatekeeper' WHERE entry = 90001;改完 SQL 记得让世界服重载生物模板(GM 命令reload creature_template 90001),否则内存里缓存的还是旧数据,你会以为脚本没生效然后白折腾半小时——这个坑我踩过不止一次。
4.4 Lua 路线:Eluna 与不重编译的热更新
C++ 模块强在性能和自由度,弱在每次改动都要走一遍编译链接。如果你只是想调数值、加提示、做活动脚本,Lua 引擎模块更合适。它把RegisterPlayerEvent、RegisterCreatureEvent这些钩子暴露出来,脚本放在模块的lua_scripts/下,改完执行reload eluna就生效。
-- modules/mod-eluna/lua_scripts/gatekeeper.lua local NPC_ENTRY = 90001 -- 3 = PLAYER_EVENT_ON_LOGIN,事件编号对照官方枚举 RegisterPlayerEvent(3, function(event, player) player:SendBroadcastMessage("|cff00ff00Gatekeeper module online.|r") end) -- 1 = CREATURE_EVENT_ON_GOSSIP_HELLO RegisterCreatureEvent(NPC_ENTRY, 1, function(event, player, creature) player:SetHealth(player:GetHealth() + 500) creature:SendUnitSay("Rest, traveler.", 0) -- 0 = 通用语言 end)两条路线的取舍很直白:跟核心数据打交道、需要遍历大量对象、对性能敏感的逻辑走 C++;活动、公告、临时玩法、GM 调试工具走 Lua。同一个模块里两者可以共存,C++ 侧负责数据部分,Lua 侧负责表现部分,用自定义事件或者数据库表通信。
5. 避坑与排查:编译、启动、登录三个卡点的血泪记录
5.1 编译到 80% 突然报链接错误
现象:进度条走到七八成,跳出一串undefined reference to 'AddSC_xxx()',或者进程直接消失、日志空白。
原因:两种可能。一是新增的.cpp没写进模块的CMakeLists.txt,编译器编都没编它,链接时自然找不到符号;二是并行数开太高把内存打满,ld被 OOM Killer 干掉,表现就是无声退出。
解决:先确认AC_ADD_SCRIPT里每个源文件都列到了,路径用${CMAKE_CURRENT_LIST_DIR}而不是相对路径。再降-j到 4 重试,如果错误消失就是内存问题,装个 ccache 也能显著减少二次编译时间。
5.2 worldserver 启动即退,日志说找不到地图
现象:进程起来不到两秒就退出,控制台或Server.log里有Cannot find DataDir或Map file ... does not exist。
原因:DataDir路径写错,或者只生成了maps没生成mmaps就启动。相对路径在 systemd 拉起的场景下会以/为基准解析,跟你在终端里手动跑的结果完全不同。
解决:DataDir一律写绝对路径,末尾不要带斜杠。启动前用ls确认四个子目录都在且非空,mmaps目录下应该是成堆的.mmtile文件,如果是空的说明提取器中途失败了,重跑mmaps_generator并观察它的输出。
5.3 能连上但卡在认证,或者选人后进不去世界
现象:客户端一直停在“已连接/正在认证”,或者选人界面点进入后黑屏退回。
原因:前者是 3724 端口不通,或realmlist.address写成了127.0.0.1而客户端在另一台机器;后者是 8085 不通,或acore_characters库的表结构落后于acore_world,角色数据反序列化失败。
解决:先用telnet <服务器IP> 3724和8085各测一遍。地址必须是客户端能访问到的那个 IP,不能图省事填回环地址。进不去世界的情况去看worldserver控制台,它会打印具体是哪个字段解析失败,然后跑一遍DBUpdater把三个库补到同一版本。
5.4 模块明明加了,游戏里一点反应没有
现象:modules/下新建了目录、写好了 C++、编译也过了,进游戏右键 NPC 毫无反应,登录提示也没出现。
原因:三种情况——加完模块没重新执行 CMake,构建系统压根不知道新目录存在;加载器函数没被调用,脚本对象根本没构造;或者creature_template.ScriptName没更新、缓存没重载。
解决:按顺序排查。先看编译输出里有没有你这个模块的.cpp文件名,没有就是 CMake 没重跑,删掉build/CMakeCache.txt重新配置。再在加载器函数里加一行日志,确认它被执行。最后查数据库ScriptName字段并执行reload creature_template。
5.5 Windows 下提取器一闪而过
现象:双击mapextractor.exe窗口闪一下就没了,什么也没生成。
原因:提取器的工作目录是它自己所在的位置,双击运行时目录不对;另外路径里带非 ASCII 字符时,老版本的文件读取会直接失败。
解决:从命令行进到bin目录再执行,或者写个.bat先cd /d %~dp0再调用。整个服务端目录放在纯英文无空格的路径下,D:\acore\这种最省心。
6. 二次开发:改一个 NPC 到验证不炸服的闭环
改脚本这件事,难点从来不是写代码,而是确认它真的按你以为的方式生效了。我的习惯是强制走三层验证:编译期看输出里有没有目标文件;运行期在关键分支打日志或发系统消息;交互期用 GM 命令实际触发一遍。跳过任何一层,后面出问题你都分不清是逻辑错了还是根本没加载。
举个具体例子,把上面那个门卫 NPC 从“固定回 500 血”改成“按玩家等级缩放”,改动只有一行算式,但验证要走完整流程:
// 按等级缩放,1 级回 100,80 级回 8000,上限 8000 uint32 heal = std::min<uint32>(player->GetLevel() * 100, 8000); player->ModifyHealth(heal);改完的回归清单我一般这么走:
| 步骤 | 动作 | 通过标准 |
|---|---|---|
| 1 | 重新cmake+make | 输出里有mod_gatekeeper.cpp的编译行 |
| 2 | make install后重启世界服 | 启动日志无脚本加载失败告警 |
| 3 | 用 GM 账号登录 | 收到模块上线提示,证明脚本已构造 |
| 4 | 右键目标 NPC | 血量按等级变化,附近能看到喊话 |
| 5 | reload config后重复步骤 4 | 改配置里的数值立即生效,无需重启 |
第三条最容易被省略,但它恰恰是区分“脚本没加载”和“脚本逻辑写错”的分水岭。少了这一步,你就会在错误的文件里反复改逻辑,改到怀疑人生。
还有一个血泪经验:动src/server/game/下的核心代码之前,先想清楚能不能用脚本钩子绕过去。核心改动会让后续上游更新变成手工合并的噩梦,而绝大多数需求——改伤害公式、加自定义货币、拦截聊天、改掉落——PlayerScript、UnitScript、WorldScript的钩子里都有对应入口。真要动核心,就在改动点旁边留一行带日期的注释,标明改了哪个上游行为,不然半年后你自己都不敢合代码。
从那以后我每次加新模块,第一件事就是先把“世界脚本读配置 + 玩家登录打日志”这个最小骨架跑通,确认链路通了再往里填业务逻辑,而不是一口气写完再看结果。希望这个顺序能帮你少走几趟弯路。
本文还有配套的精品资源,点击获取