Skip to main content
  1. Posts/

New Day 3 - Mono and Docker

NewDays drag-spin-exp Docker Mono SQL

Symptoms of infections mononucleosis shown on a Docker whale mascot
Symptoms of infections mononucleosis shown on a Docker whale mascot

Revisiting the existing Dragon’s Spine game server, making sure we can easily build and run it.

From .NET Framework 2.0 to Mono
#

First things first, I wanted to start building the game server in an open-source environment. Given the age of the code, it looks like I’ll have more luck with Mono and xbuild than the more recent dotnet products. So pull down the code on a current-ish Ubuntu install (the current winner in the category of can-I-find-help-on-the-web), a quick sudo apt install mono-complete mono-dbg mono-xbuild, and awaaay we go.

The Usual Suspects
#

$ XBUILD_EMIT_SOLUTION=yes xbuild /p:Configuration=Debug DragonsSpine.sln

...
CSC: error CS2001: Source file './WindowForms/CharacterEditor.designer.cs' could not be found.
...

$ ls ./WindowForms/CharacterEditor.*
./WindowForms/CharacterEditor.cs           ./WindowForms/CharacterEditor.resx
./WindowForms/CharacterEditor.Designer.cs

As expected when going from a case-insensitive OS to a case-sensitive OS, case mismatches which were silently tolerated before will now cause issues. In this case, we have a .Designer.cs file referred to by .designer.cs in the project file. Easy enough to correct in the file (even though I’m not using this Windows Forms code currently) and move on.

$ XBUILD_EMIT_SOLUTION=yes xbuild /p:Configuration=Debug DragonsSpine.sln

...
/usr/lib/mono/xbuild/14.0/bin/Microsoft.Common.targets:  warning : Reference 'GameLib,
 Version=0.2.2039.20837, Culture=neutral' not resolved
...
For searchpath bin/Debug/
  Considered './bin/Debug/GameLib.dll' as a file, but the file does not exist
...
/usr/lib/mono/xbuild/14.0/bin/Microsoft.Common.targets:  warning : Reference 'Net,
 Version=1.0.0.0, Culture=neutral, processorArchitecture=MSIL' not resolved
...
For searchpath bin/Debug/
  Considered './bin/Debug/Net.dll' as a file, but the file does not exist
...
GameSystems/Network/Clients/Protocol/ProtoClientIO.cs(7,7): error CS0246: The type or namespace
 name 'Net' could not be found (are you missing a using directive or an assembly reference?)
World/Map.cs(6,7): error CS0246: The type or namespace name 'GameLib' could not be found (are you
 missing a using directive or an assembly reference?)
...

$ ls ./Dlls/
DevAge.Core.dll     DevAge.SourceGrid3.dll             DevAge.Windows.Forms.dll    Net.dll
DevAge.Data.dll     DevAge.SourceGrid3.Extensions.dll  DevAge.WindowsPlatform.dll
DevAge.Drawing.dll  DevAge.Utilities.dll               GameLib.dll

Another common porting issue, search paths. I initially considered just copying the two DLLs into the output directory (and this worked when I tried it). But some digging sent me to an old stackoverflow question that led me to include a HintPath instead.

DragonsSpine/DragonsSpine.csproj
103
104
105
106
107
108
109
  <ItemGroup>
  <Reference Include="GameLib, Version=0.2.2039.20837, Culture=neutral">
    <HintPath>Dlls\GameLib.dll</HintPath>
  </Reference>
  <Reference Include="Net, Version=1.0.0.0, Culture=neutral, processorArchitecture=MSIL">
    <HintPath>Dlls\Net.dll</HintPath>
  </Reference>
$ XBUILD_EMIT_SOLUTION=yes xbuild /p:Configuration=Debug DragonsSpine.sln

...
Build succeeded.
...

Cleanup
#

But are we throwing any warnings we might want to look into?

./DragonsSpine.sln:  warning : Don't know how to handle GlobalSection SourceCodeControl, Ignoring.
DragonsSpine/DragonsSpine.sln
11
12
13
14
15
16
17
  GlobalSection(SourceCodeControl) = preSolution
    SccNumberOfProjects = 1
    SccProjectUniqueName0 = DragonsSpine.csproj
    SccProjectName0 = \u0022$/Dragons\u0020Spine\u00202005/DragonsSpine\u0022,\u0020
    SccLocalPath0 = .
    SccProvider0 = MSSCCI:Microsoft\u0020Visual\u0020SourceSafe
  EndGlobalSection

A reference to a project in Microsoft Visual SourceSafe? I think I can safely remove that.

DragonsSpineMain.cs(972,20): warning CS0219: The variable 'DeviceID' is assigned but its value is
 never used
GameObjects/GamePlayer.cs(309,22): warning CS0169: The field 'GamePlayer.m_isCorpseCarried' is
 never used
GameObjects/GamePlayer.cs(313,24): warning CS0414: The field 'GamePlayer.m_displayText' is
 assigned but its value is never used
GameSystems/Groups/Group.cs(30,14): warning CS0414: The field 'Group.m_pauseToBuff' is
 assigned but its value is never used
GameObjects/GamePlayer.cs(190,22): warning CS0414: The field 'GamePlayer.m_isAFK' is
 assigned but its value is never used
GameSystems/Network/Clients/Protocol/ProtoClientIO.cs(14,22): warning CS0414: The field
 'ProtoClientIO.running' is assigned but its value is never used
GameObjects/GamePlayer.cs(195,24): warning CS0414: The field 'GamePlayer.m_afkMessage' is
 assigned but its value is never used
GameSystems/Mail/Mail.cs(17,14): warning CS0414: The field 'Mail.m_sent' is
 assigned but its value is never used
GameObjects/GamePlayer.cs(197,22): warning CS0414: The field 'GamePlayer.m_wasInvisible' is
 assigned but its value is never used

  9 Warning(s)
  0 Error(s)

Those all seem pretty innocuous, but let’s make a clean build. After commenting out all of those fields:


Build succeeded.
   0 Warning(s)
   0 Error(s)

Will it Run?
#

$ mono --debug bin/Debug/DragSpinExp.exe

Unhandled Exception:
System.DllNotFoundException: kernel32 assembly:<unknown assembly> type:<unknown type> member:(null)
  at (wrapper managed-to-native) DragonsSpine.WinConsole.GetConsoleWindow()
  at DragonsSpine.WinConsole.Initialize () [0x00011] in ./GameSystems/Utilities/WinConsole.cs:283 
  at DragonsSpine.DragonsSpineMain.Main (System.String[] args) [0x00016] in ./DragonsSpineMain.cs:88 
[ERROR] FATAL UNHANDLED EXCEPTION: System.DllNotFoundException: kernel32 assembly:<unknown assembly>
 type:<unknown type> member:(null)
  at (wrapper managed-to-native) DragonsSpine.WinConsole.GetConsoleWindow()
  at DragonsSpine.WinConsole.Initialize () [0x00011] in ./GameSystems/Utilities/WinConsole.cs:283 
  at DragonsSpine.DragonsSpineMain.Main (System.String[] args) [0x00016] in ./DragonsSpineMain.cs:88 

Ok, it crashes immediately. WinConsole certainly sounds like something that might expect that it’s running under Windows. Let’s see:

DragonsSpine/DragonsSpineMain.cs
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
public static int Main(string[] args)
{
  SetInstance(new DragonsSpineMain());
  m_settings = GameServerSettings.Load();

  #region Initialize Console
  WinConsole.Initialize();

  //// to make Debug or Trace output to the console, do the following.
  ////Debug.Listeners.Remove("default");
  ////Debug.Listeners.Add(new TextWriterTraceListener(new ConsoleWriter(...)));

  Console.SetError(new ConsoleWriter(Console.Error, ConsoleColor.Red | ConsoleColor.Intensified | ConsoleColor.WhiteBG, ConsoleFlashMode.FlashUntilResponse, true));
  //WinConsole.Color = ConsoleColor.Blue | ConsoleColor.Intensified | ConsoleColor.BlueBG;
  WinConsole.Flash(true);
  #endregion
  //WinConsole.Visible = false;
  DragonsSpineMain main = new DragonsSpineMain(); // create our main class
  DragonsSpineMain.ServerStatus = DragonsSpineMain.ServerState.Starting;
  Utils.Log(APP_NAME + " " + APP_VERSION, Utils.LogType.SystemGo);

Since I’m intending to run the server in the background, what happens if I just comment out these WinConsole calls?

$ mono --debug bin/Debug/DragSpinExp.exe

5/30/2023 8:23:59 PM: {SystemGo} Drag Spin Exp 2.0.99.18
> 5/30/2023 8:23:59 PM: {SystemGo} Compiling scripts.
> 5/30/2023 8:23:59 PM: {SystemGo} Compiled!
> 5/30/2023 8:23:59 PM: {SystemGo} Added Facet: Agate
> 5/30/2023 8:23:59 PM: {SystemGo} Added Land: Beginner's Game to Facet: Agate
> 5/30/2023 8:23:59 PM: {SystemGo} Added Land: Advanced Game to Facet: Agate
> 5/30/2023 8:23:59 PM: {SystemGo} Added Land: Underworld to Facet: Agate
> 5/30/2023 8:23:59 PM: {SystemGo} Loaded Map: Island of Kesmai
> 5/30/2023 8:23:59 PM: {SystemGo} Loaded Map: Leng
> 5/30/2023 8:23:59 PM: {SystemGo} Loaded Map: Axe Glacier
> 5/30/2023 8:23:59 PM: {SystemGo} Loaded Map: Oakvael
> 5/30/2023 8:23:59 PM: {SystemGo} Loaded Map: Underkingdom
> 5/30/2023 8:23:59 PM: {SystemFailure} Land.FillLand() failed while loading map file for Island of Kesmai.
> 5/30/2023 8:23:59 PM: {SystemFailure} Land.FillLand() failed while loading map file for Leng.
> 5/30/2023 8:23:59 PM: {SystemFailure} Land.FillLand() failed while loading map file for Axe Glacier.
...

The Usual Suspects - Part Two
#

World/Land.cs
124
125
126
127
128
129
130
131
132
133
134
135
136
137
public bool FillLand() // fill this land with the appropriate maps
{
  try
  {
    DAL.DBWorld.LoadMaps(this);

    string mapBase = "..\\..\\maps";

    foreach (Map map in this.Maps)
    {
      if (!map.LoadMap(mapBase + "\\" + map.Name + ".txt", this.FacetID, this.LandID, map.MapID))
      {
        Utils.Log("Land.FillLand() failed while loading map file for " + map.Name + ".", Utils.LogType.SystemFailure);
      }

Another common issue when porting code that touches the file system: path separators. Putting slashes or backslashes directly into a path string will work only where that path separator is in use. I could switch these to slashes, but it’s better to make it work anywhere. In this case we can just use Path.DirectorySeparatorChar, although there may be other options.

It also looks for the maps with a relative path, assuming the current working directory is the executable’s location. Let’s find the correct path no matter where we start, and enhance the log to make it easier to debug in future:

World/Land.cs
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
public bool FillLand() // fill this land with the appropriate maps
{
  try
  {
    DAL.DBWorld.LoadMaps(this);

    string mapBase = Utils.GetStartupPath() +
    ".." + Path.DirectorySeparatorChar + ".." + Path.DirectorySeparatorChar +
    "maps";

    foreach (Map map in this.Maps)
    {
      string mapFilename = mapBase + Path.DirectorySeparatorChar + map.Name + ".txt";
      if (!map.LoadMap(mapFilename, this.FacetID, this.LandID, map.MapID))
      {
        Utils.Log("Land.FillLand() failed while loading map file " + mapFilename +
          " for " + map.Name + ".", Utils.LogType.SystemFailure);
      }

This also looks a little odd:

$ tree -d bin
bin
├── Debug
└── Debug\
  ├── Logs
  │   └── 5_30_23
  └── Scripts

Another errant backslash:

DragonsSpine/GameSystems/Utilities/Utils.cs
236
237
238
239
240
241
public static string GetStartupPath()
{
  Assembly executingAssembly = Assembly.GetExecutingAssembly();
  string exeName = Path.GetFileNameWithoutExtension(executingAssembly.Location);
  return (Path.GetDirectoryName(executingAssembly.Location) + @"\");
}

Also easily replaced with Path.DirectorySeparatorChar.

$ mono --debug bin/Debug/DragSpinExp.exe

5/30/2023 11:17:58 PM: {SystemGo} Drag Spin Exp 2.0.99.18
> 5/30/2023 11:17:58 PM: {SystemGo} Compiling scripts.
> 5/30/2023 11:17:58 PM: {SystemGo} Compiled!
> 5/30/2023 11:17:58 PM: {SystemGo} Added Facet: Agate
> 5/30/2023 11:17:58 PM: {SystemGo} Added Land: Beginner's Game to Facet: Agate
> 5/30/2023 11:17:58 PM: {SystemGo} Added Land: Advanced Game to Facet: Agate
> 5/30/2023 11:17:58 PM: {SystemGo} Added Land: Underworld to Facet: Agate
> 5/30/2023 11:17:58 PM: {SystemGo} Loaded Map: Island of Kesmai
> 5/30/2023 11:17:58 PM: {SystemGo} Loaded Map: Leng
...
> 5/30/2023 11:18:07 PM: {SystemGo} Listening for connections on port 3000.
> Protocol Server listening to port 4000.
5/30/2023 11:18:07 PM: {SystemGo} Starting main game loop.
> 5/30/2023 11:23:07 PM: NPCs: [1755] | Players: [0] | CPU: [??%] | Rnd: [6]
...
            A Oeuia
   []. A . . . . 
   []. . . . . . 
   | . . . . . . 
     []> --    
   . . . . . . . 
   . . . . . . . 
   . . ~~~~~~~~. 
                                               
Oeuia: Fresh Balm of Gilead here!                                                            
                                               
                                               
                                               
                                               
                                               
                                               
                                               
                                               
                                               
 ->                                                                                          
                                               
 R                           Hits       : 40/40            Hits Taken : 0    
 L                           Experience : 1600   
               Stamina    : 9   

I’m sure more issues will come up, but this is working well enough to move forward.

Into a Container
#

Database First
#

Instead of reviving the laptop where I got Microsoft SQL Server running and happy on the network, let’s spin up a container. Luckily Microsoft maintains Microsoft SQL Server Ubuntu-based images, documentation for SQL Server on Linux 2017, and even some nice Docker examples.

Leveraging their examples, I threw together a Dockerfile and supporting scripts to bring up my databases:

Dockerfile
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
FROM mcr.microsoft.com/mssql/server:2017-latest

# Create a config directory
RUN mkdir -p /usr/config
WORKDIR /usr/config

# Bundle config source
COPY docker/. /usr/config
COPY EntireDB-minimal.sql /usr/config/
COPY EntireDB-production.sql /usr/config/

# Grant permissions for our scripts to be executable
RUN chmod +x /usr/config/entrypoint.sh
RUN chmod +x /usr/config/configure-db.sh

ENV ACCEPT_EULA=Y
ENV SA_PASSWORD=TempBadPassw0rd
RUN ["./configure-db.sh"]

ENTRYPOINT ["./entrypoint.sh"]

Unlike the examples, I don’t want an image with no databases. Since importing the data takes time (especially the production data), I want to do it when creating the image. So I run my configure-db.sh script while building the image; it starts up the server, imports the canned data, and shuts it down again. I need a default password for the database server while I’m doing this, so TempBadPassw0rd it is.

configure-db.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/bin/bash

# Start SQL Server
/opt/mssql/bin/sqlservr &

# Wait 60 seconds for SQL Server to start up by ensuring that 
# calling SQLCMD does not return an error code, which will ensure that sqlcmd is accessible
# and that system and user databases return "0" which means all databases are in an "online" state
# https://docs.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-databases-transact-sql?view=sql-server-2017 

DBSTATUS=1
ERRCODE=1
i=0

while [[ $((DBSTATUS + ERRCODE)) -ne 0 ]] && [[ $i -le 60 ]]; do
  i=$(( i + 1 ))
  echo "Checking server status ($i of 60)..."
  DBSTATUS=$(/opt/mssql-tools/bin/sqlcmd -h -1 -t 1 -U sa -P $SA_PASSWORD -Q "SET NOCOUNT ON; Select SUM(state) from sys.databases")
  ERRCODE=$?
  sleep 1
done

echo $DBSTATUS
echo $ERRCODE
if [[ $DBSTATUS -ne 0 ]] || [[ $ERRCODE -ne 0 ]]; then 
  echo "SQL Server took more than 60 seconds to start up or one or more databases are not in an ONLINE state"
  exit 1
fi

# Run the setup script to create the DB and the schema in the DB
echo "Adding production database..."
/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $SA_PASSWORD -d master -v MYDATABASE="production" -i EntireDB-production.sql > production.out
echo "Adding minimal database..."
/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $SA_PASSWORD -d master -v MYDATABASE="minimal" -i EntireDB-minimal.sql > minimal.out
echo "Databases added"
sleep 1
echo "Shutting down the database..."
/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P $SA_PASSWORD -Q "SHUTDOWN"
sleep 1
echo "Done"
entrypoint.sh
1
2
3
4
#!/bin/bash

# Start SQL Server with password reset to SQLSERVR_SA_PASSWORD
/opt/mssql/bin/sqlservr --reset-sa-password

Note that we force the password to be reset when bringing the server back up via the entrypoint. Accidentally leaving a running server with a default password that’s in a public repo is bad form.

Does it work?

$ SQLSERVR_SA_PASSWORD=StrongPassw0rd docker run -e 'ACCEPT_EULA=Y' -e SQLSERVR_SA_PASSWORD -p 1433:1433 --name sql --net drag-spin-exp -d 
mssql-dragspinexp
a0f9cbd094e36685f5093335e92d9e8eefd2e13ebd8ab034f70662892a78c83f
$ docker ps
CONTAINER ID   IMAGE               COMMAND             CREATED          STATUS          PORTS                                       NAMES
a0f9cbd094e3   mssql-dragspinexp   "./entrypoint.sh"   17 seconds ago   Up 16 seconds   0.0.0.0:1433->1433/tcp, :::1433->1433/tcp   sql
SQL Connection properties
SQL Connection properties
SQL Connection test - success!
SQL Connection test - success!
> SELECT catalogID, itemID, identifiedName, shortDesc
FROM minimal.dbo.CatalogItem
WHERE name = 'ring'

catalogID|itemID|identifiedName|shortDesc|
---------+------+--------------+---------+
     57| 13990|Recall Ring   |a ring   |
     69| 13100|Gold Ring     |a ring   |

2 row(s) fetched.

> SELECT catalogID, itemID, identifiedName, shortDesc
FROM production.dbo.CatalogItem
WHERE name = 'ring'

catalogID|itemID|identifiedName                    |shortDesc|
---------+------+----------------------------------+---------+
     41| 13250|Ring of Major Strength            |a ring   |
     57| 13990|Recall Ring                       |a ring   |
     58| 13999|Symbol of the Order               |a ring   |
...

For now, one image with both databases (minimal for integration tests, production for running the game) seems to make sense; I can always split them later if it makes sense.

Game Server Next
#

Now that we have the game server running under Mono, we can take advantage of the official Mono Docker images. For now, we can use the same base image to build and to run the game server, with a pretty simple Dockerfile:

Dockerfile
1
2
3
4
5
6
7
8
FROM mono:6

ADD . /source
WORKDIR /source/DragonsSpine

RUN xbuild /p:Configuration=Debug DragonsSpine.sln

CMD [ "mono", "--debug", "bin/Debug/DragSpinExp.exe" ]

In the App.config file, we refer to the database server simply as sql, which will resolve to the SQL Server container by that name if we put them on the same Docker network.

But wait, the other database details are also baked into the App.config file, which would leave them baked into the image and not able to be changed when it’s run:

App.config
14
15
16
17
18
  <add key="UnderworldEnabled" value="False"/>
  <add key="NPCSkillGain" value="True"/>
<add key="SQL_CONNECTION" value="User ID='sa';Password='StrongPassw0rd';Initial Catalog='minimal';Data Source='tcp:sql,1433';Connect Timeout=15"/>
</appSettings>
<runtime>

So instead, let’s bake in some to-be-changed placeholder text:

App.config
15
16
17
18
  <add key="NPCSkillGain" value="True"/>
  <add key="SQL_CONNECTION"
    value="User ID='sa';Password='TempBadPassw0rd';Initial Catalog='TempDatabaseName';Data Source='tcp:sql,1433';Connect Timeout=15"/>
</appSettings>

And an entrypoint script which will use sed to replace the placeholders with environment variables (and complain and exit if they’re not set) before launching the server:

entrypoint.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/bin/bash

if [ -z "$SQLSERVR_SA_PASSWORD" ]; then
  echo "The SQLSERVR_SA_PASSWORD environment variable must be set to the password for the 'sa'"
  echo " user on the SQL server."
  exit 1
fi

if [ -z "$SQLSERVR_DB_NAME" ]; then
  echo "The SQLSERVR_DB_NAME environment variable must be set to name of the database on the SQL"
  echo " server to use for this instance of the game server."
  exit 1
fi

sed -i s/TempBadPassw0rd/$SQLSERVR_SA_PASSWORD/ bin/Debug/DragSpinExp.exe.config
sed -i s/TempDatabaseName/$SQLSERVR_DB_NAME/ bin/Debug/DragSpinExp.exe.config

mono --debug bin/Debug/DragSpinExp.exe

Then we just call that from the Dockerfile:

Dockerfile
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
FROM mono:6

ADD . /source
WORKDIR /source/DragonsSpine

RUN xbuild /p:Configuration=Debug DragonsSpine.sln

# Grant permissions for our scripts to be executable
RUN chmod +x entrypoint.sh

# The entrypoint script relies on both SQLSERVR_SA_PASSWORD and SQLSERVR_DB_NAME environment
#  variables being set.
ENTRYPOINT ["./entrypoint.sh"]

Will it Run? - Part Two
#

Starting up both containers and playing on the minimal map.
Starting up both containers and playing on the minimal map.

In a word, yes. (Animated GIF courtesy of Charm’s VHS.)

But those commands look pretty awkward. Certainly there’s an easier way? Tomorrow.

One More Thing
#

If you’re curious what a user manual for an MMO launched in 1985 looked like, check it out. There had been scans online, but I did a quick OCR and conversion to Markdown so it’s easy to work with. The “Inhabitants of the Dungeon” section is missing from this particular copy, so I may try to find that somewhere else in future.


More to come
More to come

drag-spin-exp New Day 3 code