Cómo crear un launcher de actualización profesional (.NET) para MU Online
Construye un launcher .NET completo para tu servidor de MU Online — con manifiesto de versión en JSON, verificación por hash SHA-256, descarga incremental de parches, barra de progreso e inicio automático del Main.exe.
Un launcher de actualización es la puerta de entrada de tu servidor: es la primera pantalla que el jugador ve, el mecanismo que garantiza que todos corran la misma build y el canal por donde distribuyes correcciones sin pedirle a nadie que descargue un ZIP manualmente. Un launcher amateur — que solo
Un launcher de actualización es la puerta de entrada de tu servidor: es la primera pantalla que el jugador ve, el mecanismo que garantiza que todos corran la misma build y el canal por donde distribuyes correcciones sin pedirle a nadie que descargue un ZIP manualmente. Un launcher amateur — que solo abre el Main.exe — desperdicia esa oportunidad y genera un flujo interminable de "mi cliente está desactualizado" en el soporte. En este tutorial vas a construir un launcher profesional en .NET, con manifiesto de versión en JSON, verificación por hash, descarga incremental, barra de progreso, manejo de errores e inicio automático del juego.
El foco aquí es el cliente: el ejecutable que corre en la máquina del jugador, los archivos de la carpeta del MU y la distribución de parches. No vamos a configurar el game server — para eso, mira la guía de cómo crear un servidor de MU Online. Aquí tratamos exclusivamente la capa que el jugador ve.
Cómo funciona un launcher profesional
El principio es simple y no cambia entre seasons: el launcher compara lo que está instalado localmente con un manifiesto alojado en tu servidor web y descarga solo las diferencias.
JUGADOR (Launcher.exe)
│
│ 1. HTTP GET https://cdn.meuservidor.com/version.json
│ → manifiesto: versión, lista de archivos, hash, tamaño
▼
COMPARACIÓN LOCAL
│ para cada archivo: ¿hash local == hash del manifiesto?
│ si es igual → salta; si es distinto/ausente → marca para descarga
▼
DESCARGA INCREMENTAL
│ 2. GET https://cdn.meuservidor.com/patches/Data/Item.bmd ...
│ graba solo los archivos modificados, actualiza la barra
▼
INICIAR JUEGO
│ 3. Process.Start("Main.exe") → el launcher se cierra
▼
Main.exe se conecta al Connect Server
El manifiesto es la pieza central. Lista cada archivo distribuible (texturas BMD, archivos de la carpeta Data, el propio Main.exe), con el hash SHA-256 y el tamaño. El launcher nunca "adivina" lo que cambió: confía en el hash. Esto hace que el sistema sea idempotente — correr el launcher dos veces seguidas no descarga nada la segunda vez.
Requisitos previos
Antes de escribir cualquier línea de código, ten el entorno listo:
| Ítem | Recomendación | Observación |
|---|---|---|
| IDE | Visual Studio 2022 Community | Gratuito; incluye la carga de trabajo ".NET desktop development" |
| Framework | .NET Framework 4.7.2 | Máxima compatibilidad en el público de MU (Windows 7+) |
| Paquete JSON | System.Text.Json o Newtonsoft.Json | Vía NuGet |
| Servidor web | Apache/Nginx con HTTPS | Puede ser el mismo VPS del sitio |
| Cliente base | Carpeta completa del MU de tu season | Main.exe ya con la IP editada |
| Herramienta de hash | PowerShell Get-FileHash | Ya viene en Windows |
También necesitas una decisión de arquitectura: dónde se alojan los parches. Lo ideal es separar el servidor de archivos (un subdominio tipo cdn. o patch.) del game server. Así, un pico de descargas el día del parche no afecta la latencia del juego. Puede ser el mismo VPS con un virtual host dedicado, o un hosting estático barato.
Paso 1 — Definir el manifiesto de versión (version.json)
El manifiesto es un JSON estático servido por HTTP. Estructura recomendada:
{
"version": "3.2.0",
"min_launcher": "1.4.0",
"server_name": "ViciadosMU",
"patch_base_url": "https://cdn.meuservidor.com/patches/",
"main_exe": "Main.exe",
"news_url": "https://www.meuservidor.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
}
]
}
Campos importantes:
- version: versión del contenido. Solo sirve para mostrar y registrar; la decisión real de descargar viene del hash.
- min_launcher: permite forzar la actualización del propio launcher. Si el launcher instalado es más antiguo, avisas al jugador para que descargue uno nuevo.
- patch_base_url: prefijo donde se buscará cada
path.patch_base_url + path= URL final del archivo. - files[].hash: SHA-256 en hexadecimal minúsculo. Es la fuente de la verdad.
> Las rutas en path reflejan la estructura real de la carpeta del cliente. Los nombres Item.bmd, Text.bmd y las subcarpetas son ejemplos comunes de Season 6 — la organización exacta varía por season/cliente. Lo importante es que el path en el manifiesto sea idéntico a la ruta relativa dentro de la carpeta del MU.
Generar el manifiesto automáticamente
Nunca calcules hashes a mano. Usa este script de PowerShell, que recorre la carpeta del parche y emite el JSON listo:
# gerar-manifesto.ps1
$raiz = "C:\ClienteMU" # carpeta base del cliente
$version = "3.2.0"
$baseUrl = "https://cdn.meuservidor.com/patches/"
$arquivos = Get-ChildItem $raiz -Recurse -File
$lista = foreach ($f in $arquivos) {
$rel = $f.FullName.Substring($raiz.Length + 1).Replace('\','/')
$hash = (Get-FileHash $f.FullName -Algorithm SHA256).Hash.ToLower()
[ordered]@{ path = $rel; hash = $hash; size = $f.Length }
}
$manifesto = [ordered]@{
version = $version
patch_base_url = $baseUrl
main_exe = "Main.exe"
files = $lista
}
$manifesto | ConvertTo-Json -Depth 4 | Out-File "version.json" -Encoding utf8
Write-Host "Manifiesto generado con $($lista.Count) archivos."
Ejecuta esto siempre que prepares un parche, sube la carpeta y el version.json actualizado, y todos los launchers detectarán el cambio en la próxima apertura.
Paso 2 — Estructura del proyecto .NET
Crea un proyecto Windows Forms App (.NET Framework) llamado MuLauncher. Estructura sugerida:
MuLauncher/
├── Program.cs ← punto de entrada
├── MainForm.cs ← ventana y orquestación
├── MainForm.Designer.cs ← layout de los controles
├── UpdateService.cs ← lógica de manifiesto y descarga
├── Models.cs ← clases del JSON
└── Resources/
├── background.png
└── logo.png
Models.cs — mapeo del 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; }
}
}
Paso 3 — El servicio de actualización
Toda la inteligencia queda en UpdateService.cs: descargar el manifiesto, decidir qué actualizar y descargar con progreso. Fíjate en el cálculo de progreso por bytes, no por número de archivos — así la barra es honesta incluso con archivos de tamaños muy distintos.
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);
}
// Retorna solo los archivos que necesitan descargarse
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));
// graba en un archivo temporal y solo lo cambia al final (atómico)
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
});
}
}
// valida el hash del archivo descargado antes de aceptarlo
if (LocalHash(tmp) != f.Hash.ToLower())
{
File.Delete(tmp);
throw new Exception($"Hash inválido en {f.Path}. Descarga corrupta.");
}
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();
}
}
}
Dos detalles de producción que separan un launcher amateur de uno profesional:
- Descarga atómica: descargamos a
arquivo.party solo renombramos al final. Si el jugador cierra el launcher a la mitad, el archivo original no queda corrupto. - Validación de hash tras la descarga: incluso con HTTPS, una descarga puede truncarse por una caída de conexión. Rechazamos cualquier archivo cuyo hash no coincida.
Paso 4 — La ventana principal
MainForm.cs orquesta todo y cuida la experiencia: estado, barra de progreso y el botón Jugar.
using System;
using System.IO;
using System.Windows.Forms;
namespace MuLauncher
{
public partial class MainForm : Form
{
private const string MANIFEST_URL = "https://cdn.meuservidor.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 = "Verificando versión...";
_manifest = await svc.FetchManifestAsync(MANIFEST_URL);
var pending = svc.DiffFiles(_manifest);
if (pending.Count == 0)
{
lblStatus.Text = $"Cliente actualizado (v{_manifest.Version})";
}
else
{
var progress = new Progress<DownloadProgress>(p =>
{
progressBar.Value = Math.Min(p.Percent, 100);
lblStatus.Text = $"Descargando {p.FileName} — {p.Percent}%";
});
await svc.DownloadAsync(_manifest, pending, progress);
progressBar.Value = 100;
lblStatus.Text = $"¡Actualizado a v{_manifest.Version}!";
}
}
catch (Exception ex)
{
lblStatus.Text = "Falló la actualización.";
MessageBox.Show($"No se pudo actualizar:\n{ex.Message}\n\n" +
"Verifica tu conexión e inténtalo de nuevo.",
"Actualización", 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 no encontrado en la carpeta del cliente!",
"Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
System.Diagnostics.Process.Start(exe);
Application.Exit();
}
}
}
Controles mínimos en el Designer: un Label lblStatus, un ProgressBar progressBar (0–100), un Button btnPlay ("JUGAR"), un PictureBox para el fondo y, opcionalmente, un WebBrowser para las noticias provenientes del news_url.
Paso 5 — Publicar parches y hacer deploy
El flujo de publicación de un parche es siempre el mismo:
- Modifica los archivos del cliente (por ejemplo, un nuevo
Item.bmdo una textura). - Copia los archivos modificados a la carpeta pública de parches, preservando la estructura (
patches/Data/Item.bmd). - Ejecuta el
gerar-manifesto.ps1para recalcular hashes y regenerar elversion.json. - Sube el
version.jsonal último — así ningún jugador toma un manifiesto que apunta a un archivo aún no subido.
En el servidor Linux, garantiza los permisos y el acceso HTTP:
sudo mkdir -p /var/www/cdn/patches/Data
# subida vía rsync/sftp a /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.meuservidor.com/version.json # debe retornar 200
Errores comunes y soluciones
| Síntoma | Causa probable | Solución |
|---|---|---|
| El launcher descarga todo cada vez | El hash del manifiesto no coincide con el archivo publicado | Regenera el manifiesto DESPUÉS de subir los archivos; revisa mayúsculas/minúsculas del hash |
| "Hash inválido / descarga corrupta" | La conexión se cayó o el archivo cambió a la mitad | Repite la descarga; garantiza HTTPS y un servidor estable |
| El antivirus bloquea el launcher | .exe sin firmar descargando otros .exe | Firma con certificado de code signing; evita empaquetadores |
| Main.exe no inicia tras actualizar | Main.exe descargado incompleto o de season equivocada | Valida el hash; confirma que el Main del parche es el de la season correcta |
| 404 al descargar un archivo | patch_base_url + path no existe | Revisa la estructura de carpetas en el servidor y las barras en el path |
| La barra de progreso "se traba" en un archivo grande | Progreso contado por archivo, no por bytes | Usa progreso por bytes, como en el UpdateService de arriba |
| Jugadores en versiones diferentes | Manifiesto cacheado por CDN/navegador | Envía Cache-Control: no-cache en el version.json |
Buenas prácticas de seguridad y experiencia
- HTTPS obligatorio para el manifiesto y los parches. Sin él, un atacante en la red puede inyectar un Main.exe malicioso.
- Firma el ejecutable del launcher. Esto reduce drásticamente los falsos positivos del antivirus y transmite confianza.
- Nunca pidas la contraseña en el launcher. El login se hace dentro del Main.exe, en el game server. Un launcher que recopila credenciales es un antipatrón y un riesgo.
- Trata el launcher como recuperable: en caso de error de red, deja que el jugador intente jugar de todas formas cuando tenga sentido, o al menos ofrece "reintentar".
- Cache-Control en el
version.jsonpara evitar que proxies sirvan manifiestos viejos.
Lista de verificación de lanzamiento
- Manifiesto
version.jsongenerado automáticamente por script - Hashes SHA-256 coinciden con los archivos publicados
- Parches alojados con HTTPS y
Cache-Control: no-cacheen el manifiesto - Descarga atómica (
.part) implementada y probada - Validación de hash tras la descarga activa
- Barra de progreso basada en bytes
- El botón Jugar inicia el Main.exe correcto y cierra el launcher
- Ejecutable del launcher firmado con certificado
- Prueba en máquina limpia (sin el cliente instalado)
- Prueba de parche incremental (solo descargan los archivos modificados)
- Prueba de caída de conexión a mitad de la descarga
- Flujo de publicación documentado para el equipo
Preguntas frecuentes
¿Necesito un launcher si ya distribuyo el cliente en ZIP?
El ZIP funciona para la instalación inicial, pero cada parche exigiría que el jugador descargara y extrajera manualmente. Un launcher .NET verifica la versión en cada apertura y descarga solo los archivos modificados, reduciendo el soporte y manteniendo a todos en la misma build.
¿Qué framework elegir, .NET Framework o .NET moderno?
.NET Framework 4.7.2 corre en prácticamente cualquier Windows sin instalar nada extra, ideal para el público de MU. .NET 6/8 exige runtime o publicación self-contained, pero da binarios más nuevos. Para máxima compatibilidad, empieza por el Framework 4.7.2.
¿El launcher necesita HTTPS?
Muy recomendado. Sin HTTPS el manifiesto y los parches viajan en texto plano y pueden ser adulterados por un atacante en la red. Combina HTTPS con verificación de hash SHA-256 para garantizar que el archivo descargado es exactamente el publicado.
¿Cómo evito que el antivirus bloquee el launcher?
Firma el ejecutable con un certificado de code signing, evita empaquetadores agresivos y aloja los parches en un dominio propio con HTTPS. Los launchers sin firmar que descargan .exe son el patrón que dispara las heurísticas del antivirus.
¿El launcher funciona para cualquier season?
Sí. El launcher solo copia archivos a la carpeta del cliente y ejecuta el Main.exe — es agnóstico de la season. Lo que cambia por season es el contenido de los archivos (Data, offsets del Main), no la lógica de actualización. Los detalles de archivo varían por season/cliente.