This commit is contained in:
Ruan Fernandes Guimaraes 2025-06-25 05:41:15 -03:00
commit 052db244e6
22 changed files with 2535 additions and 0 deletions

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
ACCOUNT=graal1234
PASSWORD=RQWUEIBAS
# The following are optional
# Remove the # to enable
# Discord Webhook to send logs from RC to Discord
#RC_LOG_DISCORD_WEBHOOK=

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
package-lock.json
.env

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"tabWidth": 4,
"trailingComma": "all",
"singleQuote": true
}

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "ruan-graal-rc",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "Ruan Fernandes",
"type": "commonjs",
"main": "index.js",
"scripts": {
"ndmon": "nodemon src/index.ts"
},
"devDependencies": {
"@types/node": "^22.13.14",
"typescript": "^5.8.2"
},
"dependencies": {
"axios": "^1.8.4",
"chalk": "^5.4.1",
"compressjs": "^1.0.3",
"dotenv": "^16.4.7"
}
}

203
src/FileBrowser.ts Normal file
View file

@ -0,0 +1,203 @@
import { FolderRights } from './FolderRights';
import { GBufferReader, GBufferWriter } from './GBuffer';
import { PromiseManager } from './PromiseManager';
import { RCOutgoingPacket } from './misc/packet';
import { RemoteControl } from './RemoteControl';
import * as types from './types';
export interface FileBrowser {
cd(folder: string): PromiseLike<types.DirectoryListing>;
get(file: string): PromiseLike<Buffer>;
put(file: string, content: Buffer): void;
}
type FileStructure = {
name: string;
size: number;
modTime: number;
buffer: Buffer[];
};
type FileData = { [name: string]: FileStructure };
export class RCFileBrowser implements FileBrowser {
private folderRights: FolderRights;
private promiseManager: PromiseManager;
private fileData: FileData = {};
private active: string = '';
constructor(private readonly rc: RemoteControl) {
this.folderRights = new FolderRights();
this.promiseManager = new PromiseManager();
}
/////////
setFolderRights(content: string) {
this.folderRights.setFolderRights(content);
}
setFolderFiles(reader: GBufferReader, folderName: string) {
const dirList: types.DirectoryListing = {
directory: folderName,
fileList: this.folderRights.getSubFolders(folderName),
};
while (reader.bytesLeft) {
reader.readUInt8(); // skip space
const reader2 = reader.readGBuffer();
dirList.fileList.push({
name: reader2.readGString(),
type: types.FSEntryType.File,
permissions: reader2.readGString(),
fileSize: reader2.readGULong(),
modTime: reader2.readGULong(),
});
}
this.promiseManager.resolvePromise('dir://' + folderName, dirList);
}
startLargeFile(fileName: string) {
this.fileData[fileName] = {
name: fileName,
size: 0,
modTime: 0,
buffer: [],
};
this.active = fileName;
}
setActiveFileSize(size: number) {
if (this.active in this.fileData) {
this.fileData[this.active].size = size;
}
}
appendFileContent(fileName: string, modTime: number, buffer: Buffer) {
if (fileName in this.fileData) {
this.fileData[fileName].modTime = modTime;
this.fileData[fileName].buffer.push(buffer);
return;
}
this.startLargeFile(fileName);
this.setActiveFileSize(buffer.length);
this.fileData[fileName].modTime = modTime;
this.fileData[fileName].buffer.push(buffer);
this.finishLargeFile(fileName);
}
finishLargeFile(fileName: string) {
if (fileName in this.fileData) {
this.promiseManager.resolvePromise(
fileName,
Buffer.concat(this.fileData[fileName].buffer),
);
delete this.fileData[fileName];
}
if (fileName == this.active) {
this.active = '';
}
}
fileDownloadFailed(fileName: string) {
if (fileName in this.fileData) {
this.promiseManager.rejectPromise(fileName, 'File download failed');
delete this.fileData[fileName];
}
if (fileName == this.active) {
this.active = '';
}
}
////////////////////
cd(folder: string): Promise<types.DirectoryListing> {
if (!folder) {
return new Promise((resolve) => {
resolve({
directory: '/',
fileList: this.folderRights.getSubFolders(),
});
});
}
if (!folder.endsWith('/')) {
folder += '/';
}
this.rc.socket?.sendData(
this.rc.socket?.sendPacket(
RCOutgoingPacket.PLI_RC_FILEBROWSER_CD,
Buffer.from(folder),
),
);
return this.promiseManager.createPromise('dir://' + folder);
}
put(fileName: string, content: Buffer): void {
if (content.length > this.rc.maxUploadFileSize) {
throw new Error('File is too large!');
}
const largeFileThreshold = 32000;
const isLargeFile = content.length > largeFileThreshold;
if (isLargeFile) {
this.rc.socket?.sendData(
this.rc.socket?.sendPacket(
RCOutgoingPacket.PLI_RC_LARGEFILESTART,
Buffer.from(fileName),
),
);
}
const reader = GBufferReader.from(content);
while (reader.bytesLeft) {
const cur = reader.read(largeFileThreshold);
const dataSize = 3 + fileName.length + cur.length; // char(PLI_RC_FILEBROWSER_UP) + len(file) + file + cur + '\n'
const writer = GBufferWriter.create(4 + dataSize); // len(data) + '\n'
writer.writeGUInt24(dataSize);
writer.writeUInt8(0xa);
writer.writeGUInt8(RCOutgoingPacket.PLI_RC_FILEBROWSER_UP);
writer.writeGString(fileName);
writer.writeBuffer(cur);
writer.writeUInt8(0xa);
this.rc.socket?.sendData(
this.rc.socket?.sendPacket(
RCOutgoingPacket.PLI_RAWDATA,
writer.buffer,
),
);
}
if (isLargeFile) {
this.rc.socket?.sendData(
this.rc.socket?.sendPacket(
RCOutgoingPacket.PLI_RC_LARGEFILEEND,
Buffer.from(fileName),
),
);
}
}
get(fileName: string): PromiseLike<Buffer> {
this.rc.socket?.sendData(
this.rc.socket?.sendPacket(
RCOutgoingPacket.PLI_RC_FILEBROWSER_DOWN,
Buffer.from(fileName),
),
);
return this.promiseManager.createPromise(fileName);
}
}

73
src/FolderRights.ts Normal file
View file

@ -0,0 +1,73 @@
import * as types from './types';
import * as utils from './utils';
type FolderRightsData = {
folder: string;
permissions: string;
match: string;
};
type FolderRightsMap = { [name: string]: FolderRightsData };
export class FolderRights {
private folderRightsText: string = '';
private folderRights: FolderRightsMap = {};
public setFolderRights(rights: string) {
const re = /^([r]?[w]?) (.*)\/(.*)$/gm;
const matches = rights.matchAll(re);
this.folderRightsText = '';
this.folderRights = {};
for (const match of matches) {
let [, perm, folder, filematch] = match;
this.folderRights[folder] = {
folder: folder,
permissions: perm,
match: filematch,
};
this.folderRightsText += folder + '\n';
}
}
/**
* Get immediate subfolders based on the users folder rights
*
* @param folder
* @returns
*/
public getSubFolders(folder: string = ''): types.FSEntries[] {
if (folder && !folder.endsWith('/')) {
folder += '/';
}
const escapedFolder = utils.escapeRegExp(folder);
const regex = new RegExp(`^${escapedFolder}([^\/\n]+)`, 'gm');
const matches = this.folderRightsText.matchAll(regex);
const entries: { [key: string]: types.FSEntries } = {};
for (const match of matches) {
const curFolder = match[1];
if (!(curFolder in entries)) {
const folderPath = folder + curFolder;
if (folderPath in this.folderRights) {
entries[curFolder] = {
name: curFolder,
type: types.FSEntryType.Directory,
permissions: this.folderRights[folderPath].permissions,
fileSize: 0,
modTime: 0,
};
}
}
}
return Object.values(entries);
}
}

267
src/GBuffer.ts Normal file
View file

@ -0,0 +1,267 @@
const DEFAULT_BUFFER_SIZE = 32;
function decodeBits(b: Buffer) {
let res = 0;
for (let i = 0; i < b.length; i++) {
res <<= 7;
res |= b[i] - 32;
}
return res;
}
function encodeBits(v: number, b: number[]) {
for (let i = b.length; i > 0; i--) {
b[i - 1] = (v & 0x7f) + 32;
v >>= 7;
}
}
/**
* Creates a BufferReader over an existing buffer, does not copy the buffer
*
* Allows easily reading packets
*/
export class GBufferReader {
private buffer: Buffer;
private readPosition: number = 0;
public static from(buf: Buffer): GBufferReader {
return new GBufferReader(buf);
}
protected constructor(buf: Buffer) {
this.buffer = buf;
}
public get bytesLeft(): number {
return Math.max(0, this.buffer.length - this.readPosition);
}
public reset(): void {
this.readPosition = 0;
}
public seek(idx: number): void {
this.readPosition = Math.min(this.buffer.length, Math.max(0, idx));
}
public readInt8(): number {
let res = this.buffer.readInt8(this.readPosition);
this.readPosition += 1;
return res;
}
public readUInt8(): number {
let res = this.buffer.readUInt8(this.readPosition);
this.readPosition += 1;
return res;
}
public readUInt16BE(): number {
let res = this.buffer.readUInt16BE(this.readPosition);
this.readPosition += 2;
return res;
}
public readGUInt8(): number {
let res = decodeBits(
this.buffer.slice(this.readPosition, this.readPosition + 1),
);
// let res = this.buffer.readUInt8(this.readPosition) - 32;
this.readPosition += 1;
return res;
}
public readGUInt16(): number {
let res = decodeBits(
this.buffer.slice(this.readPosition, this.readPosition + 2),
);
this.readPosition += 2;
return res;
}
public readGUInt24(): number {
let res = decodeBits(
this.buffer.slice(this.readPosition, this.readPosition + 3),
);
this.readPosition += 3;
return res;
}
public readGUInt32(): number {
let res = decodeBits(
this.buffer.slice(this.readPosition, this.readPosition + 4),
);
this.readPosition += 4;
return res;
}
public readGULong(): number {
let res = decodeBits(
this.buffer.slice(this.readPosition, this.readPosition + 5),
);
this.readPosition += 5;
return res;
}
public read(len: number): Buffer {
const newReadPosition = Math.min(
this.buffer.length,
this.readPosition + len,
);
let res = this.buffer.slice(this.readPosition, newReadPosition);
this.readPosition = newReadPosition;
return res;
}
public readChars(len: number): string {
return this.read(len).toString('latin1');
}
public readGBuffer(len?: number): GBufferReader {
if (!len) {
len = this.readGUInt8();
}
return new GBufferReader(this.read(len));
}
public readGString(): string {
return this.readChars(this.readGUInt8());
}
}
/**
* Dynamically-allocated buffer, used for writing packet data
*/
export class GBufferWriter {
private internalBuffer: Buffer;
private writePosition: number;
constructor(capacity: number) {
this.internalBuffer = Buffer.allocUnsafe(capacity);
this.writePosition = 0;
}
public static create(
capacity: number = DEFAULT_BUFFER_SIZE,
): GBufferWriter {
return new GBufferWriter(capacity);
}
public get buffer(): Buffer {
return this.internalBuffer.slice(0, this.writePosition);
}
public get capacity(): number {
return this.internalBuffer.length;
}
public get length(): number {
return this.writePosition;
}
public clear() {
this.writePosition = 0;
}
public resize(minimum?: number): boolean {
minimum = minimum || this.internalBuffer.length;
if (minimum < this.internalBuffer.length) return false;
minimum *= 2;
let newbuf = Buffer.allocUnsafe(minimum);
newbuf.set(this.internalBuffer);
this.internalBuffer = newbuf;
return true;
}
public writeBuffer(buf: Buffer): void {
const newSize = this.length + buf.length;
if (newSize >= this.capacity) this.resize(newSize);
this.internalBuffer.set(buf, this.writePosition);
this.writePosition = newSize;
}
public writeChars(buf: string): void {
const newSize = this.length + buf.length;
if (newSize >= this.capacity) this.resize(newSize);
this.internalBuffer.write(buf, this.writePosition, 'latin1');
this.writePosition = newSize;
}
public writeUInt8(v: number): void {
if (this.length + 1 >= this.capacity) this.resize();
this.internalBuffer.writeUInt8(v, this.writePosition);
this.writePosition += 1;
}
public writeGUInt8(v: number): void {
if (this.length + 1 >= this.capacity) this.resize();
let b = [0];
encodeBits(Math.min(v, 223), b);
for (let i = 0; i < b.length; i++)
this.internalBuffer.writeUInt8(b[i], this.writePosition + i);
this.writePosition += b.length;
// v = Math.min(v, 223);
// this.internalBuffer.writeUInt8(v + 32, this.writePosition);
// this.writePosition += 1;
}
public writeGUInt16(v: number): void {
if (this.length + 2 >= this.capacity) this.resize();
let b = [0, 0];
encodeBits(Math.min(v, 28767), b);
for (let i = 0; i < b.length; i++)
this.internalBuffer.writeUInt8(b[i], this.writePosition + i);
this.writePosition += b.length;
}
public writeGUInt24(v: number): void {
if (this.length + 3 >= this.capacity) this.resize();
let b = [0, 0, 0];
encodeBits(Math.min(v, 3682399), b);
for (let i = 0; i < b.length; i++)
this.internalBuffer.writeUInt8(b[i], this.writePosition + i);
this.writePosition += b.length;
}
public writeGUInt32(v: number): void {
if (this.length + 4 >= this.capacity) this.resize();
let b = [0, 0, 0, 0];
encodeBits(Math.min(v, 471347295), b);
for (let i = 0; i < b.length; i++)
this.internalBuffer.writeUInt8(b[i], this.writePosition + i);
this.writePosition += b.length;
}
public writeGULong(v: number): void {
if (this.length + 5 >= this.capacity) this.resize();
let b = [0, 0, 0, 0, 0];
encodeBits(Math.min(v, 0xffffffff), b);
for (let i = 0; i < b.length; i++)
this.internalBuffer.writeUInt8(b[i], this.writePosition + i);
this.writePosition += b.length;
}
public writeGString(str: string) {
this.writeGUInt8(str.length);
this.writeChars(str);
}
}

164
src/GProtocol.ts Normal file
View file

@ -0,0 +1,164 @@
import zlib = require('zlib');
// @ts-ignore
import compressjs = require('compressjs');
export enum ProtocolGen {
Gen1 = 0,
Gen2 = 1,
Gen3 = 2,
Gen4 = 3,
Gen5 = 4,
}
enum CompressionType {
NONE = 0x02,
ZLIB = 0x04,
BZ2 = 0x06,
}
enum Direction {
Outgoing = 0,
Incoming = 1,
}
const start_iterator = [0, 0, 0x04a80b38, 0x4a80b38, 0x4a80b38, 0];
function getIteratorLimit(compressionType: CompressionType): number {
return compressionType == CompressionType.NONE ? 0x0c : 0x04;
}
export class GProtocol {
key: number = 0;
iterator: Uint32Array = new Uint32Array(2);
gen: ProtocolGen = ProtocolGen.Gen1;
public static generateKey(): number {
return Math.floor(Math.random() * 128);
}
public static initialize(gen: ProtocolGen, key?: number): GProtocol {
key = key || GProtocol.generateKey();
return new GProtocol(gen, key);
}
protected constructor(gen: ProtocolGen, key: number) {
this.key = key;
this.gen = gen;
this.iterator[0] = this.iterator[1] = start_iterator[gen];
}
apply(compressionType: CompressionType, dir: Direction, buf: Buffer) {
let limit = getIteratorLimit(compressionType);
for (let i = 0; i < buf.length; i++) {
if (i % 4 == 0) {
if (limit <= 0) break;
limit -= 1;
this.iterator[dir] =
Math.imul(this.iterator[dir], 0x8088405) + this.key;
}
buf[i] ^= (this.iterator[dir] >> ((i % 4) * 8)) & 0xff;
}
}
decrypt(buf: Buffer): Buffer {
switch (this.gen) {
case ProtocolGen.Gen1:
return buf;
case ProtocolGen.Gen2:
return zlib.inflateSync(buf);
case ProtocolGen.Gen3: {
buf = zlib.inflateSync(buf);
this.iterator[Direction.Incoming] *= 0x8088405;
this.iterator[Direction.Incoming] += this.key;
let removePos =
(this.iterator[Direction.Incoming] & 0x0ffff) % buf.length;
let newBuf = Buffer.allocUnsafe(buf.length - 1);
buf.copy(newBuf, 0, 0, removePos);
buf.copy(newBuf, removePos + 1, removePos + 1);
return buf;
}
case ProtocolGen.Gen4:
case ProtocolGen.Gen5: {
let compressionType = CompressionType.BZ2;
if (this.gen == ProtocolGen.Gen5)
compressionType = buf.readUInt8();
this.apply(compressionType, Direction.Incoming, buf.slice(1));
switch (compressionType) {
case CompressionType.NONE:
buf = buf.slice(1);
break;
case CompressionType.ZLIB:
buf = zlib.inflateSync(buf.slice(1));
break;
case CompressionType.BZ2:
buf = Buffer.from(
compressjs.Bzip2.decompressFile(buf.slice(1)),
);
break;
}
return buf;
}
}
}
encrypt(buf: Buffer): Buffer {
switch (this.gen) {
case ProtocolGen.Gen1:
return buf;
case ProtocolGen.Gen2:
return zlib.deflateSync(buf);
case ProtocolGen.Gen3:
buf = zlib.deflateSync(buf);
this.iterator[Direction.Outgoing] *= 0x8088405;
this.iterator[Direction.Outgoing] += this.key;
let removePos =
(this.iterator[Direction.Outgoing] & 0x0ffff) % buf.length;
let newBuf = Buffer.allocUnsafe(buf.length - 1);
buf.copy(newBuf, 0, 0, removePos);
buf.copy(newBuf, removePos + 1, removePos + 1);
return buf;
case ProtocolGen.Gen4:
case ProtocolGen.Gen5: {
let compressionType = CompressionType.BZ2;
// Forcing zlib over bz2 due to the bz2 implementation being coded in
// javascript rather than a native node module.
if (this.gen == ProtocolGen.Gen5)
compressionType = CompressionType.ZLIB;
switch (compressionType) {
case CompressionType.ZLIB:
buf = zlib.deflateSync(buf);
break;
case CompressionType.BZ2:
buf = compressjs.Bzip2.compressFile(buf);
break;
}
this.apply(compressionType, Direction.Outgoing, buf);
return Buffer.concat([Buffer.from([compressionType]), buf]);
}
}
}
}

159
src/GSocket.ts Normal file
View file

@ -0,0 +1,159 @@
import net = require('net');
import { GProtocol, ProtocolGen } from './GProtocol';
import { PacketHandler, PacketTable } from './PacketTable';
export type ConnectHandler = () => void;
export type DisconnectHandler = (err?: Error) => void;
export interface SocketConfigurartion {
connectCallback?: ConnectHandler;
disconnectCallback?: DisconnectHandler;
packetTable?: PacketTable;
}
export class GSocket {
private buffer: Buffer;
private enc: GProtocol | null = null;
private sock: net.Socket | null = null;
private packetHandler: PacketTable;
private config: SocketConfigurartion;
public rawBytesAhead: number = 0;
protected constructor(cfg: SocketConfigurartion) {
this.buffer = Buffer.allocUnsafe(0);
this.config = cfg;
this.packetHandler = cfg?.packetTable || new PacketTable();
}
public static connect(
host: string,
port: number,
cfg?: SocketConfigurartion,
): GSocket {
const newSock = new GSocket(cfg || {});
newSock.setProtocol(ProtocolGen.Gen2);
newSock.connect(host, port);
return newSock;
}
// Expose PacketHandler methods
public on(id: number, callback: PacketHandler) {
return this.packetHandler.on(id, callback);
}
private connect(host: string, port: number) {
this.sock = new net.Socket();
this.sock.setTimeout(10000);
this.sock.connect(port, host, this.config.connectCallback);
this.sock.on('data', (data: Buffer) => {
this.buffer = Buffer.concat([this.buffer, data]);
while (this.buffer.length > 0) {
const readLen = this.buffer.readUInt16BE();
if (this.buffer.length < readLen + 2) break;
this.processData(this.buffer.slice(2, 2 + readLen));
this.buffer = this.buffer.slice(2 + readLen);
}
});
this.sock.on('close', () => {
console.log(`[SOCKET CLOSE] ${host}:${port}`);
this.config.disconnectCallback?.();
});
this.sock.on('error', (error) => {
console.log(`[SOCKET ERROR] ${host}:${port}`);
this.disconnect();
});
this.sock.on('timeout', () => {
console.log(`[SOCKET TIMED OUT] ${host}:${port}`);
});
}
public disconnect() {
if (this.sock) {
this.sock.destroy();
this.sock = null;
}
}
public setProtocol(protocol: ProtocolGen, key?: number) {
this.enc = GProtocol.initialize(protocol, key);
}
public sendData(buf: Buffer) {
if (this.enc) buf = this.enc.encrypt(buf);
let nb = Buffer.allocUnsafe(buf.length + 2);
buf.copy(nb, 2);
nb.writeUInt16BE(buf.length, 0);
this.sock?.write(nb);
}
public sendPacket(id: number, buf?: Buffer) {
const buffers = [Buffer.from([id + 32])];
if (buf) buffers.push(Buffer.from(buf));
// Append new line to packets
if (!buf || buf[buf.length - 1] != 0xa)
buffers.push(Buffer.from([0xa]));
return Buffer.concat(buffers);
}
private processData(buf: Buffer) {
if (this.enc) buf = this.enc.decrypt(buf);
let offset = 0;
while (offset < buf.length) {
let idx;
// Read data from buffer, terminating at '\n' or rawBytesAhead if defined
if (this.rawBytesAhead > 0) {
if (this.rawBytesAhead > buf.length - offset) {
break;
}
idx = offset + this.rawBytesAhead;
} else {
idx = buf.indexOf('\n', offset);
if (idx === -1) {
break;
}
}
// Copy data into a new buffer
let b = Buffer.allocUnsafe(idx - offset);
buf.copy(b, 0, offset, idx);
if (this.rawBytesAhead > 0) {
// Reset raw
this.rawBytesAhead = 0;
offset = idx;
} else {
// Skip passed the newline
offset = idx + 1;
}
// Process the packet
let packetId = b.readUInt8();
this.processPacket(packetId - 32, b.slice(1));
}
}
private processPacket(id: number, buf: Buffer) {
let handlers = this.packetHandler.getCallbacks(id);
for (let handle of handlers) {
handle(id, buf);
}
}
}

23
src/Logger.ts Normal file
View file

@ -0,0 +1,23 @@
import chalk from 'chalk';
export class Logger {
public static log(...args: unknown[]): void {
console.log(chalk.blue('[LOG]'), ...args);
}
public static error(...args: unknown[]): void {
console.error(chalk.red('[ERROR]'), ...args);
}
public static warn(...args: unknown[]): void {
console.warn(chalk.yellow('[WARN]'), ...args);
}
public static info(...args: unknown[]): void {
console.info(chalk.green('[INFO]'), ...args);
}
public static debug(...args: unknown[]): void {
console.debug(chalk.cyan('[DEBUG]'), ...args);
}
}

476
src/NpcControl.ts Normal file
View file

@ -0,0 +1,476 @@
import { GBufferReader, GBufferWriter } from './GBuffer';
import { ProtocolGen } from './GProtocol';
import { GSocket } from './GSocket';
import { PacketTable } from './PacketTable';
import { PromiseManager } from './PromiseManager';
import { gtokenize, guntokenize } from './utils';
import { NPC, NPCManager, NPCPropID } from './misc/npcs';
import { NCIncomingPacket, NCOutgoingPacket } from './misc/packet';
import { NCEvents, NCInterface, ServerlistConfig } from './types';
enum UriConstants {
NpcPrefix = 'npcserver://npcs/',
ScriptPrefix = 'npcserver://scripts/',
WeaponPrefix = 'npcserver://weapons/',
WeaponList = 'npcserver://weapons',
LevelList = 'npcserver://levellist',
}
enum ErrorMsg {
NotFound = 'Resource not found',
}
interface NpcControlConfig {
host: string;
port: number;
}
export class NPCControl implements NCInterface {
private sock?: GSocket;
private readonly packetTable: PacketTable;
private readonly eventHandler: NCEvents;
private promiseMngr: PromiseManager = new PromiseManager();
private npcMngr: NPCManager = new NPCManager();
private classList: Set<string> = new Set<string>();
public get classes(): Set<string> {
return this.classList;
}
public get npcs(): NPC[] {
return this.npcMngr.npcs;
}
constructor(
private readonly config: ServerlistConfig,
ncConfig: NpcControlConfig,
eventHandler: NCEvents,
) {
this.eventHandler = eventHandler;
this.packetTable = this.initializeHandlers();
this.connect(ncConfig.host, ncConfig.port);
}
public connect(host: string, port: number): boolean {
if (this.sock) {
return false;
}
this.sock = GSocket.connect(host, port, {
connectCallback: () => this.onConnect(),
disconnectCallback: () => this.onDisconnect(),
packetTable: this.packetTable,
});
return true;
}
public disconnect(): void {
this.sock?.disconnect();
}
////////////////////
private onConnect(): void {
console.log('[NC] Connected!');
if (!this.sock) {
console.log('[NC] no sock?');
return;
}
// Send login packet
let nb = GBufferWriter.create();
nb.writeChars('NCL21075');
nb.writeGString(this.config.account);
nb.writeGString(this.config.password);
this.sock.sendData(this.sock.sendPacket(3, nb.buffer));
this.sock.setProtocol(ProtocolGen.Gen3, 0);
// The only proof that you passed verification, is the fact that you are
// still connected to the server.
this.eventHandler.onNCConnected?.();
}
private onDisconnect(): void {
console.log('[NC] Disconnected!');
if (!this.sock) {
console.log('[NC] no sock?');
return;
}
this.eventHandler.onNCDisconnected?.();
}
////////////////////
requestLevelList(): Promise<string> {
this.sock?.sendData(
this.sock.sendPacket(NCOutgoingPacket.PLI_NC_LEVELLISTGET),
);
return this.promiseMngr.createPromise(UriConstants.LevelList);
}
deleteWeapon(name: string): void {
this.sock?.sendData(
this.sock.sendPacket(
NCOutgoingPacket.PLI_NC_WEAPONDELETE,
Buffer.from(name),
),
);
}
requestWeaponList(): Promise<Set<string>> {
this.sock?.sendData(
this.sock.sendPacket(NCOutgoingPacket.PLI_NC_WEAPONLISTGET),
);
return this.promiseMngr.createPromise(UriConstants.WeaponList);
}
requestWeapon(name: string): Promise<[string, string]> {
this.sock?.sendData(
this.sock.sendPacket(
NCOutgoingPacket.PLI_NC_WEAPONGET,
Buffer.from(name),
),
);
return this.promiseMngr.createPromise(UriConstants.WeaponPrefix + name);
}
setWeaponScript(name: string, image: string, script: string): void {
// Weapons are sent by replacing newlines with \xa7 character
script = script.replace(/\n/g, '§');
script = script.replace(/\r/g, '');
const writer = GBufferWriter.create(
1 + name.length + 1 + image.length + script.length,
);
writer.writeGString(name);
writer.writeGString(image);
writer.writeChars(script);
this.sock?.sendData(
this.sock.sendPacket(
NCOutgoingPacket.PLI_NC_WEAPONADD,
writer.buffer,
),
);
}
deleteNpc(name: string): void {
throw new Error('Method not implemented.');
}
requestNpcAttributes(name: string): Promise<string> {
const npcObject = this.npcMngr.findNPC(name);
if (npcObject) {
const nb = GBufferWriter.create(3);
nb.writeGUInt24(npcObject.id);
this.sock?.sendData(
this.sock.sendPacket(NCOutgoingPacket.PLI_NC_NPCGET, nb.buffer),
);
return this.promiseMngr.createPromise(
UriConstants.NpcPrefix + name + '.attrs',
);
}
return Promise.reject(ErrorMsg.NotFound);
}
requestNpcFlags(name: string): Promise<string> {
const npcObject = this.npcMngr.findNPC(name);
if (npcObject) {
const nb = GBufferWriter.create(3);
nb.writeGUInt24(npcObject.id);
this.sock?.sendData(
this.sock.sendPacket(
NCOutgoingPacket.PLI_NC_NPCFLAGSGET,
nb.buffer,
),
);
return this.promiseMngr.createPromise(
UriConstants.NpcPrefix + name + '.flags',
);
}
return Promise.reject(ErrorMsg.NotFound);
}
requestNpcScript(name: string): Promise<string> {
const npcObject = this.npcMngr.findNPC(name);
if (npcObject) {
const nb = GBufferWriter.create(3);
nb.writeGUInt24(npcObject.id);
this.sock?.sendData(
this.sock.sendPacket(
NCOutgoingPacket.PLI_NC_NPCSCRIPTGET,
nb.buffer,
),
);
return this.promiseMngr.createPromise(
UriConstants.NpcPrefix + name + '.script',
);
}
return Promise.reject(ErrorMsg.NotFound);
}
setNpcFlags(name: string, script: string): void {
const npcObject = this.npcMngr.findNPC(name);
if (npcObject) {
const nb = GBufferWriter.create(3);
nb.writeGUInt24(npcObject.id);
nb.writeChars(gtokenize(script));
this.sock?.sendData(
this.sock.sendPacket(
NCOutgoingPacket.PLI_NC_NPCFLAGSSET,
nb.buffer,
),
);
}
}
setNpcScript(name: string, script: string): void {
const npcObject = this.npcMngr.findNPC(name);
if (npcObject) {
const nb = GBufferWriter.create(3);
nb.writeGUInt24(npcObject.id);
nb.writeChars(gtokenize(script));
this.sock?.sendData(
this.sock.sendPacket(
NCOutgoingPacket.PLI_NC_NPCSCRIPTSET,
nb.buffer,
),
);
}
}
deleteClass(name: string): void {
this.sock?.sendData(
this.sock.sendPacket(
NCOutgoingPacket.PLI_NC_CLASSDELETE,
Buffer.from(name),
),
);
}
requestClass(name: string): Promise<string> {
this.sock?.sendData(
this.sock.sendPacket(
NCOutgoingPacket.PLI_NC_CLASSEDIT,
Buffer.from(name),
),
);
return this.promiseMngr.createPromise(UriConstants.ScriptPrefix + name);
}
setClassScript(name: string, script: string): void {
const nb = GBufferWriter.create();
nb.writeGString(name);
nb.writeChars(gtokenize(script));
this.sock?.sendData(
this.sock.sendPacket(NCOutgoingPacket.PLI_NC_CLASSADD, nb.buffer),
);
}
private initializeHandlers(): PacketTable {
const packetTable = new PacketTable();
packetTable.setDefault((id: number, packet: Buffer): void => {
if (id !== 42) {
console.log(
`[NC] Unhandled Packet (${id}): ${packet
.toString()
.replace(/\r/g, '')}`,
);
}
});
packetTable.on(
NCIncomingPacket.PLO_NEWWORLDTIME,
(id: number, packet: Buffer): void => {
const reader = GBufferReader.from(packet);
const serverTime = reader.readGUInt32();
},
);
packetTable.on(
NCIncomingPacket.PLO_RCCHAT,
(id: number, packet: Buffer): void => {
const msg = packet.toString();
this.eventHandler.onNCChat?.(msg);
},
);
packetTable.on(
NCIncomingPacket.PLO_NC_LEVELLIST,
(id: number, packet: Buffer): void => {
const levelList = guntokenize(packet.toString());
this.promiseMngr.resolvePromise(
UriConstants.LevelList,
levelList,
);
},
);
packetTable.on(
NCIncomingPacket.PLO_NC_NPCATTRIBUTES,
(id: number, packet: Buffer): void => {
const text = guntokenize(packet.toString());
const name = text
.substring(
'Variable dump from npc '.length + 1,
text.indexOf('\n'),
)
.trimEnd();
this.promiseMngr.resolvePromise(
UriConstants.NpcPrefix + name + '.attrs',
text,
);
},
);
packetTable.on(
NCIncomingPacket.PLO_NC_NPCADD,
(id: number, packet: Buffer): void => {
const reader = GBufferReader.from(packet);
const npcId = reader.readGUInt24();
const npcObj = this.npcMngr.getNpc(npcId);
npcObj.setProps(reader);
this.eventHandler.onNpcAdded?.(
npcObj.props[NPCPropID.NPCPROP_NAME] as string,
);
// console.log(`NPC Added: ${npcObj.props[NPCPropID.NPCPROP_NAME]}`);
},
);
packetTable.on(
NCIncomingPacket.PLO_NC_NPCDELETE,
(id: number, packet: Buffer): void => {
const reader = GBufferReader.from(packet);
const npcId = reader.readGUInt24();
// this.npcMngr.deleteNpc(npcId);
const npcObj = this.npcMngr.getNpc(npcId);
const npcName = npcObj.props[NPCPropID.NPCPROP_NAME] as string;
if (this.npcMngr.deleteNpc(npcId)) {
this.eventHandler.onNpcDeleted?.(npcName);
console.log(`Delete npc ${npcName}`);
}
},
);
packetTable.on(
NCIncomingPacket.PLO_NC_NPCSCRIPT,
(id: number, packet: Buffer): void => {
const reader = GBufferReader.from(packet);
const npcId = reader.readGUInt24();
const text = guntokenize(reader.readChars(reader.bytesLeft));
const npcObj = this.npcMngr.getNpc(npcId);
if (npcObj) {
const npcName = npcObj.props[
NPCPropID.NPCPROP_NAME
] as string;
this.promiseMngr.resolvePromise(
UriConstants.NpcPrefix + npcName + '.script',
text,
);
}
},
);
packetTable.on(
NCIncomingPacket.PLO_NC_NPCFLAGS,
(id: number, packet: Buffer): void => {
const reader = GBufferReader.from(packet);
const npcId = reader.readGUInt24();
const text = guntokenize(reader.readChars(reader.bytesLeft));
const npcObj = this.npcMngr.getNpc(npcId);
if (npcObj) {
const npcName = npcObj.props[
NPCPropID.NPCPROP_NAME
] as string;
this.promiseMngr.resolvePromise(
UriConstants.NpcPrefix + npcName + '.flags',
text,
);
}
},
);
packetTable.on(
NCIncomingPacket.PLO_NC_CLASSGET,
(id: number, packet: Buffer): void => {
const reader = GBufferReader.from(packet);
const name = reader.readGString();
const script = guntokenize(reader.readChars(reader.bytesLeft));
this.promiseMngr.resolvePromise(
UriConstants.ScriptPrefix + name,
script,
);
},
);
packetTable.on(
NCIncomingPacket.PLO_NC_CLASSADD,
(id: number, packet: Buffer): void => {
const className = packet.toString();
this.classList.add(className);
},
);
packetTable.on(
NCIncomingPacket.PLO_NC_CLASSDELETE,
(id: number, packet: Buffer): void => {
const className = packet.toString();
this.classList.delete(className);
},
);
packetTable.on(
NCIncomingPacket.PLO_NC_WEAPONGET,
(id: number, packet: Buffer): void => {
const reader = GBufferReader.from(packet);
const name = reader.readGString();
const image = reader.readGString();
let script = reader.readChars(reader.bytesLeft);
script = script.replace(/\xa7/g, '\n');
this.promiseMngr.resolvePromise<[string, string]>(
UriConstants.WeaponPrefix + name,
[image, script],
);
},
);
packetTable.on(
NCIncomingPacket.PLO_NC_WEAPONLISTGET,
(id: number, packet: Buffer): void => {
const weaponList: Set<string> = new Set();
const reader = GBufferReader.from(packet);
while (reader.bytesLeft) {
weaponList.add(reader.readGString());
}
this.promiseMngr.resolvePromise(
UriConstants.WeaponList,
weaponList,
);
},
);
return packetTable;
}
}

32
src/PacketTable.ts Normal file
View file

@ -0,0 +1,32 @@
export type PacketHandler = (id: number, packet: Buffer) => void;
export class PacketTable {
static readonly ALL_HANDLER = -1;
static readonly DEFAULT_HANDLER = -2;
private handlers: PacketHandler[][] = [];
public getCallbacks(id: number): PacketHandler[] {
let callbacks = (this.handlers[PacketTable.ALL_HANDLER] || []).concat(
this.handlers[id] || [],
);
if (callbacks.length == 0)
callbacks = this.handlers[PacketTable.DEFAULT_HANDLER] || callbacks;
return callbacks;
}
public setCatchAll(callback: PacketHandler) {
this.on(-1, callback);
}
public setDefault(callback: PacketHandler) {
this.on(-2, callback);
}
public on(id: number, callback: PacketHandler) {
if (!(id in this.handlers)) this.handlers[id] = [];
this.handlers[id].push(callback);
}
}

44
src/PromiseManager.ts Normal file
View file

@ -0,0 +1,44 @@
const timedOutMessage: string = 'Timed out';
export interface PromiseData<T> {
resolve(val: T | PromiseLike<T>): void;
reject(reason?: any): void;
}
export class PromiseManager {
private promiseData: { [uri: string]: PromiseData<any> } = {};
createPromise<Type>(uri: string, timeout: number = 10): Promise<Type> {
return new Promise<Type>((resolve, reject) => {
this.promiseData[uri] = {
resolve: resolve,
reject: reject,
};
if (timeout) {
setTimeout(() => reject(timedOutMessage), timeout * 1000);
}
});
}
resolvePromise<Type>(uri: string, data: Type): void {
if (uri in this.promiseData) {
this.promiseData[uri].resolve(data);
delete this.promiseData[uri];
}
}
rejectPromise<Type>(uri: string, data: Type): void {
if (uri in this.promiseData) {
this.promiseData[uri].reject(data);
delete this.promiseData[uri];
}
}
reset() {
for (const val of Object.values(this.promiseData)) {
val.reject();
}
this.promiseData = {};
}
}

274
src/RemoteControl.ts Normal file
View file

@ -0,0 +1,274 @@
import { networkInterfaces } from 'os';
import { FileBrowser, RCFileBrowser } from './FileBrowser';
import { GBufferWriter } from './GBuffer';
import { GProtocol, ProtocolGen } from './GProtocol';
import { GSocket } from './GSocket';
import { Logger } from './Logger';
import { NPCControl } from './NpcControl';
import { PacketTable } from './PacketTable';
import { PromiseManager } from './PromiseManager';
import {
NCEvents,
NCInterface,
RCEvents,
RCInterface,
ServerEntry,
ServerlistConfig,
} from './types';
import { hashCode } from './utils';
import { PlayerProperties, RCOutgoingPacket } from './misc/packet';
enum UriConstants {
FolderConfig = 'gserver://config/folderconfig',
ServerFlags = 'gserver://config/serverflags',
ServerOptions = 'gserver://config/serveroptions',
}
interface RemoteControlEvents extends NCEvents, RCEvents {}
export class RemoteControl implements RCInterface {
private readonly config: ServerlistConfig;
public readonly server: ServerEntry;
private readonly packetTable: PacketTable;
private readonly fileBrowser: RCFileBrowser;
private readonly promiseManager: PromiseManager = new PromiseManager();
private readonly eventHandler: RemoteControlEvents;
private sock?: GSocket;
private npcControl?: NPCControl;
private disconnectMsg?: string;
private maxUploadSize = 0;
public get socket(): GSocket | undefined {
return this.sock;
}
public get FileBrowser(): FileBrowser {
return this.fileBrowser;
}
public get NpcControl(): NCInterface | undefined {
return this.npcControl;
}
public get maxUploadFileSize(): number {
return this.maxUploadSize;
}
constructor(
config: ServerlistConfig,
server: ServerEntry,
eventHandler: RemoteControlEvents,
) {
this.config = config;
this.server = server;
this.eventHandler = eventHandler;
this.fileBrowser = new RCFileBrowser(this);
this.packetTable = this.initializeHandlers();
this.connect(server.ip, server.port);
}
public isConnected(): boolean {
return !!this.sock;
}
public connect(host: string, port: number): boolean {
if (this.sock) return false;
this.sock = GSocket.connect(host, port, {
connectCallback: () => this.onConnect(),
disconnectCallback: (err?: Error) => this.onDisconnect(err),
packetTable: this.packetTable,
});
return true;
}
private onConnect(): void {
Logger.log(
`RC Connected to ${this.server.ip}:${this.server.port} (${this.server.name})`,
);
if (!this.sock) return;
this.sendLoginPacket();
}
private onDisconnect(err?: Error): void {
this.sock = undefined;
if (this.npcControl) {
this.npcControl.disconnect();
this.npcControl = undefined;
}
this.eventHandler.onRCDisconnected?.(
this,
this.disconnectMsg || err?.message,
);
Logger.warn('RC Disconnected');
}
private requestNpcServer(): void {
const writer = GBufferWriter.create(2 + 'location'.length);
writer.writeGUInt16(2); // 2 = NpcServer
writer.writeChars('location');
this.sock?.sendData(
this.sock?.sendPacket(RCOutgoingPacket.PLI_RC_FILEBROWSER_START),
);
}
private connectToNpcServer(host: string, port: number): NPCControl | null {
if (this.npcControl) {
this.npcControl.disconnect();
this.npcControl = undefined;
}
this.npcControl = new NPCControl(
this.config,
{
host: host,
port: port,
},
this.eventHandler,
);
return this.npcControl;
}
requestFolderConfig(): Promise<string> {
this.sock?.sendData(
this.sock.sendPacket(RCOutgoingPacket.PLI_RC_FOLDERCONFIGGET),
);
return this.promiseManager.createPromise(UriConstants.FolderConfig);
}
requestServerFlags(): Promise<string> {
this.sock?.sendData(
this.sock.sendPacket(RCOutgoingPacket.PLI_RC_SERVERFLAGSGET),
);
return this.promiseManager.createPromise(UriConstants.ServerFlags);
}
requestServerOptions(): Promise<string> {
this.sock?.sendData(
this.sock.sendPacket(RCOutgoingPacket.PLI_RC_SERVEROPTIONSGET),
);
return this.promiseManager.createPromise(UriConstants.ServerOptions);
}
sendRCChat(msg: string): void {
this.sock?.sendData(
this.sock.sendPacket(RCOutgoingPacket.PLI_RCCHAT, Buffer.from(msg)),
);
}
setNickName(name: string): void {
const writer = GBufferWriter.create(2 + name.length);
writer.writeGUInt8(PlayerProperties.PLPROP_NICKNAME);
writer.writeGString(name);
this.sock?.sendData(
this.sock.sendPacket(
RCOutgoingPacket.PLI_PLAYERPROPS,
writer.buffer,
),
);
}
initializeHandlers(): PacketTable {
const packetTable = new PacketTable();
packetTable.setDefault((id: number, packet: Buffer): void => {
// Logger.error(
// `[RC] Unhandled Packet (${id}): ${packet
// .toString()
// .replace(/\r/g, '')}`,
// );
});
// RC Messages
packetTable.on(74, (id: number, packet: Buffer): void => {
// console.log(`[RC] ${packet.toString()}`);
this.eventHandler.onRCEventMsg?.(packet.toString());
});
// This is just a heartbeat packet, we can ignore it
packetTable.on(42, (): void => {});
return packetTable;
}
private sendLoginPacket(): void {
if (!this.sock) return;
const key = GProtocol.generateKey();
const writer = GBufferWriter.create();
writer.writeGUInt8(key);
writer.writeChars('GSERV025');
writer.writeGString(this.config.account);
writer.writeGString(this.config.password);
writer.writeChars('mac,');
const getComputerCode = (computerHash: number) => {
const hexCode = [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'A',
'B',
'C',
'D',
'E',
'F',
];
let stringBuilder = '';
for (let i = 0; i < 32; i++) {
stringBuilder += hexCode[Math.abs(computerHash % 16)];
computerHash *= 31;
}
return stringBuilder;
};
const getComputerHash = (): number => {
const nets = networkInterfaces();
const interfaces = Object.values(nets).flat().filter(Boolean);
const macAddress = interfaces.find(
(iface) => iface && iface.mac !== '00:00:00:00:00:00',
)?.mac;
return macAddress
? hashCode(macAddress.split(':').join(''))
: Math.random() * 100000;
};
let computerCodeString;
const computerHash = getComputerHash();
computerCodeString = getComputerCode(
computerHash + hashCode(this.config.account),
);
writer.writeChars(computerCodeString);
writer.writeChars(',');
writer.writeChars(computerCodeString);
writer.writeChars(',');
writer.writeChars('""');
this.sock.sendData(this.sock.sendPacket(6, writer.buffer));
this.sock.setProtocol(ProtocolGen.Gen5, key);
}
public disconnect(): void {
this.sock?.disconnect();
}
}

61
src/index.ts Normal file
View file

@ -0,0 +1,61 @@
import { config } from 'dotenv';
import { ServerEntry, ServerlistConfig } from './types';
import { Serverlister } from './serverLister';
import { Logger } from './Logger';
import { RemoteControl } from './RemoteControl';
import axios from 'axios';
const env = config({ path: './.env' });
async function main() {
const config: ServerlistConfig = {
host: 'listserver.graalonline.com',
port: 14922,
account: env.parsed?.ACCOUNT || '',
password: env.parsed?.PASSWORD || '',
nickname: 'Ruan',
};
try {
const servers = await Serverlister.request(config);
const zodiacDev = servers.find((s: ServerEntry) => s.name === 'Zodiac');
if (!zodiacDev) {
Logger.error('Zodiac Dev server not found.');
return;
}
new RemoteControl(config, zodiacDev, {
onRCConnected: (instance) => {
Logger.debug('Connected to RC');
},
onRCDisconnected: (instance, text) => {
Logger.warn('Disconnected from RC:', text);
},
onRCChat: (text) => {
Logger.info('RC Chat:', text);
},
onRCEventMsg: (text) => {
if (!env.parsed?.RC_LOG_DISCORD_WEBHOOK) return;
try {
axios
.post(env.parsed?.RC_LOG_DISCORD_WEBHOOK, {
content: '`' + text + '`',
})
.catch((error) => {
Logger.error(
'Error sending message to Discord:',
error,
);
});
} catch (error) {
Logger.error('Error:', error);
}
},
});
} catch (error) {
console.error('Error:', error);
}
}
main();

78
src/misc/npcs.ts Normal file
View file

@ -0,0 +1,78 @@
import { GBufferReader } from '../GBuffer';
type PropData = number | string;
export enum NPCPropID {
NPCPROP_ID = 17,
NPCPROP_SCRIPTER = 49,
NPCPROP_NAME = 50,
NPCPROP_TYPE = 51,
NPCPROP_CURLEVEL = 52,
}
export class NPCManager {
private readonly npcList: { [key: number]: NPC } = {};
public get npcs(): NPC[] {
return Object.values(this.npcList); //.filter((v) => NPCPropID.NPCPROP_NAME in v.props);
}
public deleteNpc(id: number): boolean {
if (id in this.npcList) {
delete this.npcList[id];
return true;
}
return false;
}
public getNpc(id: number, create: boolean = true): NPC {
if (create && !(id in this.npcList)) {
this.npcList[id] = new NPC(id);
}
return this.npcList[id] || undefined;
}
public findNPC(name: string): NPC | undefined {
for (const [k, v] of Object.entries(this.npcList)) {
if (v.getProp(NPCPropID.NPCPROP_NAME) === name) {
return v;
}
}
}
}
export class NPC {
props: { [key: number]: PropData } = {};
constructor(public readonly id: number) {
this.props[NPCPropID.NPCPROP_ID] = id;
}
getProp(id: number): PropData {
return this.props[id];
}
setProps(reader: GBufferReader) {
while (reader.bytesLeft) {
const propId: NPCPropID = reader.readGUInt8();
switch (propId) {
case NPCPropID.NPCPROP_ID:
this.props[propId] = reader.readGUInt16();
break;
case NPCPropID.NPCPROP_SCRIPTER:
case NPCPropID.NPCPROP_NAME:
case NPCPropID.NPCPROP_TYPE:
case NPCPropID.NPCPROP_CURLEVEL:
this.props[propId] = reader.readGString();
break;
default:
console.log(`Unhandled NPC Property: ${NPCPropID}`);
return;
}
}
}
}

139
src/misc/packet.ts Normal file
View file

@ -0,0 +1,139 @@
export enum RCIncomingPacket {
PLO_DISCMESSAGE = 16,
PLO_SIGNATURE = 25,
PLO_FILESENDFAILED = 30,
PLO_NEWWORLDTIME = 42,
PLO_RC_ACCOUNTADD = 50, // Deprecated. Codr note: Unhandled by 6.037.
PLO_RC_ACCOUNTSTATUS = 51, // Deprecated. Codr note: Unhandled by 6.037.
PLO_RC_ACCOUNTNAME = 52, // Deprecated. Codr note: Unhandled by 6.037.
PLO_RC_ACCOUNTDEL = 53, // Deprecated. Codr note: Unhandled by 6.037.
PLO_RC_ACCOUNTPROPS = 54, // Deprecated. Codr note: Unhandled by 6.037.
PLO_ADDPLAYER = 55, // Unhandled by 5.07+. -Codr
PLO_DELPLAYER = 56,
PLO_RC_ACCOUNTPROPSGET = 57, // Deprecated. Codr note: Unhandled by 6.037.
PLO_RC_ACCOUNTCHANGE = 58, // Deprecated. Codr note: Unhandled by 6.037.
PLO_RC_PLAYERPROPSCHANGE = 59, // Deprecated. Codr note: Unhandled by 6.037.
PLO_UNKNOWN60 = 60, // Unhandled by 5.07+. -Codr
PLO_RC_SERVERFLAGSGET = 61, // Unhandled by 5.07+. -Codr
PLO_RC_PLAYERRIGHTSGET = 62, // Unhandled by 5.07+. -Codr
PLO_RC_PLAYERCOMMENTSGET = 63, // Unhandled by 5.07+. -Codr
PLO_RC_PLAYERBANGET = 64, // Unhandled by 5.07+. -Codr
PLO_RC_FILEBROWSER_DIRLIST = 65, // Unhandled by 5.07+. -Codr
PLO_RC_FILEBROWSER_DIR = 66, // Unhandled by 5.07+. -Codr
PLO_RC_FILEBROWSER_MESSAGE = 67,
PLO_LARGEFILESTART = 68,
PLO_LARGEFILEEND = 69,
PLO_RC_ACCOUNTLISTGET = 70, // Unhandled by 5.07+. -Codr
PLO_RC_PLAYERPROPS = 71, // Deprecated. Codr note: Unhandled by 6.037.
PLO_RC_PLAYERPROPSGET = 72, // Unhandled by 5.07+. -Codr
PLO_RC_ACCOUNTGET = 73,
PLO_RCCHAT = 74,
PLO_RC_SERVEROPTIONSGET = 76,
PLO_RC_FOLDERCONFIGGET = 77,
PLO_NPCSERVERADDR = 79,
PLO_LARGEFILESIZE = 84,
PLO_RAWDATA = 100,
PLO_FILE = 102,
PLO_RC_MAXUPLOADFILESIZE = 103,
}
export enum RCOutgoingPacket {
PLI_PLAYERPROPS = 2,
PLI_RAWDATA = 50,
PLI_RC_SERVEROPTIONSGET = 51,
PLI_RC_SERVEROPTIONSSET = 52,
PLI_RC_FOLDERCONFIGGET = 53,
PLI_RC_FOLDERCONFIGSET = 54,
PLI_RC_RESPAWNSET = 55,
PLI_RC_HORSELIFESET = 56,
PLI_RC_APINCREMENTSET = 57,
PLI_RC_BADDYRESPAWNSET = 58,
PLI_RC_PLAYERPROPSGET = 59,
PLI_RC_PLAYERPROPSSET = 60,
PLI_RC_DISCONNECTPLAYER = 61,
PLI_RC_UPDATELEVELS = 62,
PLI_RC_ADMINMESSAGE = 63,
PLI_RC_PRIVADMINMESSAGE = 64,
PLI_RC_LISTRCS = 65,
PLI_RC_DISCONNECTRC = 66,
PLI_RC_APPLYREASON = 67,
PLI_RC_SERVERFLAGSGET = 68,
PLI_RC_SERVERFLAGSSET = 69,
PLI_RC_ACCOUNTADD = 70,
PLI_RC_ACCOUNTDEL = 71,
PLI_RC_ACCOUNTLISTGET = 72,
PLI_RC_PLAYERPROPSGET2 = 73,
PLI_RC_PLAYERPROPSGET3 = 74,
PLI_RC_PLAYERPROPSRESET = 75,
PLI_RC_PLAYERPROPSSET2 = 76,
PLI_RC_ACCOUNTGET = 77,
PLI_RC_ACCOUNTSET = 78,
PLI_RCCHAT = 79,
PLI_RC_WARPPLAYER = 82,
PLI_RC_PLAYERRIGHTSGET = 83,
PLI_RC_PLAYERRIGHTSSET = 84,
PLI_RC_PLAYERCOMMENTSGET = 85,
PLI_RC_PLAYERCOMMENTSSET = 86,
PLI_RC_PLAYERBANGET = 87,
PLI_RC_PLAYERBANSET = 88,
PLI_RC_FILEBROWSER_START = 89,
PLI_RC_FILEBROWSER_CD = 90,
PLI_RC_FILEBROWSER_END = 91,
PLI_RC_FILEBROWSER_DOWN = 92,
PLI_RC_FILEBROWSER_UP = 93,
PLI_NPCSERVERQUERY = 94,
PLI_RC_FILEBROWSER_MOVE = 96,
PLI_RC_FILEBROWSER_DELETE = 97,
PLI_RC_FILEBROWSER_RENAME = 98,
PLI_RC_LARGEFILESTART = 155,
PLI_RC_LARGEFILEEND = 156,
PLI_RC_FOLDERDELETE = 160,
PLI_RC_UNKNOWN162 = 162
}
export enum NCIncomingPacket {
PLO_NEWWORLDTIME = 42,
PLO_RCCHAT = 74,
PLO_NC_CONTROL = 78,
PLO_NC_LEVELLIST = 80,
PLO_NC_NPCATTRIBUTES = 157,
PLO_NC_NPCADD = 158,
PLO_NC_NPCDELETE = 159,
PLO_NC_NPCSCRIPT = 160,
PLO_NC_NPCFLAGS = 161,
PLO_NC_CLASSGET = 162,
PLO_NC_CLASSADD = 163,
PLO_NC_LEVELDUMP = 164,
PLO_NC_WEAPONLISTGET = 167,
PLO_NC_CLASSDELETE = 188,
PLO_NC_WEAPONGET = 192
}
export enum NCOutgoingPacket {
PLI_NC_NPCGET = 103,
PLI_NC_NPCDELETE = 104,
PLI_NC_NPCRESET = 105,
PLI_NC_NPCSCRIPTGET = 106,
PLI_NC_NPCWARP = 107,
PLI_NC_NPCFLAGSGET = 108,
PLI_NC_NPCSCRIPTSET = 109,
PLI_NC_NPCFLAGSSET = 110,
PLI_NC_NPCADD = 111,
PLI_NC_CLASSEDIT = 112,
PLI_NC_CLASSADD = 113,
PLI_NC_LOCALNPCSGET = 114,
PLI_NC_WEAPONLISTGET = 115,
PLI_NC_WEAPONGET = 116,
PLI_NC_WEAPONADD = 117,
PLI_NC_WEAPONDELETE = 118,
PLI_NC_CLASSDELETE = 119,
PLI_NC_LEVELLISTGET = 150,
PLI_NC_LEVELLISTSET = 151,
}
export enum PlayerProperties {
PLPROP_NICKNAME = 0,
PLPROP_ID = 14,
PLPROP_ADDITFLAGS = 33,
PLPROP_PSTATUSMSG = 53
}

159
src/serverLister.ts Normal file
View file

@ -0,0 +1,159 @@
import { GBufferReader, GBufferWriter } from './GBuffer';
import { GProtocol, ProtocolGen } from './GProtocol';
import { GSocket } from './GSocket';
import { PacketTable } from './PacketTable';
import { ServerCategory, ServerEntry, ServerlistConfig } from './types';
function determineServerType(serverName: string): [string, ServerCategory] {
switch (serverName.substring(0, 2)) {
case 'H ':
return [serverName.substring(2).trimLeft(), ServerCategory.hosted];
case 'P ':
return [serverName.substring(2).trimLeft(), ServerCategory.gold];
case 'U ':
return [serverName.substring(2).trimLeft(), ServerCategory.hidden];
case '3 ':
return [serverName.substring(2).trimLeft(), ServerCategory.g3d];
}
return [serverName, ServerCategory.classic];
}
export class Serverlister {
private readonly config: ServerlistConfig = {
host: 'listserver.graal.in',
port: 14922,
account: '',
password: '',
nickname: 'unknown',
};
private packetTable: PacketTable;
private sock?: GSocket;
private resolvePromise:
| ((value: ServerEntry[] | PromiseLike<ServerEntry[]>) => void)
| undefined;
// private rejectPromise: ((reason?: any) => void) | undefined;
constructor(config: Partial<ServerlistConfig>) {
this.config = { ...this.config, ...config };
this.packetTable = this.initializeHandlers();
}
public static request(
config: Partial<ServerlistConfig>,
): Promise<ServerEntry[]> {
return new Promise<ServerEntry[]>(function (resolve, reject) {
const serverList = new Serverlister(config);
serverList.resolvePromise = resolve;
serverList.packetTable.on(4, (id: number, packet: Buffer): void => {
const discMsg = packet.toString();
reject(discMsg);
});
serverList.sock = GSocket.connect(
serverList.config.host,
serverList.config.port,
{
connectCallback: () => serverList.sendLoginPacket(),
disconnectCallback: (err: any) => reject(err),
packetTable: serverList.packetTable,
},
);
});
}
private disconnect() {
if (this.sock) {
this.sock.disconnect();
this.sock = undefined;
}
}
private sendLoginPacket(): void {
if (!this.sock) return;
let nb = GBufferWriter.create();
// Handshake packet
let someKey = GProtocol.generateKey();
nb.writeGUInt8(someKey);
nb.writeChars('G3D30123');
nb.writeChars('rc2');
this.sock.sendData(this.sock.sendPacket(7, nb.buffer));
this.sock.setProtocol(ProtocolGen.Gen5, someKey);
// Send credentials
nb.clear();
nb.writeGString(this.config.account);
nb.writeGString(this.config.password);
this.sock.sendData(this.sock.sendPacket(1, nb.buffer));
}
private initializeHandlers() {
const packetTable = new PacketTable();
// packetTable.setCatchAll((id: number, packet: Buffer): void => {
// console.log(`Unhandled Packet (${id}): `, packet);
// });
packetTable.on(0, (id: number, packet: Buffer): void => {
let internalBuf = GBufferReader.from(packet);
let serverCount = internalBuf.readGUInt8();
let servers = [];
for (let i = 0; i < serverCount; i++) {
internalBuf.readGUInt8();
const serverName = internalBuf.readGString();
const serverTypeData = determineServerType(serverName);
const entry: ServerEntry = {
name: serverTypeData[0],
category: serverTypeData[1],
language: internalBuf.readGString(),
description: internalBuf.readGString(),
url: internalBuf.readGString(),
version: internalBuf.readGString(),
pcount: ~~internalBuf.readGString(),
ip: internalBuf.readGString(),
port: ~~internalBuf.readGString(),
};
servers.push(entry);
}
if (this.resolvePromise) {
this.resolvePromise(servers);
this.resolvePromise = undefined;
this.disconnect();
}
});
// packetTable.on(2, (id: number, packet: Buffer): void => {
// let statusMsg = packet.toString();
// console.log(`Status Msg: ${statusMsg}`);
// });
// packetTable.on(3, (id: number, packet: Buffer): void => {
// let siteUrl = packet.toString();
// console.log(`Website: ${siteUrl}`);
// });
// packetTable.on(4, (id: number, packet: Buffer): void => {
// let discMsg = packet.toString();
// console.log(`Disconnected for ${discMsg}`);
// this.rejectPromise(discMsg);
// });
return packetTable;
}
}

View file

@ -0,0 +1,5 @@
Logger.error(
`[RC] Unhandled Packet (${id}): ${packet
.toString()
.replace(/\r/g, '')}`,
);

102
src/types.ts Normal file
View file

@ -0,0 +1,102 @@
import { NPC } from './misc/npcs';
export enum ServerCategory {
classic = 0,
gold = 1,
hosted = 2,
hidden = 3,
g3d = 4,
}
export interface ServerEntry {
name: string;
category: ServerCategory;
language: string;
description: string;
url: string;
version: string;
pcount: number;
ip: string;
port: number;
}
export interface ServerlistConfig {
host: string;
port: number;
account: string;
password: string;
nickname: string;
}
export interface RCEvents {
onRCConnected?(instance: RCInterface): void;
onRCDisconnected?(instance: RCInterface, text?: string): void;
onRCChat?(text: string): void;
onFileBrowserMsg?(text: string): void;
onRCEventMsg?(text: string): void;
}
export interface RCInterface {
get maxUploadFileSize(): number;
sendRCChat(text: string): void;
setNickName(name: string): void;
requestFolderConfig(): Promise<string>;
requestServerFlags(): Promise<string>;
requestServerOptions(): Promise<string>;
// setFolderConfig(text: string): void;
// setServerFlags(text: string): void;
// setServerOptions(text: string): void;
}
export interface NCEvents {
onNCConnected?(): void;
onNCDisconnected?(text?: string): void;
onNCChat?(text: string): void;
onNpcAdded?(name: string): void;
onNpcDeleted?(name: string): void;
}
export interface NCInterface {
get classes(): Set<string>;
get npcs(): NPC[];
requestLevelList(): Promise<string>;
deleteWeapon(name: string): void;
requestWeaponList(): Promise<Set<string>>;
requestWeapon(name: string): Promise<[string, string]>;
setWeaponScript(name: string, image: string, script: string): void;
deleteNpc(name: string): void;
requestNpcAttributes(name: string): Promise<string>;
requestNpcFlags(name: string): Promise<string>;
requestNpcScript(name: string): Promise<string>;
setNpcFlags(name: string, script: string): void;
setNpcScript(name: string, script: string): void;
deleteClass(name: string): void;
requestClass(name: string): Promise<string>;
setClassScript(name: string, script: string): void;
}
export enum FSEntryType {
File,
Directory,
}
export interface FSEntries {
name: string;
type: FSEntryType;
permissions: string;
fileSize: number;
modTime: number;
}
export type DirectoryListing = {
directory: string;
fileList: FSEntries[];
};

227
src/utils.ts Normal file
View file

@ -0,0 +1,227 @@
export function hashCode(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
return hash;
}
export function escapeRegExp(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function graal_tokenize(string: string, sep = ' ') {
let separator = sep[0];
let insideQuote = false;
let stringList = [];
let currentString = '';
let stringLength = string.length;
for (let i = 0; i < stringLength; i++) {
switch (string[i]) {
case separator: {
if (!insideQuote) {
stringList.push(currentString);
currentString = '';
} else currentString += string[i];
break;
}
case '"': {
insideQuote = !insideQuote;
break;
}
case '\\': {
if (i + 1 < stringLength) {
switch (string[i + 1]) {
case '"':
case '\\':
i++;
default:
currentString += string[i];
break;
}
} else currentString += string[i];
break;
}
default: {
currentString += string[i];
break;
}
}
}
stringList.push(currentString);
return stringList;
}
/**
* Takes a string as input, and returns an escaped string replacing new lines
* with commas and escaping slashes, and quotes
*
* @param buffer
* @returns
*/
export function gtokenize(buffer: string): string {
let output = '';
// Remove carriage return characters from the string
buffer = buffer.replace(/\r/g, '');
// Split the buffer by newlines, keeping empty values as well
const elements = buffer.split('\n', -1);
for (let el of elements) {
if (el.length > 0) {
let complex = false;
for (const ch of el) {
if (ch < '!' || ch > '~' || ch == ',' || ch == '/') {
complex = true;
break;
}
}
if (el.trim().length == 0) {
complex = true;
}
if (complex) {
el = el.replace(/\\/g, '\\\\');
el = el.replace(/\"/g, '""');
output += '"' + el + '",';
} else output += el + ',';
} else output += ',';
}
return output.slice(0, -1);
}
export function guntokenize(buffer: string): string {
let output = '';
let is_paren = false;
// Check to see if we are starting with a quotation mark.
let i = 0;
if (buffer[0] == '"') {
is_paren = true;
++i;
}
// Untokenize.
for (; i < buffer.length; ++i) {
// If we encounter a comma not inside a quoted string, we are encountering
// a new index. Replace the comma with a newline.
if (buffer[i] == ',' && !is_paren) {
output += '\n';
// Ignore whitespace.
while (i + 1 < buffer.length && buffer[i + 1] == ' ') ++i;
// Check to see if the next string is quoted.
if (i + 1 < buffer.length && buffer[i + 1] == '"') {
is_paren = true;
++i;
}
}
// We need to handle quotation marks as they have different behavior in quoted strings.
else if (buffer[i] == '"') {
// If we are encountering a quotation mark in a quoted string, we are either
// ending the quoted string or escaping a quotation mark.
if (is_paren) {
if (i + 1 < buffer.length) {
// Escaping a quotation mark.
if (buffer[i + 1] == '"') {
output += '"';
++i;
}
// Ending the quoted string.
else if (buffer[i + 1] == ',') is_paren = false;
}
}
// A quotation mark in a non-quoted string.
else output += buffer[i];
}
// Unescape '\' character
else if (buffer[i] == '\\') {
if (i + 1 < buffer.length && buffer[i + 1] == '\\') {
output += '\\';
i++;
}
}
// Anything else gets put to the output.
else output += buffer[i];
}
return output;
}
export function gCommaStrTokens(buffer: string) {
let retData: string[] = [];
// CString line;
let line: string = '';
let is_paren = false;
// // Check to see if we are starting with a quotation mark.
let i = 0;
if (buffer[0] == '"') {
is_paren = true;
++i;
}
// // Untokenize.
for (; i < buffer.length; ++i) {
// If we encounter a comma not inside a quoted string, we are encountering
// a new index. Replace the comma with a newline.
if (buffer[i] == ',' && !is_paren) {
retData.push(line);
line = '';
// Ignore whitespace.
while (i + 1 < buffer.length && buffer[i + 1] == ' ') ++i;
// Check to see if the next string is quoted.
if (i + 1 < buffer.length && buffer[i + 1] == '"') {
is_paren = true;
++i;
}
}
// We need to handle quotation marks as they have different behavior in quoted strings.
else if (buffer[i] == '"') {
// If we are encountering a quotation mark in a quoted string, we are either
// ending the quoted string or escaping a quotation mark.
if (is_paren) {
if (i + 1 < buffer.length) {
// Escaping a quotation mark.
if (buffer[i + 1] == '"') {
line += '"';
++i;
}
// Ending the quoted string.
else if (buffer[i + 1] == ',') is_paren = false;
}
}
// A quotation mark in a non-quoted string.
else line += buffer[i];
}
// Unescape '\' character
else if (buffer[i] == '\\') {
if (i + 1 < buffer.length) {
if (buffer[i + 1] == '\\') {
line += '\\';
i++;
}
}
}
// Anything else gets put to the output.
else line += buffer[i];
}
if (is_paren || line.length > 0) retData.push(line);
return retData;
}

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"rootDir": "src",
"outDir": "dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}