Como criar um servidor de MU Origin (mobile) privado
Guia técnico para criar um servidor privado de MU Origin mobile — arquitetura REST API + WebSocket, banco MySQL, servidor Node.js/PHP, modificação do APK Android e hospedagem no Linux.
O MU Origin é o MU Online para dispositivos móveis — mas sua arquitetura é completamente diferente do MU PC. Enquanto o MU PC usa uma arquitetura de software proprietário (DataServer/GameServer/ConnectServer), o MU Origin usa uma arquitetura web moderna baseada em REST API e WebSockets.
O MU Origin é o MU Online para dispositivos móveis — mas sua arquitetura é completamente diferente do MU PC. Enquanto o MU PC usa uma arquitetura de software proprietário (DataServer/GameServer/ConnectServer), o MU Origin usa uma arquitetura web moderna baseada em REST API e WebSockets.
Diferenças fundamentais: MU PC vs MU Origin
| Aspecto | MU Online (PC) | MU Origin (Mobile) |
|---|---|---|
| Protocolo | TCP binário proprietário | REST API + WebSocket |
| Banco de dados | SQL Server (Microsoft) | MySQL / PostgreSQL |
| Servidor | DataServer, GameServer (.exe) | Node.js / PHP / Java |
| Linguagem server-side | C++ (compilado) | JavaScript, PHP, Java, Go |
| Sistema de autenticação | Verificação binária | JWT / OAuth2 |
| Sistema de arquivos | .ini, .txt, .bmd | JSON, MySQL, YAML |
| Configuração de mundo | MonsterSetBase.txt | Banco de dados |
| Comunicação | Pacotes binários TCP | JSON sobre WebSocket |
Arquitetura de um servidor mobile tipo MU Origin
CLIENTE ANDROID (APK modificado)
│ HTTPS (REST API)
│ WSS (WebSocket)
▼
SERVIDOR DE APLICAÇÃO (Node.js / PHP)
├── /auth → login, registro, JWT
├── /player → dados do personagem
├── /world → estado do mundo (monstros, outros players)
├── /inventory → inventário, equipamentos
├── /shop → loja virtual
└── WebSocket → eventos em tempo real
│
▼
BANCO DE DADOS (MySQL)
├── accounts → contas de jogadores
├── characters → personagens
├── inventory → inventários
├── monsters → definição de monstros (substituindo Monster.txt)
└── maps → dados dos mapas (substituindo MonsterSetBase.txt)
Banco de dados MySQL para servidor mobile
-- Schema básico para servidor mobile tipo MU Origin
CREATE DATABASE mu_origin_pvt CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE mu_origin_pvt;
-- Contas
CREATE TABLE accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL, -- bcrypt hash
email VARCHAR(150) UNIQUE,
is_banned TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME NULL,
INDEX idx_username (username)
) ENGINE=InnoDB;
-- Personagens
CREATE TABLE characters (
id INT AUTO_INCREMENT PRIMARY KEY,
account_id INT NOT NULL,
name VARCHAR(20) NOT NULL UNIQUE,
class TINYINT NOT NULL, -- 0=DK, 1=DW, 2=Elf, etc.
level INT NOT NULL DEFAULT 1,
exp BIGINT NOT NULL DEFAULT 0,
str INT NOT NULL DEFAULT 18,
agi INT NOT NULL DEFAULT 18,
vit INT NOT NULL DEFAULT 18,
ene INT NOT NULL DEFAULT 18,
hp INT NOT NULL DEFAULT 110,
mp INT NOT NULL DEFAULT 60,
max_hp INT NOT NULL DEFAULT 110,
max_mp INT NOT NULL DEFAULT 60,
zen BIGINT NOT NULL DEFAULT 0,
map_id INT NOT NULL DEFAULT 0,
pos_x SMALLINT NOT NULL DEFAULT 130,
pos_y SMALLINT NOT NULL DEFAULT 130,
is_online TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
INDEX idx_level (level DESC),
INDEX idx_online (is_online)
) ENGINE=InnoDB;
-- Inventário
CREATE TABLE inventory (
id INT AUTO_INCREMENT PRIMARY KEY,
char_id INT NOT NULL,
slot TINYINT NOT NULL, -- slot 0-127
item_id INT NOT NULL,
item_level TINYINT NOT NULL DEFAULT 0,
item_opt TINYINT NOT NULL DEFAULT 0,
quantity INT NOT NULL DEFAULT 1,
is_equipped TINYINT(1) NOT NULL DEFAULT 0,
FOREIGN KEY (char_id) REFERENCES characters(id) ON DELETE CASCADE,
UNIQUE KEY uq_char_slot (char_id, slot)
) ENGINE=InnoDB;
-- Definição de monstros (substitui Monster.txt)
CREATE TABLE monsters (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
level INT NOT NULL,
hp INT NOT NULL,
mp INT NOT NULL DEFAULT 0,
atk_min INT NOT NULL,
atk_max INT NOT NULL,
def INT NOT NULL,
exp_reward INT NOT NULL,
zen_reward INT NOT NULL DEFAULT 0,
move_type TINYINT NOT NULL DEFAULT 1,
atk_type TINYINT NOT NULL DEFAULT 0,
respawn_sec INT NOT NULL DEFAULT 30
) ENGINE=InnoDB;
-- Spawn de monstros por mapa (substitui MonsterSetBase.txt)
CREATE TABLE map_spawns (
id INT AUTO_INCREMENT PRIMARY KEY,
map_id INT NOT NULL,
monster_id INT NOT NULL,
x1 SMALLINT NOT NULL,
y1 SMALLINT NOT NULL,
x2 SMALLINT NOT NULL,
y2 SMALLINT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
respawn_sec INT NOT NULL DEFAULT 30,
FOREIGN KEY (monster_id) REFERENCES monsters(id) ON DELETE CASCADE
) ENGINE=InnoDB;
Servidor Node.js + Express (API)
// server.js — servidor principal
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const mysql = require('mysql2/promise');
const app = express();
app.use(express.json());
// Pool de conexões MySQL
const pool = mysql.createPool({
host: 'localhost',
user: 'mu_app',
password: 'SenhaMuApp@2026!',
database: 'mu_origin_pvt',
connectionLimit: 20
});
const JWT_SECRET = 'seu_secret_jwt_aqui_muito_secreto_2026';
// === ENDPOINTS REST ===
// Registro de conta
app.post('/auth/register', async (req, res) => {
const { username, password, email } = req.body;
if (!username || !password || username.length < 4) {
return res.status(400).json({ error: 'Dados inválidos' });
}
try {
const hash = await bcrypt.hash(password, 12);
await pool.execute(
'INSERT INTO accounts (username, password, email) VALUES (?, ?, ?)',
[username, hash, email]
);
res.json({ success: true, message: 'Conta criada' });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Username já em uso' });
}
res.status(500).json({ error: 'Erro interno' });
}
});
// Login
app.post('/auth/login', async (req, res) => {
const { username, password } = req.body;
const [rows] = await pool.execute(
'SELECT id, password, is_banned FROM accounts WHERE username = ?',
[username]
);
if (!rows.length) return res.status(401).json({ error: 'Credenciais inválidas' });
if (rows[0].is_banned) return res.status(403).json({ error: 'Conta banida' });
const valid = await bcrypt.compare(password, rows[0].password);
if (!valid) return res.status(401).json({ error: 'Credenciais inválidas' });
// Atualizar last_login
await pool.execute('UPDATE accounts SET last_login = NOW() WHERE id = ?', [rows[0].id]);
const token = jwt.sign({ accountId: rows[0].id, username }, JWT_SECRET, { expiresIn: '24h' });
res.json({ token, accountId: rows[0].id });
});
// Middleware de autenticação JWT
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Token necessário' });
try {
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'Token inválido' });
}
}
// Listar personagens da conta
app.get('/player/characters', authMiddleware, async (req, res) => {
const [chars] = await pool.execute(
'SELECT id, name, class, level, is_online FROM characters WHERE account_id = ?',
[req.user.accountId]
);
res.json({ characters: chars });
});
// Dados completos do personagem
app.get('/player/character/:id', authMiddleware, async (req, res) => {
const [chars] = await pool.execute(
'SELECT * FROM characters WHERE id = ? AND account_id = ?',
[req.params.id, req.user.accountId]
);
if (!chars.length) return res.status(404).json({ error: 'Personagem não encontrado' });
const char = chars[0];
// Buscar inventário
const [items] = await pool.execute(
'SELECT slot, item_id, item_level, item_opt, quantity, is_equipped FROM inventory WHERE char_id = ?',
[char.id]
);
res.json({ character: char, inventory: items });
});
// Ranking
app.get('/ranking', async (req, res) => {
const [ranking] = await pool.execute(
`SELECT c.name, c.class, c.level, c.is_online,
ROW_NUMBER() OVER (ORDER BY c.level DESC) AS pos
FROM characters c
JOIN accounts a ON a.id = c.account_id
WHERE a.is_banned = 0
ORDER BY c.level DESC
LIMIT 50`
);
res.json({ ranking });
});
// === WEBSOCKET PARA TEMPO REAL ===
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
const onlinePlayers = new Map(); // charId → { ws, character, position }
wss.on('connection', (ws) => {
let playerId = null;
ws.on('message', async (data) => {
let msg;
try { msg = JSON.parse(data); } catch { return; }
switch (msg.type) {
case 'AUTH':
// Autenticar WebSocket com o mesmo JWT
try {
const user = jwt.verify(msg.token, JWT_SECRET);
const [chars] = await pool.execute(
'SELECT * FROM characters WHERE id = ? AND account_id = ?',
[msg.charId, user.accountId]
);
if (!chars.length) { ws.close(); return; }
playerId = msg.charId;
onlinePlayers.set(playerId, { ws, character: chars[0], pos: { x: chars[0].pos_x, y: chars[0].pos_y } });
await pool.execute('UPDATE characters SET is_online = 1 WHERE id = ?', [playerId]);
ws.send(JSON.stringify({ type: 'AUTH_OK', character: chars[0] }));
} catch {
ws.close();
}
break;
case 'MOVE':
// Movimento do personagem
if (!playerId) return;
const player = onlinePlayers.get(playerId);
if (!player) return;
player.pos = { x: msg.x, y: msg.y };
player.character.map_id = msg.mapId;
// Notificar jogadores próximos
broadcastToNearby(playerId, {
type: 'PLAYER_MOVE',
charId: playerId,
name: player.character.name,
x: msg.x, y: msg.y
});
break;
case 'CHAT':
// Mensagem de chat
if (!playerId) return;
const sender = onlinePlayers.get(playerId);
if (!sender) return;
broadcastToMap(sender.character.map_id, {
type: 'CHAT',
name: sender.character.name,
message: msg.message.substring(0, 100) // limitar tamanho
});
break;
}
});
ws.on('close', async () => {
if (playerId) {
const player = onlinePlayers.get(playerId);
if (player) {
// Salvar posição e marcar offline
await pool.execute(
'UPDATE characters SET is_online = 0, pos_x = ?, pos_y = ? WHERE id = ?',
[player.pos.x, player.pos.y, playerId]
);
}
onlinePlayers.delete(playerId);
}
});
});
// Broadcast para jogadores no mesmo mapa
function broadcastToMap(mapId, message) {
const json = JSON.stringify(message);
onlinePlayers.forEach(({ ws, character }) => {
if (character.map_id === mapId && ws.readyState === WebSocket.OPEN) {
ws.send(json);
}
});
}
// Broadcast para jogadores próximos (raio de 15 tiles)
function broadcastToNearby(fromCharId, message, radius = 15) {
const from = onlinePlayers.get(fromCharId);
if (!from) return;
const json = JSON.stringify(message);
onlinePlayers.forEach(({ ws, character, pos }, charId) => {
if (charId === fromCharId || character.map_id !== from.character.map_id) return;
const dist = Math.abs(pos.x - from.pos.x) + Math.abs(pos.y - from.pos.y);
if (dist <= radius && ws.readyState === WebSocket.OPEN) {
ws.send(json);
}
});
}
server.listen(3000, () => console.log('Servidor MU Origin rodando na porta 3000'));
Modificar o APK para apontar para o servidor privado
Para o app Android se conectar ao servidor privado:
Passo 1 — Extrair o APK
# Instalar ferramentas necessárias
sudo apt install apktool jadx default-jdk -y
# Descompilar APK
apktool d MuOrigin.apk -o mu-origin-src/
Passo 2 — Encontrar a URL do servidor
# Buscar strings com URL do servidor oficial
grep -r "webzen" mu-origin-src/ --include="*.smali"
grep -r "muorigin" mu-origin-src/ --include="*.smali"
grep -r "https://" mu-origin-src/ --include="*.xml"
# Também no código Java descompilado
jadx -d mu-origin-decompiled/ MuOrigin.apk
grep -r "api.muorigin" mu-origin-decompiled/ --include="*.java"
Passo 3 — Substituir URLs
# Substituir URL oficial pela do servidor privado
find mu-origin-src/ -name "*.smali" -exec \
sed -i 's|api.muorigin.webzen.com|api.meuservidor.com|g' {} \;
find mu-origin-src/ -name "*.xml" -exec \
sed -i 's|api.muorigin.webzen.com|api.meuservidor.com|g' {} \;
Passo 4 — Recompilar e assinar o APK
# Recompilar
apktool b mu-origin-src/ -o MuOrigin-privado-unsigned.apk
# Criar keystore para assinar (uma vez)
keytool -genkey -v -keystore meu-servidor.keystore \
-alias mu-privado -keyalg RSA -keysize 2048 -validity 10000 \
-storepass "SenhaKeystore@2026" -keypass "SenhaChave@2026" \
-dname "CN=Servidor Privado,O=MU,C=BR"
# Assinar o APK
apksigner sign --ks meu-servidor.keystore \
--ks-key-alias mu-privado \
--ks-pass pass:SenhaKeystore@2026 \
--key-pass pass:SenhaChave@2026 \
--out MuOrigin-privado.apk \
MuOrigin-privado-unsigned.apk
# Verificar assinatura
apksigner verify MuOrigin-privado.apk
Nginx como proxy reverso
# /etc/nginx/sites-available/mu-origin
server {
listen 443 ssl;
server_name api.meuservidor.com;
ssl_certificate /etc/letsencrypt/live/api.meuservidor.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.meuservidor.com/privkey.pem;
# API REST
location /auth/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /player/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /ranking {
proxy_pass http://localhost:3000;
}
# WebSocket (necessário headers específicos)
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
Checklist servidor mobile
INFRAESTRUTURA
[ ] VPS Linux (Ubuntu 22.04) com 2+ GB RAM
[ ] Node.js 18+ instalado
[ ] MySQL 8.0 instalado e configurado
[ ] Nginx configurado como proxy reverso
[ ] Certificado SSL (Let's Encrypt via certbot)
[ ] PM2 para manter o Node.js rodando
BANCO DE DADOS
[ ] Schema criado (accounts, characters, inventory, monsters, map_spawns)
[ ] Monstros populados (Monster.txt convertido para SQL)
[ ] Spawns configurados (MonsterSetBase.txt convertido para SQL)
SERVIDOR
[ ] Endpoints REST funcionando (register, login, characters)
[ ] WebSocket funcionando (AUTH, MOVE, CHAT)
[ ] JWT validação funcionando
[ ] Rate limiting nas rotas de auth
APK
[ ] APK extraído e URLs substituídas
[ ] APK recompilado e assinado
[ ] Instalação testada no Android
[ ] Conexão ao servidor privado funcionando
SEGURANÇA
[ ] Senhas em bcrypt
[ ] JWT com expiração curta (24h)
[ ] HTTPS obrigatório (sem HTTP)
[ ] Rate limiting nas rotas
Perguntas frequentes
MU Origin mobile usa os mesmos arquivos do MU Online PC?
Não — MU Origin é um jogo completamente diferente, desenvolvido separadamente. Não usa MuServer, SQL Server ou GameServer.exe. A arquitetura é REST API + WebSocket, com banco MySQL e servidor Node.js ou PHP.
É possível criar um servidor privado de MU Origin?
Tecnicamente sim, mas é muito mais complexo que o MU PC. Requer reverter engenharia do APK, implementar servidor compatível com o protocolo do jogo, e manter a infraestrutura de API.
O MU Origin original e o MU Online PC se conectam entre si?
Não — são jogos completamente separados. MU Origin é baseado no universo de MU Online mas é um produto mobile independente da Webzen, desenvolvido pela WebzenMobile.
Há distribuições prontas de servidor MU Origin como há para MU PC?
Distribuições de MU Origin são muito mais raras e menos estáveis que as de MU PC. A maioria das tentativas de servidor privado mobile tem duração limitada por falta de manutenção.