mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
When a player connects with a Factorio version and set of built in mods no lobby runs, the lobby plugin now creates one on a connected host that has the version installed or can download it, with a mod pack matching the player's built in mods. This lets a cluster accept players on several Factorio versions on the same port without any setup. It can be turned off with lobby.auto_create. To pick the mod pack the proxy now waits for the whole connection confirm before routing and passes the mods listed in it to the playerProxyRoute hook. Hosts answer the new HostFactorioVersionsRequest with the versions they have installed. The server list in lobbies only shows instances on the Factorio version of the lobby, with a checkbox to also show the others greyed out.
351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
/**
|
|
* Encoding and decoding of the parts of the Factorio network protocol the
|
|
* player proxy needs to look at: the message header and the connection
|
|
* handshake messages.
|
|
*
|
|
* Based on the Factorio 2.x wire format. Every multi-byte value is little
|
|
* endian, "space optimized" values are a single byte unless that byte is 0xff,
|
|
* in which case the full width value follows it.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
export const MessageType = {
|
|
Ping: 0,
|
|
PingReply: 1,
|
|
ConnectionRequest: 2,
|
|
ConnectionRequestReply: 3,
|
|
ConnectionRequestReplyConfirm: 4,
|
|
ConnectionAcceptOrDeny: 5,
|
|
} as const;
|
|
|
|
const TYPE_MASK = 0x1f;
|
|
const FRAGMENTED_FLAG = 0x40;
|
|
const LAST_FRAGMENT_FLAG = 0x80;
|
|
const CONFIRM_FLAG = 0x8000;
|
|
const MESSAGE_ID_MASK = 0x7fff;
|
|
|
|
/** Message types that carry a message id even when not fragmented */
|
|
const RELIABLE_TYPES: ReadonlySet<number> = new Set([
|
|
MessageType.ConnectionRequest,
|
|
MessageType.ConnectionRequestReplyConfirm,
|
|
]);
|
|
|
|
/**
|
|
* Max packet size Factorio servers tell clients to use, this is the largest
|
|
* datagram every host on the Internet is required to be able to receive.
|
|
*/
|
|
export const DEFAULT_MAX_PACKET_SIZE = 508;
|
|
|
|
export interface ApplicationVersion {
|
|
major: number;
|
|
minor: number;
|
|
sub: number;
|
|
build: number;
|
|
}
|
|
|
|
export function formatVersion(version: ApplicationVersion) {
|
|
return `${version.major}.${version.minor}.${version.sub}`;
|
|
}
|
|
|
|
export function versionsEqual(a: ApplicationVersion, b: ApplicationVersion) {
|
|
return a.major === b.major && a.minor === b.minor && a.sub === b.sub && a.build === b.build;
|
|
}
|
|
|
|
export interface MessageHeader {
|
|
type: number;
|
|
fragmented: boolean;
|
|
lastFragment: boolean;
|
|
messageId?: number;
|
|
fragmentNumber?: number;
|
|
confirms: number[];
|
|
/** Offset of the payload of the message */
|
|
payloadOffset: number;
|
|
}
|
|
|
|
export interface ConnectionRequest {
|
|
header: MessageHeader;
|
|
version: ApplicationVersion;
|
|
clientRequestId: number;
|
|
}
|
|
|
|
export interface ConnectionRequestReply {
|
|
version: ApplicationVersion;
|
|
clientRequestId: number;
|
|
serverRequestId: number;
|
|
maxPacketSize: number;
|
|
}
|
|
|
|
export interface ConnectionRequestReplyConfirm {
|
|
clientRequestId: number;
|
|
serverRequestId: number;
|
|
/** Offset of serverRequestId in the packet */
|
|
serverRequestIdOffset: number;
|
|
username: string;
|
|
}
|
|
|
|
export class BufferReader {
|
|
constructor(
|
|
private _buffer: Buffer,
|
|
public offset = 0,
|
|
) { }
|
|
|
|
private _take(length: number) {
|
|
const start = this.offset;
|
|
if (start + length > this._buffer.length) {
|
|
throw new Error("Unexpected end of message");
|
|
}
|
|
this.offset += length;
|
|
return start;
|
|
}
|
|
|
|
readUInt8() {
|
|
return this._buffer.readUInt8(this._take(1));
|
|
}
|
|
|
|
readUInt16LE() {
|
|
return this._buffer.readUInt16LE(this._take(2));
|
|
}
|
|
|
|
readUInt32LE() {
|
|
return this._buffer.readUInt32LE(this._take(4));
|
|
}
|
|
|
|
/** Read a value stored as one byte or 0xff followed by a uint16 */
|
|
readSpaceOptimizedUInt16() {
|
|
const value = this.readUInt8();
|
|
return value === 0xff ? this.readUInt16LE() : value;
|
|
}
|
|
|
|
/** Read a value stored as one byte or 0xff followed by a uint32 */
|
|
readSpaceOptimizedUInt32() {
|
|
const value = this.readUInt8();
|
|
return value === 0xff ? this.readUInt32LE() : value;
|
|
}
|
|
|
|
readString() {
|
|
const length = this.readSpaceOptimizedUInt32();
|
|
const start = this._take(length);
|
|
return this._buffer.toString("utf8", start, start + length);
|
|
}
|
|
|
|
readVersion(): ApplicationVersion {
|
|
return {
|
|
major: this.readSpaceOptimizedUInt16(),
|
|
minor: this.readSpaceOptimizedUInt16(),
|
|
sub: this.readSpaceOptimizedUInt16(),
|
|
build: this.readUInt32LE(),
|
|
};
|
|
}
|
|
}
|
|
|
|
export class BufferWriter {
|
|
private _parts: number[] = [];
|
|
|
|
writeUInt8(value: number) {
|
|
this._parts.push(value & 0xff);
|
|
}
|
|
|
|
writeUInt16LE(value: number) {
|
|
this.writeUInt8(value);
|
|
this.writeUInt8(value >>> 8);
|
|
}
|
|
|
|
writeUInt32LE(value: number) {
|
|
this.writeUInt16LE(value);
|
|
this.writeUInt16LE(value >>> 16);
|
|
}
|
|
|
|
writeSpaceOptimizedUInt16(value: number) {
|
|
if (value >= 0xff) {
|
|
this.writeUInt8(0xff);
|
|
this.writeUInt16LE(value);
|
|
} else {
|
|
this.writeUInt8(value);
|
|
}
|
|
}
|
|
|
|
writeVersion(version: ApplicationVersion) {
|
|
this.writeSpaceOptimizedUInt16(version.major);
|
|
this.writeSpaceOptimizedUInt16(version.minor);
|
|
this.writeSpaceOptimizedUInt16(version.sub);
|
|
this.writeUInt32LE(version.build);
|
|
}
|
|
|
|
toBuffer() {
|
|
return Buffer.from(this._parts);
|
|
}
|
|
}
|
|
|
|
export function messageType(buffer: Buffer) {
|
|
if (!buffer.length) {
|
|
return undefined;
|
|
}
|
|
return buffer[0] & TYPE_MASK;
|
|
}
|
|
|
|
export function parseHeader(buffer: Buffer): MessageHeader {
|
|
const reader = new BufferReader(buffer);
|
|
const typeAndFlags = reader.readUInt8();
|
|
const type = typeAndFlags & TYPE_MASK;
|
|
const fragmented = (typeAndFlags & FRAGMENTED_FLAG) !== 0;
|
|
const lastFragment = (typeAndFlags & LAST_FRAGMENT_FLAG) !== 0;
|
|
|
|
let messageId: number | undefined;
|
|
let hasConfirm = false;
|
|
if (RELIABLE_TYPES.has(type) || fragmented) {
|
|
const messageIdAndFlags = reader.readUInt16LE();
|
|
messageId = messageIdAndFlags & MESSAGE_ID_MASK;
|
|
hasConfirm = (messageIdAndFlags & CONFIRM_FLAG) !== 0;
|
|
}
|
|
|
|
let fragmentNumber: number | undefined;
|
|
if (fragmented) {
|
|
fragmentNumber = reader.readSpaceOptimizedUInt16();
|
|
}
|
|
|
|
const confirms: number[] = [];
|
|
if (hasConfirm) {
|
|
const count = reader.readUInt8();
|
|
for (let i = 0; i < count; i++) {
|
|
confirms.push(reader.readUInt32LE());
|
|
}
|
|
}
|
|
|
|
return { type, fragmented, lastFragment, messageId, fragmentNumber, confirms, payloadOffset: reader.offset };
|
|
}
|
|
|
|
export function parseConnectionRequest(buffer: Buffer): ConnectionRequest {
|
|
const header = parseHeader(buffer);
|
|
if (header.type !== MessageType.ConnectionRequest) {
|
|
throw new Error(`Expected ConnectionRequest, got message type ${header.type}`);
|
|
}
|
|
const reader = new BufferReader(buffer, header.payloadOffset);
|
|
const version = reader.readVersion();
|
|
const clientRequestId = reader.readUInt32LE();
|
|
return { header, version, clientRequestId };
|
|
}
|
|
|
|
export function parseConnectionRequestReply(buffer: Buffer): ConnectionRequestReply {
|
|
const header = parseHeader(buffer);
|
|
if (header.type !== MessageType.ConnectionRequestReply) {
|
|
throw new Error(`Expected ConnectionRequestReply, got message type ${header.type}`);
|
|
}
|
|
const reader = new BufferReader(buffer, header.payloadOffset);
|
|
return {
|
|
version: reader.readVersion(),
|
|
clientRequestId: reader.readUInt32LE(),
|
|
serverRequestId: reader.readUInt32LE(),
|
|
maxPacketSize: reader.readUInt16LE(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse the start of a ConnectionRequestReplyConfirm
|
|
*
|
|
* The message contains the mod list and startup settings of the client and
|
|
* is usually fragmented, but the fields read here are always in the first
|
|
* fragment.
|
|
*
|
|
* @param buffer - Unfragmented message or first fragment of it.
|
|
* @returns the parsed fields.
|
|
*/
|
|
export function parseConnectionRequestReplyConfirm(buffer: Buffer): ConnectionRequestReplyConfirm {
|
|
const header = parseHeader(buffer);
|
|
if (header.type !== MessageType.ConnectionRequestReplyConfirm) {
|
|
throw new Error(`Expected ConnectionRequestReplyConfirm, got message type ${header.type}`);
|
|
}
|
|
if (header.fragmented && header.fragmentNumber !== 0) {
|
|
throw new Error(`Expected first fragment, got fragment ${header.fragmentNumber}`);
|
|
}
|
|
const reader = new BufferReader(buffer, header.payloadOffset);
|
|
const clientRequestId = reader.readUInt32LE();
|
|
const serverRequestIdOffset = reader.offset;
|
|
const serverRequestId = reader.readUInt32LE();
|
|
reader.readUInt32LE(); // instanceID
|
|
const username = reader.readString();
|
|
return { clientRequestId, serverRequestId, serverRequestIdOffset, username };
|
|
}
|
|
|
|
/** Mod enabled in a Factorio client */
|
|
export interface ModId {
|
|
name: string;
|
|
version: string;
|
|
}
|
|
|
|
/**
|
|
* Join the payloads of the fragments of a message
|
|
*
|
|
* @param fragments -
|
|
* Fragments of the message in order of fragment number, or the message
|
|
* if it's not fragmented.
|
|
* @returns the payload of the message.
|
|
*/
|
|
export function joinPayload(fragments: Buffer[]) {
|
|
return Buffer.concat(fragments.map(fragment => fragment.subarray(parseHeader(fragment).payloadOffset)));
|
|
}
|
|
|
|
/**
|
|
* Read the mods enabled in the client from a ConnectionRequestReplyConfirm
|
|
*
|
|
* @param payload - Complete payload of the message, see {@link joinPayload}.
|
|
* @returns name and version of each mod.
|
|
*/
|
|
export function parseConfirmMods(payload: Buffer): ModId[] {
|
|
const reader = new BufferReader(payload);
|
|
reader.readUInt32LE(); // connectionRequestIDGeneratedOnClient
|
|
reader.readUInt32LE(); // connectionRequestIDGeneratedOnServer
|
|
reader.readUInt32LE(); // instanceID
|
|
reader.readString(); // username
|
|
reader.readString(); // password
|
|
reader.readString(); // serverKey
|
|
reader.readString(); // serverKeyTimestamp
|
|
reader.readUInt32LE(); // coreChecksum
|
|
reader.readUInt32LE(); // prototypeListChecksum
|
|
const count = reader.readSpaceOptimizedUInt32();
|
|
const mods: ModId[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const name = reader.readString();
|
|
const version = [
|
|
reader.readSpaceOptimizedUInt16(), reader.readSpaceOptimizedUInt16(), reader.readSpaceOptimizedUInt16(),
|
|
].join(".");
|
|
reader.readUInt32LE(); // crc
|
|
mods.push({ name, version });
|
|
}
|
|
return mods;
|
|
}
|
|
|
|
export function encodeConnectionRequest(version: ApplicationVersion, clientRequestId: number, messageId = 0) {
|
|
const writer = new BufferWriter();
|
|
writer.writeUInt8(MessageType.ConnectionRequest);
|
|
writer.writeUInt16LE(messageId & MESSAGE_ID_MASK);
|
|
writer.writeVersion(version);
|
|
writer.writeUInt32LE(clientRequestId);
|
|
return writer.toBuffer();
|
|
}
|
|
|
|
export function encodeConnectionRequestReply(reply: ConnectionRequestReply) {
|
|
const writer = new BufferWriter();
|
|
writer.writeUInt8(MessageType.ConnectionRequestReply);
|
|
writer.writeVersion(reply.version);
|
|
writer.writeUInt32LE(reply.clientRequestId);
|
|
writer.writeUInt32LE(reply.serverRequestId);
|
|
writer.writeUInt16LE(reply.maxPacketSize);
|
|
return writer.toBuffer();
|
|
}
|
|
|
|
/**
|
|
* Turn a Ping into the PingReply a server would send back
|
|
*
|
|
* @param ping - Ping message received.
|
|
* @returns PingReply message, or undefined if ping was malformed.
|
|
*/
|
|
export function pingReplyFor(ping: Buffer) {
|
|
if (ping.length < 3) {
|
|
return undefined;
|
|
}
|
|
const reply = Buffer.from(ping.subarray(0, 3));
|
|
reply[0] = MessageType.PingReply;
|
|
return reply;
|
|
}
|