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.
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:
| Item | Recommendation | Note |
|---|---|---|
| IDE | Visual Studio 2022 Community | Free; include the ".NET desktop development" workload |
| Framework | .NET Framework 4.7.2 | Maximum compatibility in the MU audience (Windows 7+) |
| JSON package | System.Text.Json or Newtonsoft.Json | Via NuGet |
| Web server | Apache/Nginx with HTTPS | Can be the same VPS as the site |
| Base client | Full MU folder for your season | Main.exe with the IP already edited |
| Hash tool | PowerShell Get-FileHash | Ships 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
pathwill 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:
- Atomic download: we download to
file.partand only rename at the end. If the player closes the launcher midway, the original file is not corrupted. - 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:
- Change the client files (for example, a new
Item.bmdor a texture). - Copy the changed files to the public patch folder, preserving the structure (
patches/Data/Item.bmd). - Run
generate-manifest.ps1to recompute hashes and regenerate theversion.json. - Upload the
version.jsonlast — 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Launcher downloads everything every time | Manifest hash doesn't match the published file | Regenerate the manifest AFTER uploading the files; check the hash's upper/lowercase |
| "Invalid hash / corrupt download" | Connection dropped or file swapped midway | Retry the download; ensure HTTPS and a stable server |
| Antivirus blocks the launcher | Unsigned .exe downloading other .exe files | Sign with a code-signing certificate; avoid packers |
| Main.exe won't start after updating | Main.exe downloaded incomplete or from the wrong season | Validate the hash; confirm the Main in the patch is for the correct season |
| 404 when downloading a file | patch_base_url + path doesn't exist | Check the folder structure on the server and the slashes in path |
| Progress bar "freezes" on a large file | Progress counted per file, not per byte | Use per-byte progress, as in the UpdateService above |
| Players on different versions | Manifest cached by CDN/browser | Send 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.jsonto prevent proxies from serving old manifests.
Launch checklist
version.jsonmanifest generated automatically by a script- SHA-256 hashes match the published files
- Patches hosted over HTTPS with
Cache-Control: no-cacheon 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.