2004年RuneScape如何在56k拨号网络中实现多人RPG · jkm.dev

Lobsters Hottest 新闻

摘要

本文解释了2004年的RuneScape如何优化数据传输以在56k拨号调制解调器上运行多人RPG,详细介绍了网络限制、加密和客户端反编译。

<p><a href="https://lobste.rs/s/bedqru/how_2004_runescape_fit_multiplayer_rpg">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/15 05:39

# 2004年的《RuneScape》如何在56k拨号网络中运行多人RPG · jkm.dev 来源:https://jkm.dev/posts/how-2004-runescape-fit-a-multiplayer-rpg-into-56k-dialup/ 2004年时,我沉迷于用56k调制解调器玩*RuneScape*——每当妈妈拿起电话,网络就会中断。一个3D世界、单服务器最多容纳数千名玩家、同屏显示数十人——这一切都运行在浏览器中,每秒传输仅5千字节。它竟然做到了。让我们通过追踪单步操作,剖析其中奥秘。 童年时我只顾采集亚麻、猎杀哥布林,从未思考*这是如何实现的*。但答案揭示了一种近乎偏执的字节节约实践。现在,请点击角色北侧一格地面,我们将追踪从点击瞬间开始,穿过网络抵达服务器,最终显示在其他玩家屏幕上的每一个字节。 **中央喷泉,Varrock广场** ## 方法论# 本文细节源于2004年*RuneScape 2*客户端的反编译代码。代码片段经过粗略翻译,并为提升可读性做了部分整理,但逻辑保持不变。虽然不同版本的核心原则不完全相同,但从*RuneScape Classic*(2001)到当代*RuneScape 3*乃至*Old School RuneScape*,多数机制始终延续。 ## 约束条件# 让我们看看当时Jagex面临的限制: - **带宽。**56k调制解调器下行同步速率为56千比特每秒,上行更慢,还需扣除协议开销和线路噪声。实际下行约5KB/s,上行则更低。 2000年英国已普及宽带,但直到2000年代末,大多数英国家庭才接入宽带,因此大量玩家仍依赖拨号网络。 - **2004年的Java小程序浏览器环境。**Java小程序运行在安全沙箱中,无法使用原始套接字或UDP。所有数据通过单个TCP连接传输,需按序到达并承受分段开销。 - **600毫秒服务器周期。**RuneScape游戏服务器以约600毫秒的离散周期(或称“tick”)推进。每个周期内,服务器必须为*每位玩家*计算其当前视野内的所有信息,并在下一周期前发送完毕。 ## 简述加密层# 登录握手完成后、游戏数据包传输前,系统会建立一个小型加密层。此环节并非为了节省字节;它是协议栈中唯一的加密(登录握手使用的RSA加密除外)。之所以存在,是因为它保护的操作码是后续所有机制的基础。 每个数据包均以“操作码”字节开头:一个指示数据包类型的小整数。该操作码(且*仅*此操作码)通过名为ISAAC (https://github.com/Jameskmonger/isaac-crypto) 的流密码加密。系统维护两组密钥流:一组用于客户端到服务器的传输,另一组用于反向传输。双方需共享这两组流:客户端加密待发数据并解密收到的数据,服务器则对每位连接玩家执行镜像操作。两组流均从共享的四整数密钥种子生成,其中两个整数由客户端生成,另两个来自服务器握手过程。服务器到客户端的流则使用相同种子但每个词增加`50`——足以避免双向共享密钥流: ``` this.outboundCipher = new ISAAC(seed); for (int index = 0; index < 4; index++) { seed[index] += 50; } this.inboundCipher = new ISAAC(seed); ``` 发送时的加密仅需一行代码: ``` public void putOpcode(int opcode) { this.putByte(opcode + this.outboundCipher.value()); } ``` 接收时的解密则是镜像操作: ``` this.currentOpcode = (this.currentOpcode - this.inboundCipher.value()) & 0xFF; ``` 可见仅操作码被加密,数据包主体未加密。正如后文所述,操作码决定了数据包的解读方式及边界划分。没有它,主体数据就只是一堆字节,因此加密这一个字节已成为抵御第三方数据包解析器的最低成本防护。 ## 发送行走请求# 当我们点击北侧一格地面时,会发生什么?数据如何传输至服务器? 在任何网络传输前,客户端会使用本地碰撞地图进行广度优先搜索,构建从当前位置到点击位置的路径(本例路径简单),随后生成服务器可读的数据包。 寻路算法属常规技术,此处不赘述。数据包首部为操作码,紧随其后的是单字节长度标识,指示数据包主体长度。由于路径长度可变,此“长度”字节让服务器知晓需读取多少数据。仅含可变长度主体的数据包才会包含此字段。 起点坐标占用4字节(两个短整型),每个后续路径点偏移量占2字节,最后1字节表示是否按住`Ctrl`键。因此主体长度为`4 + 2 * (pathLength - 1) + 1`: ``` this.outboundStream.putOpcode(ClientToServerOpcodes.WALK_TILE); this.outboundStream.putByte(4 + 2 * (pathLength - 1) + 1); ``` 数据包包含路径首个点的绝对坐标(`x`和`z`各用2字节“短整型”表示),随后是各后续点相对于首个点的偏移量——每轴使用1字节有符号整数,其-128至127的范围足以容纳单次点击的距离。 此处仅传输偏移量(每步2字节)而非绝对坐标(每步4字节),首次体现了Jagex的网络传输节约性。单步行走数据包仅节省数字节,但每增加一个路径点可节省2字节——节约率达50%: ``` int firstX = pathX[0]; int firstZ = pathZ[0]; this.outboundStream.putShort(this.playerPositionX + firstX); this.outboundStream.putShort(this.playerPositionZ + firstZ); for (int i = 1; i < pathLength; i++) { this.outboundStream.putByte(this.pathX[i] - firstX); this.outboundStream.putByte(this.pathZ[i] - firstZ); } ``` 另一项节约设计是:`pathX`和`pathZ`数组不存储路径上的所有格点,仅存储拐角点。直线行走十格仅传输一个目标点。服务器已知起始位置,会自行计算直线路径并用碰撞地图验证。 数据包最后一字节表示`Ctrl`键状态。早期版本中此键强制“奔跑模式”,后期版本则反转当前移动模式(若关闭奔跑则点击目标奔跑,若开启则行走): ``` this.outboundStream.putByte(this.keyStatus[Keys.CTRL] == 1 ? 1 : 0); ``` 可见单步向北行走共消耗7字节,包含操作码和长度标识: **WALK_TILE数据包字节布局** 7字节客户端到服务器行走数据包:1字节操作码、1字节长度标识(值为5)、2字节目标x短整型、2字节目标z短整型、1字节奔跑切换字节。操作码与长度构成头部;剩余5字节构成主体,大小等于长度标识值。 ``` 0 1 2 3 4 5 6 opcode (加密) length = 5 x (2字节短整型) z (2字节短整型) Ctrl (奔跑切换) [头部] [主体·5字节] ``` 由于路径仅含单步,循环发送“偏移量”路径点不会触发。可交叉验证`5`字节负载与长度标识: - `4 + 2 * (pathLength - 1) + 1` = `4 + 2 * 0 + 1` = `5` 上述代码执行后,数据包已进入客户端出站流。该流大约每20毫秒向网络刷新一次。 ## 服务器接收请求# 服务器主循环大约每600毫秒唤醒一次。每次唤醒时,它会清空每位玩家的入站缓冲区,执行数据包对应的处理器,并组装接下来将分析的玩家更新数据包。 数据包若在周期开始前抵达则近乎即时处理;若在周期后抵达则需等待近600毫秒。此600毫秒周期设定了延迟的粒度。20毫秒客户端刷新及其他网络开销均远低于此阈值。这就是本文聚焦于*字节*而非*时间*的原因:延迟无需优化。 服务器清空入站缓冲区后,读取数据包的过程大致与前述相反: ``` int opcode = player.inboundStream.takeOpcode(); if (opcode == ClientToServerOpcodes.WALK_TILE) { int length = player.inboundStream.takeByte(); int deltaCount = (length - 4 - 1) / 2; int[] firstWaypoint = new int[2]; firstWaypoint[0] = player.inboundStream.takeShort(); firstWaypoint[1] = player.inboundStream.takeShort(); int[][] waypointDeltas = new int[deltaCount][2]; for (int i = 0; i < deltaCount; i++) { waypointDeltas[i][0] = player.inboundStream.takeByte(); waypointDeltas[i][1] = player.inboundStream.takeByte(); } boolean holdingCtrl = player.inboundStream.takeByte() == 1; player.processWalkTile(firstWaypoint, waypointDeltas, holdingCtrl); } ``` 可见识别操作码后,可读取长度标识并逆向写入逻辑以提取偏移量数量。前文提及并非所有数据包都包含长度字节;事实上*大多数数据包不包含*,因为多数数据包具有固定长度主体。读取这类数据包更为简单。 以“物品对物品”数据包为例——玩家“使用”背包中一个物品与另一个交互时发送: ``` if (opcode == ClientToServerOpcodes.USE_ITEM_ON_ITEM) { int sourceItemId = player.inboundStream.takeShort(); int sourceInterfaceId = player.inboundStream.takeShort(); int sourceInterfaceSlot = player.inboundStream.takeShort(); int targetItemId = player.inboundStream.takeShort(); int targetInterfaceId = player.inboundStream.takeShort(); int targetInterfaceSlot = player.inboundStream.takeShort(); player.processUseItemOnItem(/* ... */); } ``` 此数据包固定长度为12字节(6个短整型)。服务器知晓此固定长度,因此无需在数据包中传输长度标识。 ## 服务器周期# RuneScape服务器周期包含多个步骤,相关环节按以下顺序执行: - 读取传入数据包 - 处理玩家(排队动作、触发器、移动等) - 构建玩家更新(下节详述) - 刷新出站数据包 总体原则清晰:先*读*,再*执行*,最后*写入*。 ## 玩家更新# 追踪数据包前,需明确协议基础:客户端维护每位可见玩家的镜像数据。近处玩家被跟踪列表记录,每位玩家存储其最后已知位置、外观、动画和聊天状态——此外还有本地玩家自身状态。玩家更新数据包的任务是使该镜像与服务器权威版本同步——这意味着更新几乎总是*相对于客户端已知信息的增量*。 “无变化”传输代价极低,正因客户端已持有数据;服务器仅确认其仍有效。每个周期,服务器向每位玩家发送单个组合式“玩家更新数据包”。此数据包描述客户端需要了解的*所有可见玩家*(包括自身)的全部信息。接收客户端分四步解析信息,顺序如下: ``` private void readPlayerUpdates(Packet packet) { packet.accessMode(PacketAccess.BITS); this.readLocalPlayer(packet); // 客户端已跟踪的其他玩家 this.readOtherPlayers(packet); // 进入范围的新玩家,客户端应开始跟踪 this.readNewPlayers(packet); packet.accessMode(PacketAccess.BYTES); // 玩家的详细变化 this.readPlayerDetails(packet); } ``` 前三步采用*位压缩*——流按几位为单位读取,而非逐字节。仅第四步采用字节对齐。这种分割是刻意为之:移动和注册属高频操作且数据量小,故用位存储;较低频的丰富更新(玩家更换装备、挥剑或发言)则使用字节。 ### 步骤1:本地玩家# 读取本地玩家的逻辑简单,读者可自行分析: ``` private void readLocalPlayer(Packet packet) { int updated = packet.takeBits(1); // 本地玩家无移动且无细节变化 if (updated == 0) { return; } int movementType = packet.takeBits(2); // 类型1:行走 if (movementType == 1) { int direction = packet.takeBits(3); this.localPlayer.step(direction, false); int detailUpdated = packet.takeBits(1); if (detailUpdated == 1) { this.trackPlayerDetails(this.localPlayer.id); } } // 类型0:无移动,但后续有细节更新 // 类型2:奔跑——连续两个方向 // 类型3:传送 } ``` 请重读首个`if`语句。若本地玩家未移动且本周期无变化,**其在更新数据包中的全部存在仅占1位。**不是字节,而是1位。任何玩家在任何周期最常见的状态——“无变化”——被赋予了最低传输代价。 若本地玩家移动,则占1位、2位表示移动类型、3位表示方向、1位表示“是否有更多细节?”标志。**7位,不足1字节**,传达了“我走了一步”。 排除首个“需要更新”标志和移动类型,其可压缩至4位。其他类型代价也低: - 类型`0`(无移动,但有细节):零负载,0位。 - 类型`2`(奔跑):两个3位方向标志加1位“更多细节”标志,共7位。 - 类型`3`(传送):高度平面(2位)、x与z坐标(各7位)、“更多细节”标志位和“跳跃”位(用于告知客户端是否应尝试动画此移动)。稍昂贵,但仍仅18位——略超2字节。 ### 步骤2:已跟踪玩家# 此逻辑与上述相同,应用于已跟踪的玩家群体。需注意此处读取各位延续自“本地玩家”部分。换言之,若本地玩家部分仅占1位,则后续...

相似文章

为MMORPG添加离线模式和自定义服务器

Lobsters Hottest

一位开发者详细介绍了为其自制MMORPG Trolddom添加离线模式和自定义服务器支持所面临的技术挑战,灵感来源于Stop Killing Games运动,涵盖了架构和实现方面。