简介: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 接入校验,再把登录凭据转发给 AuthServer;AuthServer 查询账号表,把结果返回给 JoinServer;JoinServer 通知 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 ms | 5000 ms |
| Auth Link | 登录验证透传 | 500 ms | 3000 ms |
| Hack Link | 反外挂校验 | 200 ms | 1500 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 id="512" name="雷神之剑" kind="1H-Sword" level="12"> <base damage="45" speed="2" /> <bonus min="+2" max="+3" /> <!-- 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] DropRateMultiplier=2.0 GoldPackRate=1200 ; 万分比,下同 PotionDropRate=3500 ItemDropRate=4800 ExcellentDropRate=200 EnhanceBonus=1 ; 0 关闭,1~3 为 97d+ 增强 OwnerHoldTime=3000 ; 掉落归属保持时间(毫秒)参数说明:DropRateMultiplier是全局倍率,它会在代码里乘到每一项具体掉率上;ExcellentDropRate只在 Boss 死亡时参与判定;OwnerHoldTime决定物品在地上归击杀者所有的时长。如果在线上发现“满地图都是垃圾装备”,不是ItemDropRate太高,而是EnhanceBonus=0导致所有装备都是白板,玩家懒得捡。此时把EnhanceBonus调到 1~2,既保留稀缺性又不至于让卓越装泛滥。
4. 客户端EX803/EX401、GetHardwareId与Protect:登录链路与反外挂
4.1 main 程序的版本差异:EX803 与 EX401
压缩包里有两个客户端目录:Client_EX803和Client_EX401。EX803是后来打包的完整客户端,对应服务端 Main_EX803;EX401是一个更早期的客户端骨架,主要用来对比 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的代码里该值是 0x97D,EX803 改成了 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:Configuration=Release /p:Platform=Win32 msbuild AuthServer.vcxproj /p:Configuration=Release /p:Platform=Win32 msbuild JoinServer.vcxproj /p:Configuration=Release /p:Platform=Win32 msbuild HackServer.vcxproj /p:Configuration=Release /p:Platform=Win32逻辑说明:按这个顺序编译是因为JoinServer的头文件里引用了AuthServer生成的AuthShared.h,该头文件在 AuthServer 编译时通过GenerateAuthHeader步骤自动生成。参数说明:Platform=Win32不能改成 x64,源码里的结构体对齐方式为 1 字节对齐,x64 下默认对齐是 8 字节,会导致网络报文结构体长度变化,登录包直接解析失败。
5.2 建库与导入:Format 工具的初始化作用
首次部署时,数据库不是空库,而是用Format工具生成初始 schema。Format会创建MuOnline和Me_MuOnline两个库,前者存账号、角色、战盟,后者存商城记录和活动日志。运行Format之前,要先在 MySQL 5.7 中创建空库并对用户授权。
CREATE DATABASE IF NOT EXISTS MuOnline DEFAULT CHARSET=latin1; CREATE DATABASE IF NOT EXISTS Me_MuOnline DEFAULT CHARSET=latin1; GRANT ALL ON MuOnline.* TO 'muemu'@'localhost' IDENTIFIED BY 'muemu123'; GRANT ALL ON Me_MuOnline.* TO 'muemu'@'localhost' IDENTIFIED BY 'muemu123'; FLUSH PRIVILEGES;注意DEFAULT CHARSET=latin1不是随意选择。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 flag=false”,但游戏中看不到。
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 name="MuEmu_55906" dir=in action=allow protocol=TCP localport=55906 netsh advfirewall firewall add rule name="MuEmu_44405" dir=in action=allow protocol=TCP localport=44405然后用netstat -ano | findstr 44405确认监听地址是0.0.0.0,而不是仅本机回环,否则外网客户端永远连不上。
6. 运行验证与三个高频故障点
6.1 验证掉落系统是否生效
启动服务端后,不要急着进游戏。先用 Debug 模式跑一次:在DropMain.ini里把DropLogMode=3,这会打印每一次掉落计算的行为。在游戏里打一只BullFighter,观察服务器控制台是否输出这样的行:
[DROP] MonsterID=32 Lv=45 Map=B2 rate=4800, roll=2631 -> ItemID=512 +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] EnablePool=1 ; 开启对象池复用 PoolSize=4096我建议把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,把它改回服务端配置文件里MainVersion=0x97D的对应字节。改完重新计算 CRC,否则Protect会拒绝启动。
这套源码的价值在于它没有把登录、掉落、客户端校验包在黑盒里,你可以从DropMain一直追到DataServer的写库路径,也能在GetHardwareId里看到那个时代的机器码生成思路。当你把这三个故障点处理完后,一个可长跑的 0.97d 复古服就具备了你自己的配置痕迹——而不是原封不动的老一份。
本文还有配套的精品资源,点击获取