1
0
mirror of synced 2025-02-17 16:18:33 +01:00

Initial (dev)

This commit is contained in:
Fang Lu 2022-02-21 16:31:55 -08:00
commit d0d8c5f08e
11 changed files with 496 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
*.ini

26
README Normal file
View File

@ -0,0 +1,26 @@
CHUNICHUNIMatch
===============
Remote LAN multiplayer matching by virtualizing members into the local LAN.
*No VPN required*
Instructions
------------
***Project is currently in experimentation***
* 配置IP别名。提权cmd运行以下指令在主网卡上新增192.168.139.11-14如果必要替换Ethernet为网卡名
```
netsh interface ipv4 set interface interface="Ethernet" dhcpstaticipcoexistence=enabled
netsh interface ipv4 add address "Ethernet" 192.168.139.11 255.255.255.0 store=active
netsh interface ipv4 add address "Ethernet" 192.168.139.12 255.255.255.0 store=active
netsh interface ipv4 add address "Ethernet" 192.168.139.13 255.255.255.0 store=active
netsh interface ipv4 add address "Ethernet" 192.168.139.14 255.255.255.0 store=active
```
* 确保使用的segatools更新到至少forcebind
* 修改segatools.ini添加下面的新section以使用CCM在连接时自动生成的INI
```
[override]
netenv="D:\path\to\ccm\segatools-override.ini"
```

78
ccencap.js Normal file
View File

@ -0,0 +1,78 @@
var CCEncap = {};
const CCM_NET_SENDTCP = 0x01;
const CCM_NET_SENDUDP = 0x02;
const CCM_PARTY_CREATE = 0x30;
const CCM_PARTY_HELLO = 0x31;
const CCM_PARTY_UPDATE = 0x32;
CCEncap.encodePartyCreate = function(req) {
var msg = Buffer.from(JSON.stringify(req));
msg[0] = CCM_PARTY_CREATE;
return msg;
}
CCEncap.encodePartyHello = function(req) {
var msg = Buffer.from(JSON.stringify(req));
msg[0] = CCM_PARTY_HELLO;
return msg;
}
CCEncap.encodePartyUpdate = function(req) {
var msg = Buffer.from(JSON.stringify(req));
msg[0] = CCM_PARTY_UPDATE;
return msg;
}
CCEncap.encodePacket = function(src, dst, sport, dport, type, data) {
if (type == 'tcp') {
var header = CCM_NET_SENDTCP;
} else if (type == 'udp') {
var header = CCM_NET_SENDUDP;
} else {
throw new Error('Invalid network protocol: ' + type);
}
var msg = Buffer.alloc(data.length + 7);
msg[0] = header;
msg[1] = dst;
msg.writeUInt16LE(sport, 2);
msg.writeUInt16LE(dport, 4);
msg[6] = src;
data.copy(msg, 7);
console.log(type + "_send "+src+':'+sport+'->'+dst+':'+dport);
return msg;
}
CCEncap.decode = function(msg) {
var opcode = msg[0], data = msg;
var ret = {op: '?'};
switch(opcode) {
case CCM_PARTY_CREATE: ret.op = 'party_create'; break;
case CCM_PARTY_HELLO: ret.op = 'party_hello'; break;
case CCM_PARTY_UPDATE: ret.op = 'party_update'; break;
case CCM_NET_SENDTCP: ret.op = 'net_tcp'; break;
case CCM_NET_SENDUDP: ret.op = 'net_udp'; break;
}
switch(opcode) {
case CCM_PARTY_CREATE:
case CCM_PARTY_HELLO:
case CCM_PARTY_UPDATE:
msg[0] = 0x7b; // '{'
Object.assign(ret, JSON.parse(msg));
break;
case CCM_NET_SENDTCP:
case CCM_NET_SENDUDP:
ret.dst = msg[1];
ret.src = msg[6];
ret.sport = msg.readUInt16LE(2);
ret.dport = msg.readUInt16LE(4);
// ret.msg = msg.slice(7);
console.log(ret.op+" "+ret.src+':'+ret.sport+'->'+ret.dst+':'+ret.dport);
break;
default:
console.warn('Received unknown opcode: ' + opcode.toString(16));
}
return ret;
}
module.exports = CCEncap;

72
cchub.js Normal file
View File

@ -0,0 +1,72 @@
const ws = require('ws');
const enc = require('./ccencap');
// Note: src and dst addresses in this file are numerical suffixes only.
function CCHub(url) {
var conn = new ws.WebSocket(url);
this.ready = new Promise((resolve) => {
conn.on('open', () => {
this.conn = conn;
resolve();
});
});
this.conn = this.ready;
this.onNetForward = function(src, dst, sport, dport, type, msg) {};
this.onPartyJoin = function(subnet, suffix) {};
this.onPartyUpdate = function(members) {};
conn.on('message', (data) => {
msg = enc.decode(data);
switch (msg.op) {
case 'party_update':
this._updateParty(msg);
break;
case 'net_tcp':
case 'net_udp':
var type = msg.op.slice(4);
var payload = data.slice(7);
this.onNetForward(msg.src, msg.dst, msg.sport, msg.dport, type, payload);
break;
default:
console.warn('Unhandled packed: ', msg)
}
});
this.subnet = null;
this.mysuffix = 0;
this.members = [];
}
CCHub.prototype.send = function(msg) {
if (this.conn instanceof Promise) {
this.conn.then(() => {
this.send(msg);
});
return;
}
this.conn.send(msg);
};
CCHub.prototype.hello = function(req) {
this.send(enc.encodePartyHello(req));
};
CCHub.prototype._updateParty = function(msg) {
if (!this.subnet) {
this.subnet = msg.subnet;
this.mysuffix = msg.suffix;
this.onPartyJoin(this.subnet, this.mysuffix);
}
var diff = msg.members.filter(suffix => this.members.indexOf(suffix) == -1);
this.members = this.members.concat(diff);
this.onPartyUpdate(diff);
};
CCHub.prototype.sendNetForward = function(src, dst, sport, dport, type, data) {
var msg = enc.encodePacket(src, dst, sport, dport, type, data);
this.send(msg);
};
module.exports = CCHub;

120
ccrelay.js Normal file
View File

@ -0,0 +1,120 @@
const net = require('net');
const dgram = require('dgram');
// Note: src and dst addresses in this file are full IP strings only.
function CCRelay(host) {
console.log("Relay started for " + host);
this.host = host;
this.sockets = {};
this.servers = {};
this.onReceive = function(sport, dport, type, msg) {}
}
CCRelay.prototype.bindUDP = function(port) {
if (this.sockets['udp'+port]) {
console.warn('Attempted to bind more than once on '+this.host+':'+this.port);
return this.sockets['udp'+port];
}
var socket = dgram.createSocket('udp4');
socket.on('error', (e) => {
console.error('udp '+this.host+':'+port+' error', e);
socket.close();
this.sockets['udp'+port] = null;
this.bindUDP(port);
});
socket.on('message', (msg, rinfo) => {
this.onReceive(rinfo.port, port, 'udp', msg);
});
var bindpromise = new Promise((resolve) => {
socket.bind({address: this.host, port: port}, () => {
this.sockets['udp'+port] = socket;
resolve(socket);
});
});
this.sockets['udp'+port] = bindpromise;
return bindpromise;
};
CCRelay.prototype.sendUDP = function(dst, sport, dport, msg) {
var socket = this.sockets['udp'+sport];
if (!socket) {
socket = this.bindUDP(sport);
}
if (socket instanceof Promise) {
return socket.then(() => {
return this.sendUDP(dst, sport, dport, msg);
});
}
return new Promise((resolve) => {
socket.send(msg, dport, dst, resolve);
});
};
CCRelay.prototype._setupTCPSocket = function(socket, sport, dport) {
socket.on('data', (msg) => {
this.onReceive(sport, dport, 'tcp', msg);
});
socket.on('end', () => {
this.sockets['tcp'+sport+'-'+dport] = null;
});
socket.on('close', () => {
this.sockets['tcp'+sport+'-'+dport] = null;
});
socket.on('error', e => {
this.sockets['tcp'+sport+'-'+dport] = null;
});
}
CCRelay.prototype.listenTCP = function(port) {
if (this.servers['tcp'+port]) {
console.warn('Attempted to listen more than once on '+this.host+':'+this.port);
return this.servers['tcp'+port];
}
var server = net.createServer();
server.on('error', (e) => {
console.error('tcpserver '+this.host+':'+port+' error', e);
server.close();
this.servers['tcp'+port] = null;
this.listenTCP(port);
});
server.on('connection', (socket) => {
this._setupTCPSocket(socket, socket.remotePort, port);
console.log("Adding passive TCP socket ", 'tcp'+socket.remotePort+'-'+port)
this.sockets['tcp'+socket.remotePort+'-'+port] = socket;
});
var listenpromise = new Promise((resolve) => {
server.listen({host: this.host, port: port}, () => {
this.servers['tcp'+port] = server;
resolve(server);
});
});
this.servers['tcp'+port] = listenpromise;
return listenpromise;
};
CCRelay.prototype.sendTCP = function(dst, sport, dport, msg) {
var socket = this.sockets['tcp'+sport+'-'+dport];
if (!socket) {
socket = new Promise((resolve) => {
console.log("Adding active TCP socket", 'tcp'+sport+'-'+dport);
var newsocket = new net.Socket();
this._setupTCPSocket(newsocket, sport, dport);
newsocket.connect(dport, dst, () => {
this.sockets['tcp'+sport+'-'+dport] = newsocket;
resolve(newsocket);
});
});
this.sockets['tcp'+sport+'-'+dport] = socket;
}
if (socket instanceof Promise) {
return socket.then(() => {
return this.sendTCP(dst, sport, dport, msg);
});
}
return new Promise((resolve) => {
socket.write(msg, null, resolve);
});
};
module.exports = CCRelay;

6
config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
"server": "gcp.naominet.com",
"server_port": 61820,
"login": {},
"write_override_ini": "segatools-override.ini"
};

60
index.js Normal file
View File

@ -0,0 +1,60 @@
const cfg = require('./config');
const fs = require('fs');
const CCRelay = require('./ccrelay');
const CCHub = require('./cchub');
var hub = new CCHub('ws://' + cfg.server + ':' + cfg.server_port);
var relays = {};
var suffix = 0;
var subnet = "";
hub.ready.then(() => {
console.log('Connecting to CCM Hub ' + cfg.server + '...');
hub.hello(cfg.login || {});
});
hub.onPartyJoin = function(mysubnet, mysuffix) {
suffix = mysuffix;
subnet = mysubnet;
console.log('Join successful. Game IP should be ' + subnet+suffix);
console.log('Please wait until all match members have joined before starting game');
if (cfg.write_override_ini) {
var override =
'[netenv]\n' +
'enable=1\n' +
'forcebind=1\n' +
'addrSuffix=' + suffix + '\n' +
'[keychip]\n' +
'subnet=' + subnet + '0\n';
fs.writeFileSync(cfg.write_override_ini, override);
}
}
hub.onPartyUpdate = function(members) {
members.forEach(dst => {
console.log(subnet+dst + ' joined virtual party.');
var relay = new CCRelay(subnet+dst);
relay.bindUDP(50200);
relay.listenTCP(50200);
relay.onReceive = function(sport, dport, type, msg) {
hub.sendNetForward(suffix, dst, sport, dport, type, msg);
}
relays[dst] = relay;
});
}
hub.onNetForward = function(src, dst, sport, dport, type, msg) {
var relay = relays[src];
if (!relay) {
console.warn('Received fwd from unknown host ' + src);
return;
}
if (type == 'tcp') {
relay.sendTCP(subnet+dst, sport, dport, msg)
} else if (type == 'udp') {
relay.sendUDP(subnet+dst, sport, dport, msg);
}
};

7
netif.bat Normal file
View File

@ -0,0 +1,7 @@
set NETIF="Ethernet"
netsh interface ipv4 set interface interface=%NETIF% dhcpstaticipcoexistence=enabled
netsh interface ipv4 add address %NETIF% 192.168.139.11 255.255.255.0 store=active
netsh interface ipv4 add address %NETIF% 192.168.139.12 255.255.255.0 store=active
netsh interface ipv4 add address %NETIF% 192.168.139.13 255.255.255.0 store=active
netsh interface ipv4 add address %NETIF% 192.168.139.14 255.255.255.0 store=active
pause

40
package-lock.json generated Normal file
View File

@ -0,0 +1,40 @@
{
"name": "chunichunimatch",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"dependencies": {
"ws": "^8.5.0"
}
},
"node_modules/ws": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz",
"integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
},
"dependencies": {
"ws": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz",
"integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==",
"requires": {}
}
}
}

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"ws": "^8.5.0"
}
}

80
server.js Normal file
View File

@ -0,0 +1,80 @@
const cfg = require('./config');
const ws = require('ws');
const enc = require('./ccencap');
var server = new ws.WebSocketServer({
port: cfg.server_port,
perMessageDeflate: false
});
var subnet = '192.168.139.';
var occupancy = [0, 0, 0, 0];
var suffixes = [11, 12, 13, 14];
var clients = {};
function allocateClient() {
var slot = -1;
for (var i = 0; i < occupancy.length; i++) {
if (!occupancy[i]) {
slot = i;
occupancy[slot] = 1;
break;
}
}
return slot;
}
server.on('connection', function(conn, req) {
var slot = allocateClient();
var realip = req.socket.remoteAddress;
if (slot < 0) {
console.log('Dropping ' + realip + ' due to no party vacancy');
conn.close();
return;
}
var suffix = suffixes[slot];
clients[suffix] = conn;
console.log("[Client #" + slot + "] Allocated. Welcome " + realip);
conn.on('message', function(data) {
var msg = enc.decode(data);
switch(msg.op) {
case 'party_hello':
sendMemberChange();
break;
case 'net_tcp':
case 'net_udp':
var dstconn = clients[msg.dst];
if (!dstconn) {
console.warn('Invalid dst: ' + msg.src + '->' + msg.dst);
} else {
dstconn.send(data);
}
break;
}
});
conn.on('close', function() {
console.log("[Client #" + slot + "] Dropped.");
delete clients[suffix];
occupancy[slot] = 0;
});
conn.on('error', function(e) {
console.log("[Client #" + slot + "] Error ", e);
conn.close();
delete clients[suffix];
occupancy[slot] = 0;
});
});
function sendMemberChange() {
// var members = suffixes.filter((suffix, slot) => occupancy[slot]);
// Pre-allocate all members on the client to prevent late joiner crash
var members = suffixes;
for (var i = 0; i < occupancy.length; i++) {
if (!occupancy[i]) continue;
clients[suffixes[i]].send(enc.encodePartyUpdate({
subnet: subnet,
suffix: suffixes[i],
members: members.filter(suffix => suffix != suffixes[i])
}));
}
}