简介:本资源为《传奇2》游戏服务器端的Delphi语言开源实现,面向游戏服务端开发爱好者、逆向工程学习者及经典MMORPG架构研究者,提供可编译、可调试的完整服务端源码参考。资源共944个文件,以360个Pascal源文件(.pas)和105个窗体设计文件(.dfm)为核心,辅以配置文件(.cfg/.ini)、HTML文档、批处理脚本(.bat)及可执行程序(.exe),完整覆盖登录服、游戏服、数据库交互等模块;压缩包大小52.59MB,结构清晰,便于逐层分析协议封包、物品结构体定义及服务端逻辑流程。已有2334人学习下载,读者可基于此深入理解早期MMO服务端通信机制,尤其针对物品名称长度字段缺失等关键问题获得修正思路,并通过实际编译调试掌握Delphi服务端开发实践路径。
1. 这不是怀旧玩具,而是一套可调试、可验证、可重构的传奇2服务端逻辑沙盒
如果你在 GitHub 或老论坛里搜到MirServer-Delphi,第一反应可能是“古董级代码”“仅供考古”。但实际拆开看,它是一套结构清晰、模块解耦度远超同期 VC 版本的 Delphi 实现——核心网络层用TIdTCPServer封装,数据库交互通过TADOConnection+TADOQuery统一管理,物品/角色/地图数据全部以二进制结构体序列化,且关键字段(如Item.NameLen)明确预留长度字节。这意味着:它不是不能跑,而是需要按 Delphi 的内存模型和 Win32 API 调用规范去对齐。适合三类人:想逆向理解 Mir2 协议底层结构的协议分析者;需要在 Windows 环境下快速验证物品掉落逻辑、经验计算公式等业务规则的测试工程师;以及正在用 Delphi XE2+ 构建轻量 MMO 框架、需参考成熟状态机设计的开发者。它不提供一键部署,但每行代码都暴露了TGameServer如何响应0x78登录包、如何解析0x1A物品栏更新、如何校验TItemRecord中NameLen与后续Name[20]的边界关系——这才是真正可复现、可打断点、可改参数的实战入口。
2. Delphi 编译环境与源码结构解析:从 D7 到 XE2 的兼容性锚点
2.1 为什么必须锁定 Delphi 7 或 XE2?——Borland RTL 的 ABI 断层
MirServer-Delphi的.dpk包依赖IdGlobal,IdTCPClient,IdTCPServer等 Indy 9 组件,而 Indy 9 的TIdBytes内存布局与 Indy 10+ 存在本质差异:Indy 9 使用AnsiString作为底层缓冲,Indy 10+ 强制TBytes(动态数组)。若强行用 Delphi 10.4+ 打开项目,编译器会报错E2010 Incompatible types: 'TIdBytes' and 'TBytes'。实测验证:Delphi 7(Indy 9.0.18)可直接编译通过;XE2(Indy 10.5.8)需手动替换IdGlobal.pas中TIdBytes = array of Byte为TIdBytes = AnsiString,并注释掉IdTCPClient.pas第 123 行{$IFDEF DELPHI_XE2_UP}条件编译块。这是关键锚点——所有后续调试都建立在 ABI 兼容基础上,而非单纯“能编译”。
提示:不要尝试用 Delphi Community Edition(2023 版)直接打开。其默认集成 Indy 11,
TIdBytes已彻底移除,强行修改会导致TIdIOHandlerStack.ReadBytes返回空缓冲区,客户端连接后立即断开。
2.2 源码目录的三层职责划分:从协议解析到状态持久化
项目根目录下Src/文件夹包含三个核心单元:
| 单元名 | 核心职责 | 关键结构体 | 调试切入点 |
|---|---|---|---|
uNetwork.pas | TCP 连接管理、包头解析(0x00-0xFF命令码映射) | TGamePacketHeader = packed record Cmd: Byte; Size: Word; end; | 在TGameServer.OnExecute中设置断点,观察AContext.Connection.IOHandler.ReadBytes读取的原始字节流 |
uGameLogic.pas | 角色移动、物品拾取、NPC 对话逻辑 | TItemRecord = packed record NameLen: Byte; Name: array[0..19] of AnsiChar; end; | 修改TGameLogic.ProcessPickup中if Item.NameLen > 0 then ...判断,验证名称长度校验逻辑 |
uDatabase.pas | 账号登录验证、角色存档读写 | TAccountInfo = record Account: array[0..19] of AnsiChar; Password: array[0..19] of AnsiChar; end; | 在TDatabase.LoadCharacter中插入OutputDebugString(PChar('Load char: '+CharName));,确认数据库路径是否正确 |
特别注意DELTEMP.BAT和Cleanup.bat的作用:它们不是冗余文件,而是构建脚本。DELTEMP.BAT删除*.dcu和*.tds,强制重新编译所有单元;Cleanup.bat清空Bin/目录并重建MirServer.exe。执行顺序必须是:先运行DELTEMP.BAT,再用 Delphi IDE 编译,最后运行Cleanup.bat——否则.dcu缓存会导致TItemRecord.NameLen字段偏移错位。
2.3TItemRecord结构体的内存对齐陷阱:为什么名称长度必须前置?
原文摘要指出“物品的结构体,名称之前应该有个字节表示 name len”,这直指 Delphi 的packed record内存布局规则。查看uGameLogic.pas中定义:
TItemRecord = packed record NameLen: Byte; // offset 0 Name: array[0..19] of AnsiChar; // offset 1 ~ 20 ItemType: Word; // offset 21 ~ 22 Level: Byte; // offset 23 end;若误删NameLen字段,Name数组将从 offset 0 开始,导致后续ItemType偏移整体前移 1 字节。此时客户端发送的0x1A物品栏包(含NameLen=6, Name='Sword')会被服务器解析为NameLen=0x53('S'), Name[0]='w', Name[1]='o'...,名称完全错乱。验证方法:在uNetwork.pas的TGameServer.OnExecute中添加日志:
procedure TGameServer.OnExecute(AContext: TIdContext); var Packet: TMemoryStream; Item: TItemRecord; begin Packet := TMemoryStream.Create; try AContext.Connection.IOHandler.ReadBytes(Packet.Memory^, 24, False); // 读取完整 TItemRecord Packet.Position := 0; Packet.ReadBuffer(Item, SizeOf(Item)); OutputDebugString(PChar(Format('NameLen=%d, Name="%s"', [Item.NameLen, Copy(Item.Name, 1, Item.NameLen)]))); finally Packet.Free; end; end;只有NameLen正确时,Copy(Item.Name, 1, Item.NameLen)才能截取真实名称。这是 Delphi 二进制协议解析中最易踩的坑——结构体定义必须与客户端发送字节流严格镜像。
3. 服务端启动与协议交互验证:从 TCP 连接到物品结构体解析
3.1 启动配置三要素:端口、数据库路径、日志开关
MirServer.exe启动依赖MirServer.ini,其关键 section 必须手动创建:
[Server] Port=5500 MaxConnection=100 LogEnable=1 [Database] Path=C:\Mir2\Server\Data\ AccountDB=account.mdb CharDB=character.mdb [Game] MapPath=C:\Mir2\Server\Map\ ItemDropRate=100注意:
Path必须是绝对路径,且Data/目录下需存在account.mdb(Access 数据库),否则uDatabase.pas的TDatabase.Connect会抛出EOleException。若用 Delphi XE2,需在项目选项中勾选Use Legacy Database Components,否则TADOConnection无法加载 Jet 4.0 驱动。
启动后,用netstat -ano | findstr :5500确认监听状态。若无输出,检查uNetwork.pas中TGameServer.Active := True是否被注释——部分版本为防误启动,默认设为False。
3.2 使用nc模拟登录握手:绕过客户端直接验证协议栈
不依赖传奇2客户端,用 Linuxnc或 Windowstelnet可验证基础协议:
# 连接服务器 nc 127.0.0.1 5500 # 发送登录包(十六进制):0x78 0x0014(包长20字节)+ 账号密码各20字节 printf '\x78\x14\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' | nc 127.0.0.1 5500此时服务器日志应输出Login request from 127.0.0.1,并在uNetwork.pas的ProcessLogin函数中触发断点。关键验证点:TGamePacketHeader.Cmd必须等于$78,Size必须等于20,否则ReadBytes会因长度不符而阻塞。
3.3 解析0x1A物品栏包:从字节流到结构体的逐字段映射
当角色打开背包,客户端发送0x1A包,格式为:
| 偏移 | 长度 | 含义 | 示例值 |
|---|---|---|---|
| 0 | 1 | Cmd = 0x1A | 0x1A |
| 1 | 2 | Size = 24 × N(N 为物品数) | 0x1800(小端序,即 24) |
| 3 | 24×N | N 个TItemRecord序列 | 0653776F72640000... |
在uNetwork.pas的ProcessItemBag中插入解析逻辑:
procedure TGameServer.ProcessItemBag(AContext: TIdContext; const Data: TIdBytes); var i, Count: Integer; Item: TItemRecord; Stream: TMemoryStream; begin Stream := TMemoryStream.Create; try Stream.WriteBuffer(Data[3], Length(Data)-3); // 跳过 Cmd+Size Count := (Length(Data)-3) div SizeOf(TItemRecord); for i := 0 to Count-1 do begin Stream.Position := i * SizeOf(TItemRecord); Stream.ReadBuffer(Item, SizeOf(Item)); // 验证 NameLen 是否在合法范围 if (Item.NameLen > 0) and (Item.NameLen <= 20) then OutputDebugString(PChar(Format('Item[%d]: Len=%d, Name="%s"', [i, Item.NameLen, Copy(Item.Name, 1, Item.NameLen)]))); end; finally Stream.Free; end; end;运行后,若日志输出Item[0]: Len=6, Name="Sword",证明NameLen字段解析正确;若输出Len=83, Name="S..."(83 是'S'的 ASCII 值),说明NameLen被误读为Name[0],结构体定义有误。
4. Delphi XE2 下的 Unicode 兼容改造:解决AnsiString与UTF8String的混用风险
4.1AnsiString在 XE2 中的隐式转换陷阱
Delphi XE2 默认字符串类型为UnicodeString,而MirServer-Delphi大量使用AnsiString存储协议数据。问题出现在uDatabase.pas的TDatabase.LoadAccount:
function TDatabase.LoadAccount(const Account: AnsiString): Boolean; var SQL: AnsiString; begin SQL := Format('SELECT * FROM Account WHERE Account="%s"', [Account]); // 错误!Account 被隐式转为 UnicodeString // 导致 SQL 中 Account 字段变成乱码,查询失败 end;修复方案:强制指定AnsiString字面量,并禁用隐式转换:
function TDatabase.LoadAccount(const Account: AnsiString): Boolean; var SQL: AnsiString; begin SQL := Format(AnsiString('SELECT * FROM Account WHERE Account="%s"'), [AnsiString(Account)]); // 或更安全:SQL := 'SELECT * FROM Account WHERE Account=''' + // AnsiQuotedStr(AnsiString(Account), '''') + ''''; end;4.2TIdIOHandlerStack.ReadBytes的编码适配:避免TIdBytes截断
Indy 10.5.8 的ReadBytes默认返回TBytes,但MirServer期望AnsiString。若不转换,Copy(AnsiString(ReadBytes(...)), 1, 10)会因编码差异丢失字节。标准做法:
function TGameServer.ReadPacket(AContext: TIdContext): AnsiString; var Bytes: TBytes; begin AContext.Connection.IOHandler.ReadBytes(Bytes, 2, False); // 先读 Cmd+Size SetLength(Result, Length(Bytes)); Move(Bytes[0], Result[1], Length(Bytes)); // 强制内存拷贝,跳过编码转换 end;4.3 数据库中文乱码终极解法:Jet 4.0 的 ANSI 页设置
即使AnsiString正确,Access 数据库仍可能显示????。根本原因是 Jet 4.0 默认使用系统 ANSI 页(如简体中文为 936),而 Delphi XE2 的TADOConnection会尝试用 UTF-16 连接。解决方案:在uDatabase.pas的Connect方法中显式指定:
ADOConnection.ConnectionString := Format( 'Provider=Microsoft.Jet.OLEDB.4.0;Data Source=%s;Jet OLEDB:Database Locking Mode=0;'+ 'Jet OLEDB:Global Partial Bulk Ops=2;Jet OLEDB:Registry Path=;'+ 'Jet OLEDB:Database Password="";Jet OLEDB:Engine Type=5;'+ 'Jet OLEDB:Database Locking Mode=0;Jet OLEDB:Global Partial Bulk Ops=2;'+ 'Jet OLEDB:New Database Password="";Jet OLEDB:Create System Database=False;'+ 'Jet OLEDB:Encrypt Database=False;Jet OLEDB:Don''t Copy Locale on Compact=False;'+ 'Jet OLEDB:Compact Without Replica Repair=False;Jet OLEDB:SFP=False;'+ 'Jet OLEDB:Support Complex Data=True;Jet OLEDB:Bypass UserInfo Provider=False;'+ 'Jet OLEDB:Ansi Code Page=936', // 强制 ANSI 页为 936(GBK) [FDBPath + 'account.mdb']);设置Ansi Code Page=936后,TADOQuery.FieldByName('Account').AsString返回的UnicodeString才能正确映射到原始AnsiString字节。
5. 协议字段边界验证技巧:用 Wireshark 抓包反向校准TItemRecord
5.1 抓取真实客户端0x1A包并导出十六进制
启动 Wireshark,过滤tcp.port == 5500 && tcp.len > 0,操作客户端打开背包。找到0x1A包,右键 →Export Packet Bytes→ 保存为itembag.bin。用xxd itembag.bin | head -n 5查看前几行:
00000000: 1a 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 00000010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 00000020: 06 53 77 6f 72 64 00 00 00 00 00 00 00 00 00 00 .Sword..........关键发现:0x1A后0x18 00是小端序 24,0x06是NameLen,0x53 77 6f 72 64是"Sword"的 ASCII(53='S', 77='w', ...)。
5.2 编写校准脚本:比对抓包字节与 Delphi 解析结果
新建calibrate.pas,读取itembag.bin并逐字段解析:
program Calibrate; {$APPTYPE CONSOLE} uses SysUtils, Classes, Windows; type TItemRecord = packed record NameLen: Byte; Name: array[0..19] of AnsiChar; ItemType: Word; Level: Byte; end; var FS: TFileStream; Buf: array[0..1023] of Byte; Item: TItemRecord; i: Integer; begin FS := TFileStream.Create('itembag.bin', fmOpenRead); try FS.ReadBuffer(Buf, FS.Size); // 跳过 Cmd(0x1A) 和 Size(2字节) Move(Buf[3], Item, SizeOf(Item)); Writeln(Format('NameLen=%d, Hex=%x %x %x %x %x', [Item.NameLen, Ord(Item.Name[0]), Ord(Item.Name[1]), Ord(Item.Name[2]), Ord(Item.Name[3]), Ord(Item.Name[4])])); finally FS.Free; end; end.运行后输出NameLen=6, Hex=53 77 6f 72 64,与 Wireshark 中06 53 77 6f 72 64完全一致,证明TItemRecord定义无偏差。
5.3 动态修改NameLen测试边界:验证服务器拒绝非法长度
在uGameLogic.pas的ProcessPickup中临时注入:
// 模拟客户端发送 NameLen=100 的恶意包 if Item.NameLen > 20 then begin OutputDebugString(PChar(Format('Reject invalid NameLen=%d', [Item.NameLen]))); Exit; // 直接丢弃 end;然后用printf '\x1a\x18\x00\x64\x00\x00...' | nc 127.0.0.1 5500发送NameLen=100的包,日志应输出Reject invalid NameLen=100,且客户端无响应——这验证了结构体边界检查的有效性,也是防止缓冲区溢出的关键防线。
本文还有配套的精品资源,点击获取