Delphi传奇2服务端协议解析与结构体边界调试指南
2026/9/14 14:59:38 网站建设 项目流程

简介:本资源为《传奇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物品栏更新、如何校验TItemRecordNameLen与后续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.pasTIdBytes = array of ByteTIdBytes = AnsiString,并注释掉IdTCPClient.pas第 123 行{$IFDEF DELPHI_XE2_UP}条件编译块。这是关键锚点——所有后续调试都建立在 ABI 兼容基础上,而非单纯“能编译”

提示:不要尝试用 Delphi Community Edition(2023 版)直接打开。其默认集成 Indy 11,TIdBytes已彻底移除,强行修改会导致TIdIOHandlerStack.ReadBytes返回空缓冲区,客户端连接后立即断开。

2.2 源码目录的三层职责划分:从协议解析到状态持久化

项目根目录下Src/文件夹包含三个核心单元:

单元名核心职责关键结构体调试切入点
uNetwork.pasTCP 连接管理、包头解析(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.ProcessPickupif 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.BATCleanup.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.pasTGameServer.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.pasTDatabase.Connect会抛出EOleException。若用 Delphi XE2,需在项目选项中勾选Use Legacy Database Components,否则TADOConnection无法加载 Jet 4.0 驱动。

启动后,用netstat -ano | findstr :5500确认监听状态。若无输出,检查uNetwork.pasTGameServer.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.pasProcessLogin函数中触发断点。关键验证点:TGamePacketHeader.Cmd必须等于$78Size必须等于20,否则ReadBytes会因长度不符而阻塞。

3.3 解析0x1A物品栏包:从字节流到结构体的逐字段映射

当角色打开背包,客户端发送0x1A包,格式为:

偏移长度含义示例值
01Cmd = 0x1A0x1A
12Size = 24 × N(N 为物品数)0x1800(小端序,即 24)
324×NN 个TItemRecord序列0653776F72640000...

uNetwork.pasProcessItemBag中插入解析逻辑:

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 兼容改造:解决AnsiStringUTF8String的混用风险

4.1AnsiString在 XE2 中的隐式转换陷阱

Delphi XE2 默认字符串类型为UnicodeString,而MirServer-Delphi大量使用AnsiString存储协议数据。问题出现在uDatabase.pasTDatabase.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.pasConnect方法中显式指定:

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..........

关键发现:0x1A0x18 00是小端序 24,0x06NameLen0x53 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.pasProcessPickup中临时注入:

// 模拟客户端发送 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,且客户端无响应——这验证了结构体边界检查的有效性,也是防止缓冲区溢出的关键防线。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询