BLUEM2源码:M2Server编译、网关协议与二次开发
简介:BLUEM2源码.rar面向希望研究经典Mir2服务端实现、开展二次开发或学习Delphi工程组织的开发者。压缩包共19426个文件,约680.28MB,以pas、dcu、dfm、dpr、bdsproj等Delphi/Pascal源码与工程文件为主,并包含cpp、h、res、bmp、rc、dpk、htm、txt等资源、界面、脚本与说明文件;目录预览可见服务端、登录服务与网关等工程线索,便于梳理模块划分、编译配置与运行依赖。已有1292人学习下载。对于需要对照源码理解登录、网关与服务端逻辑,或进行兼容性改造、排错和功能扩展的读者,这份完整工程具有较高参考价值,也可作为研究旧版服务端架构、工程组织与构建流程的样本;大量窗体、资源与编译产物能呈现从界面到服务端的组织方式,方便定位关键单元、复用组件并建立调试思路。
1. BLUEM2 源码拿到手先别急着编译:它到底能干什么、哪里会卡住人
BLUEM2 源码是一套 2D 俯视角 MMO 服务端的完整引擎工程,核心是 M2Server 这个逻辑主进程,外面挂着登录、选角、跑图三类网关,再往后是数据库服。它最大的价值在于「逻辑不是黑盒」:技能判定、掉落计算、怪物 AI、NPC 脚本、行会与摆摊这些平时只能靠猜的规则,全在源码单元里摆着,改一行就能验证一行。适合两类人:想把这套老引擎迁到新环境做二次开发的,和想拿一套跑通的服务端架构当教材逐行读的。不适合只想解压点开玩两天的人,编译链、BDE 数据表、GBK 编码这三关就够劝退一轮。下面按「先编译通、再协议通、再数据通、最后压得住」的顺序拆。
2. 编译环境搭起来:从 .dpr 到第一个能跑起来的 M2Server
老引擎源码最容易死在第一步——不是代码有错,是环境不对。我见过太多人解压完直接双击 .dpr,Delphi 弹一堆「找不到单元」,然后就断定「这源码是残的」。实际上这类工程本身就带着强烈的时代印记:控件依赖、字符集、路径写法都停留在十几年前,你得先把环境复刻出来,代码才谈得上能不能跑。
2.1 先认目录:哪些是要编译的,哪些是要喂给引擎的
解压后不要急着找 exe,先按职责把目录分三堆。第一堆是工程源码,特征是有.dpr主工程、大量.pas单元和.dfm窗体文件;第二堆是运行期数据,通常是Envir这类目录,里面塞着 NPC 脚本、地图配置、怪物刷新点;第三堆是数据库文件,形如StdItems.DB、Monster.DB、Magic.DB,这类不是文本,得用 BDE 体系去读。
| 目录/文件 | 职责 | 处理顺序 |
|---|---|---|
*.dpr | 主工程,决定编译出哪个 exe | 第 1 步,先读 uses 段 |
*.pas/*.dfm | 逻辑单元与窗体 | 第 2 步,随工程一起编译 |
Envir/ | 脚本、地图信息、刷怪配置 | 第 3 步,运行期才读 |
*.DB | 物品/怪物/技能数据表 | 第 3 步,需 BDE 或导出工具 |
Map/ | 地图二进制文件 | 第 4 步,跟地图编号强绑定 |
判断工程完整性的土办法:打开.dpr,把uses段里带in 'xxx.pas'的条目逐个对照磁盘文件,缺哪个补哪个。缺的那几个往往是原作者机器上的私有单元,不含核心逻辑,用空实现顶上也比编译不过强。
2.2 补齐控件依赖:找不到单元九成是这里的问题
.pas里uses了但磁盘上没有的单元,通常来自第三方控件包。老工程常见的几类:表格/界面增强类(Rx 系列)、字符串与容器类(Jedi 系列)、皮肤与停靠类。处理方式不是去改源码删 uses,而是把这些控件包按原版本装进 IDE,让编译器能解析到。
// M2Server.dpr 精简后的工程骨架,重点看 uses 段的分层 program M2Server; uses Forms, M2Share in 'M2Share.pas', // 全局配置、路径常量、启动参数 ObjBase in 'ObjBase.pas', // 角色/怪物基类与属性容器 UsrEngn in 'UsrEngn.pas', // 玩家会话、在线列表管理 MagicMgr in 'MagicMgr.pas', // 技能表加载与释放 ScriptEngine in 'ScriptEngine.pas',// 脚本解析与命令分发 frmMain in 'frmMain.pas' {MainForm}; {$R *.res} begin Application.Initialize; Application.CreateForm(TMainForm, MainForm); Application.Run; end.这段骨架的意义在于给出编译顺序的判断依据:M2Share几乎被所有单元引用,它是最底层的配置源;ObjBase定义数据结构,被UsrEngn和MagicMgr依赖;frmMain在最外层,只做界面和启动。编译报错如果集中在M2Share,说明环境问题;报错散落在业务单元,多半是数据结构没对上。
参数上要注意两点:一是工程的「搜索路径」(Search Path)必须把源码目录和控件目录都加进去,否则 IDE 里能打开、命令行编译就找不到;二是输出目录单独设一个,别让 exe 和源码混在一起,后面跑起来时工作目录搞错会读不到Envir。
2.3 用命令行编译,把「能不能过」变成可重复的事
IDE 里点编译,成功失败都带一堆缓存干扰。真要判断工程健康度,走命令行更干净。
# 全量重编译,输出到独立目录,避免旧 dcU 干扰 dcc32 -B M2Server.dpr \ -U"src;src\common;lib\rx;lib\jedi" \ -I"src;src\common" \ -NU"build\dcu" \ -E"build\bin"参数逐个说清楚:-B是强制全量重编,老工程增量编译经常因为.dcu版本不一致产生玄学问题,第一次必须带上;-U是单元搜索路径,分号分隔,控件目录一定要列全;-I是 include 文件路径,对应源码里的{$I xxx.inc};-NU指定编译产物的中间文件放哪,跟源码隔离;-E指定最终 exe 的输出位置。跑完看退出码,0 才算过。
编译期报错基本集中在三类:File not found: 'xxx.pas'是路径或控件缺失;Undeclared identifier大量出现且都在同一批符号上,通常是某个公共单元没编进去;字符相关的报错(Incompatible types: 'Char' and 'AnsiChar')说明这份代码原本是 ANSI 时代写的,被丢进了新版 IDE 的 Unicode 编译器里。第三类最麻烦,不建议逐行改,优先找对应年代的编译器版本,环境对齐比改代码省事得多。
3. 封包链路拆解:网关和 M2 之间的协议怎么对上
编译过了只是能启动,真正让引擎「活」起来的是网络层。这类引擎的网络拓扑很固定:客户端连登录网关,网关转发给 M2 做校验,选角后切到跑图网关,跑图网关再把玩家消息透传给 M2。中间每一跳都走同一套报文格式,所以只要把这一层摸透,整条链路就通了。
3.1 报文头和加解密入口在哪找
报文结构是定长文本头 + 变长正文。头部包含两段:识别码(这条消息是什么类型)和正文长度。长度放在头部是为了解决 TCP 流的粘包半包问题——接收方先读固定长度的头,拿到长度后再决定还要不要继续收。
// 报文解码:先判头,再判长度,两关都过才算一个完整包 function DecodeMessage(const ARaw: string; out AIdent: Integer; out ABody: string): Boolean; var HeadStr, LenStr: string; BodyLen: Integer; begin Result := False; if Length(ARaw) < 12 then Exit; // 头部不足 12 字节,半包,回缓冲继续等 HeadStr := Copy(ARaw, 1, 4); // 4 位识别码 LenStr := Copy(ARaw, 5, 8); // 8 位长度,十六进制 AIdent := StrToIntDef('$' + HeadStr, -1); if AIdent < 0 then Exit; // 识别码非法,直接丢,别往下走 BodyLen := StrToIntDef('$' + LenStr, -1); if (BodyLen < 0) or (BodyLen > 65535) then Exit;// 长度越界,视为脏包 if Length(ARaw) - 12 < BodyLen then Exit; // 正文没收全,等下一次 recv ABody := Copy(ARaw, 13, BodyLen); Result := True; end;这段的关键不在语法,在三个判断的顺序。先判长度够不够 12,是为了防越界读;再判识别码能不能转成数字,是为了拦住乱码和扫描流量;最后判正文够不够,才是真正的半包处理。顺序错了就会出现「包没到齐却已经改了接收缓冲指针」这种血泪问题。参数上,65535这个上限要跟网关侧的发送缓冲对齐,两边不一致时大包会被静默截断,表现是「偶尔某个功能没反应」,特别难查。
加解密入口一般就在发送和接收的两个函数里,形如EncodeMessage/DecodeMessage的成对实现。常见做法是 6bit 编码或者简单的异或滚动,目的不是安全,是防止有人拿现成工具直接刷包。真要改协议,把这两个函数当成唯一出入口,别在业务代码里到处加解密。
3.2 用最小客户端验证一条完整链路
别指望一上来就跑通登录+选角+进图,先用脚本发一条最简消息,确认网关愿意回你。
import socket, binascii HOST, PORT = "127.0.0.1", 7200 # 网关监听地址,按实际配置改 IDENT = "1001" # 4 位识别码,取源码里定义的最小可用消息 def pack(ident: str, body: bytes) -> bytes: # 头 = 4 位识别码 + 8 位长度(十六进制),长度只算正文 return (ident + f"{len(body):08X}").encode("ascii") + body def recv_one(sock: socket.socket) -> bytes: head = b"" while len(head) < 12: # 严格收满头部,少一个字节都不解析 chunk = sock.recv(12 - len(head)) if not chunk: raise ConnectionError("连接被关闭") head += chunk body_len = int(head[4:12], 16) body = b"" while len(body) < body_len: # 按长度收正文,这里就是半包处理 chunk = sock.recv(body_len - len(body)) if not chunk: raise ConnectionError("正文未收全即断开") body += chunk return head + body with socket.create_connection((HOST, PORT), timeout=5) as s: s.sendall(pack(IDENT, b"\x00" * 8)) # 正文按源码里的结构体长度填,先全零试 print(binascii.hexlify(recv_one(s)).decode())逻辑说明:pack负责组包,长度字段只统计正文,头部本身不计入,这是这类协议最容易写错的地方;recv_one拆成两段循环,第一段保证头部完整,第二段按头部声明的长度收正文,两段都不允许「收到多少算多少」。参数上,IDENT必须是从源码消息常量表里挑的、服务端一定会回包的那个;正文字节数要跟源码里对应结构体的大小严格一致,差一个字节,服务端解析出来的字段就整体错位,返回的往往是「参数错误」而不是崩溃,容易误判成协议对不上。
3.3 改协议时必须同步的三处参数
只要动了报文格式,这三个地方必须一起改,漏一个就等着排查半天:网关侧的「最大包长」配置,决定超长包是被拒绝还是被截断;M2 侧的接收缓冲区大小,小于最大包长时会在边界值上随机丢包;客户端侧的拆包逻辑,如果客户端是别人写的二进制,你改服务端就等于单方面撕毁协议。我的习惯是先在源码里把这几个常量提成同一个配置项引用,改一处全链路生效,别留三份硬编码互相打架。
4. 脚本与数据库:让引擎真正跑起来的两块数据
编译通、协议通之后,服务端还是空壳——没有脚本就没有 NPC,没有数据表就没有物品和怪物。这两块是这套源码里最「可玩」的部分,也是最容易被改崩的部分,因为它们不走编译,改错了不报错,只表现为运行期行为诡异。
4.1 脚本解析流程与自定义命令的挂载点
脚本是纯文本,放在Envir目录下,语法是典型的条件-动作结构:#IF段写判断,#ACT段写执行,#SAY段写对话。引擎启动时把这些文件读进内存,按标签切段,玩家点 NPC 时按标签定位并逐行解释执行。
真正有价值的是#ACT后面的命令怎么变成代码。引擎内部有一张命令表,命令名映射到处理函数,参数个数也登记在里面。想加自定义命令,就是在初始化时往这张表里塞一条。
// 注册自定义脚本命令 GIVEEXP,脚本里写 GIVEEXP 500 就调到这里 procedure RegisterCustomCommands; begin AddProcessCode('GIVEEXP', @CmdGiveExp, 1); // 命令名(大写)、处理函数、参数个数 end; function CmdGiveExp(APlayer: TPlayObject; const AArgs: array of string): Boolean; var Amount: Integer; begin Result := False; if APlayer = nil then Exit; // 玩家已下线,直接返回失败 Amount := StrToIntDef(AArgs[0], 0); if (Amount <= 0) or (Amount > 1000000) then Exit; // 上限兜底,防脚本写错刷爆经验 APlayer.AddExp(Amount); Result := True; // 返回 False 时脚本引擎会中断后续 #ACT end;逻辑和参数说明:命令名必须全大写,脚本里写小写会匹配不到;参数个数声明错了,引擎传进来的数组长度不对,取AArgs[0]就会越界;返回值语义很关键——返回False表示这条命令执行失败,脚本引擎通常会中断本段后续动作,所以校验分支要Exit但要清楚这意味着后面几行都不执行。数值类命令一定要有上限判断,脚本是运营侧随手能改的东西,少一个兜底就是一次事故。
4.2 DBC 数据表:字段含义与安全改法
物品、怪物、技能三张表是这套引擎的地基。它们不是文本格式,直接拿编辑器打开是乱码,得用 BDE 体系(Database Desktop 一类的工具)打开,或者导出成 CSV 再处理。物品表的核心字段大致是这些:
| 字段 | 含义 | 改动风险 |
|---|---|---|
Idx | 唯一编号,被脚本和其他表引用 | 极高,改号等于断链 |
Name | 显示名称,GBK 编码 | 中,超长会被截断 |
StdMode | 物品归类,决定能不能装备/叠加/使用 | 高,错值导致行为异常 |
Shape | 外观与子类型 | 中,影响客户端显示 |
Weight/Price | 重量、价格 | 低 |
AC/MAC/DC/MC/SC | 防御/魔防/攻击/魔法/道术区间 | 中,上下限写反会导致数值为负 |
DuraMax | 持久上限 | 低 |
改表最稳的路径是「导出 CSV → 脚本校验 → 导回」,不要在原表上直接手改。校验至少查三件事:编号重复、必填字段为空、数值字段上下限颠倒。
import csv, collections # 从物品表导出的 CSV,用 GBK 读,别用默认 UTF-8 rows = list(csv.DictReader(open("StdItems.csv", encoding="gbk"))) idx_dup = [k for k, v in collections.Counter(r["Idx"] for r in rows).items() if v > 1] bad_mode = [r["Name"] for r in rows if not r["StdMode"].strip().isdigit()] # 攻击上下限反转:DC 字段常见写法是 "下限/上限" reversed_range = [] for r in rows: dc = r.get("DC", "").strip() if "/" in dc: lo, hi = dc.split("/")[:2] if lo.isdigit() and hi.isdigit() and int(lo) > int(hi): reversed_range.append(r["Name"]) print("重复 Idx:", idx_dup[:20]) print("StdMode 非数字:", bad_mode[:20]) print("攻击上下限反转:", reversed_range[:20]) print("总条目:", len(rows))这三个检查覆盖了改表事故的绝大多数:重复Idx会让引擎加载时其中一条被静默覆盖;StdMode写成文本会导致该物品无法被识别归类;上下限反转不会报错,只表现为「打怪伤害显示负数」这种诡异现象。参数上,encoding="gbk"不能省,这类表的名称字段就是 GBK,用 UTF-8 读出来全是替换字符,后续按名字匹配的脚本会全部失配。
4.3 引擎参数配置:改完必须重启哪些进程
引擎的主配置一般是一个文本文件,管着经验倍率、爆率基数、攻速上限、行会人数上限这类全局参数。这里要注意进程边界:配置由 M2 主进程读取,但玩家数据在数据库服那边缓存,改完配置文件只重启 M2,数据库服里的在线数据还是旧值,表现就是「改了倍率,老号没变,新建号生效」。正确顺序是停网关 → 停 M2 → 停数据库服 → 改配置 → 反序启动。攻速上限这类参数还牵扯客户端表现,服务端放宽了但客户端有本地限制,改完要实际打一场才知道有没有生效。
5. 踩坑记录:编译期与运行期最容易翻车的五处
下面这五条都是「现象看着像 A,实际原因是 B」的类型,按现象、原因、解决三段写,遇到同类问题可以直接对号入座。
5.1 编译期与依赖相关的三处
现象一:编译零报错,双击 exe 窗口一闪就没了。原因不是程序崩了,是工作目录不对——引擎启动时按相对路径找Envir和数据库文件,从资源管理器双击时当前目录是 exe 所在目录,而 exe 被输出到了build\bin,跟数据目录不在同一层。解决办法是把 exe 放回数据目录同级,或者写个启动脚本先cd到正确目录再拉起进程。我一般还会在启动日志里打一行当前工作目录,出事第一眼就能看出来。
现象二:Undeclared identifier: 'TRxXXX'一片一片地报。原因是有控件包没装进 IDE,或者装了但版本不对,单元名对得上、类名对不上。解决方式是先在 IDE 的已安装控件列表里确认包存在,再确认工程搜索路径包含该包的源码目录。别去改源码把这些类型换成标准控件,界面单元改动量会失控。
现象三:字符相关报错集中在字符串赋值和Length调用上。原因是这份工程是 ANSI 时代写的,被丢进了 Unicode 版编译器,string的语义从「字节数组」变成了「UTF-16 序列」。解决方向是环境对齐——找对应年代的编译器版本编译,而不是逐行加类型转换。真要迁到新版,得先把网络层和脚本层这两处的字符串处理单独抽出来重写,混在一起改必然出乱码。
5.2 运行期与数据相关的两处
现象四:客户端能登录、能选角,一进图就黑屏或者直接断开。八成是地图编号对不上:地图信息表里登记了某张图,但Map目录下没有对应的二进制文件,或者文件在但编号越界。排查手法是看 M2 的启动日志里地图加载那几行,加载失败会打文件名;再对着地图信息表逐条核编号。这类问题不会崩服务端,只会让单个玩家卡在进图那一步,容易误判成网络问题。
现象五:改了爆率、经验倍率,重启 M2 之后部分玩家不生效。原因是数值被数据库服缓存了一份,M2 重启只刷新了自己那份。解决就是按上一节说的顺序,数据库服也要一起重启,或者干脆在配置里关掉缓存、改成每次读取。另外还要注意脚本里可能有硬编码的倍率覆盖,配置改得再对,脚本里写死一个数字照样不生效,这种情况只能全局搜数值去定位。
6. 二次开发的验证手法:压测、日志与一次回归
改完源码最怕的是「本地单人跑得好好的,一上人就原形毕露」。这类引擎的性能瓶颈很集中,压测不需要多复杂,几十个模拟连接就能把问题逼出来。
先给三个必看指标和判断标准:
| 指标 | 采集方式 | 健康区间参考 |
|---|---|---|
| 登录到进图耗时 | 客户端打时间戳,服务端日志打收到时刻 | 局域网内应稳定在百毫秒级,抖动大于 3 倍要查 |
| 单地图广播耗时 | 在 AOI 广播函数入口出口各打一次计时 | 满屏人数下不应随人数线性飙升 |
| 进程内存增长 | 定时采样工作集,跑 30 分钟看斜率 | 稳定后应接近水平,持续上扬就是泄漏 |
压测脚本用最朴素的多连接就够了,重点是让它们保持在线而不是发完就断:
import socket, threading, time def hold(host, port, seconds=300): try: with socket.create_connection((host, port), timeout=5) as s: s.sendall(b"1001" + f"{8:08X}".encode() + b"\x00" * 8) # 最小握手包 end = time.time() + seconds while time.time() < end: # 挂着不掉,模拟真实在线 s.settimeout(1.0) try: s.recv(4096) # 持续读,防止缓冲写满被断 except socket.timeout: pass except Exception as e: print("连接异常:", e) for i in range(50): # 先 50 个,稳住再加 threading.Thread(target=hold, args=("127.0.0.1", 7200), daemon=True).start() time.sleep(300)关键是那个持续recv的循环。很多人压测只发不收,服务端发送缓冲写满后连接会被判定异常,压出来的数据全是假的。人数从 50 起步,观察内存曲线和广播耗时,出现拐点再加一档。
日志这块我的习惯是分两级:一级打在协议收发和脚本执行入口,只记识别码、长度、耗时,量可控;二级打在具体业务分支,用开关控制,排查时临时开。千万别把完整报文打进日志,跑一晚上能把磁盘写满——这事我翻过一次车,凌晨被磁盘告警叫起来。
回归验证只做三件事就够证明改动安全:一是一条完整链路(登录→选角→进图→打怪→掉落→下线)手工跑一遍;二是把改动涉及的脚本标签全量触发一遍,看有没有返回值中断;三是压测跑 30 分钟,盯内存斜率和广播耗时。从那以后我每次动网络层或脚本引擎的代码,都强制走一遍这三步,再急也不跳——跳过的那次,代价通常是整个晚上。希望帮到你。
本文还有配套的精品资源,点击获取