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

Sou1gh0st
9 min readMar 19, 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.

Series of Articles

Overview

In this article, we will analyze the entire process from TCP connection establishment, login authentication, character selection, starting the game and in-game interactions from the client side.

Start-up Process

WinForm’s Entry Point Program.cs

Similar to the server side, the client side is also a WinForm application. After the application starts, it jumps to AMain to check for hot updates and then to CMain to start the main client logic:

// Program.cs
[STAThread]
private static void Main(string[] args)
{
// ...
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);

if (Settings.P_Patcher) Application.Run(PForm = new Launcher.AMain());
else Application.Run(Form = new CMain());
// ...
}

Listening to the EventLoop

In the constructor of CMain, we listen to the Application Idle event as the client EventLoop:

// CMain.cs
public CMain()
{
InitializeComponent();

Application.Idle += Application_Idle;

// ...
}

In the Application_Idle method, we update the client-side global timestamp via UpdateTime, process the network packets via UpdateEnvironment and process the client-side rendering via RenderEnvironment:

private static void Application_Idle(object sender, EventArgs e)
{
try
{
while (AppStillIdle)
{
UpdateTime();
UpdateEnviroment();
RenderEnvironment();
}

}
catch (Exception ex)
{
SaveError(ex.ToString());
}
}

Client Side Scene Division

The UpdateEnvironment method does not do anything before the user logs in, so we skip this method first to see how the RenderEnvironment is handled, which is actually a Direct 3D based client rendering loop. Please note the MirScene.ActiveScene.Draw call, the game divides logics into different scenes, such as LoginScene, CharacterSelectScene and GameScene:

private static void RenderEnvironment()
{
try
{
if (DXManager.DeviceLost)
{
DXManager.AttemptReset();
Thread.Sleep(1);
return;
}

DXManager.Device.Clear(ClearFlags.Target, Color.CornflowerBlue, 0, 0);
DXManager.Device.BeginScene();
DXManager.Sprite.Begin(SpriteFlags.AlphaBlend);
DXManager.SetSurface(DXManager.MainSurface);

// Note here
if (MirScene.ActiveScene != null)
MirScene.ActiveScene.Draw();

DXManager.Sprite.End();
DXManager.Device.EndScene();
DXManager.Device.Present();
}
catch (Direct3D9Exception ex)
{
DXManager.DeviceLost = true;
}
catch (Exception ex)
{
SaveError(ex.ToString());

DXManager.AttemptRecovery();
}
}

So where is the ActiveScene set? It is actually set to LoginScene at definition.

public abstract class MirScene : MirControl
{
public static MirScene ActiveScene = new LoginScene();
// ...
}

So the Draw method above actually draws the login page, let’s skip the GUI-related part here and look directly at how the client establishes the connection and make the login request.

TCP Connection Establishment

Each Scene is an UI object inherited from MirControl, which provides a Shown callback to listen to the UI display event, and we will open a TCP connection in this callback.

public LoginScene()
{
// ...
Shown += (sender, args) =>
{
Network.Connect();
_connectBox.Show();
};
}

Network is the network management class for the client, in the Connect method we create a TcpClient and open the connection, the server side information is obtained through the Settings.

// Network.cs
public static void Connect()
{
if (_client != null)
Disconnect();

ConnectAttempt++;

_client = new TcpClient {NoDelay = true};
_client.BeginConnect(Settings.IPAddress, Settings.Port, Connection, null);
}

Similar to the server-side processing, in the asynchronous callback of BeginConnect, we create two queues, receiveList and sendList, and then receive the server-side data via BeginReceive, process them info packets, and add it to the receiveList queue.

At the beginning of each frame, we process the receiveList to synchronize the server-side state, while creating packets based on user input, add them to the sendList, and finally send these data to the server.

The First Data Packet

The Server sends S.Connected

Through the above analysis, we know that the first step of client startup is to request a TCP connection, and the server will create a MirConnection object, when accepting the client (if you don’t remeber this, please refer to the first article). In the constructor of MirConnection the server sends a S.Connected packet to client, that is the first packet:

public MirConnection(int sessionID, TcpClient client)
{
// ...
_receiveList = new ConcurrentQueue<Packet>();
_sendList = new ConcurrentQueue<Packet>();
_sendList.Enqueue(new S.Connected());
_retryList = new Queue<Packet>();

Connected = true;
BeginReceive();
}

The Client Processing S.Connected

We mentioned eariler that calls to UpdateEnviroment from the Application Idle Event Loop are ignored until the TCP connection is established, after that, the UpdateEnviroment calls to Network.Process to process the server packets. These packets are routed to the ActiveScecne for processing, where it is the LoginScene:

public static void Process()
{
// ...
while (_receiveList != null && !_receiveList.IsEmpty)
{
if (!_receiveList.TryDequeue(out Packet p) || p == null) continue;
MirScene.ActiveScene.ProcessPacket(p);
}

if (CMain.Time > TimeOutTime && _sendList != null && _sendList.IsEmpty)
_sendList.Enqueue(new C.KeepAlive());

if (_sendList == null || _sendList.IsEmpty) return;

TimeOutTime = CMain.Time + Settings.TimeOut; // 5000ms

List<byte> data = new List<byte>();
while (!_sendList.IsEmpty)
{
if (!_sendList.TryDequeue(out Packet p)) continue;
data.AddRange(p.GetPacketBytes());
}

CMain.BytesSent += data.Count;

BeginSend(data);
}

The ProcessPacket method in LoginScene includes initialization and processing of account-related data for the client. As the current data packet is S.Connected, it will naturally enter the case for ServerPacketIds.Connected. Subsequently, the client will send a data integrity check request through SendVersion (where the Executable will be hashed).

public override void ProcessPacket(Packet p)
{
switch (p.Index)
{
case (short)ServerPacketIds.Connected:
Network.Connected = true;
SendVersion();
break;
case (short)ServerPacketIds.ClientVersion:
ClientVersion((S.ClientVersion) p);
break;
// ...
default:
base.ProcessPacket(p);
break;
}
}

The data integrity check process is similar to the interaction of Connected packet. First, the client sends the executable hash to the server, after the server verifies it, the result is returned to the client.

Client Login Process

After passing the above checks, the client will display the login form. Once the user fills the form and clicks the login button, the Login method will be called to send the login request:

// LoginScene.cs
private void Login()
{
OKButton.Enabled = false;
Network.Enqueue(new C.Login {AccountID = AccountIDTextBox.Text, Password = PasswordTextBox.Text});
}

As an early game, the password of Mir2 is transmitted in plain text. After the server receives the C.Login packet, it will try to look up the maching account from the Account Database, and if the validation fails, it will send S.Login to return the reason for the login failure, and if it succeeds, it will send S.LoginSuccess:

// Envir.cs
public void Login(ClientPackets.Login p, MirConnection c)
{
// ...
if (!AccountIDReg.IsMatch(p.AccountID))
{
c.Enqueue(new ServerPackets.Login { Result = 1 });
return;
}

if (!PasswordReg.IsMatch(p.Password))
{
c.Enqueue(new ServerPackets.Login { Result = 2 });
return;
}
var account = GetAccount(p.AccountID);

if (account == null)
{
c.Enqueue(new ServerPackets.Login { Result = 3 });
return;
}

// ...

if (string.CompareOrdinal(account.Password, p.Password) != 0)
{
if (account.WrongPasswordCount++ >= 5)
{
account.Banned = true;
account.BanReason = "Too many Wrong Login Attempts.";
account.ExpiryDate = DateTime.Now.AddMinutes(2);

c.Enqueue(new ServerPackets.LoginBanned
{
Reason = account.BanReason,
ExpiryDate = account.ExpiryDate
});
return;
}

c.Enqueue(new ServerPackets.Login { Result = 4 });
return;
}
account.WrongPasswordCount = 0;

lock (AccountLock)
{
account.Connection?.SendDisconnect(1);

account.Connection = c;
}

c.Account = account;
c.Stage = GameStage.Select;

account.LastDate = Now;
account.LastIP = c.IPAddress;

MessageQueue.Enqueue(account.Connection.SessionID + ", " + account.Connection.IPAddress + ", User logged in.");
c.Enqueue(new ServerPackets.LoginSuccess { Characters = account.GetSelectInfo() });
}

Accordingly, the client-side also contains code to process S.Login and S.LoginSuccess:

// LoginScene.cs
public override void ProcessPacket(Packet p)
{
switch (p.Index)
{
// ...
case (short)ServerPacketIds.Login:
Login((S.Login) p);
break;
case (short)ServerPacketIds.LoginSuccess:
Login((S.LoginSuccess) p);
break;
default:
base.ProcessPacket(p);
break;
}
}

When login fails, it will call the overloaded method private void Login(S.Login p) to display the reason for the login failure (In fact, for security reasons, the message for login failure should be kept as vague as possible):

// LoginScene.cs
private void Login(S.Login p)
{
_login.OKButton.Enabled = true;
switch (p.Result)
{
case 0:
MirMessageBox.Show("Logging in is currently disabled.");
_login.Clear();
break;
case 1:
MirMessageBox.Show("Your AccountID is not acceptable.");
_login.AccountIDTextBox.SetFocus();
break;
case 2:
MirMessageBox.Show("Your Password is not acceptable.");
_login.PasswordTextBox.SetFocus();
break;
case 3:
MirMessageBox.Show(GameLanguage.NoAccountID);
_login.PasswordTextBox.SetFocus();
break;
case 4:
MirMessageBox.Show(GameLanguage.IncorrectPasswordAccountID);
_login.PasswordTextBox.Text = string.Empty;
_login.PasswordTextBox.SetFocus();
break;
}
}

When login succeeds, it will call the overloaded method private void Login(S.LoginSuccess p) to swtich to the Character Select Scene and wait for the user’s next action. To avoid additional data transmission, the server returns the list of characters in the S.LoginSuccess packet.

// LoginScene.cs
private void Login(S.LoginSuccess p)
{
Enabled = false;
_login.Dispose();
if(_ViewKey != null && !_ViewKey.IsDisposed) _ViewKey.Dispose();

SoundManager.PlaySound(SoundList.LoginEffect);
_background.Animated = true;
_background.AfterAnimation += (o, e) =>
{
Dispose();
ActiveScene = new SelectScene(p.Characters);
};
}

Start the Game

The Server Sends Character Data

After the user selects a character and clicks the start game button, the client will send a C.StartGame packet containing the character selection information to the server.

// SelectScene.cs
public void StartGame()
{
// ...
Network.Enqueue(new C.StartGame
{
CharacterIndex = Characters[_selected].Index
});
}

After receiving the C.StartGame packet, the server reads character data from the database, creates a new PlayerObject, and calls its StartGame method.

// MirConnection.cs
private void StartGame(C.StartGame p)
{
// ...
CharacterInfo info = null;

for (int i = 0; i < Account.Characters.Count; i++)
{
if (Account.Characters[i].Index != p.CharacterIndex) continue;

info = Account.Characters[i];
break;
}
if (info == null)
{
Enqueue(new S.StartGame { Result = 2 });
return;
}

// ...

Player = new PlayerObject(info, this);
Player.StartGame();
}

In the StartGame method of the PlayerObject, the server adds the character to the map, and then calls to the StartGameSuccess method:

// PlayerObject.cs
public void StartGame()
{
Map temp = Envir.GetMap(CurrentMapIndex);

if (temp != null && temp.Info.NoReconnect)
{
Map temp1 = Envir.GetMapByNameAndInstance(temp.Info.NoReconnectMap);
if (temp1 != null)
{
temp = temp1;
CurrentLocation = GetRandomPoint(40, 0, temp);
}
}

if (temp == null || !temp.ValidPoint(CurrentLocation))
{
temp = Envir.GetMap(BindMapIndex);

if (temp == null || !temp.ValidPoint(BindLocation))
{
SetBind();
temp = Envir.GetMap(BindMapIndex);

if (temp == null || !temp.ValidPoint(BindLocation))
{
StartGameFailed();
return;
}
}
CurrentMapIndex = BindMapIndex;
CurrentLocation = BindLocation;
}
temp.AddObject(this);
CurrentMap = temp;
Envir.Players.Add(this);

StartGameSuccess();

//Call Login NPC
CallDefaultNPC(DefaultNPCType.Login);

//Call Daily NPC
if (Info.NewDay)
{
CallDefaultNPC(DefaultNPCType.Daily);
}
}

Then, in the StartGameSuccess method, the server sends the S.StartGame packet and character data packets to the client. The purpose of each Get method here is to synchronize map and character data to the client:

// PlayerObject.cs
private void StartGameSuccess()
{
Connection.Stage = GameStage.Game;
// ...
Enqueue(new S.StartGame { Result = 4, Resolution = Settings.AllowedResolution });
ReceiveChat(string.Format(GameLanguage.Welcome, GameLanguage.GameName), ChatType.Hint);

// ...

Spawned();

SetLevelEffects();

GetItemInfo();
GetMapInfo();
GetUserInfo();
GetQuestInfo();
GetRecipeInfo();

GetCompletedQuests();

GetMail();
GetFriends();
GetRelationship();

if ((Info.Mentor != 0) && (Info.MentorDate.AddDays(Settings.MentorLength) < DateTime.Now))
MentorBreak();
else
GetMentor();

CheckConquest();

GetGameShop();

// ...
}

private void GetUserInfo()
{
string guildname = MyGuild != null ? MyGuild.Name : "";
string guildrank = MyGuild != null ? MyGuildRank.Name : "";
S.UserInformation packet = new S.UserInformation
{
ObjectID = ObjectID,
RealId = (uint)Info.Index,
Name = Name,
GuildName = guildname,
GuildRank = guildrank,
NameColour = GetNameColour(this),
Class = Class,
Gender = Gender,
Level = Level,
Location = CurrentLocation,
Direction = Direction,
Hair = Hair,
HP = HP,
MP = MP,

Experience = Experience,
MaxExperience = MaxExperience,

LevelEffects = LevelEffects,

Inventory = new UserItem[Info.Inventory.Length],
Equipment = new UserItem[Info.Equipment.Length],
QuestInventory = new UserItem[Info.QuestInventory.Length],
Gold = Account.Gold,
Credit = Account.Credit,
HasExpandedStorage = Account.ExpandedStorageExpiryDate > Envir.Now ? true : false,
ExpandedStorageExpiryTime = Account.ExpandedStorageExpiryDate
};

Info.Inventory.CopyTo(packet.Inventory, 0);
Info.Equipment.CopyTo(packet.Equipment, 0);
Info.QuestInventory.CopyTo(packet.QuestInventory, 0);

//IntelligentCreature
for (int i = 0; i < Info.IntelligentCreatures.Count; i++)
packet.IntelligentCreatures.Add(Info.IntelligentCreatures[i].CreateClientIntelligentCreature());
packet.SummonedCreatureType = SummonedCreatureType;
packet.CreatureSummoned = CreatureSummoned;

Enqueue(packet);
}

The Client Start the Game

The client is currently in the SelectScene. After receiving the S.StartGame packet indicating a successful game start, the client will adjust the resolution according to the returned data and switch to the GameScene.

public void StartGame(S.StartGame p)
{
StartGameButton.Enabled = true;

switch (p.Result)
{
case 0:
MirMessageBox.Show("Starting the game is currently disabled.");
break;
case 1:
MirMessageBox.Show("You are not logged in.");
break;
case 2:
MirMessageBox.Show("Your character could not be found.");
break;
case 3:
MirMessageBox.Show("No active map and/or start point found.");
break;
case 4:

if (p.Resolution < Settings.Resolution || Settings.Resolution == 0) Settings.Resolution = p.Resolution;

switch (Settings.Resolution)
{
default:
case 1024:
Settings.Resolution = 1024;
CMain.SetResolution(1024, 768);
break;
case 1280:
CMain.SetResolution(1280, 800);
break;
case 1366:
CMain.SetResolution(1366, 768);
break;
case 1920:
CMain.SetResolution(1920, 1080);
break;
}

ActiveScene = new GameScene();
Dispose();
break;
}
}

In the GameScene, the client will receive and handle data packets from server containing character information, map data, npc data, other player data, etc. For example, after receiving the S.UserInformation packet from server, the client will create a new UserObject:

// GameScene.cs
public override void ProcessPacket(Packet p)
{
switch (p.Index)
{
// ...
case (short)ServerPacketIds.UserInformation:
UserInformation((S.UserInformation)p);
break;
// ...
}
}

private void UserInformation(S.UserInformation p)
{
User = new UserObject(p.ObjectID);
User.Load(p);
MainDialog.PModeLabel.Visible = User.Class == MirClass.Wizard || User.Class == MirClass.Taoist;
Gold = p.Gold;
Credit = p.Credit;

InventoryDialog.RefreshInventory();
foreach (SkillBarDialog Bar in SkillBarDialogs)
Bar.Update();
}

Next Step

The entire client startup process has been analyzed up to this point. The main game logic mainly focuses on the server synchronizing state to the client and the client sending character behavior to the server. In the following articles, we will delve into the processing of these interactions.

--

--