It’s only a personal note. Don’t treat this as a tutorial.
An attempt to do a fully working Tibia login protocol with help of Node.js, based on The Forgotten Server. It’s only a personal note. Don’t treat this as a tutorial.
As a kid, I spent a lot of time playing in Tibia. Whether on the official servers or the Open Tibia Servers. With this game, I had my first attempt at coding.
One time, I’ve started thinking - how Tibia engines are made, how they work. I got an idea in my head, to rewrite one of these engines from C++ to the Javascript.
I’ve started to look at the source code of the most popular engines like The Forgotten Server and YurOTS. I quickly understood that’s a hard task that needs a lot of time, so it was clear that has no sense to rewrite the whole engine from C++ to JS.
But I decided to rewrite a small part of this engine at least that will be responsible for the user authentication & authorization. Provide login details, validate them, check if the user exists - if so, display appropriate status based on different conditions - if the user account or IP address is banished and when everything is fine - display characters list.
I’ve decided that I’ll try to make an authentication system for 7.6 protocol because I’ve spent the most time on it.
Because I was playing by a long time on one of the polish 7.6 OTS, I’ve chosen that I’ll take YurOTS 0.9.4F as a pattern. He’s buggy, messy but I think we can choose this engine just for the experiment only.
Additionally, I’ve made some research with the hope that someone did that. Unfortunately, there were few people, but everybody quickly gave up (now I know why).
Nonetheless, I’ve found one repository on the Github, that seems to be promising.
So we start coding and experimenting!
npm init -y
npm install nodemon -g
touch server.js
Added "start": "nodemon server.js"
to the package.json
I start by creating a TCP server, that will be listening on the PORT 7171.
// server.js
const net = require("net")
const GameServer = require("./src/gameServer")
const PORT = 7171
const server = net.createServer()
const gameServer = new GameServer()
server.listen(PORT, gameServer.listen(server))
// src/gameServer.js
class GameServer {
listen() {
console.log("Listening...")
}
}
module.exports = GameServer
Okay, I’ve got a working server that listens on the PORT 7171. Now I need to receive the data.
// src/gameServer.js
class GameServer {
listen(server) {
console.log("Listening...")
server.on("connection", this.connection().bind(this))
}
connection() {
console.log("Connection estabilished.")
return function (socket) {
try {
socket.on("data", data => this.onData(socket, data))
socket.on("end", this.onEnd())
} catch (err) {
console.log(err)
}
}
}
onData(socket, data) {
console.log(data)
}
onEnd() {
return function () {
console.log("Connection closed.")
}
}
}
module.exports = GameServer
Now let’s try to login and see what gonna happen. As a client, I’ll use the OTClient - open-source version of Tibia client.
As you can see, my server received data during a login attempt on the 127.0.0.1:7171.
Now we need to get some more information from this packet.
// src/networkMessage.js
class NetworkMessage {
constructor() {
this.buffer = []
this.offset = 2
}
getU16() {
const v = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8)
this.offset += 2
return v
}
}
module.exports = NetworkMessage
// src/gameServer.js
const NetworkMessage = require('./networkMessage');
...
onData(socket, data) {
const msg = new NetworkMessage();
msg.buffer = data;
console.log(msg.getU16());
}
Let’s try to login one more time, that’s what we gonna see.
Let’s use decimal to hex converter 513 = 201
Reviewing a source code of the other engines, I’ve found a code snippet that is responsible for the logging. He contains that hex - 0x0201 (as a protId) - boom, we good a match, we need to go deeper.
Now, let’s implement a method that resets our buffer offset.
// src/networkMessage.js
reset() {
this.offset = 2;
}
Reviewing a source and docs of TibiaAPI results that hex 0x0A is responsible for displaying messages from the server during login.
Let’s try, but first, we need methods, that will create the right packet.
// src/networkMessage.js
addByte(value) {
this.buffer[this.offset] = value & 0xFF;
++this.offset;
}
addU16(value) {
this.buffer[this.offset++] = value;
this.buffer[this.offset++] = value >> 8;
}
addString(str) {
this.addU16(str.length);
for (let i = 0; i < str.length; ++i) {
this.addByte(str.charCodeAt(i));
}
}
// src/gameServer.js
onData(socket, data) {
const msg = new NetworkMessage();
msg.buffer = data;
const protId = msg.getU16();
if (protId === 0x0201) {
console.log('Got 0x0201 - authenticate');
msg.reset();
msg.addByte(0x0A);
msg.addString('test');
socket.write(msg.buffer);
} else {
console.log('Unknown packet.')
}
}
Now let’s try to login, we gonna see:
Server sent to the client:
<Buffer 91 00 0a 04 00 74 65 73 74 9d 43 be 52 98 43 e7 dd c5 56 69 f9 26 13 06 00 64 73 61 64 73 61 52 3b a0 a7 a8 c2 7f 82 2c 6b 78 fb e3 05 12 17 63 51 ee ... >
0a - stands for 0x0A
74 65 73 74 - stands for test
message
Okay, now we know how to display a login error message, now we need to find a way to get an account number and password.
Let’s try to login on that credentials: 111111/tibia and look on the Wireshark.
Server got from the client packet that looks like:
91 00 01 02 00 f8 02 33 5a 9d 43 be 52 98 43 e7 dd c5 56 07 b2 01 00 05 00 74 69 62 69 61
c2 18 10 1b 09 df 8a 61 c1 30 f7 9c 91 15 b7 b8 18 0e bb 18 14 b6 32 ac b5 ea b3 95 64 9c
72 aa c1 ee 7d de 26 cd 95 d2 75 aa a3 9f 78 7b 51 1d 99 ad d9 a5 3f 2d 82 af 98 35 e1 35
0c ff 5d 40 3a 07 08 8e a6 0b 42 ae 01 63 4a 85 35 ed 29 a3 1c 1b 13 42 08 67 b9 6b 05 c3
9f 1b 00 29 f3 d7 b6 0c f9 92 98 b1 86 52 85 35 a0 76 8e 54 c1 0c 9c a2 c7 19 2f
We start to read a packet from the offset 3 because we have offset init value that equals 2.
91 00 - ignore
01 02 - ignore because of getu16 protid offset+2
00 f8 02 33 5a 9d 43 be 52 98 43 e7 dd c5 56 - skip 15 bytes
07 b2 01 00 - 111111 (account number) (hex to decimal 32 unsigned integer little endian)
74 69 62 69 61 - tibia (password)
It follows that bits 19-22 are responsible for the account number and 24-? for the password.
The default offset is 2, also it has been moved by another 2 positions by the getU16() method. The packet analysis shows that we need to move offset by another 15 bits, so let’s implement a method.
// src/networkMessage.js
skipBytes(count) {
this.offset += count;
}
The next 4 bits are responsible for the account number, so we need the right method.
// src/networkMessage.js
getU32() {
const v = ((
this.buffer[this.offset]) |
(this.buffer[this.offset+1] << 8) |
(this.buffer[this.offset+2] << 16) |
(this.buffer[this.offset+3] << 24));
this.offset += 4;
return v;
}
Next, we gonna need a method that will return the password.
// src/networkMessage.js
getByte() {
return this.buffer[this.offset++];
}
getString() {
const stringlen = this.getU16();
let v = '';
for (let i = 0; i < stringlen; ++i) {
v += String.fromCharCode(this.getByte());
}
return v;
}
Now, let’s try to console log that credentials.
// src/gameServer.js
onData(socket, data) {
const msg = new NetworkMessage();
msg.buffer = data;
const protId = msg.getU16();
if (protId === 0x0201) {
msg.skipBytes(15)
const accnumber = msg.getU32();
const password = msg.getString();
console.log({ accnumber, password });
} else {
console.log('Unknown packet.')
}
}
In the console, we can see
Okay, now we have an account number and password, now let’s make some simple database.
I’m gonna use Docker for this one.
docker pull mysql
docker run --name otnodedb -e MYSQL_ROOT_PASSWORD=otnode -d mysql:latest
docker exec -it otnodedb bash
mysql -u root -p
CREATE DATABASE otnodedb;
exit
docker exec -i otnodedb mysql -uroot -potnode otnodedb < otnode.sql
/* otnode.sql */
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
--
-- Database: `otnodedb`
--
-- --------------------------------------------------------
--
-- Table structure for table `accounts`
--
CREATE TABLE `accounts` (
`id` int(11) NOT NULL,
`account` int(10) NOT NULL,
`password` varchar(30) NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
--
-- Dumping data for table `accounts`
--
INSERT INTO `accounts` (`id`, `account`, `password`, `created_at`) VALUES
(1, 123, 'password', '2020-02-28 12:48:40'),
(2, 123123, 'tibia', '2020-02-28 12:48:40'),
(3, 111111, 'tibia', '2020-03-06 20:34:06');
-- --------------------------------------------------------
--
-- Table structure for table `players`
--
CREATE TABLE `players` (
`id` int(11) NOT NULL,
`nickname` varchar(50) NOT NULL,
`accountId` int(11) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
--
-- Dumping data for table `players`
--
INSERT INTO `players` (`id`, `nickname`, `accountId`) VALUES
(1, 'Nath Rakol', 1),
(2, 'Seyrino Faryk', 1),
(3, 'Durus Lazy', 2),
(4, 'Malis Suchendor', 3);
--
-- Indexes for dumped tables
--
--
-- Indexes for table `accounts`
--
ALTER TABLE `accounts`
ADD PRIMARY KEY (`id`);
--
-- Indexes for table `players`
--
ALTER TABLE `players`
ADD PRIMARY KEY (`id`),
ADD KEY `accountId` (`accountId`);
--
-- AUTO_INCREMENT for dumped tables
--
--
-- AUTO_INCREMENT for table `accounts`
--
ALTER TABLE `accounts`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4;
--
-- AUTO_INCREMENT for table `players`
--
ALTER TABLE `players`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=5;
--
-- Constraints for dumped tables
--
--
-- Constraints for table `players`
--
ALTER TABLE `players`
ADD CONSTRAINT `players_ibfk_1` FOREIGN KEY (`accountId`) REFERENCES `accounts` (`id`);
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
We have 3 accounts in the database.
123/password/ - 2 characters - Nath Rakol & Sayrino Faryk
123123/tibia - 1 character - Durus Lazy
111111/tibia - 1 character - Malis Suchendor
Now we need to handle the connection with the database from the server. I’m going to use mysql2 package.
// src/database.js
const mysql = require("mysql2/promise")
class Database {
constructor() {
this.host = "172.17.0.2"
this.user = "root"
this.password = "otnode"
this.database = "otnodedb"
}
connection() {
const connection = mysql.createPool({
host: this.host,
user: this.user,
password: this.password,
database: this.database,
})
return connection
}
async getAccount(account, password) {
try {
const [rows] = await this.connection().execute(
`SELECT pl.nickname FROM accounts AS acc INNER JOIN players AS pl WHERE account = "${account}" AND password = "${password}" AND acc.id = pl.accountId`
)
return rows
} catch (error) {
return error
}
}
}
module.exports = Database
// src/gameServer.js
const db = require('./database');
...
async onData(socket, data) {
const msg = new NetworkMessage();
msg.buffer = data;
const protId = msg.getU16();
if (protId === 0x0201) {
msg.skipBytes(15)
const accnumber = msg.getU32();
const password = msg.getString();
console.log({ accnumber, password });
const db = new Database();
const account = await db.getAccount(accnumber, password);
console.log(account);
} else {
console.log('Unknown packet.')
}
A login attempt on the 111111/tibia credentials, shows us:
We can now implement the login error message in case of invalid credentials.
If the user will try to send a request with empty data, we need to handle that.
// src/gameServer.js
if (accnumber === 0 || password === "") {
msg.addByte(0x0a)
msg.addString("No account number or password.")
socket.write(msg.buffer)
} else {
const db = new Database()
const account = await db.getAccount(accnumber, password)
console.log(account)
}
If credentials will be invalid, we’ll display the ‘Invalid credentials’ message.
// src/gameServer.js
if (accnumber === 0 || password === "") {
msg.addByte(0x0a)
msg.addString("No account number or password.")
socket.write(msg.buffer)
} else {
const db = new Database()
const account = await db.getAccount(accnumber, password)
if (account.length > 0) {
// todo
} else {
msg.reset()
msg.addByte(0x0a)
msg.addString("Invalid credentials.")
socket.write(msg.buffer)
}
}
Now we can implement the characters list. Hex responsible for displaying characters list is 0x64.
Blaster_89
TcpClient (Tibia 7.6) Character List
-------------------------------------------
2 bytes packet length
1 byte packet type (0x14)
2 bytes motd length
x bytes motd (id:0-255 + message, example: 123 + "\n\nyaddayadda")
0x64 (not sure what this is) - defines character list?
1 byte amount of characters
--- loop this
---2 bytes character name length u16
---x bytes character name 2 bytes for string length, then 1 char 1 byte
---2 bytes server name length u16
---x bytes server name 2 bytes for string length, then 1 char 1 byte
---4 bytes server ip u32
---2 bytes server port u16
---2 bytes premium days u16
// src/gameServer.js
if (account.length > 0) {
msg.reset()
msg.addByte(0x64)
msg.addByte(account.length)
for (let i in account) {
msg.addString(account[i].nickname)
msg.addString("World")
msg.addU32(0x0d58a8c0) // reversed 192.168.88.13 => 13.88.168.192
msg.addU16(7171)
}
msg.addU16(12)
socket.write(msg.buffer)
}
A login attempt on the 111111/tibia credentials, shows us:
And for 123/password
When we’re trying to login character to the game world, the server gets packet 522 (0x020a).
When trying to login on the account 111111 / tibia, the server receives such a packet
85 00 0a 02 00 f8 02 00 07 b2 01 00 0f 00 4d 61 6c 69 73 20 53 75 63 68 65 6e 64 6f 72 05
00 74 69 62 69 61 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
We can get client OS, version, account number, nickname and password from this packet.
// src/gameServer.js
if (protId === 0x0201) {
...
} else {
if (protId === 0x020A) {
const clientOS = msg.getByte();
const version = msg.getU16();
const unknown = msg.getByte();
const accnumber = msg.getU32();
const nickname = msg.getString();
const password = msg.getString();
console.log({
clientOS,
version,
unknown,
accnumber,
nickname,
password
});
// { clientOS: 0,
// version: 760,
// unknown: 0,
// accnumber: 111111,
// nickname: 'Malis Suchendor',
// password: 'tibia' }
}
Based on this we can display a login error message if the player is banned for example.
ALTER TABLE players ADD COLUMN isBanished BOOL DEFAULT 0;
UPDATE players SET isBanished = 1 WHERE id = 4;
// src/database.js
async isBanished(nickname) {
try {
const [rows] = await this.connection().execute(`SELECT isBanished FROM players WHERE nickname = "${nickname}"`);
if (rows[0].isBanished) {
return true;
}
return false;
} catch (error) {
return error;
}
}
// src/gameServer.js
...
const nickname = msg.getString();
const password = msg.getString();
const isBanished = await db.isBanished(nickname);
if (isBanished) {
msg.reset()
msg.addByte(0x14);
msg.addString('Your character has been banished!');
socket.write(msg.buffer);
}
If everything is fine, we can login to the game world.
// src/gameServer.js
if (isBanished) {
msg.reset()
msg.addByte(0x14)
msg.addString("Your character has been banished!")
socket.write(msg.buffer)
} else {
msg.reset()
msg.addByte(0xff)
socket.write(msg.buffer)
msg.reset()
msg.addByte(0xb4)
msg.addByte(0x12)
msg.addString("Hello world")
socket.write(msg.buffer)
}