Legend of Mir2 Source Code Analysis and remake in Unity (1)

Sou1gh0st
6 min readMar 12, 2023

--

Topic Introduction

This topic will analyze the source code of LOMCN’s Legend of Mir2 (Developed in C# and based on official Korean Legend of Mir2). I will explain in detail about the client-server data interaction, state management and client-side rendering, etc. In addition, I will share the whole process of porting the client-side part to Unity and rewriting the server-side part in a modern programming language.

Related Information

Overview

In this article, we will start with the server-side start-up process and analyze the procedures including TCP connection listening, packet processing and client-side state management.

Start-up Process

WinForm’s Entry Point — SMain.cs

The server side has written the control panel based on WinForm, so the environment is started in the Load callback of WinForm, where the DB is loaded and the server environment is started. The DB here is the database of the whole game.

Note that there are two completely separate Envir instances here, one is EditEnvir and the other is Main (named Envir), the LoadDB method will load maps, items, NPCs, quests and other data to the Envir instance. Here it will first check if the DB can be loaded into EditEnvir, and if it succeeds, start the Main Envir (In fact, the database needs to be loaded into Main Envir later):

private void SMain_Load(object sender, EventArgs e)
{
var loaded = EditEnvir.LoadDB();

if (loaded)
{
Envir.Start();
}

AutoResize();
}

Start Envir

The code above calls Envir’s Start method, which opens the entire server-side WorkLoop by creating a new thread, note that the server-side here contains two parts: UI and GameLoop, where the UI part is handled by WinForm and the GameLoop part is handled by the WorkLoop:

public void Start()
{
if (Running || _thread != null) return;

Running = true;

_thread = new Thread(WorkLoop) {IsBackground = true};
_thread.Start();

}

The general structure of the entire WorkLoop is as follows, from which we can see that the database is first loaded into the Main Envir via StartEnvir (similar to loading data into EditEnvir above), then start the network listening via StartNetwork and begin the real gameloop with a while loop:

private void WorkLoop()
{
//try
{
// ...
StartEnvir();
// ...
StartNetwork();
// ...
try
{
while (Running)
{
// Game Loop
}
}
catch (Exception ex)
{
// ...
}

// ...
StopNetwork();
StopEnvir();
SaveAccounts();
SaveGuilds(true);
SaveConquests(true);
}
_thread = null;
}

TCP Connectoin Management

We will go into the detail of the gameloop in the next articles, but for now we will focus on the network processing part.

Before the gameloop starts, it intializes the server network via StartNetwork, loads the account data from Server.MirADB via LoadAccounts method in order to handle client authentication, and then opens a tcp listener to start accepting client connections asynchronously (note that the connection callback will be called from anchor thread):

private void StartNetwork()
{
Connections.Clear();

LoadAccounts();

LoadGuilds();

LoadConquests();

_listener = new TcpListener(IPAddress.Parse(Settings.IPAddress), Settings.Port);
_listener.Start();
_listener.BeginAcceptTcpClient(Connection, null);

if (StatusPortEnabled)
{
_StatusPort = new TcpListener(IPAddress.Parse(Settings.IPAddress), 3000);
_StatusPort.Start();
_StatusPort.BeginAcceptTcpClient(StatusConnection, null);
}

MessageQueue.Enqueue("Network Started.");
}

Each connection is wrapped as a MirConnectoin, and each of them has its own ReceiveList and SendList message queues, and a Connected message will be enqueued to the SendList as soon as the connection is established:

public MirConnection(int sessionID, TcpClient client)
{
SessionID = sessionID;
IPAddress = client.Client.RemoteEndPoint.ToString().Split(':')[0];

// ...

_client = client;
_client.NoDelay = true;

TimeConnected = Envir.Time;
TimeOutTime = TimeConnected + Settings.TimeOut;

_receiveList = new ConcurrentQueue<Packet>();
_sendList = new ConcurrentQueue<Packet>();
_sendList.Enqueue(new S.Connected());
_retryList = new Queue<Packet>();

Connected = true;
BeginReceive();
}

Here S is Packet Factory, the corresponding namespace on the client-side and the server-side are ServerPackets and ClientPackets respectively, each packet has an id, for example, here S.Connected Packet is defined as follows:

namespace ServerPackets
{
public sealed class Connected : Packet
{
public override short Index
{
get { return (short)ServerPacketIds.Connected; }
}

protected override void ReadPacket(BinaryReader reader)
{
}

protected override void WritePacket(BinaryWriter writer)
{
}
}
}

The MirConnection’s BegainReceive method start to asynchronously listener to the socket buffer via the TcpClient’s BeginReceive method:

private void BeginReceive()
{
if (!Connected) return;

try
{
_client.Client.BeginReceive(_rawBytes, 0, _rawBytes.Length, SocketFlags.None, ReceiveData, _rawBytes);
}
catch
{
Disconnecting = true;
}
}

Here rawBytes defaults to 8 * 1024 = 8KB, it is used for TCP Sticky packets, where multiple packets may be combinded into a single packet and arriving at the same time.

private void ReceiveData(IAsyncResult result)
{
if (!Connected) return;

int dataRead;

try
{
dataRead = _client.Client.EndReceive(result);
}
catch
{
Disconnecting = true;
return;
}

if (dataRead == 0)
{
Disconnecting = true;
return;
}

byte[] rawBytes = result.AsyncState as byte[];

byte[] temp = _rawData;
_rawData = new byte[dataRead + temp.Length];
Buffer.BlockCopy(temp, 0, _rawData, 0, temp.Length);
Buffer.BlockCopy(rawBytes, 0, _rawData, temp.Length, dataRead);

Packet p;
while ((p = Packet.ReceivePacket(_rawData, out _rawData)) != null)
_receiveList.Enqueue(p);

BeginReceive();
}

The Packet class located at Shared/Packet.cs is shared between the server and the client, it defines the data structure of the packet, and all packets has a common header structure: length and identifier, so the ReceivePacket method can read the common header to know the length and type of the it, then create a specific packet instance based on the type, fill it with received data, and enqueue it to the ReceiveList message queue:

public static Packet ReceivePacket(byte[] rawBytes, out byte[] extra)
{
extra = rawBytes;

Packet p;

if (rawBytes.Length < 4) return null; //| 2Bytes: Packet Size | 2Bytes: Packet ID |

int length = (rawBytes[1] << 8) + rawBytes[0];

if (length > rawBytes.Length || length < 2) return null;

using (MemoryStream stream = new MemoryStream(rawBytes, 2, length - 2))
using (BinaryReader reader = new BinaryReader(stream))
{
try
{
short id = reader.ReadInt16();

p = IsServer ? GetClientPacket(id) : GetServerPacket(id);
if (p == null) return null;

p.ReadPacket(reader);
}
catch
{
return null;
//return new C.Disconnect();
}
}

extra = new byte[rawBytes.Length - length];
Buffer.BlockCopy(rawBytes, length, extra, 0, rawBytes.Length - length);

return p;
}

Client Packet Processing

After the above analysis we know that the packets from clients will be processed and enqueued to the ReceiveList message queue, then the server will process them in the gameloop:

lock (Connections)
{
for (var i = Connections.Count - 1; i >= 0; i--)
{
Connections[i].Process();
}
}

In the Process method, the server first dequeues a packet from the ReceiveList message queue, and handle it by id, and then generate the server response packet and sends it asynchronously.

Here we will briefly analyze the handling of client-side character walking as an example:

while (!_receiveList.IsEmpty && !Disconnecting)
{
Packet p;
if (!_receiveList.TryDequeue(out p)) continue;
TimeOutTime = Envir.Time + Settings.TimeOut;
ProcessPacket(p);

if (_receiveList == null)
return;
}

private void ProcessPacket(Packet p)
{
if (p == null || Disconnecting) return;

switch (p.Index)
{
// ...
case (short)ClientPacketIds.Walk:
Walk((C.Walk) p);
break;
// ...
}
}

private void Walk(C.Walk p)
{
if (Stage != GameStage.Game) return;

if (Player.ActionTime > Envir.Time)
_retryList.Enqueue(p); // retry later if the client time is abnormal
else
Player.Walk(p.Direction); // processing walk packet and response to client
}

public void Walk(MirDirection dir)
{
// ...
Enqueue(new S.UserLocation { Direction = Direction, Location = CurrentLocation });
Broadcast(new S.ObjectWalk { ObjectID = ObjectID, Direction = Direction, Location = CurrentLocation });
// ...
}

Summary

In this article, we focus on the three processes of server-side startup process, network initialization and connection processing, and briefly analyze the implementation of the gameloop and packet processing. In the next article, we will analyze the whole process from game start, login to the basic game interaction from the client-side perspective.

--

--