Brazil's biggest MU Online portal — since 2003
Tutorial Advanced Client

How to build a professional update launcher (.NET) for MU Online

Build a complete .NET launcher for your MU Online server — with a JSON version manifest, SHA-256 hash verification, incremental patch downloads, a progress bar and automatic startup of Main.exe.

BR Bruno · Updated on Jun 18, 2024 · ⏱ 24 min read
Quick answer

An update launcher is your server's front door: it's the first screen the player sees, the mechanism that guarantees everyone runs the same build, and the channel through which you ship fixes without asking anyone to download a ZIP by hand. An amateur launcher — one that just opens Main.exe — wastes

An update launcher is your server's front door: it's the first screen the player sees, the mechanism that guarantees everyone runs the same build, and the channel through which you ship fixes without asking anyone to download a ZIP by hand. An amateur launcher — one that just opens Main.exe — wastes that opportunity and generates an endless stream of "my client is out of date" support tickets. In this tutorial you'll build a professional .NET launcher, with a JSON version manifest, hash verification, incremental downloads, a progress bar, error handling and automatic game startup.

The focus here is the client: the executable that runs on the player's machine, the files in the MU folder and the patch distribution. We won't configure the game server — for that, see the guide on how to set up a MU Online server. Here we deal exclusively with the layer the player sees.

How a professional launcher works

The principle is simple and doesn't change between seasons: the launcher compares what is installed locally with a manifest hosted on your web server and downloads only the differences.

PLAYER (Launcher.exe)
   │
   │ 1. HTTP GET https://cdn.myserver.com/version.json
   │    → manifest: version, file list, hash, size
   ▼
LOCAL COMPARISON
   │  for each file: local hash == manifest hash?
   │  if equal → skip;  if different/missing → mark for download
   ▼
INCREMENTAL DOWNLOAD
   │ 2. GET https://cdn.myserver.com/patches/Data/Item.bmd ...
   │    writes only the changed files, updates the bar
   ▼
START GAME
   │ 3. Process.Start("Main.exe")  → launcher closes
   ▼
Main.exe connects to the Connect Server

The manifest is the central piece. It lists every distributable file (BMD textures, Data folder files, Main.exe itself), with its SHA-256 hash and size. The launcher never "guesses" what changed: it trusts the hash. This makes the system idempotent — running the launcher twice in a row downloads nothing the second time.

Prerequisites

Before writing a single line of code, have the environment ready:

ItemRecommendationNote
IDEVisual Studio 2022 CommunityFree; include the ".NET desktop development" workload
Framework.NET Framework 4.7.2Maximum compatibility in the MU audience (Windows 7+)
JSON packageSystem.Text.Json or Newtonsoft.JsonVia NuGet
Web serverApache/Nginx with HTTPSCan be the same VPS as the site
Base clientFull MU folder for your seasonMain.exe with the IP already edited
Hash toolPowerShell Get-FileHashShips with Windows

You also need an architecture decision: where the patches are hosted. The ideal is to separate the file server (a subdomain like cdn. or patch.) from the game server. That way a spike in downloads on patch day doesn't affect game latency. It can be the same VPS with a dedicated virtual host, or cheap static hosting.

Step 1 — Define the version manifest (version.json)

The manifest is a static JSON served over HTTP. Recommended structure:

{
  "version": "3.2.0",
  "min_launcher": "1.4.0",
  "server_name": "ViciadosMU",
  "patch_base_url": "https://cdn.myserver.com/patches/",
  "main_exe": "Main.exe",
  "news_url": "https://www.myserver.com/api/news.json",
  "files": [
    {
      "path": "Data/Item.bmd",
      "hash": "9f2c1a7b3e5d8c4f0a6b2e9d1c7a4f8b0e3d6c9a2b5f8e1d4c7a0b3e6d9c2f5a",
      "size": 128432
    },
    {
      "path": "Data/Local/Text.bmd",
      "hash": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
      "size": 54120
    },
    {
      "path": "Main.exe",
      "hash": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4",
      "size": 3841024
    }
  ]
}

Important fields:

  • version: the content version. It only serves for display and logging; the real decision to download comes from the hash.
  • min_launcher: lets you force an update of the launcher itself. If the installed launcher is older, you warn the player to download a new one.
  • patch_base_url: the prefix where each path will be fetched. patch_base_url + path = the file's final URL.
  • files[].hash: SHA-256 in lowercase hex. It is the source of truth.

> The paths in path reflect the real structure of the client folder. The names Item.bmd, Text.bmd and the subfolders are common Season 6 examples — the exact organization varies by season/client. What matters is that the path in the manifest be identical to the relative path inside the MU folder.

Generate the manifest automatically

Never compute hashes by hand. Use this PowerShell script, which scans the patch folder and emits the ready-made JSON:

# generate-manifest.ps1
$root    = "C:\ClienteMU"          # client base folder
$version = "3.2.0"
$baseUrl = "https://cdn.myserver.com/patches/"

$files = Get-ChildItem $root -Recurse -File
$list = foreach ($f in $files) {
    $rel  = $f.FullName.Substring($root.Length + 1).Replace('\','/')
    $hash = (Get-FileHash $f.FullName -Algorithm SHA256).Hash.ToLower()
    [ordered]@{ path = $rel; hash = $hash; size = $f.Length }
}

$manifest = [ordered]@{
    version        = $version
    patch_base_url = $baseUrl
    main_exe       = "Main.exe"
    files          = $list
}

$manifest | ConvertTo-Json -Depth 4 | Out-File "version.json" -Encoding utf8
Write-Host "Manifest generated with $($list.Count) files."

Run this whenever you prepare a patch, upload the folder and the updated version.json, and every launcher will detect the change on its next launch.

Step 2 — The .NET project structure

Create a Windows Forms App (.NET Framework) project named MuLauncher. Suggested structure:

MuLauncher/
├── Program.cs           ← entry point
├── MainForm.cs          ← window and orchestration
├── MainForm.Designer.cs ← control layout
├── UpdateService.cs     ← manifest and download logic
├── Models.cs            ← JSON classes
└── Resources/
    ├── background.png
    └── logo.png

Models.cs — mapping the JSON

using System.Text.Json.Serialization;

namespace MuLauncher
{
    public class Manifest
    {
        [JsonPropertyName("version")]        public string Version { get; set; }
        [JsonPropertyName("patch_base_url")] public string PatchBaseUrl { get; set; }
        [JsonPropertyName("main_exe")]       public string MainExe { get; set; }
        [JsonPropertyName("files")]          public FileEntry[] Files { get; set; }
    }

    public class FileEntry
    {
        [JsonPropertyName("path")] public string Path { get; set; }
        [JsonPropertyName("hash")] public string Hash { get; set; }
        [JsonPropertyName("size")] public long   Size { get; set; }
    }
}

Step 3 — The update service

All the intelligence lives in UpdateService.cs: fetch the manifest, decide what to update and download with progress. Note the progress calculation by bytes, not by number of files — that way the bar is honest even with files of very different sizes.

using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading.Tasks;

namespace MuLauncher
{
    public class DownloadProgress
    {
        public string FileName { get; set; }
        public long   BytesDone { get; set; }
        public long   BytesTotal { get; set; }
        public int    Percent => BytesTotal == 0 ? 0 : (int)(BytesDone * 100 / BytesTotal);
    }

    public class UpdateService
    {
        private readonly HttpClient _http = new HttpClient();
        private readonly string _clientDir;

        public UpdateService(string clientDir) => _clientDir = clientDir;

        public async Task<Manifest> FetchManifestAsync(string url)
        {
            string json = await _http.GetStringAsync(url);
            return JsonSerializer.Deserialize<Manifest>(json);
        }

        // Returns only the files that need to be downloaded
        public List<FileEntry> DiffFiles(Manifest m)
        {
            var pending = new List<FileEntry>();
            foreach (var f in m.Files)
            {
                string local = Path.Combine(_clientDir, f.Path.Replace('/', '\\'));
                if (!File.Exists(local) || LocalHash(local) != f.Hash.ToLower())
                    pending.Add(f);
            }
            return pending;
        }

        public async Task DownloadAsync(Manifest m, List<FileEntry> pending,
                                        IProgress<DownloadProgress> progress)
        {
            long total = 0, done = 0;
            foreach (var f in pending) total += f.Size;

            foreach (var f in pending)
            {
                string url   = m.PatchBaseUrl.TrimEnd('/') + "/" + f.Path;
                string local = Path.Combine(_clientDir, f.Path.Replace('/', '\\'));
                Directory.CreateDirectory(Path.GetDirectoryName(local));

                // write to a temporary file and swap only at the end (atomic)
                string tmp = local + ".part";
                using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead))
                using (var src  = await resp.Content.ReadAsStreamAsync())
                using (var dst  = File.Create(tmp))
                {
                    byte[] buffer = new byte[81920];
                    int read;
                    while ((read = await src.ReadAsync(buffer, 0, buffer.Length)) > 0)
                    {
                        await dst.WriteAsync(buffer, 0, read);
                        done += read;
                        progress?.Report(new DownloadProgress
                        {
                            FileName  = Path.GetFileName(local),
                            BytesDone = done,
                            BytesTotal = total
                        });
                    }
                }

                // validate the downloaded file's hash before accepting it
                if (LocalHash(tmp) != f.Hash.ToLower())
                {
                    File.Delete(tmp);
                    throw new Exception($"Invalid hash on {f.Path}. Corrupt download.");
                }

                if (File.Exists(local)) File.Delete(local);
                File.Move(tmp, local);
            }
        }

        private static string LocalHash(string path)
        {
            using var sha = SHA256.Create();
            using var stream = File.OpenRead(path);
            return BitConverter.ToString(sha.ComputeHash(stream))
                               .Replace("-", "").ToLower();
        }
    }
}

Two production details that separate an amateur launcher from a professional one:

  1. Atomic download: we download to file.part and only rename at the end. If the player closes the launcher midway, the original file is not corrupted.
  2. Post-download hash validation: even with HTTPS, a download can truncate on a dropped connection. We reject any file whose hash doesn't match.

Step 4 — The main window

MainForm.cs orchestrates everything and handles the experience: status, progress bar and the Play button.

using System;
using System.IO;
using System.Windows.Forms;

namespace MuLauncher
{
    public partial class MainForm : Form
    {
        private const string MANIFEST_URL = "https://cdn.myserver.com/version.json";
        private readonly string _dir = AppDomain.CurrentDomain.BaseDirectory;
        private Manifest _manifest;

        public MainForm()
        {
            InitializeComponent();
            Shown += async (s, e) => await RunUpdateFlow();
        }

        private async System.Threading.Tasks.Task RunUpdateFlow()
        {
            btnPlay.Enabled = false;
            var svc = new UpdateService(_dir);
            try
            {
                lblStatus.Text = "Checking version...";
                _manifest = await svc.FetchManifestAsync(MANIFEST_URL);

                var pending = svc.DiffFiles(_manifest);
                if (pending.Count == 0)
                {
                    lblStatus.Text = $"Client up to date (v{_manifest.Version})";
                }
                else
                {
                    var progress = new Progress<DownloadProgress>(p =>
                    {
                        progressBar.Value = Math.Min(p.Percent, 100);
                        lblStatus.Text = $"Downloading {p.FileName} — {p.Percent}%";
                    });
                    await svc.DownloadAsync(_manifest, pending, progress);
                    progressBar.Value = 100;
                    lblStatus.Text = $"Updated to v{_manifest.Version}!";
                }
            }
            catch (Exception ex)
            {
                lblStatus.Text = "Update failed.";
                MessageBox.Show($"Could not update:\n{ex.Message}\n\n" +
                    "Check your connection and try again.",
                    "Update", MessageBoxButtons.OK, MessageBoxIcon.Warning);
            }
            btnPlay.Enabled = true;
        }

        private void btnPlay_Click(object sender, EventArgs e)
        {
            string exe = Path.Combine(_dir, _manifest?.MainExe ?? "Main.exe");
            if (!File.Exists(exe))
            {
                MessageBox.Show("Main.exe not found in the client folder!",
                    "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
                return;
            }
            System.Diagnostics.Process.Start(exe);
            Application.Exit();
        }
    }
}

Minimum controls in the Designer: a Label lblStatus, a ProgressBar progressBar (0–100), a Button btnPlay ("PLAY"), a PictureBox for the background and, optionally, a WebBrowser for news coming from news_url.

Step 5 — Publish patches and deploy

The workflow for publishing a patch is always the same:

  1. Change the client files (for example, a new Item.bmd or a texture).
  2. Copy the changed files to the public patch folder, preserving the structure (patches/Data/Item.bmd).
  3. Run generate-manifest.ps1 to recompute hashes and regenerate the version.json.
  4. Upload the version.json last — that way no player gets a manifest pointing to a file that hasn't been uploaded yet.

On the Linux server, make sure of the permissions and HTTP access:

sudo mkdir -p /var/www/cdn/patches/Data
# upload via rsync/sftp to /var/www/cdn/patches/
sudo chown -R www-data:www-data /var/www/cdn
sudo chmod -R 755 /var/www/cdn
curl -I https://cdn.myserver.com/version.json   # should return 200

Common errors and fixes

SymptomLikely causeFix
Launcher downloads everything every timeManifest hash doesn't match the published fileRegenerate the manifest AFTER uploading the files; check the hash's upper/lowercase
"Invalid hash / corrupt download"Connection dropped or file swapped midwayRetry the download; ensure HTTPS and a stable server
Antivirus blocks the launcherUnsigned .exe downloading other .exe filesSign with a code-signing certificate; avoid packers
Main.exe won't start after updatingMain.exe downloaded incomplete or from the wrong seasonValidate the hash; confirm the Main in the patch is for the correct season
404 when downloading a filepatch_base_url + path doesn't existCheck the folder structure on the server and the slashes in path
Progress bar "freezes" on a large fileProgress counted per file, not per byteUse per-byte progress, as in the UpdateService above
Players on different versionsManifest cached by CDN/browserSend Cache-Control: no-cache on version.json

Security and experience best practices

  • HTTPS is mandatory for the manifest and the patches. Without it, an attacker on the network can inject a malicious Main.exe.
  • Sign the launcher's executable. This drastically reduces antivirus false positives and conveys trust.
  • Never ask for a password in the launcher. Login happens inside Main.exe, on the game server. A launcher that collects credentials is an antipattern and a risk.
  • Treat the launcher as recoverable: on a network error, let the player try to play anyway when it makes sense, or at least offer "try again."
  • Cache-Control on version.json to prevent proxies from serving old manifests.

Launch checklist

  • version.json manifest generated automatically by a script
  • SHA-256 hashes match the published files
  • Patches hosted over HTTPS with Cache-Control: no-cache on the manifest
  • Atomic download (.part) implemented and tested
  • Post-download hash validation active
  • Byte-based progress bar
  • Play button starts the correct Main.exe and closes the launcher
  • Launcher executable signed with a certificate
  • Tested on a clean machine (without the client installed)
  • Incremental patch tested (only changed files download)
  • Dropped-connection-mid-download tested
  • Publishing workflow documented for the team

Frequently asked questions

Do I need a launcher if I already ship the client as a ZIP?

The ZIP works for the initial install, but every patch would require the player to download and extract it manually. A .NET launcher checks the version on each launch and downloads only the changed files, cutting support load and keeping everyone on the same build.

Which framework should I choose, .NET Framework or modern .NET?

.NET Framework 4.7.2 runs on virtually any Windows without installing anything extra, ideal for the MU audience. .NET 6/8 requires a runtime or a self-contained publish but gives you newer binaries. For maximum compatibility, start with Framework 4.7.2.

Does the launcher need HTTPS?

Strongly recommended. Without HTTPS the manifest and patches travel in plain text and can be tampered with by an attacker on the network. Combine HTTPS with SHA-256 hash verification to guarantee the downloaded file is exactly the one you published.

How do I stop antivirus from blocking the launcher?

Sign the executable with a code-signing certificate, avoid aggressive packers and host the patches on your own domain over HTTPS. Unsigned launchers that download .exe files are exactly the pattern that trips antivirus heuristics.

Does the launcher work for any season?

Yes. The launcher only copies files into the client folder and runs Main.exe — it is season-agnostic. What changes per season is the content of the files (Data, Main offsets), not the update logic. File details vary by season/client.

BR
Events, maps & items editor

Bruno specializes in MU Online events, maps, bosses and item economy. He documents every detail based on real gameplay.

Keep reading

Related articles