用自己闲置的香港优化服务器给魔兽世界乌龟服(亚洲)做加速

距离魔兽世界乌龟服亚服已经开了已经半年多了,对我一个偶尔想玩下的人是非常合适的,原汁原味,而且还免费。但是它有一点很恶心就是既然服务器在香港了,为什么不来点国内优化,还得用加速器。作为VPSer我就想能否用现有的资源给游戏加速下,最好啥软件都不用的前提下就能加速(毕竟加速器是只能1个人登录不能共享)。

前言

魔兽世界乌龟服亚服 (Turtle WOW 艾泽拉斯的秘密 - 亚洲领域) 是欧洲非常出名的魔兽世界私服,在去年9月上线了亚洲服务器(差不多就是为国人准备的)它开发团队甚至改写地图,改写游戏任务,以及多种特色玩法来增加游戏体验。以至于原本你打一个怪都是生不如死的,到现在在不影响体验的前提下,因为任务增多所以等级会比升更快,几乎每一次从越级打怪变成了降级打怪,哪怕是战士也是很好的一个人玩,而且它对工作室,金团打击非常严格,游戏体验好得多。但是它有一个很恶心的地方,就是从内地直连延迟很高,因为我有一台深港的服务器,从深圳到香港几乎5ms的延迟,我就在想能否用这台服务器来为游戏加个速。

这里不会涉及任何与代理相关的国内非法软件

折腾原因

玩过魔兽世界私服的都知道,魔兽世界1.12游戏目录下,有一个realmlist.wtf的文件,这个文件决定了当前游戏客户端连接着哪一个服务端,这个文件内容是这样的 当然下面的地址早就不能用了,这是九城的魔兽世界服务器地址

1
set realmlist cn1.logon.warcraftchina.com

乌龟服 Turtle WOW 的realmlist.wtf是这样的,我们用 BestTrace 来看下路由

1
2
set realmlist cnlogon.turtle-wow.org
set patchlist cnlogon.turtle-wow.org

路由路线

route-trance

从上面的图,我们可以看到,它访问游戏登陆服务器,会先跑到美国,然后从美国访问香港,这延迟不高才怪。就像我们去香港,先要坐飞机到美国,然后再美国飞往香港,这时间没个一天一夜都困难。至于为什么会出现这个情况,很简单一句话:服务器商家给国内运营商的钱不够多。加速器的原理就很简单,说到底加速器就是让你坐上直达班车,使得你能快速到香港。

传统加速原理

这是我们此刻访问游戏服务器

general-route-map

而这是使用网易UU加速器访问游戏服务器

UU-boost-route-map

基本加速原理都是如此,使用更优化的网络来为游戏加速。所以,我就在想,魔兽世界既然提供了设置服务器地址,那么游戏服务器肯定也是在这里面。

开始折腾

丢个想法

我们都知道魔兽世界登陆账号后,会有一个选择服务器的过程,每一个服务器应该是一个独立的端口或IP,然后每一个私服都有自己的服务器名字,所以这里我猜测,游戏服务器的IP和端口信息,很有可能是由登陆服务器提供的,但是是否是加密信息就不清楚了。只不过有如此多的私服,加密方式应该也不是啥秘密 其实,它压根没有加密

抓包

基于上述的结论,我用wiresharek进行抓包,我打开抓包后,并进行登陆,选择服务器,登陆到角色,得到的数据包如下,好吧,wiresharek都已经能识别到wow游戏包了。

wireshark

我们找到最后一条,Realm List 数据大小是281的数据包,内容如下

image-20240619142902737

这不是就是我要找的数据么,数据命令是 Realm List 0x10,数据包大小224,共5个领域,这就是我需要的。

server-list

然后我找第一个服务器 Blood Ring,看看里面的值,它的服务器地址就是 169.150.222.245:8090

wireshark-capture

思路明确

因为我有一台 深圳到香港几乎5ms的延迟的服务器,所以我是不是可以这么做

  1. 我首先写一个代理服务器,代理它的认证端口3724(从抓包软件得知,魔兽世界端口是3724)

    first-step

  2. 修改服务器地址,让游戏能连接到我深圳服务器

    step2-changeserver

  3. 然后我将所有15001端口的请求全部转发到原来的服务器

    step3-portforward

  4. 登陆成功,游戏ing

    step4-login-success

这样子,我就不需要在电脑上装任何软件,只需要代理服务部署在服务端,只需修改配置,就可以不需要加速器,不需要任何软件的前提下,玩上低延迟的游戏了。

great-delay

p.s. 如果你有一台香港对国内优化服务器,那么也可以作为加速游戏使用

if you have CN2 GIA

开始编码

实现登陆部分

首先我需要能转发代理登陆部分,实现捕获魔兽世界登陆服务器,我使用java socket来实现请求。代码如下,这段代码能帮我们实现转发登录请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package com.ruterfu;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketForwarder {
// 这个就是 cnlogon.turtle-wow.org 对应的IP地址
private static final String LOGIN_SERVER = "169.150.222.73";
// 这个是登录端口
private static final int LOGON_PORT = 3724;
private static final int SOCKET_LISTEN = 16000;

private static final String logonServer = LOGIN_SERVER;

public static void main(String[] args) {
try {
ServerSocket serverSocket = new ServerSocket(SOCKET_LISTEN);
System.out.println("Listening on port " + SOCKET_LISTEN + "...");
while (true) {
Socket clientSocket = serverSocket.accept();
clientSocket.setSoTimeout(60000);
System.out.println("Connection from " + clientSocket.getInetAddress());
Thread clientThread = new Thread(new ClientHandler(clientSocket, true));
clientThread.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}

private record ClientHandler(Socket clientSocket, boolean needReplace) implements Runnable {

@Override
public void run() {
try(Socket serverSocket = new Socket(logonServer, LOGON_PORT);
InputStream clientInput = clientSocket.getInputStream();
OutputStream clientOutput = clientSocket.getOutputStream();
InputStream serverInput = serverSocket.getInputStream();
OutputStream serverOutput = serverSocket.getOutputStream()) {

byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = clientInput.read(buffer)) != -1) {
serverOutput.write(buffer, 0, bytesRead);
serverOutput.flush();
System.out.println("sent " + bytesRead + " data to server");
bytesRead = serverInput.read(buffer);
System.out.println("received(first buffer 【" + buffer[0] + "】) " + new String(buffer, 0, bytesRead) + " data from server");
clientOutput.write(buffer, 0, bytesRead);
clientOutput.flush();
}
System.out.println("Connection from " + clientSocket.getInetAddress() + " closed");
} catch (IOException e) {
e.printStackTrace();
}
}
}

}

运行这段代码,将游戏服务器地址改成自己本机地址(我这里是虚拟机运行 + 本地跑java),输出日志如下,看到需要的数据了,此时游戏也进入了角色选择画面,表示已经成功收到原始的文本了然后这里要做的就是,将原始文本进行修改,然后将其返回给游戏客户端就行了

1
2
3
4
5
6
7
8
9
10
Connection from /10.211.55.5
reading data from client
sent 41 data to server
received(first buffer 【0】) =��K+�O_˦����̌萳ۯZDϚYI�m�; ��>*��<��^����SP)�[��[S�^dK��q/�)@���M������֓��b����OU����� !W�7?�i��� data from server
reading data from client
sent 75 data to server
received(first buffer 【1】)  ���cM8��DŽ�n�lD� data from server
reading data from client
sent 5 data to server
received(first buffer 【16】) �Blood Ring169.150.222.245:8090E� ?Emerald Dream169.150.222.71:8090��?Hogger169.150.222.69:8090��R?Ravenshire169.150.222.223:8090Z+?Stormstout169.150.222.244:8090�'�> data from server

研究编码方式

输出的第一个buffer,就是指令码,我看到这个github wireshark - packet-wow.c ,里面这个内容

1
2
3
4
5
6
7
8
9
10
typedef enum {
AUTH_LOGON_CHALLENGE = 0x00,
AUTH_LOGON_PROOF = 0x01,
REALM_LIST = 0x10,
XFER_INITIATE = 0x30,
XFER_DATA = 0x31,
XFER_ACCEPT = 0x32,
XFER_RESUME = 0x33,
XFER_CANCEL = 0x34
} auth_cmd_e;

其中REALM_LIST是0x10,而0x10对应的int就是16,和上面第一个buffer的值对应上了,然后,根据c语言代码的373-421行,知道了REALM_LIST返回的包的结构如下(从上到下从左到右平铺,为了方便观看我用了4行来显示,其实只有单一一行)

Command 包大小 服务器数量 3
PVE 标志 0 霍格 127.0.0.1:8090 热度 高 角色 1 类别 1 ID 0
PVP 标志 0 血环 127.0.0.2:8090 热度 高 角色0 类别 1 ID 0
PVE 标志 0 翡翠梦境 127.0.0.3:8090 热度 中 角色 0 类别 1 ID 0
剩余数据(啥数据我不知道,也不管。。)

效果验证

代码写完后(代码在最后),启动转发,将连接地址改为我本机。realmlist.wtf如下

1
2
set realmlist 10.211.55.2:16000
set patchlist cnlogon.turtle-wow.org

进入游戏,登录账户,此时控制台打印如下信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Listening on port 16000...
Connection from /10.211.55.5
sent 41 data to server
Receive command is 0, forwarded
sent 75 data to server
Receive command is 1, forwarded
sent 5 data to server
Receive command is Realm list, replacement and forward
Replace server Blood Ring host from 169.150.222.245:8090 to 国内优化_服务器_IP地址:13001
Replace server Emerald Dream host from 169.150.222.71:8090 to 国内优化_服务器_IP地址:13002
Replace server Hogger host from 169.150.222.69:8090 to 国内优化_服务器_IP地址:13003
Replace server Ravenshire host from 169.150.222.223:8090 to 国内优化_服务器_IP地址:13004
Replace server Stormstout host from 169.150.222.244:8090 to 国内优化_服务器_IP地址:13005
Connection from /10.211.55.5 closed // 魔兽世界点击进入游戏后,会和认证服务器断开

看看游戏效果

delay_time

然后看看没有加速过的延迟,对比还是超级明显的

origin_delay

我将其部署到我的阿里云上,然后将登陆地址改为我阿里云地址。ok,可以给朋友分享了,低延迟,不用加速器,完美。

1
2
set realmlist my_aliyun_ip_address:16000
set patchlist cnlogon.turtle-wow.org

关于服务器上如何配置

关于程序

因为程序用java写的,而且里面没有涉及到任何第三方包,所有使用的包都是java原生的,所以非常简单,我复制到我的阿里云上,运行下面命令就好了。(一般服务器都会有java包的,如果没有,用 apt install java 就行)

1
2
javac SocketForwarder.java
java SocketForwarder

学java的应该知道,java需要编译成class,运行 javac SocketForwarder.java 会生成 'SocketForwarder$ClientHandler.class' SocketForwarder.class ,因为我使用了内部类,所以会生成2个,不用动,直接 java SocketForwarder 就行了。

后台运行

每次启动程序都比较麻烦,那么就让他自己开机启动,这样就随时都可以玩了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Unit]
Description=World of Warcraft proxy Service
After=syslog.target

[Service]
Environment=TZ='Asia/Shanghai'
#User=www-data
#Group=www-data
Type=simple
# 你的SocketForwarder.java编译后的文件目录
WorkingDirectory=/home/WowForward
# 因为我代码中,获取了加了true,才转发,否则不转发,所以后面有一个true
# 代码在 boolean needReplace = args.length > 0 && "true".equals(args[0]) 这里
ExecStart=/usr/bin/java SocketForwarder true
Restart=always

[Install]
WantedBy=multi-user.target

关于稳定性

其实这一套我其实已经用了快1个星期了,几乎每天都在玩,基本没有奔溃过。

关于服务器转发设置

这里服务器配置就一笔带过,其实就是普通的端口转发,游戏会去请求你服务器的IP:端口,而你要做的就是将对应端口的流量转发到Turtle WOW真实的端口上,千万别转发错了,IP看清楚,实现流量转发,你可以使用Socatnginx stream,以及iptables都随你开心,去搜下服务器端口转发,资料一大把,我是使用iptables转发的,仅供参考。(魔兽世界基于TCP进行传输)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash
iptables -t nat -F
# 别忘了去/etc/sysctl.conf中增加 net.ipv4.ip_forward = 1

# 血环
iptables -t nat -A PREROUTING -p tcp --dport 13001 -j DNAT --to-destination 169.150.222.245:8090
iptables -t nat -A POSTROUTING -p tcp -d 169.150.222.245 --dport 8090 -j SNAT --to-source 192.168.1.17

# 翡翠梦境
iptables -t nat -A PREROUTING -p tcp --dport 13002 -j DNAT --to-destination 169.150.222.71:8090
iptables -t nat -A POSTROUTING -p tcp -d 169.150.222.71 --dport 8090 -j SNAT --to-source 192.168.1.17

# 霍格
iptables -t nat -A PREROUTING -p tcp --dport 13003 -j DNAT --to-destination 169.150.222.69:8090
iptables -t nat -A POSTROUTING -p tcp -d 169.150.222.69 --dport 8090 -j SNAT --to-source 192.168.1.17

# 拉文郡
iptables -t nat -A PREROUTING -p tcp --dport 13004 -j DNAT --to-destination 169.150.222.223:8090
iptables -t nat -A POSTROUTING -p tcp -d 169.150.222.223 --dport 8090 -j SNAT --to-source 192.168.1.17

# 风暴烈酒
iptables -t nat -A PREROUTING -p tcp --dport 13005 -j DNAT --to-destination 169.150.222.244:8090
iptables -t nat -A POSTROUTING -p tcp -d 169.150.222.244 --dport 8090 -j SNAT --to-source 192.168.1.17

最终代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
// package com.ruterfu;
import java.io.*;
import java.net.*;
import java.nio.ByteBuffer;
import java.util.*;

public class SocketForwarder {
// 这个就是 cnlogon.turtle-wow.org 对应的IP地址,其实你可以也把登录进行代理掉,因为登录慢点就慢点,就没有做转发,反正游戏实际连接的是代理后的服务器。
private static final String LOGIN_SERVER = "169.150.222.73";
// 这个是登录端口
private static final int LOGON_PORT = 3724;
// 代理监听端口
private static final int LISTEN_PORT = 16000;

// 这个是我们自己的服务器地址,也就是我们返回给游戏客户端的服务器地址,魔兽世界会去连接这个IP地址
private static final String SERVER = "这里_改成_国内优化_服务器_IP地址";
// 映射关系
public static final String[] REPLACE_HOST = {
// Blood Ring
"169.150.222.245:8090 -> " + SERVER + ":13001",
// Emerald Dream
"169.150.222.71:8090 -> " + SERVER + ":13002",
// Hogger
"169.150.222.69:8090 -> " + SERVER + ":13003",
// Ravenshire
"169.150.222.223:8090 -> " + SERVER + ":13004",
// Stormstout
"169.150.222.244:8090 -> " + SERVER + ":13005"
};


public static void main(String[] args) {
try {
boolean needReplace = args.length > 0 && "true".equals(args[0]);
ServerSocket serverSocket = new ServerSocket(LISTEN_PORT);
System.out.println("Listening on port " + LISTEN_PORT + "...");
while (true) {
Socket clientSocket = serverSocket.accept();
clientSocket.setSoTimeout(60000);
System.out.println("Connection from " + clientSocket.getInetAddress());
Thread clientThread = new Thread(new ClientHandler(clientSocket, needReplace));
clientThread.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}

private record ClientHandler(Socket clientSocket, boolean needReplace) implements Runnable {

@Override
public void run() {
try(Socket serverSocket = new Socket(LOGIN_SERVER, LOGON_PORT);
InputStream clientInput = clientSocket.getInputStream();
OutputStream clientOutput = clientSocket.getOutputStream();
InputStream serverInput = serverSocket.getInputStream();
OutputStream serverOutput = serverSocket.getOutputStream()) {

byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = clientInput.read(buffer)) != -1) {
serverOutput.write(buffer, 0, bytesRead);
serverOutput.flush();
System.out.println("sent " + bytesRead + " data to server");
bytesRead = serverInput.read(buffer);
// 如果buffer[0] == 16,表示开启了realm list,需要替换
if(needReplace && buffer[0] == 16) {
System.out.println("Receive command is Realm list, replacement and forward");
byte[] replaceBuffer = replaceRealmHost(buffer, bytesRead);
clientOutput.write(replaceBuffer, 0, replaceBuffer.length);
} else {
// 否则,就可能是其他数据,例如登录认证啊,啥的,直接转发原始数据
System.out.println("Receive command is " + buffer[0] + ", forwarded");
clientOutput.write(buffer, 0, bytesRead);
}
clientOutput.flush();
}
System.out.println("Connection from " + clientSocket.getInetAddress() + " closed");
} catch (IOException e) {
e.printStackTrace();
}
}
}

private static byte[] replaceRealmHost(byte[] buffer, int read) {
try {
Map<String, String> replaceMap = new HashMap<>();
for (String s : SocketForwarder.REPLACE_HOST) {
int index = s.indexOf("->");
String left = s.substring(0, index).trim();
String right = s.substring(index + 2).trim();
replaceMap.put(left, right);
}
ByteBuffer byteBuffer = ByteBuffer.wrap(buffer, 0, read);
// len 1 command 16 表示获取服务器地址
byteBuffer.get();
// len2, 3 表示数据包长度,后面再替换
byteBuffer.get();
byteBuffer.get();

// data 4,5,6,7 空数据,跳过
byteBuffer.getInt();

// 紧接着第8个数据,就是服务器数量
byte realmCount = byteBuffer.get();
// 遍历上面的值
List<Object[]> realms = new LinkedList<>();
for (int i = 0; i < realmCount; i++) {
// 第一个是单个服务器的类型,是PVP服务器(1)还是normal服务器0
int type = byteBuffer.getInt();
// 第二个是服务器的标志,好像都是0
byte flags = byteBuffer.get();
// 服务器名字,一直读取到buffer == 0为止
byte[] realmName = readWhenReachZERO(byteBuffer);
// 服务器IP地址,一直读取到buffer == 0为止
byte[] server = readWhenReachZERO(byteBuffer);
// 热度,也就是我们看到的负载
int population= byteBuffer.getInt();
// 角色数
byte characterCount = byteBuffer.get();
// 类别,好像都是1
byte category = byteBuffer.get();
// 服务器ID,和我们没关系
byte realmId = byteBuffer.get();
realms.add(new Object[]{type, flags, realmName, server, population, characterCount, category, realmId});
}
// 读取剩余的数据,原样返回即可
byte[] remaining = new byte[byteBuffer.remaining()];
byteBuffer.get(remaining);
// 构建一个1024长度的buffer,因为 buffer[0]和buffer[1,2]要随着数据变更而变化,所以后面添加
ByteBuffer realmBuffer = ByteBuffer.allocate(1024);
// 添加4个空白字符
realmBuffer.put((byte) 0);
realmBuffer.put((byte) 0);
realmBuffer.put((byte) 0);
realmBuffer.put((byte) 0);
// 写入服务器数量
realmBuffer.put(realmCount);
// 遍历新的服务器
for (Object[] realm : realms) {
// 写入服务器类型
realmBuffer.putInt((int) realm[0]);
// 写入服务器标志flag
realmBuffer.put((byte) realm[1]);
// 写入服务器名字(这里你其实可以改服务器名)
String serverName = new String((byte[]) realm[2]);
realmBuffer.put(serverName.getBytes());
// 最后写一个 0,表示服务器名字结束
realmBuffer.put((byte) 0);
// 写入服务器新地址
String originServerHost = new String((byte[]) realm[3]);
String replaceServerHost = replaceMap.getOrDefault(originServerHost, originServerHost);
byte[] replaceServerHostBytes = replaceServerHost.getBytes();
System.out.println("Replace server " + serverName + " host from " + originServerHost + " to " + new String(replaceServerHostBytes));
realmBuffer.put(replaceServerHostBytes);
// 写一个0,表示地址结束
realmBuffer.put((byte) 0);

// 原封不动写回热度
realmBuffer.putInt((int) realm[4]);
// 原封不动写回角色数量
realmBuffer.put((byte) realm[5]);
// 原封不懂写回看不懂的类型
realmBuffer.put((byte) realm[6]);
// 原封不懂写回服务器ID
realmBuffer.put((byte) realm[7]);
}
// 最后原封不动将末尾数据写进去
realmBuffer.put(remaining);
// 获得实际的字节
byte[] realmBytes = new byte[realmBuffer.position()];
realmBuffer.flip();
realmBuffer.get(realmBytes);

// 数据长度是上面字节 + 3,这3个就是前面的command,数据包长度
byte[] dataByte = new byte[realmBytes.length + 3];
// 写回第一个数据,command = Realm List
dataByte[0] = 16;
// 计算最新的数据长度
byte[] len = intToBytes(realmBytes.length);
dataByte[1] = len[0];
dataByte[2] = len[1];
// 将新的数据写回去
System.arraycopy(realmBytes, 0, dataByte, 3, realmBytes.length);
return dataByte;
} catch (Exception e) {
e.printStackTrace();
}
return buffer;
}
public static byte[] readWhenReachZERO(ByteBuffer buffer) {
try(ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte b;
while((b = buffer.get()) != 0) {
baos.write(b);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
return null;
}
}

private static byte[] intToBytes(int number) {
byte[] bytes = new byte[2];
for (int i = 0; i < 2; i++) {
bytes[i] = (byte) (number >> (i * 8));
}
return bytes;
}

}