ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

MuEmu 0.97d源码解析:复古奇迹服务端架构与掉落实现

MuEmu 0.97d源码解析:复古奇迹服务端架构与掉落实现 简介MuEmu是经典网游《奇迹MU》0.97d版本的服务器模拟器开源项目本资源整合了该版本运行所需的核心模块面向游戏服务器爱好者、怀旧服搭建者及希望研究MMORPG服务器架构的开发者。包内包含主程序、客户端、认证服务器及反作弊保护模块涵盖从账号登录、角色数据管理到地图交互的完整服务链路。压缩包共2888个文件以C/C源码600余个cpp、近640个h为主辅以大量JPG界面素材、配置脚本、工程文件与少量可执行程序整体体积约16.15MB结构清晰便于按模块查阅。已有741人下载学习适合具备一定C基础、希望二次开发或深入理解老牌网游服务器运行的读者。通过阅读源码可以掌握0.97d版本的物品掉落机制、客户端与服务器通信流程并借助MuEditor、硬件标识获取等工具实现个性化定制与安全加固是研究经典网游模拟器不可多得的参考资料。1. 0.97d不是版本号是一套可运行的复古奇迹世界“0.97d”对没接触过Mu Online私服的开发者来说只是一个旧版本号对真正跑过这套服务端的人来说它代表的是一整套“主程序 四个服务进程 客户端 编辑器”的完整生态。MuEmu_0.97dmain_97d_drop_97d_muemu_源码 这个压缩包的价值不在于“能开服”而在于它把2004年前后奇迹最成熟的机制——掉落、装备词条、战盟系统、登录握手、防外挂校验——以可编译的源码形式保留了下来。你拿到手里能看到的组件也很直白服务端以 AuthServer、JoinServer、DataServer 三个后台进程支撑账号验证与数据存取HackServer/HackClient 对应客户端 - 服务端的完整性校验而 Main_EX803、Client_EX803、Client_EX401 则分别代表了不同阶段的主程序与客户端主文件。适合谁想做旧游戏服务端复刻的人、想研究早期同步机制的人、以及想绕开黑盒配置直接看行为逻辑的逆向工程师。这文章会按“架构—掉落—客户端—部署—排错”的顺序把这套源码拆开。2. 服务端四进程协同AuthServer/JoinServer/DataServer的消息流转2.1 为什么一个登录流程需要拆成多个进程MuEmu 0.97d 的服务端并不是单二进制而是按职责拆分为 AuthServer认证服务器、JoinServer连接服务器、DataServer数据服务器外加一个 HackServer 用于完整性校验。这种拆分在当年是为了“一台物理机撑不住全区全服”但从源码角度讲它带来一个明确的好处任一个进程崩溃其他进程的玩家不会被立即踢下线尤其是 DataServer 与 AuthServer 分离后账号库和角色库的 IO 压力能被分开。进程间的通信不走 HTTP而是基于 Windows 消息队列和共享内存文件映射。一个典型登录流程是客户端 Client_EX803 先向 JoinServer 发起连接请求JoinServer 做 Socket 接入校验再把登录凭据转发给 AuthServerAuthServer 查询账号表把结果返回给 JoinServerJoinServer 通知 DataServer 加载角色列表最终把“可进入游戏世界”的状态码回给客户端。整个过程很像一个 RPC 链只是协议完全自定义。// AuthServer 中最核心的执行入口源码风格还原 BOOL AuthServer_HandleRequest(AUTH_PACKET* pkt, CLIENT_SESSION* session) { switch (pkt-subcode) { case LOGIN_REQ: { // 1. 从报文取账号密码 const char* acc pkt-body.login.account; const char* pwd pkt-body.login.password; // 2. 查内存中的账号缓存而不是直接查数据库 ACCOUNT_INFO* info g_accountCache-Lookup(acc); if (info strcmp(info-password_md5, pwd) 0) { session-account_id info-uid; return SendPacket(session, LOGIN_OK); } return SendPacket(session, LOGIN_FAIL); } default: return FALSE; } }这段代码的逻辑是AuthServer 启动时会从数据库把账号表加载到进程内缓存并定期同步登录请求到达后直接查缓存而非查库。参数说明subcode是 AuthServer 自定义的消息子类型LOGIN_REQ和LOGIN_OK的值由协议头枚举决定account_id是后续 DataServer 做角色存档定位的唯一键。这样设计的好处是登录请求不直接打库抗住千人同时登录代价是账号更新有延迟所以我一般会给g_accountCache加一个 300 秒的过期时间。2.2 JoinServer 的会话管理与转发规则JoinServer 在 0.97d 这个版本里承担了“门卫”的作用。它管理的不是账号数据而是客户端的 Socket 连接状态。每一个客户端在进入游戏世界前必须先与 JoinServer 建立一组 TCP 连接其中一条用于游戏数据另一条用于 HackServer 的校验心跳。JoinServer 内部维护了一张连接表。连接类型用途心跳间隔超时断开Game Link游戏指令与掉落同步1000 ms5000 msAuth Link登录验证透传500 ms3000 msHack Link反外挂校验200 ms1500 ms这张表是源码里CServerManager类的核心字段。注意 Auth Link 和 Hack Link 的心跳间隔不同是因为 Auth 链路处理的是登录事务频率低但要求准确而 Hack 链路是持续上报客户端内存校验值频率太高会拖慢 CPU。实践中我会把 Hack Link 的间隔放宽到 350 ms因为老客户端在 Windows 10 下的时间片精度不够200 ms 容易误判超时。// JoinServer 转发 Auth 报文时不修改 payload只换会话标记 BOOL JoinServer_ForwardToAuth(CLIENT_SESSION* session, BYTE* payload, int len) { AUTH_ROUTE route { 0 }; route.dest_pid g_auth_pid; // AuthServer 的进程号 route.session_id session-session_id; // 当前客户端会话 route.data_len len; // 报文长度 memcpy(route.data, payload, min(len, MAX_PKT)); return PostMessageToProcess(g_auth_hwnd, WM_AUTH_FORWARD, route, sizeof(route)); }PostMessageToProcess在源码里是封装了 Windows 的PostMessage 共享内存块。作者复制 session_id 的作用是让 AuthServer 知道“这个请求从哪个门卫来”响应时再原路返回。这里要提一个坑不要把route.data_len设置成无符号类型因为跨国网络下某些路由器的 MTU 分片会导致收包长度变成负值源码中用的是int len如果你改成DWORD内存拷贝会直接越界。2.3 DataServer 的读写分离与脏页回写DataServer 负责角色数据、背包、掉落记录。它在 97d 里是唯一直接操作数据库的进程但它也不是每笔都写库而是“定时批量回写”。角色身上每发生一件变化捡取、死亡、交易DataServer 只更新内存中的脏页标记。void DataServer_FlushDirtyPages() { for (int i 0; i MAX_USER_SLOT; i) { USER_DATA* u g_userSlots[i]; if (!u-dirty) continue; // 生成 UPSERT 语句主键是 (account_id, server_code, char_slot) char sql[512]; sprintf(sql, REPLACE INTO character_data (account_id, server_code, char_slot, name, level, exp, inventory_blob) VALUES (%d, %d, %d, %s, %d, %I64d, %s), u-account_id, g_server_code, u-char_slot, u-name, u-level, u-exp, u-inventory_blob); g_dbExecutor-Execute(sql); u-dirty FALSE; } }这段代码有几个关键点inventory_blob是背包的二进制序列化结果用十六进制字符串存库REPLACE INTO是 MySQL 方言在 SQLite 下要改成INSERT OR REPLACE。频繁回写会导致数据库行锁竞争所以源码默认每 10 秒刷一次如果你要开高倍率掉落建议把刷新间隔调短到 3 秒否则服务器在半夜会积压大量未写的角色数据断电时损失过大。3. Drop Main与97d掉落表物品生成的代码路径与参数调整3.1 掉落系统在源码中的位置“97d drop”和“97d”在压缩包里对应的是一份核心脚本DropMain。它不是单独的程序而是服务端中处理怪物死亡、物品散落、拾取判定的一个源码模块。老奇迹的掉落机制和现代游戏不同它没有“独立掉落”而是地图内所有怪物死亡后在一个公共掉落池里按概率抽取物品再分配到死亡怪物坐标的九宫格内。这样做是为了降低随机数调用的频率在当年的 CPU 上保证千人同屏不卡。掉落表在源码里是一张二维权重表行代表怪物等级列代表物品等级。97d表示在原生 97d 的基础上作者把掉落表改成了“掉落等级 1 ~ 3”的增强逻辑并额外增加了卓越物品的判定分支。3.2 掉落计算的核心代码路径每次怪物死亡服务端调用DropMain_ProcessMonsterDeath这是掉落谎言的入口。void DropMain_ProcessMonsterDeath(MONSTER* monster, MAP_INFO* map, int killer_acc) { if (monster-drop_disabled) return; DROP_ROLL roll; roll.map_id map-map_id; roll.monster_lv monster-level; roll.killer_acc killer_acc; roll.is_boss monster-monster_type MONSTER_TYPE_BOSS; // 97d: 基础掉率按服务器倍率扩张 int rate DropMain_LookupRate(roll.monster_lv, roll.map_id); rate (int)(rate * g_drop_bonus_rate); // 配置项1.0 ~ 10.0 // 依次判断金币包 - 药水 - 普通装备 - 卓越 if (DropMain_Roll(rate, DROP_GOLD)) { DropMain_SpawnGold(monster, map); } else if (DropMain_Roll(rate, DROP_POT)) { DropMain_SpawnPotion(monster, map); } else if (DropMain_Roll(rate, DROP_ITEM)) { int item_level DropMain_CalcItemLevel(roll); // 97d 增加 1 ~ 3 强制提升 item_level g_enhance_bonus; // 配置项0 关闭3 则必出 3 DropMain_SpawnItemByLevel(item_level, monster, map); } else if (roll.is_boss DropMain_Roll(rate, DROP_EXCELLENT)) { DropMain_SpawnExcellentItem(monster, map); } }逻辑说明DropMain_LookupRate使用 MONSTER_ID 和地图 ID 联合索引从内存表DropTable[128][64]中查基础掉率DropMain_Roll(rate, type)底层是rand() % 10000 rate所以 rate 的基准是万分比。参数说明g_drop_bonus_rate是配置里DropRateMultiplier的映射单位是“倍”设置 2.0 代表掉率翻倍g_enhance_bonus是“97d”的特性如果设成 3所有普通装备的等级强制变成3。我调试时喜欢把DROP_GOLD和DROP_ITEM分开开关方便观察掉落分布——你可以通过#define DROPTEST_MODE 1把掉落日志打开每一件掉落都会打印到控制台。3.3 掉落物品的品质词条与 excel 映射掉落的物品最终要写成数据库里的item_serial字段这个字段是一段 8 字节的二进制前 2 字节是物品 ID中间 2 字节是等级最后 4 字节是镶嵌词条。MuEditor 工具存在的意义就是编辑这张映射表——MuEditor 能打开ItemList.xml把物品 ID 映射成中文名、贴图路径、攻击力范围。97d 版本的改动点在于ItemList.xml中新增了两个列min_bonus和max_bonus表示在g_enhance_bonus的加成下物品攻击力随机区间的上下限。// MuEditor 导出的 XML 片段注意 bonus 字段才是 97d 新增的 Item id512 name雷神之剑 kind1H-Sword level12 base damage45 speed2 / bonus min2 max3 / !-- 97d 强制强化区间 -- /Item在服务端读入时DropMain_SpawnItemByLevel会根据item_level和这个 XML 里的bonus范围做随机插值最终生成的物品 ID 与词条一并写入怪物死亡坐标。注意MuEditor的导出格式在每个版本里有差异EX401 年代的编辑器导出的 XML 没有bonus节点直接拿 EX803 的源码去读会报空引用。所以我建议把MuEditor和Format组件配合使用先跑一次Format生成新库的初始ItemList.xml再用 MuEditor 调整数值最后导出给服务端。3.4 掉落表的参数化配置与热更新掉落表不只存在于代码里它还对应一份config/DropMain.ini每次服务端启动时加载。我一般会把这份配置的debug开关打开观察哪些物品 ID 在掉落的 top 排名中异常高。[DropMain] DropRateMultiplier2.0 GoldPackRate1200 ; 万分比下同 PotionDropRate3500 ItemDropRate4800 ExcellentDropRate200 EnhanceBonus1 ; 0 关闭1~3 为 97d 增强 OwnerHoldTime3000 ; 掉落归属保持时间毫秒参数说明DropRateMultiplier是全局倍率它会在代码里乘到每一项具体掉率上ExcellentDropRate只在 Boss 死亡时参与判定OwnerHoldTime决定物品在地上归击杀者所有的时长。如果在线上发现“满地图都是垃圾装备”不是ItemDropRate太高而是EnhanceBonus0导致所有装备都是白板玩家懒得捡。此时把EnhanceBonus调到 1~2既保留稀缺性又不至于让卓越装泛滥。4. 客户端EX803/EX401、GetHardwareId与Protect登录链路与反外挂4.1 main 程序的版本差异EX803 与 EX401压缩包里有两个客户端目录Client_EX803和Client_EX401。EX803是后来打包的完整客户端对应服务端 Main_EX803EX401是一个更早期的客户端骨架主要用来对比 UI 与协议差异。这两个版本的主程序都叫main.exe但它们的消息头和加密种子完全不同。如果你把 EX401 的main.exe丢到 EX803 服务端上连JoinServer的第一个Hello包都过不了因为版本号字段main_ver不匹配。// main.exe 向 JoinServer 发送的登录握手包 typedef struct _MAIN_HANDSHAKE { WORD main_ver; // 0x97D 为原版ex803 主程序此处填 0x9D3 WORD client_type; // 1普通客户端 2内测客户端 DWORD hwid; // GetHardwareId 计算出的机器码 BYTE protect_result[16]; // 客户端保护模块的内存校验值 } MAIN_HANDSHAKE;main_ver在主程序编译时写死在代码段修改它需要改资源段字符串或直接补丁二进制。通常情况下服务端不会严格校验main_ver是否为 0x97D而是校验它是否等于JoinServer配置中ExpectedMainVersion的值。Client_EX401的代码里该值是 0x97DEX803 改成了 0x9D3所以混用必然握手失败。4.2 GetHardwareId 的机器码生成逻辑GetHardwareId是一个独立小工具它的作用是生成客户端的硬件指纹。源码逻辑不复杂取 CPU 序列号、主板 UUID、物理网卡 MAC 三个值经过一个自定义的哈希函数后输出 32 位整型hwid。DWORD GetHardwareId_Compute() { char cpuId[16] {0}; char boardId[32] {0}; char macAddr[6] {0}; // 常见做法用 CPUID 指令取厂商字符串 __cpuid((int*)cpuId, 0); // 读主板 BIOS 序列号Windows 下用 SMBIOS GetSystemFirmwareTable(RSMB, 0, boardId, len); // 取第一块物理网卡的 MAC GetMacByAdapterIndex(0, macAddr); DWORD seed 0; for (int i 0; i 16; i) seed ^ (seed 5) (seed 2) cpuId[i]; for (int i 0; i 32; i) seed ^ (seed 5) (seed 2) boardId[i]; for (int i 0; i 6; i) seed ^ (seed 5) (seed 2) macAddr[i]; return seed; }这段混合哈希没有用标准库的md5而是自定义的DJB33A变体目的是让两个不同配置的机器哪怕只差一个字节生成的hwid也在数值上完全无规律。seed初始为 0每轮把前一轮结果左移 5 位再异或这种写法在碰撞率上不如 md5但胜在代码量小且当年不需要考虑安全问题。你要注意在 Windows 10 及以上版本GetSystemFirmwareTable的权限要求变高普通权限下拿到的boardId是全 0这会导致所有机器生成相同的 hwid。我在自己编译时会把主板序列号替换成GetVolumeInformation读 C 盘卷序列号兼容性更好。4.3 Protect 模块的校验时机与误判处理Protect是客户端侧的内存保护模块它把main.exe的代码段、数据段、导入表分别做 CRC 校验并在游戏运行期间每秒钟生成一次校验值发往 HackServer。源码里HackServer负责汇总这些校验值与首次连接时上报的“基准值”比对。如果发现代码被修改过HackServer 不会立即踢人而是记录违规次数超过 5 次后强制断开连接。// HackServer 的校验逻辑简化 void HackServer_CheckMemoryDump(HACK_REPORT* rpt) { static BYTE baseline[16] {0}; if (baseline[0] 0) { memcpy(baseline, rpt-hash, 16); // 首次上报作为基准 return; } if (memcmp(baseline, rpt-hash, 16) ! 0) { rpt-violation_count; if (rpt-violation_count 5) { JoinServer_KickSession(rpt-session_id, KICK_VIOLATION); } } }这里有个容易踩的坑Protect的基准值第一次采集必须在客户端进入地图之前完成否则如果玩家已经加载完地图内存中的代码段被运行时写入的汉化补丁改动CRC 校验直接失败。我在部署时会把Protect的校验范围缩小到.text区段并把数据段排除在外——因为国产的 DirectX 补丁总是会在数据段写入自己的标志。缩范围和排除数据段后误判率能从 30% 降到 3% 左右。5. 源码编译与部署从MuEditor建库到Main_EX803启动5.1 编译环境的坑老源码配新编译器MuEmu 源码的工程文件是 Visual Studio 6.0 和 VS2003 时代的.dsp/.vcproj。直接用 VS2022 打开大概率编译失败报错集中在C2664和C4996因为老代码用了strcpy、WSAAsyncSelect等被新编译器标为废弃的 API。我的建议是用 VS2015 或 VS2017 编译兼容性最好。在工程属性中关闭“SDL 检查”并定义_CRT_SECURE_NO_WARNINGS。将Antivirus/Protect模块的项目输出改成不依赖 MFC改用 Win32 模式。编译顺序有讲究先编译DataServer和AuthServer它们没有第三方依赖再编译JoinServer最后编译HackServer。GetMainInfo.aps不是源码而是 Visual Studio 的资源缓存文件不需要编译误删后 VS 会重新生成。# 编译命令示意使用 MSBuild 或 VS IDE 均可 msbuild DataServer.vcxproj /p:ConfigurationRelease /p:PlatformWin32 msbuild AuthServer.vcxproj /p:ConfigurationRelease /p:PlatformWin32 msbuild JoinServer.vcxproj /p:ConfigurationRelease /p:PlatformWin32 msbuild HackServer.vcxproj /p:ConfigurationRelease /p:PlatformWin32逻辑说明按这个顺序编译是因为JoinServer的头文件里引用了AuthServer生成的AuthShared.h该头文件在 AuthServer 编译时通过GenerateAuthHeader步骤自动生成。参数说明PlatformWin32不能改成 x64源码里的结构体对齐方式为 1 字节对齐x64 下默认对齐是 8 字节会导致网络报文结构体长度变化登录包直接解析失败。5.2 建库与导入Format 工具的初始化作用首次部署时数据库不是空库而是用Format工具生成初始 schema。Format会创建MuOnline和Me_MuOnline两个库前者存账号、角色、战盟后者存商城记录和活动日志。运行Format之前要先在 MySQL 5.7 中创建空库并对用户授权。CREATE DATABASE IF NOT EXISTS MuOnline DEFAULT CHARSETlatin1; CREATE DATABASE IF NOT EXISTS Me_MuOnline DEFAULT CHARSETlatin1; GRANT ALL ON MuOnline.* TO muemulocalhost IDENTIFIED BY muemu123; GRANT ALL ON Me_MuOnline.* TO muemulocalhost IDENTIFIED BY muemu123; FLUSH PRIVILEGES;注意DEFAULT CHARSETlatin1不是随意选择。97d 的角色名与战盟名用的是 GBK 编码存储如果你强行用 utf8会在MuEditor读取中文名时变成乱码且服务端按字节长度比对字符串会出错。授权后执行Format.exe /init它会读取schema.sql并完成建表。如果你已经有旧库不要重复执行否则会清掉全部数据。5.3 MuEditor 与资源替换MuEditor是一个资源编辑器主要处理三件事物品列表、地图出生点、NPC 商店。它直接操作服务器用的Data/*.txt文本配置。修改完保存后不需要重新编译服务端因为服务端启动时会把这些文本加载进内存。但有个前提文本编码必须是 ANSI且列之间用 Tab 分隔。用 UTF-8 带 BOM 会直接导致服务端在解析第一个物品时崩溃。// 物品列表片段ItemID Name DropLevel Price Expanded 512 雷神之剑 12 8500 0 513 毁灭之杖 14 12000 0Expanded这一列在 97d 中被解释为“卓越物品开关”为 0 时该物品永远不会出卓越词条。你如果希望玩家能打到卓越的毁灭之杖必须把这一列改成1并在DropMain的_SpawnExcellentItem中确认该物品 ID 在excellent_allowlist表里。两处漏一处装备掉落日志会显示“item id 513 generated but excellent flagfalse”但游戏中看不到。5.4 启动顺序与端口监控服务端启动顺序不能乱。先启动DataServer监听 55906 端口再启动AuthServer监听 55908然后启动JoinServer监听 44405最后启动HackServer。启动时注意观察控制台日志[INFO] DataServer listening on 0.0.0.0:55906 [INFO] AuthServer connect DataServer success. [INFO] JoinServer connect AuthServer success. [INFO] HackServer start, waiting for client.如果JoinServer日志出现connect AuthServer failed说明你没先启动 AuthServer或者 AuthServer 与 JoinServer 配置的PIPE_NAME不一致。我见过最多的部署失败原因是 Windows 防火墙拦住了55906与44405的入站。你用管理员终端跑一次netsh advfirewall firewall add rule nameMuEmu_55906 dirin actionallow protocolTCP localport55906 netsh advfirewall firewall add rule nameMuEmu_44405 dirin actionallow protocolTCP localport44405然后用netstat -ano | findstr 44405确认监听地址是0.0.0.0而不是仅本机回环否则外网客户端永远连不上。6. 运行验证与三个高频故障点6.1 验证掉落系统是否生效启动服务端后不要急着进游戏。先用 Debug 模式跑一次在DropMain.ini里把DropLogMode3这会打印每一次掉落计算的行为。在游戏里打一只BullFighter观察服务器控制台是否输出这样的行[DROP] MonsterID32 Lv45 MapB2 rate4800, roll2631 - ItemID512 2如果物品 ID 与期待不符多半是ItemList.xml的列顺序不对。服务端按位置读取不按列名识别所以你在MuEditor里看似改对了实际导入时错位。解决方法是先用Format.exe /export导出一份标准ItemList.txt再用MuEditor打开这份标准文件不要自己新建。6.2 故障一客户端卡在“连接服务器失败”这个问题 80% 不是端口没开而是GetHardwareId生成的 hwid 被服务端拒绝。JoinServer首次允许连接时会把 hwid 写入AllowedHWID列表如果你在测试机上换过网卡或用过虚拟机hwid 会变。此时有两种解法第一种在数据库的account_hwid表里把对应账号的硬件码改成全FFFFFFFF表示不校验第二种把 HackServer 的StrictHWID配置改成0这样Protect模块只上报不校验。6.3 故障二DataServer 内存占用只增不减97d 的 DataServer 有个已知缺陷角色背包每次变更都会向内存分配器申请一块新 buffer老 buffer 没有及时释放。运行 48 小时后内存能从 300MB 涨到 1.2GB。源码里有一个隐藏开关// DataServer.ini [Memory] EnablePool1 ; 开启对象池复用 PoolSize4096我建议把EnablePool设为 1并将PoolSize调到8192。对象池只针对USER_DATA结构体其余临时字符串仍然走malloc/free所以这个参数不会解决全部问题但能显著缓解。如果你要长期挂机写一个监控脚本当DataServer进程内存超过 800MB 时自动重启它——注意要先发一条SIG_WARM_RESTART指令让它把脏页回写再执行taskkill /pid。6.4 故障三登录后选择角色没反应角色列表为空多半是DataServer连接了错误的数据库。检查AuthServer.ini的DB_NAME是否与Format建库名一致。如果角色列表出来了但点击“开始”没反应查看JoinServer日志中的MAP_BIND记录——0.97d需要客户端主程序main_ver与服务端MapServer配置一致而EX803主程序的版本号如果你在编译时改过资源段这里的匹配会失败。最笨也最有效的办法用十六进制编辑器打开Main_EX803.exe搜索字符串9D3把它改回服务端配置文件里MainVersion0x97D的对应字节。改完重新计算 CRC否则Protect会拒绝启动。这套源码的价值在于它没有把登录、掉落、客户端校验包在黑盒里你可以从DropMain一直追到DataServer的写库路径也能在GetHardwareId里看到那个时代的机器码生成思路。当你把这三个故障点处理完后一个可长跑的 0.97d 复古服就具备了你自己的配置痕迹——而不是原封不动的老一份。本文还有配套的精品资源点击获取
返回列表