# Rosh Online / Karos Online — Findings pro Emulador

## ATUALIZAÇÃO: pivô pro client "Karos Arena"

Depois de descobrir que o client original (via site GameXP) depende de uma plataforma de distribuição
inteira (autenticação remota incerta), pivotamos pro client **Karos Arena**
(`C:\Users\Administrator\Downloads\Arena\`), que usa **launcher local** (`launcher.exe`, sem depender de
site externo). Tem MAIS proteção anti-cheat (XIGNCODE + XTrap + Protect64.dll), mas o fluxo de auth é
autocontido — sem a incerteza da validação remota contra uma plataforma terceira.

Server real desse client: `94.23.148.106:10012` (mesmo algoritmo de descriptografia do `ServerList.ten`,
confirmado — é o mesmo motor de jogo, cifra idêntica).

**Achado importante:** `startlog.txt` (log de uma sessão anterior) mostra que `AMo.exe` é chamado **sem
nenhum argumento de linha de comando** (`"C:\Games\Arena\AMo.exe"`, sozinho) — diferente do client GameXP
que precisava de `<modo> <token> <param3>`. Isso sugere login clássico usuário/senha direto no client, ou
um handoff local via arquivo (existe um `key.bin` de 20 bytes, modificado na última sessão — candidato
óbvio).

Sequência de checks do anti-cheat (todos OK na última sessão, pro nosso entendimento de quais módulos
existem): `FC1`, `EDB`, `FC2`, `FFALLOWED`, `GAFA`, `CTH`/`CTH1`/`CTH2`/`CTH4`, `AH START`, `MEMCH ALL ADD`,
`isspfh` (start/dxinit/end), `LLK INST`, `LLM INST`.

### PROBLEMA GRANDE: `AMo.exe` está protegido com Themida

Análise estática (Ghidra) não achou NENHUMA string relevante (nem "Login", nem nada) — investigando o
PE, confirmado: seções com nomes em branco + `.themida` + entropia ~7.9-8.0 (praticamente aleatório =
criptografado), entry point dentro de uma seção `.boot` separada (o stub loader do Themida).

**Themida é uma das proteções comerciais mais fortes que existem** — virtualização de código (converte
partes do binário pra um bytecode customizado, não x86 real), anti-debug agressivo, anti-dump. Não dá
pra simplesmente abrir no Ghidra: o código real só existe descriptografado em memória durante a execução,
e mesmo lá pode estar rodando dentro da VM do Themida (instruções não-nativas).

Isso é um nível de dificuldade bem acima do que enfrentamos até agora nesta sessão (que eram checagens
simples em código compilado normal, resolvidas com patches de 1-2 instruções). Desempacotar Themida
tipicamente exige: dump em memória no momento certo (após o unpack, achar o OEP - Original Entry Point),
ferramentas especializadas, ou nesse caso específico, pode ser inviável sem MUITO mais tempo/ferramental.

**Decisão necessária:** dado que os dois clients candidatos têm obstáculos sérios (GameXP = incerteza de
validação remota; Arena = Themida), precisamos decidir como seguir.

---

## (Histórico) Client original via GameXP — pausado

**Fluxo de login real (confirmado pelo usuário):** o login usuário/senha acontece num **SITE** (web,
fora dos executáveis). O site, após autenticar, encaminha/lança o `AMo.exe` (o client principal) —
provavelmente passando um token/sessão (via argumento de linha de comando, protocolo de URL customizado,
ou arquivo temporário). `karos.exe` é só o launcher local (não faz o login em si).

Arquivos relevantes pra essa ponte site->client: `gmxp-cfg/`, `gmxp-info.json`, `shop_api.dll`,
`shop_api_v2.dll` (parecem ser SDK de integração com o portal web/loja).

O `CLoginReqPacket` encontrado no `AMo.exe` provavelmente carrega esse TOKEN (não usuário/senha
diretamente) pro WorldSvr/GameServer — bate com os campos de múltiplas portas vistos no parse
(`FUN_00651183`), que parece mais uma resposta de "info de conexão" do que uma submissão de credenciais.

Projeto Ghidra: `D:\Cabal\Tools\ghidra_projects\KarosOnline` (`AMo.exe` importado).

### Mecanismo de handoff site -> client — CONFIRMADO

`AMo.exe` é chamado com a linha de comando no formato:

```
AMo.exe <modo> <token_de_sessao> <terceiro_parametro>
```

Parseado em `FUN_00423b17` (é o `WinMain` de fato):
```c
sscanf(param_3, "%s %s %s", &DAT_008dcca8, &DAT_008dc9b8, &DAT_008dca80);
```

- `DAT_008dcca8` = **modo**: `"real"`, `"alpha"` ou `"karos"` (processado em `FUN_00421dd6` @ `0x00421dd6`).
  `"real"`/`"alpha"` ativam o XIGNCODE (anti-cheat comercial); `"karos"` não.
- `DAT_008dc9b8` = **TOKEN DE SESSÃO** — é copiado pra `DAT_008dcb00`, usado depois na URL da loja
  (`gamexp_sid=%s`). Valor especial `"gg"` = modo sem token (guest/dev?).
- `DAT_008dca80` = terceiro parâmetro, ainda não investigado.

Isso confirma: o site, após autenticar o usuário/senha, deve gerar esse comando (provavelmente via um
protocolo de URL customizado registrado no Windows, tipo `algumacoisa://real/TOKEN/...`, ou um pequeno
launcher/atalho que o navegador baixa e executa). **Ainda falta achar o registro desse protocolo** —
verificar no registro do Windows (`HKEY_CLASSES_ROOT`) da máquina onde o jogo normalmente roda, ou
procurar strings de protocolo customizado no `karos.exe` (launcher).

`DAT_008dcc94` (setado a partir do modo/token) controla depois qual "modo de verificação" é usado —
aparece em `FUN_005b20e6` (a mesma função que baixa `ServerList.ten`/`verify.ten`) com um branch
específico pra `== 0xe` (modo "tem token") que provavelmente é o passo que valida esse token contra o
servidor.

### ATUALIZAÇÃO IMPORTANTE — existe uma plataforma inteira por trás (GameXP)

Achado: protocolo `gamexp://` registrado no Windows (`HKEY_CLASSES_ROOT\gamexp\shell\open\command`)
aponta pra `gamexp-launcher.exe -uri "%1"`, instalado em
`%LOCALAPPDATA%\Programs\GameXP\Launcher\`.

O log desse launcher (`gamexp-launcher.log`) revela que é uma **plataforma de distribuição de jogos
completa**, tipo uma mini-Steam russa, não é específica do Karos:

- Autenticação via WebSocket assinado: `tcp://ws-app.gamexp.com:55555/?aid=<uuid>&ts=<timestamp>&sign=<hmac>`
- API de autenticação: `clotho-auth.gamexp.com`
- CDN de download/atualização: `clotho-download*.gamexp.com`, `clotho-update*.gamexp.com`
- Access point: `accesspoint-api.gamexp.com:8443`
- Sistema de "distribution packs" genérico (Karos é distribution id `48`, `pr`/`windows-amd64` branch)

**Isso muda o escopo do projeto.** O fluxo real é:
1. Usuário loga no SITE (gamexp.com ou domínio do jogo)
2. Site aciona o protocolo `gamexp://...`
3. `gamexp-launcher.exe` autentica na plataforma GameXP (WebSocket assinado), baixa/atualiza o jogo,
   e então lança `AMo.exe <modo> <token> <param3>` (o comando exato que descobrimos antes)
4. `AMo.exe` conecta no server do jogo (`ServerList.ten` -> `85.119.149.223:10001`) usando esse token

**Pergunta em aberto crítica:** o server do jogo (WorldSvr-equivalente) valida esse token *localmente*
(ex: verificando uma assinatura com chave pública conhecida) ou ele *também* chama de volta pros
servidores da GameXP pra validar? Se for só local, dá pra emular sem se preocupar com a GameXP inteira
(só precisamos gerar um token que passe na validação local). Se validar contra a GameXP, o projeto fica
bem maior (teríamos que também emular partes da plataforma, ou encontrar outro jeito do server aceitar
conexões).

### Próximos passos
1. ~~Decidir: o server de jogo valida o token localmente ou contra a GameXP?~~ **RESOLVIDO por
   raciocínio**: essa pergunta só importa pra quem quer se conectar no server privado EXISTENTE
   (que não é nosso). Como o objetivo é construir **nosso próprio server do zero**, nós decidimos
   como validar — não precisamos replicar a validação da GameXP. Só precisamos que o CLIENT aceite
   as respostas que o NOSSO server mandar. Isso destrava o projeto.
2. Entender o terceiro parâmetro da linha de comando (`DAT_008dca80`).
3. Seguir mapeando os pacotes `AMOPacket` restantes (opcode, campos) usando o mesmo método (RTTI ->
   vtable -> construtor/parse).
4. Considerar: já que existe uma sessão real (log do launcher tem UUIDs/tokens reais), pode valer a pena
   capturar tráfego de rede numa sessão de jogo real com Wireshark, se o usuário puder gerar uma.

### Teste empírico — rodar `AMo.exe` direto (sem launcher/site) apontando pro nosso listener

Em vez de continuar só estática, montamos um teste real: `ServerList.ten` redirecionado (mesma cifra,
seed `0x3042`) pra `127.0.0.1:19999`, e um listener Python (`D:\RoshEmulator\docs\capture_log.txt`)
logando tudo que chegar em hexdump. Backup do original em `C:\Games\Karos\ServerList.ten.orig_backup`.

**Achado sobre os modos de linha de comando** (corrige o que estava escrito antes): decompilando
`FUN_00421dd6` (chamada pelo `WinMain`, `FUN_00423b17`) com o `AMo.exe` ainda no meio do parse do modo:

```c
undefined4 FUN_00421dd6(char *param_1)
{
  strcmp(param_1,"real")  -> seta flag=2, CAI no carregamento do XIGNCODE (nao pula)
  strcmp(param_1,"alpha") -> seta flag=1, PULA o carregamento do XIGNCODE (goto LAB_00421e64)
  strcmp(param_1,"karos")  -> nao seta flag, TAMBEM cai no carregamento do XIGNCODE
  strcmp != nenhum desses -> return 0 (modo invalido)

  // bloco de carregamento do XIGNCODE (roda pra "real" E "karos", NAO pra "alpha"):
  iVar1 = FUN_0041f6a9("ND7cyspcIB8n","XIGNCODE",0);
  if ((iVar1 == 0) || (FUN_0041f501(100,&LAB_00421977,0) == 0)) {
      GetLastError();
      return 0;   // <-- falha aqui = WinMain nao entra no jogo, so sai limpo (exit 0)
  }

LAB_00421e64:
  // processa o token (2o parametro):
  if (strlen(token) != 0) {
      if (strcmp(token,"gg") == 0) DAT_008dcc94 = 0;      // guest / sem validacao de token
      else { DAT_008dcc94 = 0xe; copia token pra DAT_008dcb00; }  // "tem token, precisa validar"
  }
  return 1;
}
```

Ou seja: **só o modo `"alpha"` pula o carregamento do XIGNCODE.** `"karos"` (que a doc anterior dizia
que pulava) na verdade tenta carregar o XIGNCODE igual ao `"real"` — e como não temos XIGNCODE
configurado nesse ambiente de teste, `FUN_0041f6a9`/`FUN_0041f501` falha, `FUN_00421dd6` retorna 0,
e o `WinMain` sai silenciosamente (exit code 0, sem janela) — foi exatamente o que observamos ao
tentar `AMo.exe karos gg 0`.

Com `AMo.exe alpha gg 0`: o processo abre normalmente, janela title
`"Karos Online - ver.01.140508.nhn.rus.alpha - unpak"` — e mostra uma **tela de login clássica local**
("KAROS REBIRTH", campos ID/PW, botão LOGIN, Cadastre-se/Voltar/Cancelar, tudo em português). Isso muda
o entendimento: o build "alpha" (renomeado "Rebirth" por quem operava esse client) tem login
usuário+senha DIRETO no client, sem depender de site nenhum. **Confirma token `"gg"` = modo guest sem
pré-validação, ideal pra testes.**

### BREAKTHROUGH — pacote de login real capturado (371 bytes)

Preenchemos ID/PW (valores de teste, ficaram meio embaralhados por causa da automação via SendKeys —
não importa, só queríamos ver o formato) e clicamos em LOGIN. O client conectou de verdade no nosso
listener e mandou um pacote de **371 bytes**. Salvo em
`D:\RoshEmulator\captures\login_packet_01_raw.bin` (hexdump completo também no histórico).

**Framing confirmado:** primeiros 4 bytes = `00 00 01 6f` = **0x16f = 367 (big-endian)** = tamanho do
resto do pacote (367 + 4 = 371, bate exato). Ou seja: **header de 4 bytes, big-endian, tamanho da
payload que segue** — é o formato de framing TCP do protocolo.

Bytes 4-8: `02 00 00 00` — provável opcode/tipo de pacote (little-endian, valor 2) — candidato a
"opcode de login request".

**Achado estranho:** o meio do pacote (~bytes 8 até ~0x110) contém um monte de strings ASCII que NADA
têm a ver com login — são mensagens de erro/paths do sistema de árvores do engine (SpeedTree):
`"Unable to retrieve maps from [%s] - %s"`, `"ResUnpak/Map/Tree/Shaders/"`,
`"CSpeedTreeForestManager::CSpeedTreeForest"`, `"No trees loaded, cannot populate forest randomly"`.
Isso é muito provavelmente um **bug de memory disclosure**: o buffer de envio é reaproveitado de uma
alocação anterior (relacionada a carregamento de árvores/mapa) e o client não limpa o buffer inteiro
antes de escrever os dados de login nele — só sobrescreve o necessário e manda o buffer inteiro
(367 bytes) do jeito que está, vazando memória antiga. **Não afetou o funcionamento** (o campo de
tamanho `0x16f` é fixo, não reflete conteúdo "válido" real) — pro nosso emulador isso não importa, só
precisamos saber onde estão os campos que IMPORTAM.

**Os campos que importam estão no final do pacote**, uma sequência de strings terminadas em `\0`:

```
. \0 testusertes \0 teste123 \0 a755441959f0fb071dda530f086cc58c \0 pt \0 0bb79b04cf60d1a2a223df6212ed6bde \0
```

| campo | valor capturado | provável papel |
|---|---|---|
| 1 | `.` | delimitador ou campo vazio |
| 2 | `testusertes` | **ID** (username) — saiu embaralhado pela automação, não importa |
| 3 | `teste123` | **PW** (password) — em texto puro (!), também embaralhado pela automação |
| 4 | `a755441959f0fb071dda530f086cc58c` (32 hex) | hash MD5-shaped — **NÃO bate com md5(senha)** testado (verificado, não é isso) — provável fingerprint de hardware/máquina |
| 5 | `pt` | código de idioma/locale (português — o SO desta VM está em pt) |
| 6 | `0bb79b04cf60d1a2a223df6212ed6bde` (32 hex) | outro hash MD5-shaped — provável segunda parte do fingerprint, ou hash relacionado ao `CheckFileListW.ten` (manifest de integridade) |

**Confirmado: a senha vai em texto puro no pacote** (pelo menos nesse build/modo "alpha" sem
XIGNCODE) — ótima notícia pro emulador, não precisamos decifrar hash de senha nenhum, só aceitar
qualquer coisa no nosso server de teste.

Depois de mandar o pacote, o client ficou esperando resposta (nosso listener só loga, não responde) —
após o timeout de 15s do listener o processo `AMo.exe` fechou sozinho. Esperado — ainda não
implementamos NENHUMA resposta do lado servidor.

**Próximo passo direto:** achar no Ghidra a função exata que MONTA esse pacote (provável handler do
clique do botão LOGIN), pra confirmar o layout campo-a-campo com certeza (em vez de inferir só pelo
hexdump) e entender os dois hashes.

### CONFIRMADO 100% — layout exato do `CLoginReqPacket` (via decompilação da função Parse)

Achamos a função `Parse` de verdade: **`FUN_00650f15`** (slot 1 da vtable, endereço `0x00650f15`).
Decompilamos ela + o construtor (`FUN_006506bf` @ `0x006506bf`) + destrutor (`FUN_00650e9e` @
`0x00650e9e`) + os dois lugares que CHAMAM o construtor (`FUN_00651183` @ `0x00651183` e
`FUN_0073c7b0` @ `0x0073c7b0`).

**Bati campo por campo contra a captura real (`login_packet_01_raw.bin`) e todos os offsets calculados
pelo código batem exatamente com o que capturamos.** Layout confirmado:

```
buffer recebido (comeca logo apos o header de 4 bytes big-endian de tamanho total):
  offset 0x00 (2 bytes): assinatura -- DEVE ser 02 00 (Parse rejeita qualquer outra coisa)
  offset 0x02 .. 0x114  : bloco de ~275 bytes -- na nossa captura client->server vinha tudo zero/lixo
                           de memoria reaproveitada (nao e lido pelo Parse nessa direcao). Só é
                           relevante quando o SERVER manda de volta (ver abaixo).
  offset 0x115          : STRING 1 (\0-terminada) = ID
  logo em seguida        : STRING 2 (\0-terminada) = PW (texto puro!)
  logo em seguida        : STRING 3 (\0-terminada) = hash hex 32 chars (fingerprint, nao é md5 da senha)
  logo em seguida        : STRING 4 (\0-terminada) = locale ("pt" no nosso teste)
  logo em seguida        : STRING 5 (\0-terminada) = hash hex 32 chars (segundo fingerprint)
```

Objeto `CLoginReqPacket` em memória (offsets a partir do `this`, DWORDs):
`[0]` vtable · `[1]`=+4 ponteiro pro buffer bruto · `[2]`=+8 STRING1(ID) · `[3]`=+0xc STRING2(PW) ·
`[4]`=+0x10 STRING3(hash1) · `[5]`=+0x14 STRING4(locale) · `[6]`=+0x18 ponteiro bruto pra
`buffer+0x16` (não é string, aponta pra dentro do bloco binário de cabeçalho) · `[7]`=+0x1c
STRING5(hash2).

**`FUN_00651183`** é quem chama o construtor + `Parse` sobre um buffer recebido, e depois lê (via
`ntohs`/`ntohl`) valores nos offsets 0x8, 0xa, 0xc, 0xe, 0x10, 0x12 do MESMO buffer — exatamente os
bytes que, na direção client->server, vinham zerados/sem uso. **Conclusão: o mesmo formato de pacote
(`CLoginReqPacket`) é usado nos DOIS sentidos** — client->server carrega ID/PW/hashes/locale nas
strings (cabeçalho ignorado); server->client provavelmente usa o cabeçalho pra mandar **portas de
sub-servidores** (chat/trade/etc, um padrão clássico desse tipo de engine coreano, igual o Cabal) e
reaproveita os slots de string pra outra coisa (um deles é copiado pra `DAT_008dc9b8`, que é o MESMO
global usado pro **token de sessão** no handoff `AMo.exe <modo> <token> <param3>` — ou seja, o server
manda de volta um NOVO token de sessão como parte da resposta de login).

**Isso é o suficiente pra começar a escrever código real do servidor de teste** — já sabemos
exatamente como parsear o pacote de login que o client manda. A resposta ainda precisa de mais
engenharia reversa/teste empírico pra fechar o formato exato (quantas portas, em que ordem, o que vai
em cada slot de string).

Código de referência salvo em `D:\RoshEmulator\server\login_packet.py` (parser Python do formato
acima).

**Nota sobre automação de GUI nessa VM:** tentamos automatizar clique/digitação no client (pra gerar
mais capturas de login sem depender de ação manual) via `SendKeys`, `mouse_event`/`keybd_event` e até
`PostMessage` direto na fila de mensagens da janela (bypass de foreground). O client usa DirectInput ou
input raw (não responde a `PostMessage` de teclado/mouse), e o `SetForegroundWindow` é bloqueado de
forma consistente porque a própria janela do app Claude (processo `claude.exe`, é o que renderiza essa
sessão) mantém o foreground travado. A ÚNICA captura que funcionou foi a primeira tentativa (antes de
qualquer outra automação ter "sujado" o foreground). **Se precisarmos de mais capturas de pacotes reais,
o caminho mais confiável é pedir pro usuário clicar manualmente** (ele já demonstrou que consegue ver a
janela e a usa normalmente) — automação cega de UI nessa VM não é confiável pra esse client.

`ServerList.ten` do client em `C:\Games\Karos\` está **atualmente redirecionado pra
`127.0.0.1:19999`** (backup do original em `ServerList.ten.orig_backup` na mesma pasta) — é assim que
conseguimos interceptar. Lembrar de restaurar se for testar contra o server real de novo.

## Servidor real (referência, não é nosso)

- IP: `85.119.149.223`
- Porta: `10001`
- Fonte: `ServerList.ten` descriptografado.

## Algoritmo de criptografia dos arquivos `.ten`

Cifra de subtração encadeada. Confirmada em `ServerList.ten` e `CheckFileListW.ten` (mesma seed `0x3042`).
Função de referência no client: `FUN_00720fba` @ `0x00720fba`.

```python
def decrypt_ten(buf: bytes, seed: int) -> bytes:
    key = seed & 0xff
    out = bytearray(len(buf))
    for i, c in enumerate(buf):
        b = (c - key) & 0xff
        out[i] = b
        signed_b = b if b < 128 else b - 256   # char é signed no MSVC x86
        key = (key + signed_b) & 0xff
    return bytes(out)
```

Uso: `decrypt_ten(open('ServerList.ten','rb').read(), 0x3042)` -> `b'85.119.149.223\r\n10001\r\n'`

Formato do `CheckFileListW.ten` decriptado: primeira linha = contagem (760), depois
`caminho\thash` por linha (manifest de integridade de arquivos, provavelmente MD5).

Outros arquivos de dado provavelmente usam o mesmo algoritmo com seeds diferentes —
ainda não mapeamos quais.

## Camada de rede (Winsock)

Funções wrapper identificadas em `AMo.exe`:

| Função | Endereço | O que faz |
|---|---|---|
| `FUN_0042416a` | `0x0042416a` | `WSAStartup(0x202, ...)` |
| `FUN_00738510` | `0x00738510` | wrapper de `connect()` |
| `FUN_00738070` | `0x00738070` | wrapper de `closesocket()` |
| `FUN_007373a0` | `0x007373a0` | wrapper de `WSASend()` (I/O overlapped) |
| `FUN_00737330` | `0x00737330` | wrapper de `WSARecv()` (I/O overlapped) |
| `FUN_007385f0` | `0x007385f0` | monta `sockaddr_in` (porta via `htons`, IP via `inet_addr`) e chama o connect wrapper |

Usa I/O overlapped (`CreateIoCompletionPort`, estruturas `OVERLAPPED` nos parâmetros de
`WSASend`/`WSARecv`) — é uma classe C++ de socket assíncrono, não bloqueante simples.

## Arquitetura de pacotes — classes C++ (RTTI)

O client usa uma classe base `AMOPacket`, com uma subclasse **por tipo de pacote**
(nomes recuperados via RTTI/type descriptors do MSVC, não estão stripped). Lista parcial
relevante a login/conta:

- `CLoginReqPacket` — **provável pacote de requisição de login (usuário+senha)**
- `CLoginStartCharPacket`
- `CLoginCreateCharPacket`
- `CLoginDeleteCharPacket`
- `CLoginCharInfoPacket` / `CLoginCharViewInfoPacket`
- `CLoginChannelListPacket` / `CLoginChannelChangePacket`
- `CChannelWaitLoginPacket`
- `CLoginSecondPasswdRegisterPacket` / `...RevisionPacket` / `...DeletePacket` / `...CheckPacket`
  (sistema de "segunda senha", tipo o do Cabal)
- `CLoginMatrixCheckPacket`
- `CItemAccountStash*Packet` (baú de conta compartilhado)

RTTI type descriptor de `CLoginReqPacket` está em `0x008b4140` (string `.?AVCLoginReqPacket@AMOPacket@@`).

### `AMOPacket::CLoginReqPacket` — detalhes

Vtable em `0x008131a8`. Construtor `FUN_006506bf` @ `0x006506bf`, destrutor `FUN_00650e9e` @ `0x00650e9e`.

Layout do objeto (offsets em DWORDs a partir do `this`):
- `[0]` vftable
- `[1]` campo simples (não liberado no destrutor — provável inteiro/opcode)
- `[2..5]` ponteiros liberados no destrutor (`FUN_00444486` = provável `delete`/free) — strings alocadas
- `[6]` não liberado — provável inteiro/flag
- `[7]` liberado — outra string

Vtable (slots, offset 0x008131a8):
| slot | endereço alvo | possível papel |
|---|---|---|
| 0 | `FUN_006510ad` | ? |
| 1 | `FUN_00650f15` | **Parse/Deserialize** — chamado logo após construir, recebe buffer bruto |
| 2-5, 9-12 | `FUN_004365a2` | provável getter genérico (mesma função em 8 slots — GetFieldAsString por índice?) |
| 6 | `0x84ca18` | ponteiro pra dado, não função (RTTI-relacionado) |
| 7 | `FUN_0065954f` | ? |
| 8 | `FUN_006506ec` | ? |
| 13 | `0x84ca64` | ponteiro pra dado (RTTI) |
| 14 | `FUN_00650791` | ? |

`FUN_00650f15` (slot 1, Parse) é chamado em ambos os usos encontrados do construtor — reforça que é o
método que lê os bytes crus da rede pros campos do objeto.

Um dos usos (`FUN_00651183`) mostra campos sendo lidos com `ntohs`/`ntohl` em vários offsets dentro do
buffer parseado — parece ser resposta de "info de conexão" com múltiplas portas (offsets +8, +0xc, +0xe,
+0x10, +0xa, +0x12 do buffer), possivelmente portas de serviços diferentes (Chat/Trade/etc, como no Cabal),
não necessariamente o pacote de credenciais em si. **Precisa investigar mais** — pode ser que
`CLoginReqPacket` seja usado tanto pro request (client->server) quanto pra alguma resposta relacionada,
ou que essa função específica não seja o fluxo principal de envio de login.

**Conclusão parcial:** engenharia reversa estática pura está ficando lenta pra mapear o formato exato
byte-a-byte dos pacotes. Recomendo fortemente capturar tráfego real (Wireshark) numa sessão de jogo real
pra validar/acelerar isso, se for possível.

## Sessão 2026-08-20 — testando a resposta experimental de login (end-to-end)

Retomamos depois de uma sessão travada por erro de autenticação (18/08). Rodamos o `login_server.py`
de verdade contra o `AMo.exe alpha gg 0` rodando ao vivo, com o usuário clicando LOGIN manualmente
(automação de UI ainda não confiável nessa VM).

**Confirmado, ponta a ponta:**
- O pacote de request chega certinho: `LoginRequest(id='tomara', pw='certo', hash1=..., locale='pt', hash2=...)`
  — parser Python bate 100% com o formato documentado.
- A resposta experimental (296 bytes) chega no client via `WSARecv` (breakpoint em `0x00737330`
  disparou, confirmado com x32dbg anexado ao processo ao vivo, PID do teste 1968).
- A resposta é processada por `FUN_00651183` (breakpoint em `0x00651183` disparou) — o `Parse`
  (`FUN_00650f15`) retorna sucesso (`AL=1`), confirmando que o formato do cabeçalho (assinatura
  `02 00` + campos de porta) é válido.
- Decompilamos `FUN_00651183` por completo. Fluxo real dos campos do cabeçalho da resposta:
  - offset `0x8` (`ntohs`) → guardado em `*(param_1+0x660)` (campo do objeto conexão) — porta "principal".
  - offset `0xC`, `0xE`, `0x10`, `0xA` (`ntohs`) → guardados em globais `DAT_008db7ec + 0x74d4/0x74d8/0x74dc/0x74e4`
    (portas de sub-serviços, provavelmente Chat/Trade/etc, no mesmo padrão do Cabal).
  - offset `0x12` (`ntohl`) → se `> 0`, é tratado como **código de erro** e dispara uma messagebox
    (mandamos `0`, por isso não vimos popup).
  - Se porta do offset `0xC` for `!= 10`: copia uma das nossas strings de resposta pro token de sessão
    global `DAT_008dc9b8` (o MESMO global usado no handoff `AMo.exe <modo> <token> <param3>`) —
    confirmado, mandamos `11` e o token novo foi de fato copiado.
  - No final, **sempre** chama `FUN_0042bc37()` e `FUN_004518c0()`.

**Rastreado até a raiz do "nada acontece":** `FUN_004518c0` acessa um objeto global de estado
(`DAT_008e0360`, apontando pra `0x033A2D50` no teste — **não é nulo**, tem estruturas de nome de
osso/animação por perto tipo "Arrow_01_Bone01", então é algum tipo de gerenciador de
cena/personagem/rede reaproveitado). Confirmamos ao vivo (dump de memória) que o campo
`DAT_008e0360[0x33]` (offset `+0xCC`) está em `0`, o que **deveria** satisfazer a condição de entrada
do bloco (`< 1`). Dentro do bloco, a decisão de trocar de tela depende de `FUN_00455612()` — que é só
um getter de 1 byte (`*(param_1+0x5f)`, provavelmente uma flag "ocupado"/"em transição") — e depois,
se passar, chama uma função de vtable (`(**(code**)(*obj+0x3c))(6,0)`), que é quase certamente o
disparo real de mudança de tela.

`FUN_0042bc37` também roda sempre: chama `FUN_006e55d8` duas vezes com campos do objeto de conexão
(`+8` e `+4` — prováveis ponteiros de fila/callback), depois `FUN_0042bbfa`.

**Conclusão parcial:** a resposta É aceita e processada de ponta a ponta (não é rejeitada
silenciosamente, ao contrário do que parecia). O "nada acontece" visualmente deve estar em uma
condição mais fina dentro dessa máquina de estados (byte de flag em `+0x5f`, ou os campos
`[0x2e]`/`[0x42]`/`[0x2b]` de `FUN_0047ce02`, que é quem de fato invoca o dispatcher de tela via vtable
`+0x3c`) — não dá pra fechar só lendo o decompilado, precisa inspecionar os valores reais em tempo de
execução no momento exato da chamada (voltar ao debugger, breakpoint em `FUN_0047ce02` @ `0x0047ce02`
e inspecionar `param_1[0x33]`, `param_1[0x42]`, `param_1[0x2e]`, `param_1[0x2b]` ali).

**Próximo passo direto (retomar daqui):** breakpoint em `0x0047ce02` (função que decide e dispara a
troca de tela via vtable), clicar LOGIN de novo, e no momento em que parar, ler os campos citados acima
do objeto (`ECX`/primeiro parâmetro) pra saber qual branch está sendo tomado e por que não chega no
`(**(code**)(*param_2 + 0x3c))(param_5,param_2)` final.

### Continuação (mesma sessão) — confirmado: o branch de transição É tomado, mas ainda não muda a tela

Objeto global confirmado ao vivo: `DAT_008e0360 = 0x033A2D50` (não é nulo — tem dados de nome de osso
de animação por perto, tipo `Arrow_01_Bone01`, então é algum gerenciador de cena/personagem
reaproveitado pra guardar também esse estado de rede).

Breakpoint em `FUN_0047ce02` (`0x0047ce02`) disparou. `ECX` = `0x033A2D50` (mesmo objeto). Os argumentos
de chamada vêm hardcoded no `FUN_004518c0` (`param_5=1, param_6=1, param_7=0xffffffff, param_8=0`), não
dependem de runtime. Rastreamos o assembly real:

- `[esi+0xCC]` (`param_1[0x33]`) != `0xB002` → não retorna cedo, continua.
- `param_5(=1)` não é `0xA001`/`0xA007`; `[esi+0x108]` não é `0xA059` → cai no branch que chama
  `FUN_00455612()` (getter de 1 byte, `*(this+0x5f)`, provável flag "em transição/ocupado").
- **`FUN_00455612()` retornou `EAX=0`** (confirmado com breakpoint em `0x0047CE65`, logo após o `call`) →
  `test eax,eax; je 47CECA` **tomou o salto** pro bloco `LAB_0047CECA`, que é o bloco que de fato
  reseta o estado (`param_1[0x33..0x39]=0`, seta `param_1[0x42]=param_5`, etc.) e no final chama
  `(**(code**)(*param_1 + 0x48))()` — **essa é a próxima candidata real ao "trigger" visual**, ainda não
  decompilada.

**Resultado do teste:** deixamos rodar livre depois de confirmar o branch. **A tela continuou
exatamente igual** (login), e **nenhuma conexão nova chegou nas portas que mandamos** (testamos com um
listener extra na 10001 — nada). A conexão original na 19999 ficou aberta (`ESTABLISHED`) sem fechar
dessa vez.

**Conclusão:** o "sim, vai trocar de tela" já está confirmado no nível de branch (`EAX=0` → bloco
`LAB_0047CECA` executa), mas o efeito visual real depende de pelo menos mais uma camada de vtable
(`*param_1 + 0x48`, slot 0x12) que ainda não foi decompilada/rastreada. É bem provável que tenha MAIS
uma ou duas camadas de indireção até a UI de verdade trocar — típico desses engines antigos com
gerenciador de estado genérico reaproveitado.

**Próximo passo direto (retomar daqui):** decompilar a vtable de `DAT_008e0360` (dump de
`*0x033A2D50` pra achar o endereço da vtable, depois o slot `+0x48` = índice 18) e ver o que essa função
realmente faz. Alternativa mais rápida: breakpoint direto no endereço da função de destino (depois de
achar via vtable) e repetir o ciclo de teste (LOGIN → ver se dispara → ver o que faz).

### Continuação — PISTA FALSA descartada: vtable+0x48 é só layout de UI, não transição de estado

Seguimos o rastro: vtable do objeto lida ao vivo (`*0x033A2D50 = 0x008004DC`), slot `+0x48` aponta pra
`FUN_0047dc1b` (`0x008004DC + 0x48 = 0x00800524` → função em `0x0047DC1B`). Decompilamos por completo.

**Resultado: não é o que procurávamos.** Essa função calcula retângulos/posições (`local_30..local_24`
formam claramente um `RECT` left/top/right/bottom), chama `FUN_0042c2ad`/`FUN_0042c2eb` (prováveis
`SetPosition`/`SetSize` de widget) e compara contra `DAT_008db7ec+0x240`/`0x23c` (prováveis
largura/altura de tela). **É uma função de reposicionar painéis filhos de UI ("OnLayout"/"OnResize")**,
chamada genericamente sempre que esse objeto muda de estado — não é o dispatcher real de troca de tela.
Ela nem chega a fazer nada de útil se os ponteiros de painel (`param_1+0xec/0xf0/0xf4`) forem nulos
(retorna `0` direto), o que é bem provável ainda na tela de login (painéis da tela de seleção de
personagem ainda não existem).

**Isso descarta a hipótese "vtable+0x48 = trigger real".** O verdadeiro disparo de troca de tela deve
estar em outro lugar — candidatos que ainda não olhamos: as chamadas que ficaram pra trás dentro do
próprio `LAB_0047CECA` antes dessa (`FUN_0047c3ec`, as chamadas condicionais em `param_1[0x3a]`/`[0x3b]`/
`[0x3d]` via vtable `+0x48` DE OUTROS objetos — não o mesmo `DAT_008e0360` — e o `(**(code**)(*param_1+0x48))()`
final no MESMO objeto que investigamos agora — pode ser que o vtable correto seja de um desses outros
ponteiros, não do objeto principal).

**Avaliação honesta pra próxima sessão:** a abordagem "seguir vtable por vtable" está com retorno
decrescente — 6 rodadas de debugger numa sessão só pra cair numa função de layout de UI. Duas
alternativas mais eficientes pra próxima vez:
1. **Capturar tráfego real** (Wireshark) de uma sessão de login bem-sucedida contra o servidor de
   verdade (`85.119.149.223:10001`, se ainda existir/aceitar conexão) — ter a resposta REAL do servidor
   pra comparar byte a byte com a nossa experimental, em vez de adivinhar campo por campo.
2. Testar variações mais radicais da nossa resposta experimental empiricamente (valores diferentes nos
   campos de porta, no offset `0x12`, etc.) e ver se ALGUMA combinação muda a tela — pode ser mais rápido
   que continuar rastreando vtables uma por uma.

## Sessão 2026-08-20 (continuação) — captura REAL contra o servidor de produção (opção 1 acima)

Em vez do Wireshark, usamos o mesmo truque do proxy de opcode do Cabal: escrevemos
`D:\RoshEmulator\server\real_server_proxy.py` (proxy TCP transparente, loga tudo em hexdump) e
`D:\RoshEmulator\server\redirect_to_proxy.py` (aponta o `ServerList.ten` pro proxy, que encaminha pro
servidor real). Reiniciamos o `AMo.exe alpha gg 0` (processo novo, PID muda a cada relaunch — o
`ServerList.ten` só é lido na abertura) e ele reenviou sozinho os campos de login residuais do teste
anterior (`id=sdfsadf`, sem intervenção do usuário — parece cache de formulário).

**Resultado: popup real do client** — *"A sua autorização expirou. Por favor, reinicie o jogo e faça
login novamente."* — e o servidor **derrubou a conexão** logo em seguida. Confirma o que o usuário já
esperava: o servidor de produção não aceita login direto sem o token da plataforma GameXP.

Mas a troca capturada (salva em `D:\RoshEmulator\captures\real_server_reject_01.log`) é valiosa:

1. **Handshake de 19 bytes** logo após conectar: servidor manda `0b 00 00 00 00 01 00 <7 bytes de
   desafio> 00`, client ecoa quase igual, só troca o byte de sequência `01`→`02`. Isso é uma etapa do
   protocolo que a gente **não estava implementando** no nosso `login_server.py` experimental — pode ser
   parte do motivo do nosso teste anterior não ter avançado de tela também.
2. **Bug de vazamento de memória espelhado no lado do SERVIDOR** (não só no client): a resposta de 290
   bytes tem strings tipo `/usr/local/gg_libs/boost/include/boost-1_35/...` — confirma servidor Linux,
   C++, Boost 1.35 (biblioteca de ~2008-2009).
3. **ACHADO CENTRAL: opcode de tipo `02 06`, não `02 00`** — o pacote final de 36 bytes (provável
   pacote de erro/rejeição, chegou bem antes do popup) começa com assinatura `02 06`. Isso sugere que o
   segundo byte da assinatura é o **opcode real do tipo de pacote** (`00`=login normal, `06`=algum tipo
   de erro/rejeição), não uma constante fixa como assumimos.

Tentamos achar a string "autorização expirou" direto no `AMo.exe` (ASCII e UTF-16) e também num
`AMo.exe` alternativo dentro de `gmxp-cfg/lang-pt/` — **não achamos em nenhum dos dois**. Deve estar
num arquivo de dados criptografado (mesmo padrão dos `.enc` do Cabal) — não vale a pena decifrar isso
agora, o opcode `02 06` já é pista suficiente.

**Próximo passo direto (retomar daqui):**
1. Implementar o handshake de 19 bytes no `login_server.py` (provavelmente obrigatório antes do
   servidor aceitar processar o login de verdade).
2. No Ghidra, procurar onde o client despacha por esse segundo byte da assinatura (`02 XX`) — deve ter
   um `switch`/tabela que decide o que fazer com cada `XX`. Comparar o handler de `06` (erro) com o de
   `00` (que já mapeamos, `FUN_00651183`) pra confirmar a teoria.
3. Reaproveitar a infra já pronta: `real_server_proxy.py` (proxy pro servidor real, útil pra mais
   capturas) e `probe_10001.py` (listener de teste).

**Nota de infraestrutura pra próxima sessão:** o `login_server.py` já está rodando sem buffer
(`python -u`), e um listener de teste extra (`D:\RoshEmulator\server\probe_10001.py`) foi criado pra
capturar tentativas de conexão em portas alternativas — reaproveitar/adaptar pra outras portas
suspeitas se necessário.

## 🎯 BREAKTHROUGH — Sessão 2026-08-20 (continuação final): arquitetura real mapeada com captura de login bem-sucedido

Depois de esgotar a via de debugger/vtable (múltiplos becos sem saída), pedimos pro usuário logar
**de verdade** pelo launcher normal (`C:\Games\Karos\karos.exe`, via site/GameXP) enquanto capturávamos
o tráfego real com **`pktmon`** (ferramenta nativa do Windows, sem precisar instalar nada) filtrando a
sub-rede `85.119.149.0/24`. Resultado: captura completa de um login bem-sucedido, do zero até a tela de
personagem. Arquivos salvos permanentemente:

- `D:\RoshEmulator\captures\real_success_full_flow.pcapng` (captura bruta, 150 pacotes)
- `D:\RoshEmulator\captures\real_success_full_flow_parsed.txt` (payloads em hexdump, já decodificados)
- `D:\RoshEmulator\captures\parse_pcapng.py` (parser Python reutilizável pra próximas capturas)
- `D:\RoshEmulator\captures\real_server_reject_01.log` (captura de uma rejeição, pra comparação)

### DESCOBERTA CENTRAL 1 — arquitetura é de DOIS SALTOS (login server → world server), não um só

Todo o trabalho anterior (guiar a resposta pra fazer a MESMA conexão avançar de tela) estava caçando
uma coisa que **nunca acontece** nesse protocolo. O fluxo real é:

1. Client conecta no servidor "de conta" (`85.119.149.223:10001`, o mesmo do `ServerList.ten` — mas só
   usado nessa primeira etapa!) e manda o `CLoginReqPacket` (370 bytes, formato já 100% conhecido).
2. Servidor responde com handshake (19 bytes) + um pacote pequeno (36 bytes, ver Descoberta 2) + um
   pacote grande (290 bytes, ainda majoritariamente vazamento de memória do servidor).
3. **O CLIENT FECHA ESSA CONEXÃO SOZINHO** (`FIN` limpo, não é erro) e abre uma **conexão NOVA** pro
   servidor de mundo de verdade (`85.119.149.224:10199` no teste — mudou de `.223`pra `.224`, e a
   porta de `10001` pra `10199`, então IP *e* porta mudam).
4. Nessa segunda conexão, manda **o mesmo formato de `CLoginReqPacket`** de novo (370 bytes,
   idêntico ao primeiro — mesmo ID/senha/hashes).
5. Esse segundo servidor manda o handshake de novo, e a partir daí um monte de pacotes reais:
   dados de personagem, itens, e **até uma mensagem de chat de outro jogador** chegou nesse meio
   tempo (pacote #42, 241 bytes: `[Separation key of the Lord of the Assassin] I will sell` — prova
   que depois dessa segunda conexão o client já está "dentro do mundo" de verdade, recebendo eventos
   ao vivo).

**Isso explica 100% por que nenhum dos nossos testes anteriores (na mesma conexão, com o
`login_server.py`) jamais fez a tela mudar** — não importa o que a gente mandasse na PRIMEIRA conexão,
o client SEMPRE espera fechar e abrir uma segunda conexão em outro servidor. Testávamos uma coisa que
o protocolo real nunca faz.

### DESCOBERTA CENTRAL 2 — o pacote de resposta do login NÃO usa assinatura `02 00`

Todo o nosso `login_server.py` mandava uma resposta com assinatura `02 00` (copiada do request), e o
`AL=1` que vimos no debugger só confirmava que o **nosso próprio pacote fake** era estruturalmente
válido pra ser reparseado como se fosse OUTRO request — nunca provou que era uma resposta de verdade
sendo entendida.

**O servidor real nunca manda `02 00` de volta.** A resposta real de 36 bytes (handshake à parte) tem
assinatura **`02 06`**:

```
Rejeição (conta gg/sem token):  02 06 00 00 00 03 00 00 00 00 00 01 00 00 00 00 16 00 63 00 00 00 55 77 95 e2 d7 27 00 00 00 00
Sucesso  (conta real, via site): 02 06 00 00 00 03 00 00 00 00 00 01 00 00 00 00 01 00 63 00 00 00 55 77 95 e0 d7 27 00 00 00 00
```

Achamos a classe via RTTI: **`AMOPacket::CChannelWaitLoginPacket`**, vtable em `0x00813234`
(RTTI Type Descriptor em `0x008b421c`, string `.?AVCChannelWaitLoginPacket@AMOPacket@@` em `0x008b4224`).
Construtor: `FUN_00650812` (`param_1[2]=0; vftable = ...`). Parse (slot 1 da vtable): `FUN_0065081f`:

```c
uint __thiscall FUN_0065081f(int param_1, uint param_2) {
  if (*(int *)(param_2 + 0x10) < 10) {
    param_2 = param_2 & 0xffffff00;   // falha
  } else {
    uVar1 = *(undefined4 *)(param_2 + 8);
    *(undefined4 *)(param_1 + 4) = uVar1;
    *(undefined4 *)(param_1 + 8) = uVar1;
    param_2 = CONCAT31(..., 1);        // sucesso
  }
  return param_2;
}
```

**Nota importante:** comparando byte a byte reject vs sucesso, o único campo que muda é o offset `0x10`
(dentro do payload de 32 bytes, contando a partir do `02 06`): `0x16` na rejeição vs `0x01` no sucesso.
Só que testamos e ESSE campo específico não é o que o `Parse` acima usa pra decidir sucesso/falha (o
`< 10` do Parse não bate com os dois valores observados — os dois são maiores que 10 quando lidos como
inteiro de 4 bytes a partir desse offset). **Conclusão: a decisão real de aceitar/rejeitar não está
codificada só nesse pacote — é decidida no servidor a partir da validação do token da GameXP, e o que
diferencia rejeição de sucesso é o servidor FECHAR A CONEXÃO COM RESET (`WinError 10054`, rejeição) vs
o CLIENT fechar sozinho com `FIN` limpo depois de processar tudo normalmente (sucesso).** Isso bate:
pro nosso emulador (onde controlamos totalmente a validação), não precisamos replicar esse campo
específico — só precisamos NÃO resetar a conexão feio, e mandar o client pro próximo salto.

### DESCOBERTA CENTRAL 3 — o pacote de 290 bytes é normal (não é indicativo de erro)

Aparece **igual** tanto na rejeição quanto no sucesso — confirma que é só uma resposta "padrão" que
sempre acompanha o handshake+CChannelWaitLoginPacket, majoritariamente vazamento de memória do
servidor (paths de `/usr/local/gg_libs/boost/...`). Provavelmente não precisamos replicar o conteúdo
detalhado dela pro emulador funcionar — só a presença/tamanho pode importar (a confirmar).

### Dados capturados do SEGUNDO salto (servidor de mundo, 149.224:10199) — ainda não decodificados campo a campo, mas o formato geral já dá pra ver

- Pacote de 294 bytes (assinatura `02 00`): contém uma **string hex de 32 chars** tipo
  `7881BAFD51EE23F99D30203483BE9C9D` (provável hash de sessão/personagem) e um número decimal no fim
  (`15259767`, provável ID de conta ou personagem).
- Pacote de 302 bytes (assinatura `02 06`): parece ser uma **lista de entradas repetidas** — grupos de
  ~14 bytes cada, com índices sequenciais (01,02,03...) e um timestamp/hash de 4 bytes por grupo
  (`55 77 95 e0`, `55 77 95 e1`, `2d 4d c5 d8`, `cf f6 4d a1`...). Bom candidato pra **lista de
  personagens da conta** ou lista de itens.
- Pacotes de 240-241 bytes com assinatura DIFERENTE (`03 00 00 00 00 04...` / `03 08 00 00 00 05...`):
  são **mensagens de chat/mercado de outros jogadores chegando ao vivo** (ex: `[Separation key of the
  Lord of the Assassin] I will sell`, `[+10 Dark Souls' mighty dagger] I'm going to sell the demon +4
  0.20`) — confirma que o protocolo do "mundo" usa uma família de opcodes diferente da fase de login
  (não é mais `02 XX`, é outro esquema, ainda não mapeado).

### Próximo passo direto (retomar daqui) — construir o emulador com a arquitetura CERTA

1. Reescrever o `login_server.py` pra ser só o **primeiro salto**: aceita a conexão, manda
   handshake(19) + `CChannelWaitLoginPacket` com assinatura `02 06` (usar os bytes capturados como
   referência, ajustando o offset `0x10` pra um valor "de sucesso" tipo `01`) + o pacote de 290 bytes
   (pode reusar o nosso, já que parece ser conteúdo genérico) — e depois **fechar a conexão de forma
   limpa** (sem reset), deixando o client seguir pro próximo passo sozinho.
2. Subir um **segundo servidor de teste** (ex: porta 20001) que simula o "servidor de mundo". O
   `CChannelWaitLoginPacket` provavelmente carrega o endereço desse segundo servidor em algum lugar
   (falta mapear onde — o pacote de 36 bytes é pequeno, os candidatos são os campos ainda não
   identificados nos offsets 0x4-0xF). **Ou**, mais simples pro primeiro teste: só verificar se o
   client tenta reconectar em ALGUM lugar depois da nossa resposta (com um proxy/listener amplo tipo
   o que já fizemos), mesmo sem sabermos ainda extrair o endereço do pacote.
3. Implementar esse segundo servidor recebendo outro `CLoginReqPacket` (mesmo formato) e respondendo
   com algo que pareça o pacote de 294 bytes (assinatura `02 00`) — é o que finalmente deve fazer a
   tela mudar de verdade.
4. Depois disso funcionando, mapear os opcodes do "mundo" (chat, personagem, itens) — família de
   opcodes diferente da fase de login, começando provavelmente em `03 XX`.

## ✅ CONFIRMADO AO VIVO — a tela de escolha de mundo abre de verdade

Implementamos o `login_server.py` mandando handshake(19) + `CChannelWaitLoginPacket` `02 06` (bytes
reais capturados, caso de sucesso) + frame de 290 bytes, sem fechar a conexão por conta própria (deixa
o client decidir). Testado contra o `AMo.exe alpha gg 0` local: **a tela de escolha de mundo abriu**
("Escolhendo o mundo", com as opções de reino) — a primeira vez que qualquer coisa avançou além da
tela de login nessa investigação inteira. Os nomes dos mundos saíram incompletos (só "Azmara-Blackforest"
apareceu certo, resto ficou em branco/só "NEW") porque mandamos lixo de memória capturado no frame de
290 bytes, não uma lista de mundos de verdade — **mas a transição de tela em si funciona**.

**Descoberta adicional:** a lista de nomes de mundo provavelmente vem de **dado local do client**
(não da rede) — clicar num mundo com slot incompleto **crasha** o client (confirmado via Visualizador
de Eventos do Windows): `AMo.exe`, exceção `0xc000000d` (STATUS_INVALID_PARAMETER) no offset
`0x0075779b` (RVA `0x0035779b` + base `0x00400000`). A desmontagem mostra que é uma checagem de
parâmetro do CRT seguro da Microsoft (`PUSH 0xc000000d` manual, não uma falha de hardware) — típico de
uma função tipo `memcpy_s`/`strcpy_s` recebendo tamanho/ponteiro inválido a partir dos dados
incompletos que mandamos. **Não é mais um mistério de protocolo, é só dado de teste incompleto.**

**Próximo passo direto:** melhorar o conteúdo do frame de 290 bytes (ou achar o arquivo local de dados
de mundo do client, similar ao padrão `.ten`/`.enc` já mapeado antes) pra preencher os 5 slots de mundo
com dados consistentes, e testar clicar num mundo sem crash — depois disso, ver se o client tenta abrir
uma conexão nova (segundo salto) e pra onde.

## Investigação do crash ao clicar num mundo — caracterizado, não resolvido ainda

**Confirmado, reproduzível 100% das vezes:**
- Clicar em qualquer mundo (mesmo o único com nome certo, "Azmara-Blackforest") crasha o client.
- Sempre o mesmo endereço: `0x0075779b` (RVA `0x0035779b`), exceção `0xc000000d`
  (STATUS_INVALID_PARAMETER).
- Esse endereço fica dentro de `FUN_0075776b`, que é o handler genérico de falha de checagem segura
  do CRT da Microsoft (parecido com `__security_check_cookie`/validação de iterator do STL) — chamado
  de **milhares de lugares** no binário (não é específico de world-select), então saber que É essa
  função não diz sozinho QUAL chamador específico disparou dessa vez.
- **O frame de 290 bytes é obrigatório**: removê-lo faz o crash acontecer ANTES até de chegar na tela
  de mundo (direto após o login). Ou seja, o client claramente espera receber esse frame com um tamanho
  mínimo — só não sabemos ainda o formato interno certo dele.

**Tentamos pegar a pilha de chamadas exata ao vivo (7+ tentativas) e não conseguimos:**
- O relatório de crash do Windows (WER) não guardou um `.dmp` completo dessa vez (`ReportType=2`,
  coleta mínima) — só temos o endereço, não a pilha.
- Anexar o x32dbg e botar breakpoint em `0x0075779b` **não pausou o processo** em nenhuma tentativa
  (mesmo confirmando o breakpoint criado e habilitado na aba Breakpoints, e desligando as pausas
  automáticas de TLS Callback que atrapalhavam antes) — o processo simplesmente morre sem o debugger
  interceptar. Não sabemos por que (pode ser algo específico de como o TitanEngine do x32dbg lida com
  essa aplicação 32-bit antiga, ou uma race condition).

**Não é mistério de protocolo** — é puramente um problema de conteúdo do frame de 290 bytes (ou de um
arquivo de dados local que a gente ainda não achou) não bater com o que o client espera pra popular a
lista de mundos igualzinho ao servidor de produção.

**Ideias pra próxima sessão (mais promissoras que insistir no x32dbg ao vivo):**
1. Tentar o **WinDbg** (`windbg` ou `cdb`) em vez do x32dbg — tem melhor suporte a capturar
   automaticamente o dump completo no crash (`.dump /ma` num handler de exceção), sem depender de
   pausar exatamente no breakpoint certo.
2. Configurar o Windows pra guardar um dump COMPLETO (não só o report mínimo) nos próximos crashes:
   `HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps` com `DumpType=2`.
3. Comparar mais de perto a estrutura dos **290 bytes reais capturados** (que tem vazamento de memória
   do SERVIDOR real, não é útil pro conteúdo em si) com o que aparece no `worldselectcrash` — ou seja,
   aceitar que só temos o tamanho certo (290), não o conteúdo certo, e testar variações estruturadas
   (ex: 5 blocos de ~52 bytes, um por mundo, com nome + status) em vez de zeros.

### RESOLVIDO (parcialmente) — pegamos a pilha de chamadas real do crash via dump completo

As ideias 1 e 2 acima funcionaram na mesma sessão! Configuramos
`HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps` (`DumpFolder =
D:\RoshEmulator\captures\crashdumps`, `DumpType=2`, `DumpCount=5`) — isso faz o Windows salvar um dump
COMPLETO automaticamente em qualquer crash futuro do `AMo.exe`, sem precisar do x32dbg ao vivo (que
nunca conseguiu pausar no breakpoint certo, mesmo depois de várias tentativas). Analisamos o dump com
`cdb.exe` (do Windows SDK, `C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`):

```
cdb.exe -z caminho\do\dump.dmp -c ".ecxr; kb; r; q"
```

**Pilha de chamadas real do crash** (endereços RVA, todos dentro do `AMo.exe`):
```
0x35779b  <- handler generico de seguranca do CRT (ja conhecido)
0x1b4323  <- FUN_005b4323 (nao decompilado ainda, fora do range analisado)
0x5dc49   <- FUN_0045dc17 -- despacha clique de UI pra um filho especifico (vtable+0x3c)
0x608cc   <- FUN_004606e2 -- pega posicao do mouse (GetCursorPos/ScreenToClient), decide hover/click
0x55fb4   <- FUN_00455f6e -- percorre uma LISTA (padrao iterator estilo STL) de widgets filhos
0x53da5   <- FUN_00453bae -- outra travessia de lista, tambem com GetCursorPos, decide qual item
0x54262   <- FUN_00453e70 -- gerenciador de dialogo/janela, dispara callbacks em varios filhos
0x40859   <- FUN_0044084a -- parte do loop principal (chamado a cada frame)
```

**Conclusão:** é uma travessia de **lista de widgets filhos** (várias funções usam o padrão
`FUN_0063bb57` (constrói iterador) → `FUN_006f4b02` (compara com o fim) → `FUN_0069f69c`
(desreferencia) → chamada de vtable) — muito provavelmente a lista das **5 linhas clicáveis da tela de
mundo**. Como mandamos a região de dados dos mundos (dentro do frame de 290 bytes) **totalmente
zerada**, a lista é construída com ponteiros internos inválidos/inconsistentes: dá pra DESENHAR as 5
caixas (por isso a tela aparece, mesmo com nomes em branco), mas a travessia da lista no CLIQUE
(procurando qual item foi selecionado) anda pra fora dos dados válidos e dispara a checagem de
segurança do CRT.

**Não é mais um mistério nenhum — é achar o formato exato de como os 290 bytes viram essa lista em
memória.** Isso é uma investigação nova (mapear o parser que consome o frame de 290 bytes e monta a
lista), não uma continuação rápida da atual.

**Próximo passo direto (retomar daqui):**
1. Decompilar `FUN_005b4323` (não coberto ainda) — é o frame mais próximo do crash, deve ser o
   destino final do despacho de clique.
2. Achar a função que RECEBE o frame de 290 bytes da rede e monta essa lista (procurar quem lê o
   payload que chega logo após `CChannelWaitLoginPacket` — provavelmente outra classe `AMOPacket`
   ainda não identificada, com nome relacionado a "world"/"channel list", como
   `CLoginChannelListPacket`, já visto na lista de classes RTTI mas ainda não decompilado).
3. Assim que souber o layout, montar os 290 bytes com uma lista de 5 entradas válida (nomes +
   ponteiros de lista coerentes) e testar clique de novo — com dump automático configurado, qualquer
   crash novo já vai gerar um `.dmp` completo em `D:\RoshEmulator\captures\crashdumps\` sem esforço
   extra.

### Achado o Parse de CLoginChannelListPacket — mas ainda não fecha o formato completo

Achamos e decompilamos o `Parse` de verdade (`FUN_0065083b`, vtable em `0x008131e0`, RTTI em
`0x008b4190`, string `.?AVCLoginChannelListPacket@AMOPacket@@`):

```c
uint __thiscall FUN_0065083b(int param_1, int param_2) {
  pcVar2 = *(char **)(param_2 + 8);          // ponteiro pro buffer
  if (pcVar2[0]=='\x02' && pcVar2[1]=='\x06') {   // assinatura 02 06 (confirmado obrigatorio!)
    uVar3 = ntohs(*(u_short *)(pcVar2 + 8));  // CONTAGEM (uint16 big-endian) no offset 8
    if (uVar3 != 0) {
      aloca uVar3*2 bytes;
      pra cada entrada: FUN_004365f7(...) -- so faz ntohs() no uint16, NADA MAIS (confirmado trivial)
    }
    // consome exatamente 16 + count*2 bytes do buffer, resto fica pra depois
    return sucesso;
  }
  return falha;  // se a assinatura nao bater
}
```

**Testamos mudar `count` de `0` (valor original capturado) pra `5`** (5 entradas `0,1,2,3,4`) — **e
regrediu**: em vez de chegar na tela de mundo (mesmo que crashando ao clicar), voltou pro estado
"nada acontece, fica preso na tela de login". A função que processa cada entrada
(`FUN_004365f7`) é trivial (só `ntohs`), então não é aí que quebra.

**Hipótese pra explicar a regressão:** o pacote de 36 bytes original capturado (`count=0`) tem, nos
bytes 16-31 (depois do que agora sabemos ser o cabeçalho fixo), dados que NÃO SÃO entradas de canal —
são consumidos por outra coisa, possivelmente um segundo "sub-pacote" dentro do mesmo buffer (talvez
seja aí que mora o `CChannelWaitLoginPacket` de verdade, como pensávamos originalmente — o
`FUN_0065083b` só consome os primeiros `16 + count*2` bytes e devolve um ponteiro pro "resto", que
outra função deve processar em seguida). Ao mudar `count` pra 5, os bytes que seriam entradas (16-25)
comeram parte do que originalmente era esse "resto" — quebrando esse segundo sub-pacote.

**Estado atual do `login_server.py`:** voltou pra combinação **comprovada** (bytes originais
capturados, `count=0`) — chega na tela de mundo (mas ainda crasha ao clicar, como documentado acima).
**Não regredir dessa configuração sem entender melhor o container.**

**Próximo passo real (não tentativa cega):** decompilar o que processa o "resto" do buffer depois do
Parse de `CLoginChannelListPacket` (o ponteiro salvo em `param_1+0xc`) — é isso que vai revelar se
existe mesmo um segundo sub-pacote embutido, e qual o formato dele. Já sabemos exatamente onde esse
ponteiro é usado: procurar quem lê `*(objeto_CLoginChannelListPacket + 0xc)` depois do Parse retornar.

### Mais um beco sem saída — o único chamador do Parse é um utilitário genérico de erro

Achamos o único lugar que chama `FUN_0065083b` (o Parse): `FUN_00740600`. Decompilado, ele é só um
**wrapper genérico "Parse + pega mensagem"**: chama `Parse()`, e — sucesso ou falha — copia uma string
pra um buffer de saída via `FUN_004365a2()`. Só que `FUN_004365a2` é **trivial**: sempre retorna
`&DAT_00838053`, um ponteiro fixo pra **string vazia global** (o mesmo placeholder "string vazia" que
já tínhamos visto em vários outros lugares nessa investigação). Ou seja, esse wrapper não tem nada a
ver com popular a lista de mundos — é provavelmente um utilitário genérico reusado por várias classes
de pacote (tipo "parse isso e me dá uma mensagem de status/erro pra log", sempre vazia nesse caso).

**Conclusão honesta:** não achamos ainda quem realmente consome o resultado do Parse pra montar a UI
da lista de mundos. As pistas que seguimos (o wrapper de string) levaram a um beco sem saída genérico,
igual aconteceu antes com a vtable de UI (`FUN_0047dc1b`) e a função de array (`FUN_006e55d8`). O
padrão que se repete: várias funções desse binário são **utilitárias genéricas** compartilhadas entre
dezenas de classes de pacote, e rastrear "quem chama o Parse" sozinho não chega no consumidor real dos
dados — porque o dado provavelmente é lido diretamente dos campos do objeto (não via esses
wrappers/getters genéricos) em algum lugar do código de renderização da tela de mundo, que ainda não
localizamos.

**Ideia mais promissora pra próxima sessão:** em vez de seguir a cadeia de chamadas do Parse (esgotada
por agora), ir pelo lado da **UI**: achar a função que RENDERIZA a tela "Escolhendo o mundo" (pesquisar
no Ghidra por strings relacionadas, tipo texto de botão "OK"/"Saída", ou pela sequência de chamadas de
`DirectX`/desenho de texto logo depois do login) e rastrear de lá pra trás até achar onde ela lê os
dados do objeto `CLoginChannelListPacket` (offsets `param_1+4`, `+8`, `+0xc` — já sabemos os nomes dos
campos: `+4`=ponteiro pro buffer bruto, `+8`=array alocado de uint16, `+0xc`=ponteiro pro resto do
buffer). Isso é mais direto que continuar subindo pela árvore de chamadas do Parse.

### Achamos as classes de UI reais — `CGuiChannelListSel` e `CGuiCGuiChannelList`

Indo pelo lado da UI (em vez de insistir no Parse), achamos duas strings de RTTI muito reveladoras:
`.?AVCGuiChannelListSel@@` (linha da lista, provavelmente 1 mundo) e `.?AVCGuiCGuiChannelList@@` (o
container da lista inteira). Rastreamos as vtables (`0x0080ea0c` e `0x0080eafc`) e decompilamos os
métodos específicos de `CGuiCGuiChannelList` (os que não são compartilhados com `CGuiChannelListSel`):

- **Slot 2** (`FUN_005ae947`) — inicialização/carregamento de recursos de UI (fontes, imagens de
  botão). Só roda se `FUN_005cb9bf() > 10` — provavelmente uma contagem de recursos gráficos, não
  relacionada aos mundos em si (não é isso que populamos pela rede).
- **Slot 3** (`FUN_005b05f3`) — limpeza/destruição dos recursos acima. Também não é o alvo.
- **Slot 8 (`FUN_005b0701`) — É O HANDLER DE CLIQUE DE VERDADE.** Faz exatamente isso:
  ```c
  iVar1 = FUN_005cb9bf();           // conta quantos itens tem na lista
  for (local_8 = 0; local_8 < iVar1; local_8++) {
      piVar2 = FUN_006f4ae1(local_8);   // pega o item pelo indice
      iVar1 = *piVar2;                   // desreferencia pro objeto do item
      GetCursorPos(...); ScreenToClient(...);
      // testa se o clique caiu dentro do retangulo desse item (offsets +0xc,+0x10,+0x14,+0x18)
      if (PtInRect(...)) {
          *(param_1 + 0xbc) = local_8;   // <-- guarda o INDICE selecionado
          // toca som, atualiza estado de selecao...
      }
  }
  ```
  **Esse é o loop que crasha** quando `FUN_006f4ae1(local_8)` devolve um ponteiro pra um item mal
  formado (nossos dados de mundo zerados/incompletos).

**Conclusão da sessão sobre esse crash:** identificamos com precisão cirúrgica ONDE quebra (esse loop
de clique) e sabemos que a causa é a lista de itens (`FUN_005cb9bf()`/`FUN_006f4ae1()`, uma coleção
genérica reusada em vários lugares do código) estar mal populada. **O que falta** é achar quem
adiciona os 5 itens nessa lista a partir da resposta de rede (`CLoginChannelListPacket`) — ou seja,
a "ponte" entre `Parse()` (que só extrai um array de `uint16`) e a construção de cada `CGuiChannelListSel`
com bounding rect + nome + estado. Essa ponte ainda não foi localizada; é o próximo alvo real.

**Resumo do que já temos de munição pronta pra próxima sessão (sem precisar redescobrir nada):**
- Formato exato do `CLoginChannelListPacket` (`sig=02 06`, `count@offset8 u16be`, `array@offset16`)
- Vtables e handlers de clique da UI (`CGuiCGuiChannelList` @ `0x0080eafc`, slot 8 = `0x005b0701`)
- Dump completo automático configurado (`LocalDumps`, `D:\RoshEmulator\captures\crashdumps\`)
- Proxy pro servidor real pronto (`real_server_proxy.py`) pra mais capturas se precisar
- `login_server.py` funcional (chega na tela de mundo, ainda crasha ao clicar)

### Continuação — testamos `CLoginChannelListPacket` a fundo (beco sem saída confirmado) e tentamos cdb ao vivo

**`CLoginChannelListPacket` é código morto/reflexão, não o handler real:** confirmamos com análise
completa do Ghidra (não só `-noanalysis`) que o `Parse` (`FUN_0065083b`) e o construtor
(`FUN_00650737`) só são referenciados por **um único lugar em todo o binário**: `FUN_00740600`, que por
sua vez faz parte de uma **tabela de despacho genérica** (`0x00823a70`+, um array de wrappers
"Parse+GetMessage" com um slot por classe de pacote, espaçados a cada `0x130` bytes — vimos os vizinhos
na tabela: `0x740010, 0x740140, 0x740270, ..., 0x740600 (nosso), 0x740740, ...`). Esse wrapper genérico
só extrai uma mensagem de erro (sempre a string vazia global `&DAT_00838053`) — não tem relação com
popular a UI. **`CLoginChannelListPacket`, apesar do nome sugestivo e do formato bater com o que
capturamos, não é usado pela lógica de jogo de verdade nesse build** (ou é usado só por alguma
ferramenta de validação/debug interna).

**Achamos as classes de UI reais via string RTTI:** `CGuiChannelListSel` (linha individual) e
`CGuiCGuiChannelList` (container da lista) — vtables em `0x0080ea0c` e `0x0080eafc`. O método de clique
(`FUN_005b0701`, slot 8) itera a lista testando clique dentro do retângulo de cada item — é onde o
crash acontece de verdade (já documentado acima).

**Tentamos `cdb.exe` ao vivo (anexado, não só pós-morte) pra pegar o momento exato em que a lista é
populada** — funciona bem melhor que o x32dbg (que nunca conseguiu pausar), mas esbarrou num limite de
ferramenta: `cdb -p PID -c "comandos"` roda os comandos como um lote só; quando os comandos acabam (ou
quando o processo em segundo plano do Claude Code é encerrado), o `cdb` recebe EOF no stdin e **mata o
processo alvo por padrão** (não é possível manter uma sessão interativa de verdade nesse ambiente sem
um terminal persistente). Descobrimos que `FUN_005ae947` (o método que inicializa
`CGuiCGuiChannelList`) já é chamado **na inicialização do jogo, antes do login** — então não é um bom
breakpoint pra pegar "depois que os dados de mundo chegam" (dispara cedo demais, e parece rodar
repetidas vezes, esgotando rodadas de `g` antes do usuário conseguir logar).

**Próxima tentativa de debugger ao vivo, se for tentar de novo:** usar `cdb -pv` (non-invasive attach,
não mata o processo ao desanexar) e terminar a sessão com `.detach` explícito em vez de deixar o EOF
matar tudo. ou usar o modo servidor do cdb (`.server tcp:port=...`) e conectar de uma segunda
invocação pra mandar comandos de verdade de forma interativa, em vez de um lote `-c` só.

**Avaliação honesta:** essa sub-investigação (achar a ponte pacote→UI) já consumiu muitas rodadas sem
sucesso, incluindo tentativas de debugger ao vivo com limitações reais de ferramental nesse ambiente.
O crash em si está bem caracterizado (sabemos exatamente onde quebra e por quê — dado incompleto). Pra
realmente fechar isso, o caminho mais confiável agora é: (a) o usuário rodando o x32dbg manualmente
com calma (sem pressa de mim automatizando), ou (b) aceitar o crash por enquanto e focar em outras
partes do protocolo (ex: o segundo salto pro servidor de mundo, que nunca testamos de verdade).

### Mais uma tentativa (com dados REAIS do servidor de produção) — progresso real, mas esbarrou em limite de ferramenta

Testamos de novo com a lista de mundos 100% real (os 5 nomes certos: Kart, Victorum, Bent-Gerot,
Shuradan-Ashara, Azmara-Blackforest — confirmado por print). Descobertas importantes desse round:

1. **Um "crash idêntico" que vimos antes NÃO era do jogo real** — era o `karos.exe` conectando sem
   querer no NOSSO servidor de teste, porque o `ServerList.ten` ainda estava redirecionado de testes
   anteriores. **Restauramos** pro valor real (`85.119.149.223:10001`, backup em
   `ServerList.ten.orig_backup`) — cuidado pra não deixar esse arquivo apontando pro nosso servidor de
   teste sem querer de novo.

2. **`cdb.exe -pv` (non-invasive attach) não permite controlar breakpoints** — só serve pra inspeção
   passiva (`.dump`, ler memória), não dá pra usar `bp`/`g`. Precisa do attach invasivo normal.

3. **Attach invasivo do `cdb` congela a janela do DirectX imediatamente** — isso é uma limitação séria
   nesse jogo específico (provavelmente perde o device DirectX ou trava a thread de mensagens ao ser
   pausado por um debugger externo). Depois de anexado, a janela para de responder a clique
   completamente, mesmo com `g` já liberando a execução — não conseguimos fazer o clique chegar no
   jogo com o debugger anexado. Tivemos que matar o processo.

4. **Um trace anterior (rodada sem `.detach`, matou o processo ao final) capturou 16 passos reais**
   dentro de `FUN_005b0701` — o padrão de registradores mostra `ECX` mudando pra um endereço tipo
   `0x1db6b130` por volta do passo 12-13 (bem depois do `this` inicial `0x1cc0c498` e do ponteiro da
   vtable `0x0080eafc` nos primeiros passos) — **forte candidato ao ponteiro do item da lista** (`iVar1`
   no pseudocódigo), mas o processo foi encerrado antes de conseguirmos despejar a memória nesse
   endereço pra confirmar/ler o nome. Log completo salvo em
   `D:\RoshEmulator\captures\cdb_click_trace.log`.

**ATUALIZAÇÃO — o patch de 1 byte funcionou perfeitamente!** Confirmando a ideia: escrevemos `0xCC`
direto na memória do processo com `D:\RoshEmulator\server\patch_int3.py` (usa `WriteProcessMemory`
puro via `ctypes`, sem anexar debugger nenhum) no endereço `0x005b0701` (entrada do handler de clique).
**A janela NÃO travou.** A função disparou sozinha (parece ser chamada em qualquer interação com a
tela, não só clique explícito) e o Windows salvou um dump completo automaticamente (confirma que o
`LocalDumps` funciona também pra exceção `0x80000003`/breakpoint, não só crashes de verdade). Script
reutilizável: `python patch_int3.py <pid> <endereco_hex>` — SEM debugger, SEM travar a janela. **Essa é
a técnica certa pra qualquer investigação futura que precise pausar o jogo num ponto exato.**

Analisamos o dump (`AMo.exe.1652.dmp`, capturado contra o **servidor real**, com a lista de mundos
100% correta na tela) e achamos as strings dos nomes DE VERDADE na memória:

```
"Kart"             @ 0x1d5a7070
"Victorum"         @ 0x1d5a7610
"Bent-Gerot"       @ 0x1d5a6800
"Shuradan-Ashara"  @ 0x1d5a78e0
```

(`"Azmara-Blackforest"` não apareceu na busca — pode estar com encoding ligeiramente diferente, não
investigamos mais fundo.)

**Achado importante: as distâncias entre as strings são todas múltiplos de 720 bytes** (2160, 1440,
720), mas **não seguem a ordem de exibição na tela** — sugere que estão espalhadas num **pool
genérico de strings/recursos nomeados** (um alocador de blocos de tamanho fixo, tipo intern pool),
misturadas com nomes de recursos completamente não relacionados (achamos `"DSM_DemonCry_Re_Ring"`,
`"EFF_ThunderHowl_Cone02"`, `"SHF_Fluor_RING_Plane01"` no meio — claramente efeitos sonoros/visuais de
outro sistema do jogo, não dados de mundo). **Isso não é um array dedicado de 5 entradas de mundo** —
é um pool compartilhado de strings do motor inteiro.

**Isso também refuta uma hipótese anterior:** como essas strings são alocadas dinamicamente nesse pool
(não são constantes fixas no `.exe`, que já confirmamos que não existem lá), **os nomes dos mundos
PROVAVELMENTE vêm mesmo pela rede** (contrariando a teoria de "nomes são locais, só o status vem pela
rede" que tínhamos adotado antes). Ainda não achamos o ponteiro/estrutura que LIGA cada entrada da UI
(retângulo clicável) à sua string de nome correspondente nesse pool — isso é o que falta pra fechar de
vez.

**Próximo passo direto (retomar daqui):** já que a técnica do patch de 1 byte funciona bem, usar ela
de novo — dessa vez patchando um pouco DEPOIS no handler de clique (não na entrada, mas logo depois da
linha `iVar1 = *piVar2` no pseudocódigo, que é onde o ponteiro do item de verdade fica disponível) pra
capturar o dump com esse ponteiro específico em um registrador, e a partir dele achar o campo que
aponta pro nome. Sem esse offset exato ainda, mas a decompilação já mostra que é logo no começo da
função (dentro dos primeiros ~10-15 bytes de código depois do `push ebp; mov ebp,esp`).

## 🎯 RESOLVIDO — estrutura completa do item de lista (`CGuiChannelListSel`) mapeada com dados reais

Repetimos o patch de 1 byte, dessa vez em `0x005b0748` (logo depois de `mov edi,[eax]` — o ponteiro
do item de verdade fica em EDI nesse ponto exato, confirmado no disassembly:
`005b0741 call 006f4ae1` → `005b0746 mov edi,[eax]` → **patch em `005b0748`**). Funcionou de novo sem
travar a janela, e o clique real do usuário ("Kart") gerou o dump certo.

**Achado decisivo:** o campo do NOME não é um ponteiro externo — é uma **string embutida direto no
objeto**, no offset **`+0xC0`** a partir do início do item (`this`/vtable). Confirmado duas vezes:
item 1 tinha `"Canal 1"` em `base+0xC0`, item 2 (448 bytes depois, `base+0x1C0`) tinha `"Canal 2"`
exatamente no mesmo offset relativo.

**Nota de terminologia (confirmado pelo usuário):** a tela que a UI chama de "Escolhendo o mundo" é na
verdade a **lista de SERVIDORES** (Kart, Victorum, Bent-Gerot, Shuradan-Ashara, Azmara-Blackforest —
esses são nomes de servidor, não "mundos" no sentido solto que eu vinha usando). Depois de escolher um
servidor, vem a **lista de CANAIS** (Canal 1, Canal 2, ...) — é exatamente essa segunda tela que
capturamos no dump abaixo. As duas telas usam a MESMA classe de UI (`CGuiChannelListSel`/
`CGuiCGuiChannelList`), só o conteúdo muda — por isso o breakpoint pegou a tela de canal (que já roda
pré-construída em memória) em vez da tela de servidor que estava visível na hora. Onde o resto deste
documento disser "mundo" solto, na prática quer dizer "servidor" (a tela real do jogo) — mantive as
citações literais da UI ("Escolhendo o mundo") como estão, só esclarecendo aqui pra não confundir.

O item que capturamos dessa vez era de **CANAL**, não de servidor — mas a ESTRUTURA é idêntica pras
duas telas, só muda o conteúdo, então o offset `+0xC0` vale igual pra nomes de servidor.

**Layout confirmado de `CGuiChannelListSel` (tamanho do struct ≈ `0x1C0` = 448 bytes):**

```
+0x00  vtable (CGuiChannelListSel, 0x0080ea0c)
+0x04  ponteiro (mesmo valor em todos os itens — provavel dono/gerenciador compartilhado)
+0x08  ponteiro pro container CGuiCGuiChannelList (mesmo valor em todos os itens — "pai")
+0x0C  rect.left  (int, coordenada de tela)
+0x10  rect.top   (int)
+0x14  rect.right (int — o codigo de clique faz "-0x1E" nele antes de usar)
+0x18  rect.bottom (int)
+0x1C..+0xBC  varios campos numericos/flags, ainda nao mapeados em detalhe (nao criticos)
+0xC0  STRING do nome, ASCII, terminada em \0 (ex: "Canal 1", "Kart" deve ser igual pra mundos)
       (resto do espaco ate +0x1C0 e' padding/buffer reservado, nao usado no exemplo capturado)
```

**Isso é o suficiente pra corrigir o crash do clique:** o motivo mais provável do crash com nosso dado
fake é que os campos entre `+0x1C` e `+0xBC` (ainda não mapeados) ficam com lixo/zero inconsistente —
mas com o formato completo do item confirmado, dá pra construir uma hipótese muito mais forte:
provavelmente não é sequer sobre o pacote de rede (que só manda `count` + array de `uint16`, formato
já mapeado antes) — a UI deve **alocar e popular esses estruturas localmente** a partir desse array
simples, preenchendo nome/retângulo/etc com base num índice, não recebendo o item inteiro pela rede.

**Ferramentas prontas pra próxima sessão:**
- `D:\RoshEmulator\server\patch_int3.py` — patch de 1 byte via `WriteProcessMemory`, sem travar a
  janela, gera dump automático (técnica validada e funcionando muito bem)
- Offsets exatos do disassembly de `FUN_005b0701` documentados acima, incluindo onde patchar pra
  pegar o ponteiro do item em EDI
- Layout completo de `CGuiChannelListSel` (acima)

## Investigação do crash na LISTA DE SERVIDORES (não de canais) — causa raiz confirmada

Repetimos a técnica do patch de 1 byte pra investigar o crash de verdade (clicar num SERVIDOR, não
canal). A pilha de chamadas real do crash original (`0x35779b`) passa por uma cadeia DIFERENTE da
lista de canais: `FUN_00453bae` → `FUN_00453e70` → `FUN_00454262` → `FUN_00440859` → loop principal.

Achamos o ponto exato via disassembly de `FUN_00453bae`:
```
00453c45  call  0069f69c        ; desreferencia o iterador
00453c4a  mov   esi,[eax]        ; ESI = ponteiro do item de verdade  <- patch em 0x453c4c (depois disso)
00453c4c  add   esi,0Ch          ; ESI+0xC = campo de retangulo (igual no outro fluxo)
```

Patcheamos `0x00453c4c` e capturamos um dump com o **item real da lista de servidores** em ESI
(`0x0aa29780`, vtable `0x0080d674` — uma classe DIFERENTE de `CGuiChannelListSel`, específica de
servidor). Achado:

- Campos `+0x14`/`+0x18` = `58`/`116` (bem menores que os retângulos de canal — provavelmente rect
  relativo, não absoluto).
- **Bytes claramente não-inicializados** logo depois (`+0x40` até `+0x54`): valores tipo
  `ff9a4819, a535c700, e9a07106...` — padrão de lixo de heap, não dado real.
- **Nenhuma string de nome de servidor legível** nem perto (`+0x90` tinha um fragmento de CAMINHO de
  recurso não relacionado, "...Interface_Load/rv...", claramente lixo de outra alocação).
- Busca ampla por `"Azmara"` em toda a memória: achamos **centenas de ocorrências, mas todas são de um
  CONJUNTO DE EQUIPAMENTO da loja** ("Azmara (torso)", "Azmara (pernas)", "Azmara por 7 dias"...) —
  **nenhuma é o nome do servidor "Azmara-Blackforest"**, e nenhuma fica perto do nosso item.

**Conclusão confirmada:** o item que o breakpoint capturou é um dos slots **em branco** ("NEW" sem
nome, que aparecem vazios na tela) — não o "Azmara-Blackforest" que renderiza certinho. Isso fecha o
raciocínio: **alguns dos 5 slots da lista de servidores ficam com memória não-inicializada porque o
nosso teste não fornece dado suficiente pra popular todos os 5** — clicar (ou até só o loop de
hover passando) num desses slots vazios lê lixo de heap e dispara a checagem de segurança do CRT.
Bate 100% com tudo que já sabíamos: o pacote `CLoginChannelListPacket`/`CChannelWaitLoginPacket` que
mandamos tem `count=0` (nenhuma entrada real) — o motor ainda assim desenha 5 caixas (talvez de um
cache local anterior ou de um mínimo hardcoded), mas só populated completamente a que tiver dado real
disponível.

**Conclusão final pra próxima sessão:** a correção não depende mais de engenharia reversa — é
**puramente de dado**: mandar uma resposta de rede com `count=5` de verdade (formato já mapeado:
`CLoginChannelListPacket`, sig `02 06`, count em `offset 8`, array de `uint16` em `offset 16`) deve
fazer o motor popular os 5 slots completamente, evitando os campos não-inicializados. A tentativa
anterior de mandar `count=5` regrediu (voltou pra "nada acontece") porque provavelmente faltava
mais alguma coisa no formato exato do array (cada `uint16` pode precisar ser um ID/índice válido que
bate com uma tabela local, não qualquer valor de 0-4) — próxima linha de investigação: descobrir o
significado exato de cada valor `uint16` do array (é bem provável que seja um "ID de servidor" que
precisa bater com algo que o client já conhece localmente, dado que os NOMES claramente vêm de algum
lugar local/cacheado, não da rede).

**Conclusão pra próxima sessão (histórico, antes do patch funcionar):** o debugger ao vivo (`cdb` ou
`x32dbg`) parece fundamentalmente incompatível com testar CLIQUES nesse jogo (trava a janela assim que
anexa) — **mas o patch de 1 byte contorna isso completamente**, então essa limitação não importa mais.
Duas saídas melhores:
- **Automação de clique SEM debugger**: montar um breakpoint temporário via patch de 1 byte (`0xCC`)
  escrito direto na memória do processo com um script (sem debugger formal anexado, só
  `WriteProcessMemory`), deixar o crash acontecer naturalmente (já sabemos que gera dump completo via
  `LocalDumps`), e analisar o dump depois com `cdb -z` (que já provou funcionar muito bem). Isso evita
  o problema da janela travar, porque não tem debugger real anexado durante o clique.
- **Ou simplesmente aceitar o crash-ao-clicar por enquanto** e usar o tempo pra mapear o segundo salto
  (servidor de mundo) e os opcodes do jogo em si, que é trabalho novo e não depende de resolver esse
  crash específico primeiro.

## Próximos passos

1. ~~Achar a vtable + construtor de `CLoginReqPacket`, extrair opcode e layout de campos.~~ **FEITO**
   — layout 100% confirmado (ver secao "CONFIRMADO 100%" acima), com parser Python funcionando.
2. **Fechar o formato da RESPOSTA do server** (o que faz o client sair da tela de login pra escolha
   de personagem). Sabemos que `FUN_00651183` é quem processa essa resposta (le portas via
   `ntohs`/`ntohl` em offsets fixos do cabecalho + atualiza `DAT_008dc9b8` com um novo token).
   Tentamos achar o dispatcher que chama essa função por opcode: rodamos análise completa do
   Ghidra (sem `-noanalysis`), depois achamos que `FUN_00651183` e `FUN_0073c7b0` apareciam cada
   um exatamente 1x como bytes crus dentro de um array denso de function pointers (`0x812534`,
   88 entradas, índice 21) — parecia MUITO uma tabela de dispatch por opcode. **Mas ao rastrear
   quem referencia essa tabela, caímos em `FUN_0073b010` = construtor de `AMOPacket::CCmdFunc`**
   — essa é a tabela de **comandos de chat** (tipo `/who`, `/trade`, GM commands), não o dispatcher
   de opcodes de pacote de rede. Beco sem saída por enquanto — abandonar essa linha de busca
   específica (custo/benefício ficou ruim) e ou (a) tentar de novo com mais tempo/ferramental
   melhor, ou (b) resolver empiricamente mandando respostas de teste e observando a reação do
   client (mais rápido na prática).

   Também investigamos o que roda LOGO DEPOIS do processamento da resposta (`FUN_0042bc37`,
   `FUN_0042bbfa`, `FUN_004518c0`) tentando achar um "reconecta em outra porta" explícito — não
   achamos isso ainda; parecem ser utilitários genéricos (bounds-check de array, fila de
   diálogo/erro de UI) e não algo especificamente sobre handoff pra outro server. **Retorno desse
   tipo de busca cega caiu bastante — melhor parar aqui essa frente de análise estática pura e
   focar em teste empírico (rodar o `login_server.py` de verdade e ver a reação do client) na
   próxima vez que alguém puder clicar LOGIN manualmente.**
3. Mapear as outras classes `AMOPacket` derivadas (uma vtable = um tipo de pacote = um opcode).
4. **Testar de ponta a ponta com o `login_server.py`** — precisa de alguém clicando LOGIN de
   verdade no client (automação de GUI nessa VM não é confiável, ver nota acima). Quando isso
   rolar, o server já loga o pacote parseado e manda uma resposta experimental — daria pra ver a
   reação do client (avança? erro? trava?) e iterar no formato da resposta a partir disso.
5. Depois de fechar login+entrada no mundo, seguir mapeando os pacotes de personagem/mundo do
   mesmo jeito (RTTI -> vtable -> Parse).

## 🎯 GRANDE ACHADO (21/08) — `CLoginChannelListPacket` NÃO é código morto, é do HOP 2 — formato decodificado 100%

Reexaminando a captura real (`real_success_full_flow_parsed.txt`) com atenção nova: os pacotes
`#26`-`#39` mostram uma **SEGUNDA conexão** (depois do login em `85.119.149.223:10001` fechar) pro
que chamamos de "hop 2" — `85.119.149.224:10199` — que é o **servidor de mundo/canal de verdade**.
Nunca tínhamos implementado esse segundo salto no nosso `login_server.py` de teste — só
reproduzíamos os bytes do HOP 1 (`handshake+chanwait+middle`), que **mandam o client reconectar no
servidor real de produção** (endereço embutido no próprio `chanwait`, ver abaixo). Isso explica a
causa raiz do crash: o servidor real recusa/ignora nossa sessão fake (token inválido), o client
nunca recebe a resposta do hop 2, e os campos de status/canal da lista de servidores ficam com lixo
de heap → crash ao clicar (bate 100% com a investigação anterior).

**Achado 1 — o endereço do HOP 2 vem embutido no próprio `CChannelWaitLoginPacket` (hop 1):**
payload bytes `[22:28]` = `55 77 95 e0 d7 27` = IP `85.119.149.224` (4 bytes, ordem normal) + porta
`d7 27` (`u16 little-endian` = `0x27d7` = `10199`). Confirmado reescrevendo esses 6 bytes em
`login_server.py` (`build_channel_wait_frame`, variáveis `HOP2_HOST`/`HOP2_PORT`) pra apontar pro
nosso próprio `channel_server.py` local (`127.0.0.1:20199`) em vez do servidor real — teste em
andamento (ver "Próximo passo imediato" abaixo).

**Achado 2 — o pacote `#38` (302 bytes, HOP 2 → client) é um `CLoginChannelListPacket` de verdade,
decodificado byte a byte e validado por ROUND-TRIP EXATO** (parser + encoder em
`D:\RoshEmulator\server\channel_list_packet.py`, `round-trip identico: True`):

```
offset 0   sig            = 02 06
offset 2   unknown6       = 6 bytes (00 00 00 03 00 00 na captura)
offset 8   server_count   = u16 BIG-ENDIAN   <- quantidade de SERVIDORES (nao canais!)
offset 10  total_channels = u16 BIG-ENDIAN   <- soma de canais de TODOS os servidores
offset 12  unknown4       = 4 bytes (00 00 00 00 na captura)
offset 16  server_ids[]   = server_count * u16 BIG-ENDIAN
                            valores REAIS capturados: 42, 41, 28, 22, 1
                            (NAO sao indices sequenciais 0..N — isso explica por que a tentativa
                            anterior de mandar count=5 com valores 0-4 regrediu pra "nada
                            acontece": o client provavelmente nao reconhece IDs de servidor
                            inventados e descarta o pacote inteiro)
offset 16+2*server_count
           registros de canal, 16 bytes cada, agrupados por servidor na mesma ordem de server_ids[]:
               +0  server_id    u16 LITTLE-ENDIAN (repete o ID do servidor dono)
               +2  channel_idx  u16 LITTLE-ENDIAN (1-based, sequencial dentro do servidor)
               +4  status_flag  u16 LITTLE-ENDIAN (visto variando 0/1/2 na captura — populacao?)
               +6  ip           4 bytes (dotted quad, ordem normal)
               +10 port         u16 LITTLE-ENDIAN
               +12 padding      4 bytes, zero na captura
```

Servidores/canais reais capturados: **ID 1 → 6 canais** (`85.119.149.224`, portas 10101-10106),
**ID 22 → 3 canais** (`85.119.149.226`), **ID 28 → 3 canais** (`45.77.197.216`), **ID 41 → 2 canais**
(`207.246.77.161`), **ID 42 → 3 canais** (`85.119.149.225`). Isso também explica os fragmentos de
outros IPs/portas mencionados nas notas anteriores da sessão.

**Isso resolve a contradição sobre "de onde vêm os nomes":** eles NUNCA vêm pela rede, em nenhum
pacote (confirmado com grep de "Kart"/"Victorum"/etc em toda a captura — zero ocorrências). O
client resolve nome/ícone LOCALMENTE a partir do `server_id` numérico (provavelmente usando
`ResUnpak/Interface/IE_Lod/string.lod_en` ou `EIDString.lod_en` — tentamos decifrar com a MESMA
cifra do `ServerList.ten` (`ten_crypto`, seed `0x3042`) e NÃO bateu — é um formato/cifra diferente,
ainda não decifrado, não crítico pra resolver o crash).

**Correção da conclusão anterior "`CLoginChannelListPacket` é código morto":** essa conclusão só
vale pro HOP 1 (login, onde a classe realmente não é usada pela UI). No **HOP 2** ela É o pacote de
verdade que popula a lista de servidores+canais — é só uma classe reaproveitada em dois contextos
de rede diferentes.

**Próximo passo imediato (em andamento):** criamos `D:\RoshEmulator\server\channel_server.py`
(servidor de teste do HOP 2, escutando em `127.0.0.1:20199`) que manda de volta um
`CLoginChannelListPacket` de verdade com os 5 `server_ids` REAIS (`42,41,28,22,1`) e os mesmos
canais (redirecionados pra `127.0.0.1:20199` nos campos de IP/porta dos canais, só pra não vazar
nada em servidores de produção reais). Reiniciamos `login_server.py` (agora reescrevendo o
endereço do hop 2 pro nosso `channel_server.py`) e `channel_server.py`, ambos rodando. **Falta
testar**: relançar `AMo.exe alpha gg 0` (com `ServerList.ten` apontando pra `127.0.0.1:19999`,
já confirmado) e ver se (a) o hop 2 é alcançado (log do `channel_server.py`), (b) a lista de
servidores aparece sem travar, e (c) clicar num servidor não crasha mais.

### Teste real (21/08) — resultado inesperado: tela melhora, mas aparece um "fecha silencioso" novo

Testamos ao vivo (usuário clicando de verdade): a tela de lista de servidores agora mostra dado
PARCIALMENTE diferente do "tudo em branco" de antes — 1 servidor com nome completo
("Azmara-Blackforest", badge NEW, tag "Europa"), mais 4 linhas com tag de região (2 com badge NEW,
3 só com a tag) mas sem nome. **Mas ao clicar em "Azmara-Blackforest", o jogo ainda fecha.**

**Achado importante: esse fechamento NÃO é mais o crash antigo.** Confirmado por três sinais:
1. **Nenhum dump novo foi gerado** (`LocalDumps` continua configurado certo, mas os 5 dumps em
   `crashdumps/` continuam sendo os mesmos de ontem).
2. **Nenhum evento 1000 (AppCrash) apareceu no Log de Aplicativos do Windows** pra hoje — os
   crashes de ontem (incluindo o de verdade, `0xc000000d`, e os nossos breakpoints propositais,
   `0x80000003`) sempre geravam esse evento; hoje, nada.
3. **O patch de 1 byte no endereço antigo conhecido (`0x00453c4c`) NÃO disparou** — ou seja, o
   código nem chega mais nesse ponto do handler de clique da lista de servidores.

Ou seja: **mudamos o comportamento o suficiente pra sair do crash antigo, mas caímos numa saída
"limpa" nova** (sem exceção, sem diálogo visível — confirmado pelo usuário, "não aparece nenhuma
mensagem, simplesmente fecha"). No log do `login_server.py`, a conexão do hop 1 sempre termina do
mesmo jeito nesses testes: handshake trocado certinho (15 bytes de eco, sequência incrementada
corretamente) e então **RST abrupto do lado do client** (`WinError 10054`) — na captura real de
sucesso, nesse mesmo ponto o client manda um **FIN limpo** (fecha graciosamente pra abrir a conexão
do hop 2). Isso indica que algo dá errado no client LOGO DEPOIS do eco do handshake, ANTES de
tentar abrir a conexão do hop 2 (`channel_server.py` nunca recebeu nenhuma conexão em nenhum desses
testes, nem quando reescrevemos o endereço do hop 2 pra apontar pra ele, nem quando revertemos pro
endereço original).

**Teste de controle:** revertemos a reescrita do endereço do hop 2 (voltamos a mandar os bytes
originais, sem apontar pro nosso `channel_server.py` — flag `ROSH_REWRITE_HOP2=1` precisa estar
setada no ambiente pra reescrever; por padrão agora NÃO reescreve) — o mesmo comportamento (RST
sem crash, sem chegar no hop 2) ainda acontece. **Isso sugere que a causa não é (só) a reescrita do
endereço do hop 2** — pode ser sensível a exatamente quais linhas aparecem populadas na lista (o
teste de ontem, com `count=0` puro, tinha TUDO em branco e crashava diferente; agora com dado real
parcial, o comportamento mudou de novo). Ainda não isolamos a causa exata — próximo passo é achar
ONDE no código o "fecha silencioso" acontece (patch de 1 byte em pontos mais cedo na cadeia de
clique, não mais em `0x453c4c`).

### Achado externo (21/08) — projeto de terceiros `karos-server-project-master` (2021)

O usuário encontrou uma pasta `D:\RoshEmulator\karos-server-project-master` com um projeto de
terceiros (aparentemente de 2021, achado em algum fórum/repositório, autor usa o apelido
"opensylar") que tentou fazer a mesma coisa que a gente (proxy + replay de payloads TCP), usando a
lib `kissnet`. Achados relevantes:

- **Mira um servidor DIFERENTE do nosso**: `34.197.184.173:10012` / `0.0.0.0:10012` ("Axeso5" — um
  publisher conhecido de Karos Online, provavelmente a região NA/LATAM), enquanto o nosso alvo é
  `85.119.149.223:10001` (região Europa/América conforme as tags que vimos na lista de servidores
  — pode ser o "Rosh Online" ou uma região diferente do Karos). **Portas e IP diferentes confirmam
  que não é o mesmo servidor/publisher**, mas é o MESMO CLIENT/engine (`AMo.exe`) — os hashes de
  login de teste que aparecem hardcoded no código deles (`b755441959f0fb071dda530f086cc58c`,
  `5bd5b92a6a68ffabecc9ef3b36cc3066`, literal `"NONE"`) **quase batem com os nossos**
  (`a755441959f0fb071dda530f086cc58c`/`0bb79b04cf60d1a2a223df6212ed6bde` — mesma estrutura, 1
  byte de diferença no primeiro), e as STRINGS DE MEMÓRIA VAZADA que eles capturaram
  (`hazekaros.h`, "Unable to retrieve maps from [%s]... ResUnpak/Map/Tree/Shaders/...
  CSpeedTreeForestManager::CSpeedTreeForest... No trees loaded...") **são BYTE-A-BYTE IDÊNTICAS**
  ao nosso "frame do meio" (`build_middle_frame`/pacote #21 e #37 da nossa captura) — confirma que
  é exatamente o mesmo build/versão de cliente, só servidor de login diferente.

- **`rosh-cplus/Functions.cpp` (`DeployUncrypt`/`UncryptWsBuf`)** — o autor decompilou (de forma
  bem crua/literal, nomes de variável tipo `arg0_ptr_string_enorme`) uma rotina de descriptografia
  de pacote: pega o buffer recebido (`wsa_buf`), separa os primeiros 4 bytes como campo de
  tamanho, divide o RESTO em duas metades IGUAIS (`half0`, `half1`), e pra cada posição `i` calcula
  `k[i] = (half0[i] - half1[i]) & 0xFF` (subtração, igual à cifra do `ServerList.ten`!) e
  `d[i] = (half1[i] XOR k[i]) & 0xFF`; retorna `k[]` como o texto plano real. **Isso é sugestivo**
  (mesma família de cifra por subtração que já vimos no `ten_crypto.py`), mas **não confirmamos se
  isso se aplica ao NOSSO servidor/protocolo** — pelo contrário, todos os pacotes que já decodificamos
  com sucesso (`CLoginChannelListPacket`, `CChannelWaitLoginPacket`, o frame do meio com as strings
  vazadas) vieram **perfeitamente legíveis em texto plano na captura real**, sem qualquer sinal de
  estarem cifrados (IPs, contadores e nomes de variável todos fazem sentido direto). É bem possível
  que esse esquema de descriptografia seja específico do servidor Axeso5 (talvez cifre só alguns
  tipos de pacote, ou só numa versão/região diferente) — **não crítico agora, mas vale revisitar se
  ficarmos empacados**: seria possível testar aplicando esse algoritmo (`subtração+XOR` em duas
  metades) contra qualquer pacote nosso que ainda pareça "aleatório"/não decodificado, pra ver se
  bate com algo sensato.

- **Pasta `dumps-net/1` e `dumps-net/2`**: contém os payloads brutos que ELES capturaram do
  Axeso5 (`1.bin`..`8.bin` e `1.bin`..`6.bin`), mas pelo tamanho/conteúdo (bytes de aparência
  aleatória mesmo nos primeiros, tipo `1.bin`=23 bytes) parecem ser pacotes DE VERDADE cifrados
  desse outro servidor — não deram pra decodificar de cara e não é prioridade (servidor diferente
  do nosso alvo).

**Avaliação**: esse projeto de terceiros não é um "cola" pronta pro nosso caso específico (mira
outro servidor/publisher), mas é uma confirmação externa independente valiosa de que a abordagem
de capturar+decompilar+repetir é a certa, e mostra o mesmo binário/build de cliente sendo estudado
por outra pessoa em 2021. Vale manter como referência (`DeployUncrypt` como pista de fundo caso a
gente ache algum pacote nosso que pareça cifrado no futuro).

### 🚨 Achado crítico (21/08) — o patch de 1 byte agora parece disparar um verificador de integridade (delay, não instantâneo)

Depois de mudar o dado da lista de servidores (1 de 5 linhas com nome real, ver seção acima), o
técnica de patch de 1 byte (`patch_int3.py`) que funcionou PERFEITAMENTE ontem parou de reproduzir
o crash antigo. Investigamos com uma série de testes controlados:

1. **Teste de espera pura (sem patch, sem clique)**: cliente parado na tela de lista de servidores
   por 60s seguidos, sem qualquer ação — **não fechou sozinho**. Descarta hipótese de timeout puro.
2. **Patch em `0x453bae` (entrada da função de clique) sem clique em seguida**: o processo
   continuou vivo logo depois do `WriteProcessMemory` (confirmado por `tasklist` imediatamente após
   o patch) — mas fechou sozinho pouco depois, **sem nenhum clique do usuário**, sem dump, sem
   evento de crash no Log de Aplicativos.
3. **Patch em `0x453bae` com clique IMEDIATO em seguida** (usuário com o mouse já posicionado
   antes do patch): mesmo assim fechou **antes que desse tempo de clicar**.

**Conclusão provisória**: existe um mecanismo (suspeita: verificador periódico de integridade de
código, rodando a cada poucos segundos — mais rápido que o tempo de reação humana mediada por
chat) que detecta a modificação de 1 byte no código executável (`0xCC` sobrescrevendo o opcode
original) e fecha o processo de forma LIMPA (sem exceção, sem dump, sem diálogo) — bem diferente
do crash de verdade que documentamos ontem (`0xc000000d`, com dump completo). **Isso explica por
que a técnica funcionava perfeitamente ontem** (o usuário clicava rápido o bastante, o clique
disparava o INT3 antes do próximo ciclo do verificador) **e não funciona mais hoje** (a latência de
ida-e-volta pelo chat — eu aviso, o usuário lê, clica — é maior que o ciclo do verificador).

**Isso NÃO significa que o patch de 1 byte esteja errado como técnica** — só que a JANELA de tempo
disponível encolheu. Não deu tempo ainda de confirmar se é mesmo um verificador de integridade de
código (vs. alguma outra explicação), nem de medir o período exato do ciclo.

**Próxima tentativa recomendada**: trocar de técnica pra uma que NÃO escreve no código executável
— **breakpoints de hardware via registradores de debug** (`Dr0`-`Dr3`/`Dr7` no `CONTEXT` de uma
thread, setados via `GetThreadContext`/`SetThreadContext` com `THREAD_ALL_ACCESS`, SEM
`DebugActiveProcess`/sem anexar como debugger de verdade — o que já sabemos que trava a janela
DirectX). Isso evita modificar qualquer byte de código (não deveria disparar o verificador de
integridade, se for isso mesmo) e evita o travamento de janela (não é um attach de debugger
formal). **Implementado** em `D:\RoshEmulator\server\patch_hwbp.py` (aceita até 4 endereços,
Dr0-Dr3, separados por vírgula) + `find_export.py`/`find_module_base.py` (acham RVA de export de
uma DLL 32-bit lendo o PE do disco, e o base real de um módulo carregado no processo alvo via
Toolhelp32 — necessário porque nosso `python.exe` é 64-bit e o `kernel32.dll`/`ucrtbase.dll` de
32-bit usados pelo `AMo.exe` sob WOW64 são módulos/bases DIFERENTES). Testado com o breakpoint em
`0x453bae` (mesmo endereço do patch de 1 byte) — **também não disparou**, mesmo sem escrever
nenhum byte de código. Isso **descarta de vez a hipótese do verificador de integridade** — a causa
real de não disparar é mais simples: **o código não passa mais por esse endereço quando a linha
clicada tem dado de verdade** (só passava antes quando a linha estava 100% em branco/lixo).

### 🎯 RESOLVIDO — crash real reproduzido organicamente (sem forçar nada), pilha de chamadas completa

Depois de várias tentativas de reabrir o jogo direto (`AMo.exe alpha gg 0`) falharem por
instabilidade de vídeo virtual do RDP (janela não abre, fica em ~14MB de memória e fecha sozinha —
ambiente esgotado de tanto relançar o processo hoje), o usuário abriu o jogo pelo site
(`https://play.gamexp.com/`, launcher `karos.exe` de verdade, NÃO o modo de teste "alpha gg 0") e
clicou num servidor. Resultado: **"travou, depois fechou"** — e dessa vez GEROU UM DUMP DE
VERDADE (`AMo.exe.2112.dmp`), com **exceção `0xc000000d` no endereço `0x0035779b`** — o MESMO
endereço exato do primeiro crash orgânico que vimos no dia anterior (antes de começarmos a usar
qualquer patch), confirmando que é o crash real do jogo, não um artefato da nossa investigação.

**Achado extra nesse mesmo teste**: o log do `channel_server.py` (hop 2) mostrou, pela PRIMEIRA VEZ
em todos os testes de hoje, uma conexão de verdade chegando (`conexao de ('127.0.0.1', 58158)`),
mas fechada imediatamente sem enviar nenhum byte (`recebeu 0`). **Isso sugere que o modo de teste
"alpha gg 0" pode nunca tentar o hop 2 de verdade** (em nenhum teste direto de hoje o hop 2 foi
contactado), enquanto o login de verdade pelo site TENTA conectar no hop 2 — mas nosso
`channel_server.py` ainda não trata bem uma conexão que fecha sem mandar dado (gerou só um erro de
framing, não um teste completo do fluxo).

**Pilha de chamadas completa do crash** (via `cdb -z ... -c ".ecxr; kb L20"`, convertendo os
offsets relativos do módulo pra endereços absolutos somando `0x400000`):

```
0x0075779b  <- handler generico de checagem de seguranca do CRT (crash de verdade aqui)
0x005b4323  <- metodo de algum item de lista de UI (perto de 0x5b0701, o handler de clique da
               lista de CANAIS que ja tínhamos mapeado -- mas NAO é o mesmo endereço; é um slot
               de vtable/metodo diferente, provavelmente da lista de SERVIDORES ou de outra tela)
0x0045dc49
0x004608cc  <- despachante generico de clique/hover do mouse (ja documentado antes como
               "pega posição do mouse via GetCursorPos/ScreenToClient, decide hover/click")
0x00455fb4
0x00453da5  <- MUITO perto de 0x453bae/0x453e70, a cadeia que já tínhamos mapeado ontem
               (iteração de item de lista com campo não inicializado)
0x00454262  <- MESMO endereço já documentado na cadeia antiga (lista de servidores)
0x00440859  <- MESMO endereço já documentado na cadeia antiga (loop principal)
0x004237a3
0x00423aa6
0x006a2e83
0x006a30ce
0x00423c0c
0x0075fac4  <- wrapper de inicio de thread (kernel32/ntdll)
```

**Isso fecha o raciocínio de forma definitiva**: a hipótese de ontem (crash generico do CRT ao
iterar um item de lista com campo não inicializado, porque nosso servidor de teste nunca populou
os dados de status/canal de verdade) está CONFIRMADA por um crash real, não forçado, com a MESMA
cadeia de funções já mapeada (`0x453da5`~`0x453e70` → `0x454262` → `0x440859`), só que dessa vez
alcançada por um caminho de clique ligeiramente diferente (`0x5b4323` em vez de `0x5b0701`/
`0x453bae`, que tínhamos usado antes) — bate com a teoria de que uma linha com ALGUM dado
populado (o "Azmara-Blackforest" de hoje) toma um branch de código diferente de uma linha 100% em
branco.

**Conclusão prática pra próxima sessão**: o próximo teste direto e mais produtivo é usar
`patch_hwbp.py` (ou `patch_int3.py`) no NOVO endereço `0x5b4323` (não mais `0x5b0701`/`0x453bae`)
pra pegar o ponteiro do item nesse ponto específico e continuar fechando a estrutura exata que
falta. Além disso, vale terminar de tratar no `channel_server.py` o caso de conexão que fecha sem
mandar dado (não travar/logar só como erro — registrar isso como um cenário de teste válido, já
que pode ser exatamente o que o cliente real faz nesse fluxo).

### 🎯 RESOLVIDO — não existe "token" separado entre hop 1 e hop 2: o client reautentica com as MESMAS credenciais

Usuário perguntou se não dava pra gerar um token de sessão "do mesmo jeito que o site faz" pro
hop 2 aceitar. Investigando a captura real: **o pacote que o client manda pro HOP 2 logo depois de
conectar (`packet #32`, 370 bytes) é BYTE A BYTE IDÊNTICO ao pacote de login que ele mandou pro
HOP 1 (`packet #17`)** — confirmado com comparação direta (`p17 == p32` → `True` em Python). Ou
seja: **não existe um token/sessão gerado pelo hop 1 que o client repassa pro hop 2** — o client
simplesmente reenvia o MESMO `CLoginReqPacket` (mesmo `hash1`, `hash2`, mesmo tudo) pro hop 2, que
reautentica de forma independente com as mesmas credenciais de senha. Cada servidor (login e
mundo/canal) faz sua própria checagem de senha via hash, sem handoff de token entre eles.

**Isso simplifica bastante o `channel_server.py`**: já atualizamos ele pra usar o mesmo
`parse_login_request()` do `login_packet.py` no primeiro frame recebido (em vez de só logar bytes
crus), já que sabemos exatamente o que esperar. Não precisamos implementar geração de token nenhuma
— só aceitar/logar a reautenticação (nosso servidor de teste não precisa validar senha de verdade).

### 🎯 Achado importante (21/08) — o crash real só reproduz pelo LOGIN DE VERDADE (site), não pelo modo `alpha gg 0`

Depois de reproduzir o crash real 2x seguidas (dumps `AMo.exe.2112.dmp` e `AMo.exe.12444.dmp`,
ambos idênticos: exceção `0xc000000d` em `0x35779b`, mesma pilha completa), tentamos reproduzir de
novo abrindo direto via `AMo.exe alpha gg 0` (nosso modo de teste local) — **e o jogo fechou sem
gerar dump nem evento de crash, do mesmo jeito "limpo" que vínhamos vendo o dia todo**. O usuário
notou a diferença visualmente: **"quando eu abro pelo site, ele trava antes de fechar; quando você
abre [via `alpha gg 0`], ele fecha na hora"**.

**Conclusão**: o modo `alpha gg 0` parece seguir um caminho de código DIFERENTE (provavelmente uma
saída limpa/antecipada específica de modo de teste/debug) que NUNCA chega no crash real que
estamos caçando. **Todo teste daqui pra frente precisa ser feito abrindo o jogo pelo site
(`https://play.gamexp.com/`, launcher `karos.exe` de verdade), não pelo `AMo.exe alpha gg 0`
direto.** Confirmado com um TERCEIRO crash idêntico (`AMo.exe.3728.dmp`, mesma assinatura exata)
depois de retomar o teste pelo site.

**Breakpoint de hardware (`patch_hwbp.py`) ainda não conseguiu interceptar** o endereço `0x5b4323`
em nenhuma das 2 tentativas (mesmo depois de corrigir o bug de suspender a thread antes de
Get/SetThreadContext) — o crash real aconteceu do mesmo jeito nos 2 casos, sem o breakpoint
disparar antes. Motivo ainda não confirmado (pode ser peculiaridade do ambiente virtualizado/RDP
com registradores de debug, ou o endereço exato precisa de ajuste). **Não é mais crítico**: como o
`LocalDumps` já captura dump COMPLETO de memória em qualquer crash real, dá pra fazer toda a
análise adicional em cima dos dumps que já temos, sem precisar de um breakpoint ao vivo.

### Decompilação da cadeia nova do crash (`0x5b4323` → ... → `0x453da5`)

Rodamos o Ghidra (`ExploreCrashChain0821.java`) pra decompilar os endereços novos da pilha desse
crash. Achado principal: **`0x453da5` é só um PONTO DE RETORNO DENTRO da MESMA função que já
conhecíamos, `FUN_00453bae`** (a mesma que patcheamos ontem em `0x453c4c`) — não é uma função nova,
é só uma iteração diferente do mesmo loop de lista (label `LAB_00453da5`, perto do fim do loop
`do { ... } while(true)`). Isso confirma que a cadeia de crash é a MESMA de ontem, só entrando por
um ramo interno diferente porque a linha clicada ("Azmara-Blackforest") tem dado de verdade.

Dentro de `FUN_00453bae`, pra cada item da lista (via `FUN_0069f69c()`/iterador,
`FUN_006f4b02()`/fim-de-lista, `FUN_006a42a8()`/incrementa):
- Le o ponteiro do item (`piVar3`), confere se é válido com `FUN_00455612()` (mesma checagem
  documentada ontem em `0x453c4c`).
- Faz teste de posição do mouse dentro do retângulo (`FUN_0043666a`, hit-test).
- Lê um campo em `*(int*)(*piVar3 + 4) + 0xc` (chamamos de `iVar4`) e compara contra uma lista de
  valores mágicos (`0x36b7`, `0x36bb`, `0x36c6`, `0x6d68`, `0x7334`, `0xc364`, `0xea95`) — só entra
  nesse bloco se uma flag global (`*(char*)(*(int*)(DAT_008db7ec+0x8480)+0x762)`) estiver ativa;
  parece ser lógica de outro contexto/modo de tela (não específica da lista de servidores) e
  provavelmente não é a causa direta do nosso bug.
- No final, faz uma chamada virtual `(**(code**)(*(int*)*puVar5 + 0x20))()` — provavelmente um
  callback de "renderiza/atualiza hover" do item específico. **Essa chamada virtual é o candidato
  mais provável pra ser o próximo elo da cadeia** (não seguimos a decompilação além dela ainda).

`FUN_004606e2` (endereço `0x4608cc` na pilha) é o despachante genérico de estado de mouse/hover
já documentado antes — confirma que ele lê `GetCursorPos`/`ScreenToClient` e despacha pra
`(**(code**)(*param_1 + 0x40))(uVar4, param_1)` com códigos de estado tipo enum (0x14, 0x18, 0x24,
0x26, 2, 1 — prováveis HOVER_ENTER/HOVER_EXIT/CLICK/etc.), confirmando que é um framework de UI
genérico reusado por QUALQUER elemento clicável do jogo, não código específico da lista de
servidores.

**Próximo passo recomendado pra fechar de vez**: seguir a decompilação a partir da chamada virtual
`(**(code**)(*(int*)*puVar5 + 0x20))()` dentro de `FUN_00453bae` (achar o vtable/classe que
implementa esse slot pra um item de SERVIDOR populado) — ou, mais direto, usar os 3 dumps que já
temos (`AMo.exe.2112.dmp`, `.12444.dmp`, `.3728.dmp`, todos com memória completa) pra achar o
ponteiro do item de verdade via busca por padrão de heap (mesma técnica já usada com sucesso pra
achar `CGuiChannelListSel`), sem precisar de mais nenhum teste ao vivo.

### 🚨 Achado possivelmente decisivo — pode ser invalidação de ITERADOR, não dado faltando

Dumpamos a memória em torno do registrador `ESI` no exato momento da exceção (`.ecxr; db esi-0x20
L120` no `AMo.exe.12444.dmp`). Resultado:

```
1cad62d4  0b 0b 0b 0b 78 f0 06 17-a0 f0 06 17 ac f0 06 17
```

Os primeiros 4 bytes são `0b 0b 0b 0b` — um padrão repetido suspeito, MUITO parecido com um
**valor sentinela de "poison"/preenchimento de depuração do CRT do MSVC** (o mesmo mecanismo que
usa padrões como `0xFD`/`0xCD`/`0xDD` pra marcar memória liberada/não-inicializada/"no man's
land", só que esse build usa `0x0B`). Isso muda a teoria principal: **o crash pode não ser "dado de
rede incompleto deixando um campo com lixo de heap"** (nossa hipótese até agora) **mas sim uma
INVALIDAÇÃO DE ITERADOR genuína no próprio código do jogo** — um iterador (provavelmente de
`std::vector`/`std::list`) que aponta pra dentro de uma lista que foi modificada/realocada
ENQUANTO ainda estava sendo iterada (ex: a lista de itens é reconstruída no meio do clique,
invalidando o iterador que o loop de hit-test ainda está usando) — e a checagem de depuração do CRT
(`_ITERATOR_DEBUG_LEVEL`-style) detecta esse iterador "morto" e chama `_invalid_parameter`/
`TerminateProcess` (`0xc000000d`), que é exatamente a exceção que vemos.

**Se essa teoria estiver certa**, a causa raiz não é sobre o FORMATO dos dados que mandamos (o
`CLoginChannelListPacket`, os IDs de servidor, etc. já parecem corretos o suficiente pra renderizar
"Azmara-Blackforest" direitinho) — é sobre uma condição de corrida/reentrância no próprio motor do
jogo, possivelmente disparada justamente porque ALGO no nosso fluxo (o hop 2 nunca terminando de
verdade, ou terminando de um jeito inesperado) deixa a lista num estado que o motor não espera
(ex: tenta re-popular/atualizar a lista bem no meio do clique, porque ainda está "esperando" dado
do hop 2 que nunca chega completo). Isso bateria com o padrão "trava, depois fecha" que o usuário
descreveu pelo site (a "travada" pode ser exatamente o momento da re-entrada/realocação).

**Não confirmado ainda** — precisa de mais decompilação (achar exatamente qual container/iterador
é esse, e o que dispara a invalidação) pra fechar de vez. Mas é uma pista muito mais promissora que
"só falta mandar mais campo X" — registrado aqui pra não perder o rumo na próxima sessão.

**Confirmado (e esperado) beco sem saída**: `FUN_0075778f` (o wrapper que chama o handler genérico
`FUN_0075776b`, no endereço exato do crash) é referenciada por **milhares de call sites em todo o
binário** (listamos via Ghidra — a lista de callers passa de 900 entradas e continua). Isso bate
100% com o que já documentamos ontem: é um handler de falha GENÉRICO do CRT (chamado por qualquer
`assert`/checagem de debug que falhe em qualquer lugar do jogo), não dá pra usar a lista de quem
chama ele pra identificar o ponto específico do nosso bug — esse caminho de busca estática está
esgotado. **A forma real de progredir a partir daqui é EMPÍRICA**: testar variações da nossa
resposta de rede (ex: enviar os dados do hop 2 com os endereços de canal REAIS em vez de
redirecionados pra `127.0.0.1`, ou testar se o crash ainda ocorre com uma resposta de hop 2 mais
completa/rápida) e observar se muda o comportamento — ou aceitar por ora que fechar 100% esse bug
específico exige either símbolos de depuração (que não temos) ou muito mais tempo de decompilação
manual da cadeia de vtables a partir de `FUN_00453bae`.

**Resumo do estado ao final da sessão de 21/08**: identificamos e reproduzimos o crash real de
forma 100% confiável (login pelo site, clicar em qualquer servidor com nome populado), mapeamos a
pilha de chamadas completa, confirmamos que é a mesma função (`FUN_00453bae`) já conhecida desde
ontem, e temos uma pista forte (padrão `0b0b0b0b`) de que pode ser invalidação de iterador em vez
de dado de rede incompleto. Ferramentas prontas pra continuar: `patch_hwbp.py` (breakpoint de
hardware, corrigido mas ainda não confirmado funcionando), `channel_server.py` (hop 2 funcional,
já reautentica com o parser de login), `channel_list_packet.py` (formato 100% decodificado e
validado), e 5 dumps completos do crash real salvos em `captures/crashdumps/` pra análise futura
sem precisar reabrir o jogo.

### 🎯 CONFIRMADO — o crash é 100% client-side, o hop 2 NUNCA é contactado antes dele

Teste decisivo: trocamos os IPs/portas dos canais no `channel_server.py` de `127.0.0.1` pros IPs
REAIS de produção (`85.119.149.225`, `207.246.77.161`, etc, ver `REAL_CHANNELS` no código) — pra
testar se o redirecionamento pra localhost estava causando o crash. Reiniciamos o servidor,
logamos de novo pelo site, chegamos na lista (mesmo conteúdo de sempre: "Azmara-Blackforest" +
4 linhas parciais), clicamos em "Azmara-Blackforest" — **crashou de novo, EXATAMENTE igual**
(5º crash idêntico, `AMo.exe.9268.dmp`, mesma assinatura `0xc000000d`/`0x35779b`).

**O mais importante**: o log do `channel_server.py` (hop 2) continuou **100% vazio** — nenhuma
conexão chegou, nem antes nem depois do clique. Isso prova de forma definitiva que **o crash
acontece inteiramente no processamento LOCAL do clique, sem NENHUM round-trip de rede pro hop 2**.
Ou seja: **não importa o que a gente mande no `channel_server.py`** (dado local, dado real, IPs
localhost ou de produção) — **o bug não depende do conteúdo da resposta de rede do hop 2, porque
o hop 2 nem chega a ser contactado antes do crash**. Isso fecha de vez a linha de investigação
"falta mandar campo X no pacote de canal" — não é sobre isso.

**Isso reforça fortemente a teoria da invalidação de iterador** (seção acima, padrão `0b0b0b0b`):
o bug parece ser uma condição pré-existente no próprio estado do cliente/UI quando o clique é
processado (ex: a lista de servidores foi construída de um jeito que deixa um iterador/container
em estado inconsistente), não uma falta de dado do nosso servidor de teste. **Próximo passo real**:
decompilar a fundo a partir da chamada virtual dentro de `FUN_00453bae` (`(**(code**)(*(int*)*puVar5
+ 0x20))()`) pra achar QUAL vtable/classe implementa esse slot pro item de SERVIDOR (não canal),
e using os 5 dumps completos já salvos pra achar o container/iterador exato que está sendo
invalidado — tudo isso é trabalho de análise estática que NÃO precisa mais do jogo rodando.

### 🎯🎯 DEFINITIVO — o crash NÃO acontece no servidor real; é 100% específico da nossa emulação incompleta

Teste final e decisivo da sessão: restauramos `ServerList.ten` pro valor real
(`85.119.149.223:10001`, backup em `ServerList.ten.orig_backup`) e logamos de verdade pelo site
contra o servidor de PRODUÇÃO real (não mais nosso `login_server.py`/`channel_server.py`).
Confirmações importantes:

1. **A conexão com o hop 2 acontece AUTOMATICAMENTE, antes de qualquer clique**: rodamos `netstat`
   logo que a lista de servidores apareceu (antes do clique) e já havia uma conexão TCP
   `ESTABLISHED` com `85.119.149.225:10199` (o hop 2 real do servidor "Azmara-Blackforest"/ID 42).
   **Isso confirma que no fluxo real, o client conecta no hop 2 sozinho, sem esperar clique** — e
   é exatamente esse passo automático que nunca conseguimos reproduzir no nosso
   `login_server.py`/`channel_server.py` (em NENHUM teste de hoje o hop 2 foi contactado antes do
   clique, e às vezes nem depois).

2. **Clicando em "Azmara-Blackforest" contra o servidor real: SEM CRASH.** O usuário chegou
   normalmente na tela de seleção de personagem. Nem o breakpoint de hardware em `0x5b4323`
   disparou (o que já era esperado, já que aqui não crasha).

**Conclusão definitiva da sessão**: o crash que documentamos e reproduzimos 5 vezes hoje **NÃO é
um bug do jogo** — é 100% causado por uma diferença entre nossa emulação (`login_server.py` +
`channel_server.py`) e o comportamento real do servidor de produção. Como já provamos que o
CONTEÚDO do pacote de canal não importa (testamos com localhost e com IPs reais, mesmo crash), a
diferença real deve estar em **ALGO que falta no fluxo do hop 1 pra disparar a conexão automática
com o hop 2** — o client no nosso teste nunca abre essa conexão sozinho, então quando o usuário
clica, tenta usar dados de canal/status que nunca foram buscados de verdade (nem pra localhost,
nem pra IP real), e cai no mesmo estado inconsistente (possível invalidação de iterador,
consistente com o padrão `0b0b0b0b` encontrado antes).

**Próximo passo direto pra próxima sessão**: descobrir O QUE no `handshake`/`chanwait`/
`frame do meio` (hop 1) sinaliza pro client "agora conecta no hop 2 automaticamente" — comparar
byte a byte esses 3 frames que mandamos contra uma captura NOVA do fluxo real completo (com
`pktmon`, igual fizemos antes) focando especificamente no INTERVALO DE TEMPO entre o hop 1 fechar
e o hop 2 abrir, pra ver se tem algum campo/flag que ainda não identificamos. Como prioridade,
vale re-capturar o tráfego real de HOJE (usamos login de verdade agora) com `pktmon`, já que temos
uma sessão real fresca pra comparar — isso pode revelar a diferença exata que falta.

**IMPORTANTE pra próxima sessão**: `ServerList.ten` foi deixado apontando pro SERVIDOR REAL
(`85.119.149.223:10001`) ao final desta sessão (não mais pro nosso `login_server.py` de teste).
Rodar `python redirect_to_local.py` (ou equivalente) antes de testar o servidor fake de novo.

### 🎯 PROGRESSO REAL (21/08, tarde da noite) — de CRASH pra ERRO TRATADO, depois de 2 correções

Recapturamos o fluxo real completo com `pktmon` (login de verdade pelo site,
`captures/real_flow_20260821.pcapng` → `real_flow_20260821_parsed.txt`, parser novo em
`captures/parse_flow_0821.py` que inclui timestamps). Dois achados MUITO importantes:

1. **A ORDEM dos frames do hop 1 estava invertida**: o servidor real manda **handshake → frame do
   meio (290 bytes) → CChannelWaitLoginPacket (36 bytes)** — nosso `login_server.py` mandava
   chanwait ANTES do frame do meio. Corrigido (agora manda em 3 `sendall()` separados, na ordem
   certa).

2. **Os bytes 16:20 do payload do `chanwait` NÃO são lixo/desconhecido — são `server_id` (u16 LE)
   + `channel_idx` (u16 LE) do canal padrão/pré-selecionado.** Na captura de ontem eram
   `01 00 63 00` (server=1, canal=99) — só que ontem o servidor "nomeado"/exibido na lista era
   outro (nomes vêm de resolução local por ID, lembrar seção anterior). Na captura de HOJE (fluxo
   100% real, servidor 42 "Azmara-Blackforest" selecionado), esses bytes eram `2a 00 02 00`
   (server=42, canal=2) — CONSISTENTE com o servidor que a lista mostra nomeado. Atualizamos nosso
   `build_channel_wait_frame` pra usar `2a 00 02 00 00 00` (server=42, canal=2) em vez dos valores
   antigos inconsistentes.

**Resultado depois das duas correções**: login pelo site → a tela de lista de servidores foi
**pulada inteiramente** — o cliente foi direto pra tela de **Seleção de Canal**, mostrando
**"Canal 2" com status "Baixo" (renderizado corretamente, sem lixo nenhum)**. Clicar em
"OK"/"Canal 2" dessa vez **NÃO CRASHOU** — deu uma mensagem de erro tratada e limpa:
**"Desconectado do servidor. (10038)"** (`10038` = `WSAENOTSOCK`, erro de Winsock — operação de
socket num handle inválido/já fechado). Isso é uma melhora ENORME: de um crash sem controle
(`0xc000000d`, sem diálogo) pra um erro de rede tratado normalmente pelo jogo.

**Ressalva importante**: o hop 2 (`channel_server.py`) continua com o log 100% vazio — nenhuma
conexão chegou em nenhum momento desse teste. Isso levanta a possibilidade de que "Canal 2/Baixo"
tenha vindo de um CACHE local do cliente (de quando testamos contra o servidor real mais cedo
nesta mesma sessão), não de dado fresco da nossa emulação — precisa de mais um teste (idealmente
reiniciando o processo do zero, sem nenhuma interação prévia com o servidor real na mesma sessão)
pra confirmar se a tela de canal aparece "do zero" só com nosso servidor fake, ou se depende desse
cache.

Além disso, o `login_server.py` (hop 1) não fechou a conexão sozinho -- ficou esperando 30s até
dar timeout, enquanto nas duas capturas reais (ontem e hoje) é o CLIENT quem fecha rapidamente
(FIN ontem, RST hoje) logo depois de ecoar o handshake. É possível que o erro 10038 tenha vindo
justamente de o client tentar reutilizar/reconectar um socket que ficou preso/inválido por causa
da nossa espera passiva de 30s — outro candidato pra investigar/corrigir: fazer o `login_server.py`
fechar ativamente a conexão logo depois de mandar os 3 frames, em vez de esperar o client.

**Próximo passo direto**: (a) testar de novo com sessão 100% limpa (fechar o jogo, garantir que não
sobrou nenhum estado de conexão real anterior) pra confirmar se "Canal 2" aparece só com dado
nosso; (b) fazer `login_server.py` fechar a conexão proativamente pouco depois de enviar os 3
frames, em vez de esperar 30s; (c) se ainda não conectar no hop 2, investigar o erro 10038 mais a
fundo (decompilar o handler de erro de socket pra ver o que ele reporta).

**TESTADO (b) — piorou, revertido**: fizemos o `login_server.py` fechar a conexão ativamente logo
depois de receber o eco do handshake (em vez de esperar passivamente até 30s). Resultado: o erro
"Desconectado do servidor (10038)" passou a aparecer **imediatamente no login**, antes até de ver
a lista de servidores/canal — pior que o comportamento anterior (que pelo menos chegava até a
tela de canal). **Revertido** — voltamos a esperar passivamente (`conn.settimeout(30)`, sem fechar
por conta própria). Conclusão: fechar ativo não é o que falta; o problema está em outro lugar
(provavelmente ainda relacionado a alguma diferença fina no conteúdo dos frames, ou o cliente
realmente precisa completar algo específico — talvez seja necessário aceitar e responder à conexão
do hop 2 de verdade pra essa cadeia fechar, já que em NENHUM teste de hoje o hop 2 foi contactado).

**Estado ao final da sessão**: `login_server.py` com as 2 correções que geraram melhora real
(ordem dos frames + `server_id`/`channel_idx` consistentes = `42`/`2`), fechamento ativo
revertido (voltou ao passivo). Login pelo site chega na tela de Seleção de Canal ("Canal 2" /
"Baixo") sem crashar; clicar pra entrar dá erro tratado "10038" (não crash) — melhor estado
alcançado até agora. Próxima sessão: focar em fazer o hop 2 (`channel_server.py`) realmente ser
contactado (ainda não aconteceu em nenhum teste), o que provavelmente vai resolver o 10038
de vez.

### 🎯🎯🎯 GRANDE VITÓRIA — descoberto o pacote `02 07` (pedido de troca de canal) e o hop 2 finalmente conectou de verdade

Achamos, na captura nova (`real_flow_20260821_parsed.txt`), um pacote que nunca tínhamos visto:
**assinatura `02 07`** — um "pedido de troca de canal" que o client manda de volta na MESMA
conexão onde recebeu o `chanwait` (no fluxo real isso vai pro hop 2 já conectado; no nosso teste,
como o hop 2 nunca tinha sido contactado ainda, o client mandou de volta pro hop 1). Formato
(24 bytes de payload, request e resposta têm a MESMA estrutura):

```
offset 0   sig       = 02 07
offset 2   unknown6  = 6 bytes (ultimo byte parece um contador de sequencia da conexao)
offset 8   unknown2  = 01 00 (constante)
offset 10  unknown4  = 00 00 00 00
offset 14  endereco  = 6 bytes -- IP com OCTETOS INVERTIDOS + porta BIG-ENDIAN (formato
                        DIFERENTE do resto do protocolo, que usa IP normal + porta little-endian!)
offset 20  unknown4  = 4 bytes, ainda nao decodificados (na captura real: 00 00 2a 76)
```

Implementamos a resposta em `login_server.py` (`build_channel_change_response`, escuta por
`02 07` no loop de leitura do hop 1) reescrevendo o campo de endereço (offset 14:20) pro nosso
`channel_server.py`. **Primeira tentativa** (IP normal + porta little-endian, mesmo padrão do
`chanwait`) **NÃO funcionou** — o client ficou preso, criou um socket mas nunca chamou `connect()`
(confirmado via `Get-NetTCPConnection`, socket em estado `Bound` por 8+ segundos sem progredir).
**Segunda tentativa** (IP com octetos invertidos + porta big-endian) **FUNCIONOU**: o
`channel_server.py` finalmente recebeu uma conexão de verdade (`conexao de ('127.0.0.1', 58484)`,
reautenticação com hash de senha, resposta completa enviada).

**Resultado final visual** (3 screenshots em sequência do usuário): tela de servidores → clica →
aparece um resultado de CACHE antigo ("Canal 2"/"Baixo", 1 linha só) → **a tela atualiza sozinha
pra mostrar os 3 canais REAIS do nosso servidor** ("Canal 1/2/3", todos "Médio", dado limpo sem
nenhum lixo) **sobreposta a uma tela de carregamento de mundo já em andamento** ("AGORA
CARREGANDO", barra de progresso em 1%). A tela de carregamento trava em 1% porque nosso
`channel_server.py` é só um stub que manda a lista de canais — não implementa dados de jogo de
verdade (mapa, personagem, entrada no mundo).

**Isso é o limite natural do que dá pra testar sem construir um servidor de mundo completo.**
Fechamos por completo a investigação original do crash: confirmamos e corrigimos TODAS as
diferenças entre nossa emulação e o fluxo real que causavam o comportamento quebrado (ordem dos
frames, consistência de `server_id`/`channel`, e agora o pacote `02 07` que faltava). O crash de
`0x35779b` não acontece mais em nenhum teste depois dessas correções.

**Próximos passos (fase seguinte do projeto, não mais "corrigir o crash")**:
1. Decodificar os 4 bytes finais ainda desconhecidos do pacote `02 07` (offset 20:24, valor real
   `00 00 2a 76`) — pode ser relevante pro próximo pacote da cadeia.
2. Descobrir o PRÓXIMO pacote que o client espera depois de conectar no "servidor de mundo" (dados
   de personagem/entrada no mapa) pra passar dos 1% de carregamento — provavelmente outro par
   request/response que ainda não vimos, exige uma nova captura real indo além desse ponto (o
   usuário teria que deixar carregar 100% contra o servidor real e comparar).
3. Considerar batizar essa fase como "servidor de mundo" (world server) propriamente dito, já que
   login + seleção de servidor + seleção de canal agora funcionam de ponta a ponta com nossa
   emulação.

### 🎯🎯🎯🎯 Captura completa até a tela de personagem — mapa da cadeia inteira e o campo de endereço 100% confirmado

Fizemos uma NOVA captura real (`pktmon`, `captures/real_flow_channel_enter.pcapng` →
`real_flow_channel_enter_dedup.txt`) indo além de onde já tínhamos ido, contra o servidor de
PRODUÇÃO de verdade, do login até a tela de personagem. Isso revelou a cadeia INTEIRA:

```
HOP 1 (85.119.149.223:10001)
  -> login request (370b) -> handshake -> middle(290b) -> chanwait(36b, servidor "default"=28/canal 3)
  -> client fecha com RST

HOP 2 (45.77.197.216:10103, endereco do chanwait) -- conecta AUTOMATICO, sem clique do usuario
  -> login request (370b, IDENTICO ao do hop 1) -> handshake -> middle2(294b) + channel_list(286b,
     TODOS os 5 servidores/17 canais, igual sempre)
  -> keepalive periodico a cada ~10s (frame de 19 bytes com contador incrementando; client ecoa
     com contador ligeiramente diferente -- cada lado tem seu PROPRIO contador independente,
     nao e' um eco literal)
  -> ~30s depois (tempo que o usuario levou pra clicar!), client manda "02 07" (PEDIDO DE TROCA
     DE CANAL) escolhendo servidor=42/canal=1 (DIFERENTE do "default" 28/3 do chanwait -- o
     usuario escolheu outro servidor na UI!)
  -> servidor responde "02 07" com o endereco do proximo hop
  -> client fecha com RST

HOP 3 "servidor de mundo" (85.119.149.225:10101, veio da resposta do 02 07)
  -> login request (365b, quase identico) -> handshake -> middle(294b, dessa vez TODO ZERADO)
  -> pacote NOVO sig `02 0b` (27 bytes payload) -- ainda nao decodificado
  -> pacote NOVO sig `02 01` (399 bytes payload, quase todo zero nesse teste -- PROVAVEL lista de
     personagens vazia, ja que essa conta de teste nao tem personagem)
  -> keepalive periodico continua (19 bytes, contador incrementando)
```

**Campo de endereço do pacote `02 07` CONFIRMADO 100%** (request `#123249` / response `#123821`,
24 bytes de payload cada):

```
offset 0   sig          = 02 07
offset 2   unknown3     = 3 bytes, sempre 00 00 00
offset 5   seq          = 1 byte, contador incremental da conexao (varia a cada mensagem)
offset 6   pad          = 1 byte, 00
offset 7   server_id    = u16 LITTLE-ENDIAN (request: 2a 00 = 42; a resposta ecoa o mesmo)
offset 9   channel_idx  = u16 LITTLE-ENDIAN (request: 01 00 = canal 1)
offset 11  pad3         = 3 bytes, 00 00 00 (so na resposta -- no request e' so zeros ate o fim)
offset 14  endereco     = 6 bytes: IP com OCTETOS INVERTIDOS (4 bytes) + porta BIG-ENDIAN (2
                          bytes) -- CONFIRMADO: `e1 95 77 55 27 75` invertido/decodificado da'
                          exatamente `85.119.149.225:10101`, o proximo endereco conectado de
                          verdade na captura. Bate EXATAMENTE com a tecnica que ja tinhamos usado
                          com sucesso no nosso proprio teste (ver secao anterior).
offset 20  trailer      = 2 bytes ainda nao entendidos (varia: `2a 76` num exemplo, `41 cd` noutro
                          -- nao parece ser so o server_id repetido; talvez um nonce/checksum, nao
                          bloqueou nosso teste anterior quando deixamos arbitrario)
```

**Achados extras importantes**:
- O `server_id`/`channel_idx` do "02 07" (request do usuario) pode ser DIFERENTE do "default" que
  veio no `chanwait` (aqui: chanwait tinha 28/3, mas o usuario escolheu 42/1 na UI) — confirma que
  o chanwait so serve pra iniciar a conexao AUTOMATICA de preview (que sempre traz a lista
  completa de qualquer forma), e o "02 07" e' quem de fato importa pra decidir aonde ir.
- O "keepalive" periodico (frame de 19 bytes, ~a cada 10s) usa contadores INDEPENDENTES em cada
  lado (nao e' eco fiel) -- e' so um heartbeat de conexao viva, nao carrega dado relevante.
- Descobertos dois pacotes NOVOS que vem depois de conectar no "servidor de mundo" (hop 3):
  `02 0b` (27 bytes) e `02 01` (399 bytes, provavel lista de personagens) -- formato ainda nao
  decodificado, fica pra próxima sessão.

**Conclusão**: a cadeia de rede do jogo agora está mapeada do login até a entrada no "servidor de
mundo" via captura real. O próximo trabalho é decodificar `02 0b`/`02 01` e continuar montando o
servidor de mundo de verdade -- trabalho de construção de features novas, não mais debugging de
um bug.

### 🎯 Implementado o "servidor de mundo" (hop 3) de teste — funciona ponta a ponta

Criamos `D:\RoshEmulator\server\world_server.py` replicando a sequência do hop 3 (handshake +
frame do meio zerado + `02 0b` + `02 01`/lista de personagens vazia, todos com bytes exatos da
captura real). Conectamos tudo: `login_server.py` responde `02 07` apontando pro
`channel_server.py`; **`channel_server.py` agora TAMBÉM responde `02 07`** (adicionamos
`build_channel_change_response` lá, mesma técnica do IP invertido + porta big-endian) apontando
pro `world_server.py`. Testado de ponta a ponta: login → servidor → canal (3 canais reais) →
mundo (hop 3) → cliente manda um pacote novo (`02 06`, 16 bytes) que ainda não tratamos.

### 🎯🎯🎯🎯🎯 Captura real ATÉ DENTRO DO MUNDO — criação de personagem decodificada

Fizemos mais uma captura real (`real_flow_charlist.pcapng` → `real_flow_charlist_dedup.txt`),
dessa vez indo além: como a conta usada não tinha personagem, o usuário criou um personagem
("teste", classe Maga) e entrou de verdade no mundo (andando, HUD completo, popup de prêmios de
login). Isso revelou o restante da cadeia:

```
HOP 3 (servidor de mundo) -- login -> handshake -> middle(294, zerado) -> 02 0b(27) + 02 01(399,
   lista de personagens VAZIA)
<usuario preenche a tela de criacao de personagem, ~30-40s de keepalive>
CLIENT manda 02 02 (49 bytes) -- PEDIDO DE CRIACAO DE PERSONAGEM
<pequena pausa>
SERVIDOR manda 02 0b(27, IDENTICO a antes) + 02 01 (419 bytes, AGORA COM 1 PERSONAGEM)
<mais keepalives, sem novidade nesta captura -- pararia aqui pra nao gastar mais escopo>
```

**Pacote `02 02` (pedido de criação de personagem) DECODIFICADO** (45 bytes de payload,
`#139998`):

```
offset 0   sig         = 02 02
offset 2   unknown3    = 00 00 00
offset 5   seq         = 1 byte (06)
offset 6   class_id    = u16 LITTLE-ENDIAN (58 02 = 0x0258 = 600 -- classe "Maga" escolhida)
offset 8   aparencia   = 8 bytes, TODOS ZERO nesse teste (cara/cabelo/cor = "Tipo A" default,
                          sem mudar nada na tela)
offset 16  raca_arma?  = u16 LITTLE-ENDIAN (11 00 = 17 -- possivelmente codigo combinado de
                          raca+arma+genero, ja que a Maga tem raca "Seroína"/arma "Ceptro"/genero
                          "Feminino" fixos por classe)
offset 18  pad         = 1 byte, 00
offset 19  name_len    = u16 LITTLE-ENDIAN (numero de caracteres do nome)
offset 21  name        = UTF-32 LITTLE-ENDIAN, 4 bytes por caractere (ex: "teste" = 74 00 00 00
                          65 00 00 00 73 00 00 00 74 00 00 00 65 00 00 00)
offset 21+4*name_len    terminador, 4 bytes zero
```

**Pacote `02 01` (lista de personagens) — campos identificados comparando vazio vs. populado**
(361 bytes de payload quando populado, vs 391 quando vazio -- tamanho VARIA com o numero de
personagens, não é fixo):

```
offset 0   sig            = 02 01
offset 2   unknown3       = 00 00 00
offset 5   seq            = 1 byte
offset 6   slots?         = u16 LITTLE-ENDIAN (01 00 -- igual em vazio E populado, deve ser
                             "numero de slots disponiveis", nao contagem de personagens reais)
offset 8   character_id?  = 4 bytes (00 29 9e 00 -- só aparece quando ha personagem; candidato
                             forte a ID unico do personagem, gerado no create)
offset 15  class_id       = u16 LITTLE-ENDIAN (58 02 = 600 -- MESMO valor da criacao, confirma
                             offset e formato)
offset ~120 stats?         = 4 valores u32 LE em sequencia, espacados por +400 (0000a411=42001,
                             0000a5a1=42401, 0000a731=42801, 0000a8c1=43201) -- padrao suspeito
                             demais pra ser lixo, mas significado exato NAO confirmado (talvez
                             thresholds de EXP por nivel, ou stats base)
offset ~144 outro campo    = 4 bytes (81 3e 00 00 = 16001) -- significado nao confirmado
offset ~332 name_len       = u16 LITTLE-ENDIAN (05 00 = 5)
offset ~336 name           = UTF-32 LE, mesmo formato do 02 02 ("teste")
```

**Avaliação**: já temos o suficiente pra tentar montar um `02 01` de resposta com 1 personagem
fake no `world_server.py` (copiando os bytes reais como template, só trocando o nome) -- não
precisamos entender 100% dos campos intermediários (stats etc.) pra ter uma primeira versão
funcional, mesma estratégia que já funcionou bem a noite toda (replicar bytes reais como template
em vez de reconstruir do zero). Isso é o próximo passo natural se continuarmos essa frente.

**Arquivos desta descoberta**: `captures/real_flow_charlist.pcapng` (captura bruta, 232785
pacotes) → `captures/real_flow_charlist_dedup.txt` (parseado, portas 10101/10102, deduplicado).

### 🎯 VALIDADO — lista de personagens fake aceita pelo client de verdade

Implementamos `build_0x0201_frame_populated()` no `world_server.py` usando os bytes EXATOS
capturados (personagem "teste", classe Maga/600) como molde. Testamos de ponta a ponta
(`login_server` → `channel_server` → `world_server`, todos os 3 rodando) e **a tela "Selecione o
personagem" renderizou perfeitamente** com o personagem fake (nível 1, visual de Feiticeira
correto) — confirma que o formato decodificado do `02 01` está certo o suficiente pro client
aceitar sem crashar.

**Próxima trava**: ao tentar ENTRAR com esse personagem (clicar nele + confirmar), o client deu
"Desconectado do servidor (10038)" de novo — precisa de mais um pacote de request/response que
ainda não identificamos (o "pedido de entrar no mundo com o personagem X"). Mesma dinâmica de
sempre: cada camada nova exige uma nova captura real focada nesse próximo passo específico.

**Avaliação geral da sessão**: a essa altura, isso é claramente a construção de um servidor de
mundo completo (login → servidor → canal → mundo → personagem → entrada no jogo), não mais
"debugar o crash original" (que já está 100% resolvido e confirmado há várias seções acima). Cada
etapa nova segue o MESMO padrão de trabalho que já provou funcionar a noite toda: capturar o fluxo
real com `pktmon`, decodificar o pacote novo, implementar no servidor de teste, validar. Ferramentas
e metodologia já validadas o suficiente pra continuar essa construção em qualquer sessão futura.

### ⚠️ PENDÊNCIA CONHECIDA — barra de "carregando mundo" aparece cedo demais no nosso teste

O usuário notou (e confirmou comparando contra o servidor real) uma diferença real de
comportamento: no fluxo real, a atualização de "1 canal" pra "lista completa de canais" acontece
como uma tela só, limpa, sem nenhuma barra de carregamento aparecendo no meio. No nosso teste, a
barra "AGORA CARREGANDO" aparece JUNTO com a atualização da lista de canais — ou seja, nosso
`channel_server.py`/fluxo está disparando o início do "carregamento de mundo" prematuramente,
antes do usuário efetivamente escolher e confirmar um canal específico.

**Causa provável (não confirmada)**: o `channel_server.py` manda `handshake + middle2 +
channel_list` TUDO JUNTO num único `sendall()` (um só pacote/segmento TCP), enquanto a captura
real mostra o servidor de verdade mandando `middle2` e `channel_list` como DOIS pacotes PSH
separados (mesmo que consecutivos). Vale testar separar esses sends (`conn.sendall(handshake)`,
depois `conn.sendall(middle2)`, depois `conn.sendall(chanlist_frame)`, como já fizemos no
`login_server.py`) pra ver se isso resolve a barra aparecendo cedo demais. Não impede o
funcionamento (já validamos que a sequência inteira funciona até a lista de personagens), é uma
questão de fidelidade/timing, não um bloqueador.

**ATUALIZAÇÃO**: aplicamos a correção (sends separados) no `channel_server.py` e testamos de novo
-- a barra "AGORA CARREGANDO" AINDA aparece, e persiste até mesmo voltando pra tela de lista de
servidores (clicando "Selecione o servidor"). Isso muda a explicação: **não é sobre a ordem dos
sends** -- é porque o client parece iniciar um pedido de entrada automático usando o canal padrão
do `chanwait` assim que conecta no hop 2, e essa conexão de fundo fica tentando terminar
indefinidamente porque nosso `world_server.py` ainda é só um stub incompleto (não implementa o
próximo pacote real, `02 04`, ver seção abaixo). **Conclusão**: resolver isso não depende de mexer
no `channel_server.py` -- depende de completar o suporte ao pacote `02 04` que descobrimos logo
a seguir. Assim que isso for implementado (ainda que parcialmente), a barra deve parar de ficar
presa.

### 🎯🎯🎯🎯🎯🎯 Pacote `02 04` decodificado — "entrar no mundo com o personagem"

Capturamos mais um fluxo real completo (`real_flow_enterworld2.pcapng` →
`real_flow_enterworld2_dedup.txt`), dessa vez selecionando o personagem "teste" já existente e
entrando de verdade no mundo. Depois da lista de personagens (`02 0b`+`02 01`, igual sempre), teve
uma pausa de ~2.7s (tempo do usuário clicando pra selecionar+confirmar o personagem) e então:

**Pacote `02 04` (pedido de "entrar no mundo com o personagem"), CLIENT -> SERVIDOR, 14 bytes de
payload, formato SIMPLES e limpo**:

```
offset 0   sig            = 02 04
offset 2   unknown3       = 00 00 00
offset 5   seq            = 1 byte
offset 6   pad            = 1 byte, 00
offset 8   character_id   = 4 bytes -- IDÊNTICO byte a byte ao campo "character_id" já
                             identificado na lista de personagens (offset 8 do `02 01`,
                             ex: `00 29 9e 00`) -- confirma que aquele campo É MESMO o ID único
                             do personagem, e que o client so precisa reenviar esse ID pra pedir
                             entrada no mundo (nao precisa reenviar nome/classe/etc, so o ID).
offset 12  pad2           = 2 bytes, 00 00
```

**Depois disso, o servidor responde com uma AVALANCHE de dados** (`02 05` de ~1400+416+223
bytes, depois mais pacotes `02 0a`? e outros, com floats IEEE754 em sequência — claramente
POSIÇÃO/coordenadas X-Y-Z, stats, e provavelmente dados de spawn de outras entidades/NPCs
próximos). **Isso já é o protocolo de GAMEPLAY DE VERDADE** (posição, física, sincronização de
mundo) — uma escala de trabalho completamente diferente de tudo que fizemos até agora (que era só
"handshake/menu/autenticação"). Implementar isso de verdade significa construir um servidor de
jogo completo (posições, movimento, inventário, combate, etc.) — um projeto por si só, não mais
"o próximo pacote a decodificar".

**Conclusão da sessão**: mapeamos com sucesso TODA a cadeia de conexão/autenticação/menu do jogo,
do login até o pedido de entrada no mundo (`02 04`), incluindo a criação de personagem. Esse é um
limite natural e apropriado pra parar essa frente de engenharia reversa por agora — o que vem
depois (`02 05` em diante) é o protocolo de gameplay em si, que precisa de uma abordagem de
projeto bem diferente (provavelmente mapear devagar, sistema por sistema: movimento, depois chat,
depois combate, etc., cada um com sua própria sessão de captura+decodificação).

### 🌙 ESTADO FINAL da sessão de 21/08 (madrugada) — pra retomar direto na próxima

**O que ficou rodando** (login_server.py, channel_server.py, world_server.py, todos com
`python -u`, logs em `captures/*_stdout.log`) e **`ServerList.ten` apontando pro nosso servidor
local** (`127.0.0.1:19999`) — rodar `python redirect_to_local.py` se precisar reafirmar, ou
restaurar `ServerList.ten.orig_backup` pro servidor real quando for capturar mais tráfego real.

**Tentativa final da noite (não resolvida)**: depois de decodificar o pacote `02 04` (entrar no
mundo com personagem), tentamos fazer o `world_server.py` responder com o stream real capturado
(`captures/enterworld_stream.bin`) e testamos exaustivamente (múltiplos restarts limpos,
via site E via `alpha gg 0`). Resultado inconsistente: a MESMA lista de personagens (365 bytes,
bytes exatos que já tinham funcionado antes nessa mesma sessão) ora renderizava a tela
"Selecione o personagem" corretamente, ora não renderizava nada (ficava preso em "1% carregando"
indefinidamente), sem nenhuma mudança de código entre as tentativas. **Suspeita forte: degradação
do ambiente RDP/VM** de tanto relançar o `AMo.exe` nessa mesma sessão ao longo de várias horas
(mesmo padrão de instabilidade já visto antes hoje — janela não abrindo, processo ficando em
~14MB). **Recomendação pra próxima sessão**: reiniciar a sessão RDP (ou a VM) antes de retomar
testes ao vivo, pra garantir um ambiente limpo — o protocolo em si (até onde mapeamos) parece
correto; o gargalo virou o ambiente, não mais a lógica.

**Também tentamos e revertemos**: reescrever o byte de sequência do pacote `02 01` (de `9` pra
`4`, pra "continuar" a sequência da nossa própria conexão) — piorou (nem renderizou a tela).
Revertido pro valor original capturado (`9`). Não confirmamos se sequência importa ou não; fica
em aberto.

**Próximo passo direto quando retomar**: com ambiente limpo, testar de novo se o `world_server.py`
(já com o handler de `02 04` implementado, respondendo com `enterworld_stream.bin`) consegue
levar o personagem pra dentro do mundo de verdade. Se funcionar, o próximo trabalho é mapear os
~20 tipos de pacote novos descobertos nesse stream (`02 05`, `0a 0f`, `04 04`, `0f 11`, `16 01`,
`08 07/08/00/01/02`, `09 02/04`, `05 05/0c`, `06 05/25/28`, `0d 0f`, `0e 0c`, `0a 0c`) — trabalho
de "construir servidor de jogo", não mais de debugging.

### 🎯 Sessão de 22/08 (manhã) — keepalive implementado e validado, mistério da "tela dupla" reaberto e reconsiderado

**Achado 1 (CONFIRMADO por captura real com timestamps)**: as conexões do nosso
`channel_server.py`/`world_server.py` estavam resetando sozinhas (pelo client) poucos segundos
(1.1s-3.8s) depois de mandar dado, SEM o usuário ter pedido nada ainda. Implementamos keepalive
periódico (frame de 19 bytes, formato do handshake, com contador incrementando) em ambos —
**funcionou**: as conexões passaram a durar dezenas de segundos, com o client ecoando cada
keepalive normalmente. Timeout curto (3s no hop2, 0.5s no hop3) + até 240 rounds de tolerância
(120s total) antes de desistir de vez.

**Achado 2 (capturas reais novas, `real_flow_autoconnect_dedup.txt`, com um teste onde
deliberadamente esperamos 5-6s parados na tela de servidores antes de clicar)**: descobrimos que
a teoria de "hop 2 conecta automaticamente logo após o `chanwait`, sem esperar clique" **estava
ERRADA** (contradiz o que a análise de ontem sugeria). Na captura de hoje, o hop 2 só conectou
**~40 segundos** depois do `chanwait` — bem depois do usuário efetivamente clicar. Ou seja, o hop 2
SEMPRE espera o clique do usuário, tanto no nosso servidor quanto no real — isso é normal, não é
bug nosso.

**Achado 3**: depois de entregar a lista de personagens, o servidor real levou só ~5.5s até o
client mandar "02 04" (usuário clicando "Jogar") — **sem mandar nenhum keepalive nesse meio
tempo**. Isso enfraquece a teoria de que keepalive é *a* causa raiz (o servidor real não precisa
disso pra aguentar alguns segundos parado) — mas como o keepalive **funciona na prática pra nós**
(evita os resets prematuros que estávamos vendo em 1-4s), mantivemos ele mesmo sem entender 100%
o mecanismo exato. Possível explicação alternativa não confirmada: algo no nosso lado (buffering
do socket Python, ou o próprio ato de mandar QUALQUER byte) desbloqueia um estado travado, e o
conteúdo específico do keepalive é incidental.

**Mistério da "tela dupla" (placeholder "Canal 2/Baixo" → depois lista completa) continua SEM
explicação definitiva.** Confirmado (com vídeo real, `bandicam 2026-08-22 01-26-47-568.mp4`) que
o servidor real NÃO mostra essa tela dupla — vai direto pra lista completa e populada. Tentamos:
(a) trocar o IP embutido no `chanwait`/resposta do "02 07" de `127.0.0.1` (loopback) pro IP real
da máquina (`144.217.65.115`) — não resolveu; (b) buscar via Ghidra/RTTI o handler de
`CChannelWaitLoginPacket` pra entender a logica de "auto-connect vs esperar clique" — deu numa
vtable inválida/lixo, sem resultado. **Não resolvido, mas parece ser puramente cosmético** (não
bloqueia o fluxo funcional) — deixado de lado por enquanto pra focar em fechar o fluxo completo
até a entrada no mundo.

**IP de teste ATUAL**: `login_server.py`/`channel_server.py` estão configurados com
`HOP2_HOST`/`WORLD_HOST = "144.217.65.115"` (IP real da máquina, não mais loopback) — lembrar de
reverter pra `127.0.0.1` se isso causar problema de rede/firewall em sessões futuras (não deveria,
já que é o IP da própria máquina, mas fica registrado).

### 🔬 Sessão de 22/08 (tarde) — descompilação de verdade do fluxo "Jogar" (a pedido explícito do usuário)

Depois de 6 tentativas frustradas de resolver a "tela dupla" só mexendo em dados/bytes, o usuário
pediu explicitamente pra parar de tentar-e-errar e **descompilar de verdade** o cliente (`AMo.exe`)
via Ghidra headless, focando em fazer o botão "Jogar" funcionar. Usamos a técnica de RTTI (procurar
a string mangled `.?AV<Nome>@@` no binário, validar o vtable resultante checando se os primeiros
slots apontam pra código válido dentro de `0x00400000`-`0x00d44000`) pra achar classes C++ de
verdade em vez de adivinhar nomes. Resultado: mapa completo do fluxo, do clique até o pacote de
rede, incluindo a causa mais provável de por que o personagem não fica selecionável.

**Mapa de opcodes confirmado via RTTI** (classe → opcode → função de Parse):

| Opcode | Classe | Parse |
|---|---|---|
| `02 01` | `CLoginCharViewInfoPacket` (lista de personagens) | `FUN_00650aac` @ `0x650aac` |
| `02 02` | `CLoginCreateCharPacket` (criar personagem) | `FUN_00647f67` |
| `02 03` | `CLoginDeleteCharPacket` (deletar personagem) | `FUN_006506ec` |
| `02 04` | `CLoginStartCharPacket` (**"Jogar" / entrar no mundo**) | `FUN_005ab98b` |
| `02 05` | `CLoginCharInfoPacket` (rajada de dados ao entrar no mundo) | `FUN_00650710` |
| `02 06` | `CChannelWaitLoginPacket` **e** `CLoginChannelListPacket` (mesma assinatura, classes diferentes) | `0x65081f` / `0x65083b` |
| `02 07` | `CLoginChannelChangePacket` (pedido de troca de canal) | `FUN_006507ba` |

**A tela de seleção de personagem é a classe `CGuiChrSelect`** (confirmado pelo construtor,
`FUN_005ad2d1` — grava `*this = CGuiChrSelect::vftable`). Vtable em `0x0080e8e4`. Campos
relevantes do objeto (offsets em bytes a partir do `this`):

- `+0x1b4` a `+0x1c0`: array de 4 ponteiros, um por slot de personagem (dados do personagem
  carregado naquele slot, ou `0` se vazio).
- `+0x1c4`: **índice do personagem selecionado atualmente** (`-1` = nenhum selecionado). É
  exatamente o campo que o código de "mandar pacote de entrar no mundo" checa antes de fazer
  qualquer coisa.

**Cadeia completa do clique em "Jogar" até o pacote `02 04` sair pela rede:**

1. `FUN_0043a3cc` — handler de clique do botão "Jogar" (fora da classe `CGuiChrSelect`, no frame
   externo). Primeiro chama `FUN_004521c1(0x7562)` — uma busca genérica de "controle pela ID"
   dentro da árvore de UI atual (`0x7562` = a ID da própria tela `CGuiChrSelect`, criada por
   `FUN_00446866` quando o cliente troca pra essa tela). Se o controle existir (ou seja, a tela de
   personagem está mesmo montada), chama `FUN_005ad352`.
2. `FUN_005ad352` — construtor do pacote de verdade. Só faz algo `if (*(this+0x1c4) != -1)`
   (**se tiver um personagem selecionado**). Extrai o character_id do slot selecionado, monta o
   pacote via `FUN_005ad289` e manda com `FUN_0064458f`.
3. Existe um **segundo caminho** equivalente, dentro da própria `CGuiChrSelect`: o dispatcher de
   mensagens da classe é o slot 15 da vtable (`FUN_005ad49c`) — recebe `(msg, remetente)`. Quando
   `msg == 0x20a` e o remetente é a própria tela (auto-mensagem), roda a mesma lógica (checa
   `+0x1c4 != -1`, extrai character_id, `FUN_005ad289` + `FUN_0064458f`). Esse `0x20a` é disparado
   quando o usuário clica no **botão "Jogar" interno do próprio painel** (um dos 4 botões
   filhos que esse dispatcher reconhece: índice 0 = ação quando não há nenhum personagem, índice
   1 = "Jogar" [manda `0x20a` pra si mesma], índice 2 = "Deletar", índice 3 = "Criar personagem").

**A peça que faltava — quem seleciona um personagem:** `FUN_005ab9c9(this, slot_index)`. Faz
literalmente `*(this + 0x1c4) = slot_index` (com uma animação de "desmarcar o anterior /
destacar o novo" em volta). Chamada em 3 contextos:
- Depois de deletar um personagem, auto-seleciona o primeiro slot restante.
- Ciclar pelos slots (provavelmente Tab/atalho de teclado).
- **`FUN_005ac6f8` (vtable slot 8 de `CGuiChrSelect`) — o handler de picking 3D do mouse.**

**Achado potencialmente crítico**: `FUN_005ac6f8` faz **ray-casting 3D** contra os modelos dos
personagens renderizados na cena de seleção (chama `FUN_005a669d`/`FUN_005ac397`, testa contra tipo
de objeto `0xf` via `FUN_0069e558`). **A seleção de personagem nesse cliente não é um clique em
botão 2D — é literalmente clicar no modelo 3D do personagem parado na cena.** Só quando esse "pick"
acerta um personagem é que `FUN_005ab9c9` é chamada com o índice certo. Isso significa que, mesmo
com a lista de personagens renderizando o nome/ícone corretamente, **se o modelo 3D do personagem
não estiver spawnando/renderizando na posição clicável da cena, o clique nunca seleciona nada** —
e por isso "Jogar" nunca manda o pacote (`+0x1c4` fica `-1` pra sempre), consistente com o sintoma
relatado ("abri pelo site, mas não chega no personagem").

**Também confirmamos que o pacote `02 01` que já mandamos está com o formato correto** (decompilamos
o `Parse` de verdade): opcode (`02 01`) + 4 bytes de padding + 1 byte "é o último registro?" (offset
6) + character_id em 4 bytes **big-endian/network order** (offset 7-10, via `ntohl`) + blob fixo de
0x175 (373) bytes de dados do personagem (offset 0xF) + tamanho do nome em 2 bytes big-endian
(offset 0x184) + nome em UTF-32LE, 4 bytes por caractere, mas só os 2 bytes baixos de cada são
usados (efetivamente vira UTF-16LE) (offset 0x187+). Conferimos byte a byte o
`_POPULATED_0201_HEX` que já usamos em `world_server.py` contra esse layout: bate 100% (inclusive a
flag de "último registro" no offset 6 = `01`), porque ele foi capturado de uma resposta real do
servidor de verdade, não escrito à mão. **Ou seja: o pacote de lista de personagens já está correto
e chega no handler certo** (confirmamos também que `FUN_00651331`, o handler que processa esse
pacote e finaliza a tela, é referenciado só a partir de uma tabela de dispatch de opcodes — dispatch
de verdade, não código morto).

**Conclusão prática / próximo passo recomendado**: como os dados que mandamos já são bytes reais
capturados (não inventados), a hipótese mais provável agora não é mais "o pacote está errado", e
sim "o modelo 3D do personagem não está aparecendo/clicável na cena de seleção" — o que pode ser
por causa de dados que faltam na sequência (posição inicial da câmera, algum pacote extra que o
servidor real manda e a gente não mapeou ainda, ou simplesmente o `02 0b` de 23 bytes cujo formato
ainda não entendemos). **Teste concreto pra fazer na próxima sessão ao vivo**: ao chegar na tela de
personagem, tentar clicar bem em cima do modelo 3D do personagem (não só em qualquer lugar do
painel) e ver se aparece algum efeito visual de "selecionado" (glow/anel — o código de
`FUN_005ab9c9` liga um efeito de estado `0x38a`/`0x38b` no personagem selecionado). Se nem isso
acontecer, o problema está confirmado como sendo no lado do render 3D da cena, não no protocolo de
rede em si — e o próximo alvo de investigação vira o pacote `02 0b` (ainda não decodificado) e
qualquer outro dado de cena/câmera que o servidor real manda nessa janela.

### ✅ Sessão de 22/08 (tarde) — "Jogar" FUNCIONANDO de ponta a ponta

**Causa raiz do travamento em "1% carregando" encontrada e corrigida.** A captura real
(`real_flow_channel_enter_dedup.txt`, pacotes #124101-#124108) mostrou algo que a gente tinha
passado batido: o servidor real manda o par `02 0b` (27 bytes) + `02 01`/lista de personagens
(399 bytes) **QUATRO VEZES seguidas** (uma por slot de personagem, a conta tinha 4 slots vazios
nessa captura específica) — a gente só mandava **UM** par. O `world_server.py` foi corrigido pra
mandar os 4 pares sempre.

Isso revelou uma segunda pegadinha, achada relendo `FUN_00651331` (o handler real do opcode
`02 01`, confirmado via decompilação): o byte 6 do payload ("é o último registro?") **não pode**
vir ligado em todos os 4 -- só o time que carrega essa flag decide se a tela finaliza mostrando
"criar personagem" (se o character_id DAQUELE registro específico for 0) ou "personagem
carregado, habilita Jogar" (se for != 0). Mandar os 3 vazios com a flag ligada em cada um (copiando
literal da captura de conta 100% vazia) fazia o cliente mostrar "Crie um personagem" mesmo com o
"teste" processado antes. **Fix**: os 3 registros vazios vão SEM a flag (viram no-op), e o
personagem de verdade vai por ÚLTIMO, com a flag ligada.

Com isso: a tela "Selecione o personagem" renderiza de verdade, com o modelo 3D do personagem, os
4 slots, e os botões Jogar/Novo personagem/Remover. Clicando no modelo (picking 3D, confirmado
nesta mesma sessão) + "Jogar", o cliente manda `02 04` de verdade -- confirmado no log:
`[11:12:43.668] [hop3] recebido 02 04 (entrar no mundo) -- respondido com o stream real capturado
(12123 bytes)`. O cliente mostra a tela de carregamento do mundo ("AGORA CARREGANDO", 100%).

**Pendências conhecidas (não bloqueiam mais o fluxo principal)**:
- O nome do personagem ("teste") não aparece no slot da lista (só um indicador tipo "Lvl. 1"/"Ur.
  1") -- os bytes do nome estão presentes no payload capturado (confirmado via decode manual, UTF-32LE
  "teste" no final do `_POPULATED_0201_HEX`), então é possível que seja outro campo/mecanismo de
  exibição ainda não mapeado, não falta de dado.
- A tela dupla de canal (placeholder "Canal 2/Baixo" antes da lista real) continua sem explicação
  definitiva -- a tentativa de investigar via "tabela de dispatch" nessa mesma sessão foi um beco
  sem saída (não é dispatch por opcode, provavelmente só a seção geral de vtables do binário).
  `ROSH_REWRITE_HOP2=1` (variável de ambiente que reescreve o IP do hop2 embutido no chanwait pro
  nosso `channel_server.py` em vez do servidor real) foi testado e NÃO resolveu a tela dupla nem
  mudou o comportamento visível -- mantido ligado mesmo assim (é estruturalmente mais correto),
  mas a causa real da tela dupla segue desconhecida.
- O que acontece DEPOIS da tela de "carregando 100%" ainda não foi testado/confirmado (o handler
  de `02 04` só reproduz um stream capturado fixo de ~12KB, não é uma simulação de mundo de
  verdade -- é esperado que trave ou pare de fazer sentido em algum ponto depois do loading).

**Durante essa investigação, o usuário colou várias respostas de outra IA/ferramenta externa (sem
acesso às nossas capturas) com "diagnósticos" que contradiziam dado real já validado** (ordem de
bytes do IP no `02 07`, alegação de que `channel_server.py` não loga conexão, sugestão de remover
o `02 06` do envio inicial, e um pacote `02 0b` sintetizado do zero com campos inventados). Nenhuma
delas foi aplicada -- todas foram checadas contra código/captura real primeiro e descartadas com
evidência concreta. A causa real (achada por decompilação + comparação direta com captura real)
não tinha nenhuma relação com o que essas sugestões propunham.

### 🎣 Hook de rede real (`tools/hook/hook.dll` + `injector.exe`)

Construído nessa mesma sessão: DLL "freestanding" (sem CRT, só depende de `kernel32.dll`) que
faz patch direto na IAT do `AMo.exe` pra hookear `WSASend`/`WSARecv` **e** `GetQueuedCompletionStatus`
(o jogo usa IOCP -- sem hookear isso, `RECV` fica invisível, já que os bytes só existem depois que
o `GetQueuedCompletionStatus` devolve o mesmo ponteiro `OVERLAPPED` passado ao `WSARecv`; `SEND` não
tem esse problema, o conteúdo já está pronto na hora da chamada, overlapped ou não). Endereços de
IAT hardcoded pra essa build específica (`0x007f646c`=WSASend, `0x007f6470`=WSARecv,
`0x007f618c`=GetQueuedCompletionStatus -- só válido enquanto o binário não mudar, já que não tem
ASLR). Loga tudo em `C:\RoshHook\traffic.log`. `injector.exe` espera o processo `AMo.exe` aparecer
e injeta via `CreateRemoteThread`+`LoadLibraryA`.

Restaurado o `ServerList.ten` original (backup em `ServerList.ten.orig_backup`) pra testar contra o
servidor REAL de produção -- funcionou, captura completa desde o login até dentro do mundo (~1.4MB,
16k+ linhas). Parser em `captures/parse_hook_capture.py` remonta os frames por socket/direção
(`captures/hook_parsed/`).

**Achado da captura real, sobre a tela dupla de canal**: o `chanwait` real tinha `server_id=28,
channel_idx=99` como "canal padrão" -- só que o servidor 28 na lista real só tem canais 1/2/3, não
99. Ou seja, **o mismatch entre o "canal padrão" do chanwait e a lista de canais real é NORMAL**,
acontece no servidor de verdade também -- isso invalida a teoria (aplicada antes nessa sessão) de
que "consertar" esse mismatch resolveria a tela dupla. A causa continua desconhecida.

**Lista de canais restaurada com dados 100% reais** (`channel_server.py`): os 5 servidores originais
(42, 41, 28, 22, 1) com os canais/IPs/portas/status exatos da captura -- conferido que nosso encoder
reproduz os 282 bytes reais **byte-a-byte idênticos**. A simplificação pra 1 servidor só fazia
sentido durante a depuração do travamento em "1%", que já foi resolvida -- não tem mais motivo pra
manter dado simplificado.

**Catálogo de opcodes pós-"Jogar" vistos na captura real** (frequência, pra priorizar o que
implementar primeiro no `world_server.py`): `04 01` (7284x -- provável sync de posição/movimento,
de longe o mais frequente), `04 02` (694x), `04 03` (665x), `08 06` (256x), `06 5c` (28x), `08 01`
(19x), `05 00` (12x), depois vários de baixa frequência (`06 03`, `05 03`, `08 02`, `06 04`, `08 09`,
`03 02`, etc.) -- provavelmente eventos pontuais (chat, stats, spawn/despawn de outros jogadores).
Isso é trabalho de "construir servidor de mundo", escopo bem maior que o debugging de hoje --
continua na próxima seção.

### 🌍 Tentativa de "sair do loading" pós-Jogar -- limite real de escopo encontrado

Depois de trocar `enterworld_stream.bin` por um V2 mais completo (500 frames reais, começando do
`02 05`/char info em vez do stream antigo), o mundo passou a **renderizar de verdade atrás da carta
de "Agora Carregando"** (paisagem, cavalo, cidade ao fundo -- confirmado por screenshot do
usuário). Mas a carta de loading não some.

**Diagnóstico via captura real** (correlacionando timestamps SEND/RECV do hook): nos primeiros 5
segundos depois do `02 04`, o servidor real já manda **centenas de pacotes `04 01`/`04 02`/`04 03`
por segundo** -- sincronia contínua de posição/spawn/despawn de NPCs/vida ambiente do mapa. Não é
uma rajada finita que termina; é um fluxo vivo e constante desde o primeiro segundo.

**Teste feito**: implementei uma thread separada (`_loop_sender` em `world_server.py`, com lock
pra serializar os `sendall()` entre ela e a thread principal) que reenvia em loop ~410KB de tráfego
real capturado (6000 frames), tentando simular "presença constante de tráfego" sem se importar com
o conteúdo fazer sentido. **Resultado: piorou** -- conexão caiu em ~9s (contra ~19s só com a rajada
estática). Explicação provável: os pacotes reais referenciam IDs de entidade específicos de UMA
sessão real específica (spawnados via `04 02` naquele contexto); replayar isso fora de ordem/contexto
na NOSSA sessão gera referências a entidades que "não existem" do ponto de vista do cliente,
confundindo-o mais rápido que silêncio simples. **Desligado** (código comentado, não removido, pra
referência futura).

**Conclusão de escopo**: fazer o cliente sair de vez do "carregando" e ficar de fato jogável exige
simular estado de verdade (spawn/despawn consistente, IDs de entidade coerentes, resposta a
`04 00`/`08 09` que o próprio client manda) -- isso é **construir um servidor de mundo de verdade**,
não mais mapear/replayar pacotes. Escopo de vários dias, não de uma sessão. O que já temos hoje
(personagem carrega, mundo real renderiza atrás do card, `02 04` confirmado funcionando) é o limite
prático razoável pra essa sessão -- próximo trabalho nessa frente precisa de planejamento dedicado
(provavelmente começar pequeno: manter SÓ o personagem do jogador vivo/parado, sem NPCs, e ver se
isso sozinho já destrava o loading, antes de tentar simular o mapa inteiro).

### 🔍 Tela dupla de canal -- nova pista real (não resolvida, mas avançou)

Retomado com dados do hook de hoje. **Achado 1 (timing)**: na captura real, o hop2 conecta só
**~150ms** depois do chanwait chegar, SEM nenhum "02 07" no hop1 antes disso -- parece auto-connect
do client, não clique do usuário. No nosso servidor, o hop1 só recebe o "02 07" (que libera o hop2)
**~29 segundos** depois do chanwait -- tempo de reação humana claro, confirmado pelo usuário: "só
sai da primeira tela quando clica pra entrar no canal 2/baixo". Ou seja: **no fluxo real essa
transição parece não precisar de clique nenhum; na nossa reprodução, precisa.**

**Achado 2 (teste com bytes reais, revertido)**: troquei nosso chanwait pelos bytes EXATOS da
captura real de hoje (só reescrevendo o endereço do hop2) -- `server_id=28`/`channel_idx=99` em vez
do nosso `42`/`1`. Resultado: o client mostrou uma tela **completamente diferente** ("Escolhendo o
mundo", mapa-múndi com nomes reais tipo "Bent-Gerot" = server_id 28!) em vez da lista simples que
aparecia antes. **Isso prova que o valor do `server_id` no chanwait NÃO é cosmético -- ele decide
qual UI inteira o client mostra** (mapa-múndi vs lista simples). Só que `server_id=28` não existe
de verdade no nosso `channel_server.py` (só o 42 está implementado), e clicar em "Bent-Gerot"
crashou o jogo. **Revertido** pra `server_id=42/channel_idx=1` (valores que sabemos funcionar) pra
não perder o fluxo do "Jogar" que já funciona.

**Não resolvido, mas a pista mais promissora até agora**: investigar por que a transição hop1→hop2
não é automática pra nós. Candidatos pra próxima sessão: (a) descompilar o handler client-side que
decide "auto-conectar vs esperar clique" baseado no chanwait (provavelmente relacionado a
`CChannelWaitLoginPacket::Parse`, que já sabemos que é `FUN_00650...`-ish, ver mapa de opcodes); (b)
testar se a tela de "mapa-múndi" É o comportamento correto pra múltiplos servidores reais, e o que
chamamos de "tela dupla" na verdade sempre foi ESSA tela (mapa-múndi) mostrando mal-formada quando
só tínhamos 1 servidor configurado -- ou seja, talvez com TODOS os 5 servidores implementados de
verdade no `channel_server.py`/`world_server.py` (não só o 42), a tela de mapa-múndi funcione
plenamente e a "tela dupla" desapareça por completo.

## 🔓 Sessão de 22/08 (continuação) — cifra do `.lod` quebrada de verdade

Retomando o pedido "tenta descompilar" pra achar os nomes reais dos 5 servidores direto do
client (sem precisar testar cada `server_id` na mão). Confirmado antes que o cabecalho `.ten`
(seed `0x3042`, `FUN_00720fba`) NÃO se aplica a `.lod` -- são mecanismos completamente
diferentes.

**Achado o loader de verdade**: `FUN_00426e92` é a função mestra que carrega TODOS os `.lod` do
jogo (mais de 100 arquivos, cada um com seu parser específico). Ela monta o path completo via
`FUN_00426b04` (só um `sprintf("%s%s", base_dir, path_relativo)`) e delega a leitura+decifragem
pra um par genérico de funções:

- `FUN_0072104a` -- "abre" o stream cifrado: lê um cabeçalho de **93 bytes** do arquivo, extrai
  uma **tabela de substituição de 31 bytes** (pegando 1 a cada 3 bytes do cabeçalho, começando no
  byte 1), usa o **último byte do cabeçalho** (byte 92) como máscara XOR de um "skip count" lido
  logo em seguida (quantos bytes pular/descartar antes do conteúdo real começar), e por fim lê 1
  byte que vira a chave inicial (`key_byte = byte_lido + 'O'`).
- `FUN_007210d7` -- o loop de decifragem byte-a-byte, uma **cifra de fluxo auto-chaveada com
  tabela** (mais sofisticada que a do `.ten`, que só tinha 1 byte de seed): pra cada byte,
  `b1 = raw XOR (tabela[contador % 31] + key_byte)`, `saida = b1 - key_byte`, e a chave é
  atualizada como `key_byte = tabela[contador % 31] + b1 + 'O'`. Se o byte decifrado é `0x00`
  (fim de string) e uma flag do stream está ativa, consome mais 1 byte extra do arquivo
  (delimitador).

Isso foi verificado **direto na disassembly crua** (não só no pseudo-C do decompiler, que tinha
convenções de chamada erradas por causa do `-noanalysis` -- ex: achava que várias funções eram
`__thiscall` com args na ordem errada). Batendo instrução por instrução ficou 100% claro.

**Confirmado contra o arquivo real** (`string.lod_pt`, 6.5MB): o consumidor de `String.lod`
(`FUN_004511d5`) lê 4 bytes (contagem de registros, int32 LE) e depois registros fixos de
`0x404`=1028 bytes (4 bytes de ID + 1024 bytes de texto terminado em NUL). Implementei em Python
([lod_crypto.py](../server/lod_crypto.py)) e rodei contra o arquivo real:

- contagem decifrada = **3212** registros → `4 + 3212*1028 = 3.301.940 bytes`, bate quase exato
  com o total decifrado (3.301.985, a diferença é só sobra de padding no fim do arquivo)
- texto sai **perfeitamente legível em português**, e os dois nomes de servidor que já
  tínhamos confirmado empiricamente aparecem exatamente onde deveriam:
  - id `1001` = **"Azmara-Blackforest"** (nosso `server_id=42`)
  - id `1028` = **"Bent-Gerot"** (o `server_id=28` que apareceu na tela de mapa-múndi)

**Ainda em aberto**: a relação `id - 1000 == server_id` NÃO é geral (bate pro 28, não bate pro
42 -- se fosse, 1001 teria que ser `server_id=1`). Ou seja, existe uma tabela de tradução
`server_id -> string_id` em algum lugar (candidato mais forte: `IE_Maplist.lod`, carregado
ANTES de `String.lod` no loader mestre, com seu próprio parser `FUN_005ce1f2` ainda não
craqueado -- tentei aplicar a mesma cifra genérica nele e o resultado não fez sentido, então o
parâmetro de "flag" ou o layout de registro dele é diferente do de `String.lod`). Sem essa
tabela, não dá pra saber com 100% de certeza quais dos ids 1001-1042 (todos nomes de mundo
plausíveis: Posetunia, Berneo, Tempteron, Bhalasong, Lupinel, Kezra, Cyrus, Victorum, etc.)
correspondem aos `server_id` 41, 22 e 1 que faltam.

**Ferramenta nova**: [lod_crypto.py](../server/lod_crypto.py) -- decifra qualquer stream `.lod`
(`decrypt_lod_stream`) e faz o parse específico de `String.lod` (`parse_string_lod` /
`load_string_lod`). Reaproveitável pra qualquer outro `.lod` do jogo, desde que se descubra o
layout de registro certo (cada tipo tem o seu, definido por uma função-parser própria).

## 🧵 Sessão de 22/08 (continuação) — atrás da tabela server_id→nome (sem sucesso ainda)

Continuando atrás dos 3 nomes que faltam (server_id 41, 22, 1). Vários becos sem saída
explorados a fundo, documentados aqui pra não repetir o caminho:

- **`IE_Maplist.lod`**: decifrado (mesma cifra), mas é só o banco de MAPAS/ZONAS do jogo
  (cidades, dungeons -- ex: id 19002="asmara", 19003="berneo"), sem relação com a lista de
  servidores.
- **`WorldMap.lod`**: também usa a mesma cifra (confirmado via `FUN_0066ec0c`), registros de
  72 bytes, mas o conteúdo parece ser sobre regiões do mini-mapa in-game (coordenadas), não a
  tela de escolha de servidor.
- **`karos.exe`** (launcher): binário Go (framework "Clotho" da GameXP,
  `gitlab.tech.gamexp.com/distribution/clotho/apps/karos/*`). Rodando ele direto trava com
  "Vulkan check fail" e depois tenta abrir `C:\Games\Karos\loader.exe`, que **não existe** nessa
  instalação (nem em `AppData\Local\Programs\GameXP\Launcher`). Confirma que esse launcher é
  só um wrapper de pré-checagem quebrado nessa instalação "crua" -- não é ele quem desenha a
  tela de seleção de mundo.
- **Achado via RTTI** (strings `.?AV...@@` + reconstrução manual de vtable, já que rodamos sem
  analysis e o Ghidra não linka essas referências sozinho -- técnica: `type_info = rtti_str-8`,
  procurar ponteiro pra ele = `CompleteObjectLocator`, procurar ponteiro pro COL = `vtable-4`):
  as classes de verdade da tela de canal: `CGuiServerList`, `CGuiServerSelectList`,
  `CGuiChannelListSel`, `CGuiCGuiChannelList`, `CLoginChannelListPacket`. Mas:
  - `CGuiServerList` monta uma lista de até 10 slots genéricos ("Ambiente N", ids de string
    tipo `0x5090`) lendo de config externo -- parece um seletor de ambiente
    debug/produção/teste, não a tela do jogador.
  - `CGuiServerSelectList`/`CGuiChannelListSel`/`CGuiCGuiChannelList`: os métodos únicos por
    classe (fora do boilerplate de GUI compartilhado) são todos sobre mouse
    hover/hit-testing (`GetCursorPos`/`PtInRect`) ou init/cleanup de widgets -- não populam
    dados dinâmicos.
  - `CLoginChannelListPacket::Parse` (`FUN_0065083b`) confirma opcode `02 06`, e lê um array
    prefixado por contagem (`count` de elementos de 2 bytes) ANTES dos campos que já
    conhecíamos (server_id/channel_idx/ip/port) -- na nossa captura real esse `count` é 0
    (array vazio), então nunca exploramos esse caminho. O que esse array representa e onde os
    campos "conhecidos" são de fato parseados (deve ser uma continuação do Parse ou uma
    chamada seguinte) ainda não foi encontrado.
  - Busca por literais 1001/1028 direto no binário: **falso-positivo** -- essas constantes
    colidem com código totalmente não relacionado (erro de lib matemática/`libm`, parser de
    arquivo SpeedTree `.spt` de vegetação, estado de modo de vídeo). Confirma que buscar por
    valor numérico literal não é confiável aqui (números pequenos demais, usados em todo canto).

**Conclusão por ora**: a lógica que de fato transforma `server_id` em nome exibido na tela
"Escolhendo o mundo" ainda não foi localizada com certeza -- é bem provável que fique numa
função ainda não visitada nessa árvore de chamadas (o "resto" do `Parse`, ou um handler que
recebe o pacote já parseado). Dado o volume de becos sem saída, a via mais rápida pra fechar os
3 nomes que faltam continua sendo o teste empírico (ciclar `server_id` no chanwait e ler a tela),
não mais descompilação cega.

## ✅ Sessão de 22/08 (fechamento) — os 5 nomes reais confirmados por teste empírico

Abandonada a descompilação cega (sem sucesso), fomos direto no empírico: ciclamos
`ROSH_TEST_SERVER_ID` (env var nova em `login_server.py`, sobrescreve o byte de `server_id` no
chanwait sem precisar editar código) e lemos o nome mostrado na tela real do client, um valor de
cada vez, sem clicar (pra não crashar nos servidores não implementados). **5 de 5 bateram
exatamente com a previsão** feita a partir da tabela `String.lod` (padrão `string_id = 1000 +
server_id`, ver seção anterior):

| `server_id` | nome real (client) | `string_id` |
|---|---|---|
| 1  | Azmara-Blackforest | 1001 |
| 22 | Shuradan-Ashara    | 1022 |
| 28 | Bent-Gerot         | 1028 |
| 41 | Victorum           | 1041 |
| 42 | Kart               | 1042 |

Isso também **corrige um erro anterior**: a suposição de que `server_id=42` era
"Azmara-Blackforest" (usada em sessões passadas) estava errada -- 42 é na verdade "Kart"; quem é
"Azmara-Blackforest" é o `server_id=1`.

**Rename pedido pelo usuário**: `server_id=42` (o único que funciona de ponta a ponta hoje) virou
**"Axion"** no lugar de "Kart" -- por enquanto só documentado em `channel_server.py`
(`REAL_SERVER_NAMES`); ainda falta editar de verdade o `string.lod` do client (todos os idiomas)
pra trocar o texto exibido -- ver TODO abaixo.

**Simplificação da lista de canais**: a tela mostrava um segundo item "NEW" vazio/lixo do lado
do nome do servidor. Investigamos e a causa NÃO é o nosso pacote (reduzimos
`REAL_CHANNEL_ENTRIES`/`REAL_SERVER_IDS` pra só 1 entrada -- server_id 42/canal 1 -- e o "NEW"
vazio continuou aparecendo do mesmo jeito). Ou seja, esse "slot 2" e os 5 tags coloridos
Europa/América + mapa-múndi no fundo da tela são **conteúdo fixo do client** (provavelmente uma
UI genérica pensada pra até 2 "mundos novos" em destaque + um mapa decorativo estático, nada a
ver com o pacote de canal). Tentamos achar a função de desenho responsável via vtable (varias
rodadas em `CGuiCGuiChannelList`/`CGuiServerSelectList`/`CGuiChannelListSel`) sem sucesso -- só
achamos codigo generico de desenho de borda/fundo. **Conclusão**: pra achar isso precisaria de
debugger ao vivo (x64dbg/breakpoint na funcao de texto), não dá pra continuar só lendo código
estático no Ghidra. Decisão: deixar como está (é cosmético, não trava "Jogar") e simplificamos
mesmo assim `channel_server.py` pra só anunciar o Axion (1 servidor, 1 canal) -- lista limpa,
sem os outros 4 mundos clicáveis-mas-quebrados.

**TODO pendente (não feito ainda)**: editar `string.lod` de verdade pra trocar "Kart"->"Axion"
no texto que o client mostra (precisa escrever a função de CRIPTOGRAFAR simétrica ao
`decrypt_lod_stream` -- inversão direta das fórmulas, já mapeada mas ainda não implementada em
`lod_crypto.py`).

**ATUALIZAÇÃO (mesma sessão, mais tarde)**: TODO acima **feito** -- `lod_crypto.py` ganhou
`encrypt_lod_body`/`patch_lod_file`/`patch_string_lod_text` (inversão exata das fórmulas,
validada com round-trip decifra->cifra->decifra idêntico). `string.lod_pt` real do client foi
editado (`Kart`->`Axion` no id 1042), com backup em `string.lod_pt.orig_backup`. Confirmado
visualmente no client: tela mostra "Axion" de verdade agora.

## 🐛 Sessão de 22/08 (fechamento 2) — bug real da "tela dupla de canal" encontrado (parcial)

Voltamos a investigar a tela dupla de canal com dados de log ao vivo (não mais suposição).
Achados concretos:

1. **Bug bloqueante encontrado e corrigido**: depois de clicar OK na tela "Seleção do canal", o
   client manda pro HOP2 (`channel_server.py`) um pacote de **16 bytes com assinatura `02 06`**
   (`02 06 00 00 00 0e 00 00 00 00 00 00 00 00 00 00`) que **nunca tratávamos** (só
   verificávamos `02 07`). Sem resposta, o client ficava ~20s mandando keepalive e desistia
   (`WinError 10054` = "conexão fechada à força pelo host remoto") -- esse era o "disconnect"
   reportado pelo usuário. **Corrigido**: `channel_server.py` agora responde esse `02 06` do
   client com o mesmo redirecionamento pro `world_server` que já usávamos pro `02 07`. Depois
   do fix, o fluxo completo passou a funcionar de ponta a ponta: login -> personagem -> Jogar ->
   Escolhendo o mundo (Axion) -> Seleção do canal -> **dentro do mundo de verdade**, com UI real
   (popup de "Prêmios de entrada" carregando dados do `world_server`) -- o mais longe que já
   chegamos no jogo.

2. **Achado na captura real, testado e REVERTIDO**: comparando byte a byte o pacote #20 da
   captura real (`real_success_full_flow_parsed.txt`) contra o nosso `build_channel_wait_frame`,
   o campo que sempre tratamos como `channel_idx` (offset 18 do payload, u16 LE) é `0x63`=**99**
   na captura real -- não um canal concreto tipo 1/2/3. Forte suspeita de ser um valor sentinela
   ("nenhum canal pré-selecionado, deixa o hop2 decidir"). **Testado**: trocar de 1 pra 99 causou
   **crash imediato ao clicar no servidor** (o client deve tratar 99 como índice de array em
   algum lugar sem o contexto adicional que o servidor real manda em outro campo/pacote que
   ainda não replicamos). Revertido pra 1 (não crasha, só mostra a tela dupla).

3. **Tela dupla ainda não eliminada, mas deixou de ser bloqueante**: com o fix do item 1, a tela
   "Seleção do canal" ainda aparece duas vezes (uma com fundo estático + status "Baixo", outra
   sobre o "AGORA CARREGANDO" + status "Médio"), mas agora o jogador consegue clicar OK nas duas
   e seguir em frente normalmente -- deixou de travar/desconectar. É cosmético a partir de agora.

4. **Tentativa de usar debugger ao vivo (`patch_int3.py` + dump automático do Windows Error
   Reporting, técnica reaproveitada de `D:\RoshEmulator\captures\crashdumps\` de uma sessão
   anterior)**: reconstruímos a vtable de `CChannelWaitLoginPacket` via RTTI (mesma técnica de
   hoje cedo), mas os slots decompilados eram todos boilerplate compartilhado (checagem de
   opcode de OUTROS tipos de pacote, destrutores) -- não achamos o método real de
   parse/decisão. Armamos um breakpoint em `FUN_0065081f` (0x0065081f, o slot mais promissor)
   e ele **nunca disparou** durante um fluxo completo (login até dentro do mundo) -- ou seja,
   esse endereço não faz parte do caminho de código realmente executado; descartado como pista.

**Pendente pra uma próxima sessão**: achar de verdade onde/como o client decide desenhar a
"Seleção do canal" duas vezes (a pista mais forte que resta é o campo sentinela 99 -- precisa
descobrir que OUTRO campo/pacote acompanha esse valor no servidor real pra não crashar, em vez
de tentar de novo às cegas).

## 🔬 Sessão de 22/08 (fechamento 3) — mais debug ao vivo no crash do `channel_idx=99`

Aprofundamos a análise do crash com `cdb`/WinDbg direto nos `.dmp` (não só registradores, também
desmontagem ao redor do ponto de erro). Achados:

- **Função real do crash localizada**: `FUN_005ae715` (0x005ae715) constrói o texto de status
  ("Baixo"/"Médio"/etc, tabela de string IDs por faixas de `status_flag`) pra UMA linha da lista
  de canais, e é chamada dentro de um loop que usa `FUN_005be664` pra avançar um iterator (essa é
  a mesma função que lança a exceção STL quando o iterator chega no fim/incompatível -- já vista
  antes). Ou seja, o crash acontece tentando **desenhar uma linha de canal além do que a lista de
  verdade contém**.
- **Duas hipóteses testadas e DESCARTADAS** (mesmo crash idêntico nos dois casos, byte a byte
  igual no dump -- mesma pilha, mesmo `ebx=0x63`/`edi=0x62`):
  1. Mandar só `channel_idx=99` no chanwait (lista do hop2 continua com `channel_idx=1`) -- crasha.
  2. Mandar `channel_idx=99` no chanwait E TAMBÉM anunciar uma entrada com `channel_idx=99` na
     lista do hop2 (pra "existir" o índice) -- crasha EXATAMENTE IGUAL. Descarta de vez a ideia
     de que só falta uma entrada correspondente na lista.
- **Conclusão**: o valor `99` aciona algum comportamento no client que não depende simplesmente
  de existir ou não uma entrada com esse índice -- é mais provável que seja usado como CONTAGEM
  ou limite de iteração em outro lugar (não como chave de busca), e nosso servidor simplificado
  não fornece o que quer que seja necessário pra esse número fazer sentido. Sem instrumentar
  o client de forma mais precisa (breakpoint no INÍCIO da função que gera a contagem de linhas
  antes do loop, não só no ponto do crash), não dá pra avançar mais sem outra rodada de
  descompilação focada em achar essa função "container.size()"-like.
- **Estado final, revertido pro seguro**: `channel_idx=1` nos dois arquivos (login_server.py e
  channel_server.py) -- fluxo completo funciona de ponta a ponta até dentro do mundo, só com a
  tela dupla cosmética (sem crash).

## ✅ Sessão de 22/08 (RESOLVIDO) — tela dupla de canal ELIMINADA

### O que o campo do offset 18 do chanwait realmente é

Mapa completo de testes (tudo o mais igual, só variando esse campo):

| valor | resultado |
|---|---|
| `1`  | **único que funciona** -- desenha 1 linha ("Canal 1") = a primeira tela fantasma |
| `0`  | CRASH ao clicar no servidor |
| `99` | CRASH ao clicar no servidor (mesma pilha/dump do `0`) |

Como `0` **também** crasha, não é uma "quantidade" (contagem zero seria o caso mais seguro
possível). O padrão -- só o valor que existe de verdade na lista funciona, qualquer outro
estoura o iterator do `std::map` -- é de um **`find()` cujo resultado é usado sem checar
"não encontrado"**. Ou seja: **não existe valor de chanwait que faça a primeira tela sumir**;
o client sempre desenha pelo menos uma linha. Toda a linha de investigação do "sentinela 99"
está encerrada como beco sem saída.

### Ordem dos pacotes (corrigida de quebra)

A ordem que estava no código (`handshake -> middle -> chanwait`) estava **errada**: veio de uma
leitura do `real_server_reject_01.log` (fluxo de REJEIÇÃO). No fluxo de sucesso
(`real_success_full_flow_parsed.txt`) a ordem real é:

    #19 handshake (19 bytes)  ->  #20 chanwait 02 06 (36)  ->  #21 middle 02 00 (290)

Corrigido pra ordem real. Sozinho isso **não** resolveu a tela dupla, mas agora bate com o
servidor de produção.

### A solução que funcionou

Na captura real, o client **fecha o hop1 sozinho ~150ms depois do chanwait e vai direto pro
hop2, sem nunca mandar um `02 07`** -- ou seja, no servidor real a primeira tela de "Seleção de
canal" simplesmente não existe. No nosso caso o client desenhava a tela e ficava parado
esperando o clique, que só então gerava o `02 07`.

**Fix**: o `login_server.py` (hop1) agora **empurra a resposta `02 07`** (o pacote com o endereço
do hop2) logo depois do chanwait, **sem esperar o pedido do client** -- é exatamente a mesma
resposta que mandaríamos se o usuário tivesse clicado, só que imediata.

**Resultado, medido no log:**

    17:37:02.885  hop1: handshake + chanwait + middle
    17:37:02.886  hop1: 02 07 empurrado           (1ms depois)
    17:37:02.921  client fecha o hop1 SOZINHO     (35ms -- sem clique humano)
    17:37:03.022  hop2: client conecta            (137ms apos o login)

O servidor real faz esse mesmo salto em ~150ms; o nosso agora faz em 137ms. **A primeira tela
não aparece mais** -- confirmado visualmente pelo usuário. O único clique humano que resta
(~7s depois) é na tela do hop2, que é a tela legítima.

### Validação end-to-end com log dos 3 hops (17:52)

Rodada limpa com logging ligado nos três servidores ao mesmo tempo (o `world_server` rodava
sem log até então -- por isso a parte final do fluxo era cega):

    17:52:22.119  hop1: handshake + chanwait + middle
    17:52:22.232  hop1: 02 07 empurrado
    17:52:22.322  client fecha o hop1 SOZINHO      <- 90ms, sem clique (tela fantasma nao aparece)
    17:52:22.349  hop2: conecta
    17:52:32.396  hop2: client manda 02 07         <- 10s = clique humano na UNICA tela de canal
    17:52:32.501  hop3: conecta
    17:52:32.794  hop3: lista de personagens enviada
    17:52:37.309  hop3: client manda 02 04         <- 5s = clique humano em "Jogar"
    17:52:37.313  hop3: stream real V2 (45744 bytes) enviado
    17:52:43.619  client manda pacote de MOVIMENTO (04 00)
    17:52:51.347  desconecta (WinError 10054)

O pacote de movimento decodificado confirma presença real no mundo:

    opcode 04 00 | char_id 0x299E | X=8920.0  Y=6520.0  Z=627.0  (floats LE)

**Efeito colateral conhecido e aceito**: o botão "Selecione o servidor" na tela de canal ficou
sem destino, porque a etapa que ele voltaria (o world-select do hop1) é justamente a que
eliminamos. Ele não gera pacote nenhum (verificado no log: só keepalives), então não quebra
nada no servidor. Com um único servidor configurado (Axion), o botão não teria o que selecionar
de qualquer forma.

**Limitação remanescente (independente deste fix, já documentada antes)**: ~14s depois de entrar
no mundo o client desconecta, porque o `world_server.py` só reproduz um trecho gravado finito.
Resolver isso exige simulação de estado de mundo de verdade (spawn/despawn de entidades,
broadcast de movimento) -- escopo de vários dias, não deste fix.

## 🗺️ Sessão de 22/08 (noite) — mapa de opcodes extraído do binário

Com a skill `descompilar-protocolo` carregada, atacamos o passo que faltava: mapear o
protocolo inteiro em vez de decodificar pacote a pacote na mão.

**Resultado: 94 opcodes mapeados para os nomes reais das classes** -- ver
[opcodes.md](opcodes.md) para a tabela completa e o método (reproduzível via
`Tools/ghidra_projects/MapOpcodes0822.java`).

A técnica: o binário tem RTTI, então de cada string `.?AVC<Nome>Packet@AMOPacket@@` dá pra
chegar na vtable (type_info → CompleteObjectLocator → vtable-4) e daí no método `Parse`
(slot 1), que começa comparando os 2 bytes do opcode. O script indexa a memória uma vez só
(500k valores) em vez de fazer uma busca por classe.

**Análise de frequência do stream do mundo** (`enterworld_loop_v1.bin`, 6000 frames
capturados do servidor real) cruzada com o mapa:

| opcode | classe | % do tráfego | tamanho |
|---|---|---|---|
| `04 01` | `CMoveMovePacket` | 73.5% | 42 bytes fixos |
| `04 02` | `CMoveAppearPacket` | 11.4% | 260-266 bytes |
| `04 03` | `CMoveDisappearPacket` | 10.9% | 11 bytes fixos |

Ou seja: **96% do fluxo contínuo do mundo é spawn/movimento/despawn de outras entidades.**
Isso fecha o diagnóstico da desconexão após ~14s: não é um pacote especial faltando, é a
ausência do fluxo constante de entidades. E confirma por que reproduzir o gravado em loop
piorava -- os IDs de entidade são de outra sessão.

Também identificados no stream: `08 06` `CCharInfoGageCurHPMPPacket` (HP/MP), `08 00`/`08 01`
gauges, `03 00` `CCommunityChatPacket`, `05 00` `CAttackStartPacket`, `06 04`
`CItemDropPacket`, `02 05` `CLoginCharInfoPacket`.

**Próximo passo concreto** (não feito ainda): decodificar o layout interno de
`CMoveAppearPacket` e `CMoveMovePacket` (decompilar os `Parse` em `0x00666d0f` e
`0x0065f36f`) e gerar esses pacotes com IDs de entidade próprios, em vez de reproduzir bytes
de sessão alheia.

## 🌍 Sessão de 22/08 (noite) — simulação de entidades: client PARA de desconectar

Com o mapa de opcodes na mão, decodificamos os 3 pacotes que formam 96% do fluxo do mundo,
cruzando a captura real (6000 frames) com análise de variância byte a byte:

```
CMoveMovePacket (04 01, 42 bytes)
  [2:6]   seq         u32 BIG-endian
  [6]     0x01        [7] flag
  [8:12]  entity_id   u32 BIG-endian
  [12:24] origem      3 floats LE (x, y, z)
  [24:36] destino     3 floats LE (x, y, z)
  [36:40] velocidade  float LE
  [40][41] flags

CMoveDisappearPacket (04 03, 11 bytes)
  [2:6] seq u32 BE   [6] 0x01   [7:11] entity_id u32 BE

CMoveAppearPacket (04 02, 260 bytes p/ NPC sem nome)
  [8:12]  entity_id  u32 LITTLE-endian   <- endianness DIFERENTE do Move (confirmado em
                                             100% das 683 amostras; protocolo inconsistente)
  [12:24] posicao    3 floats LE
  [0x102] tam do nome u16 BE   [0x104] nome
  resto: boilerplate CONSTANTE nos 682 NPCs -> copiado de template
```

Os dados batem fisicamente: 35 entidades (ids 1169-1300) perambulando com velocidades de
0 a 7.4 -- os NPCs da região.

### O que foi construído

[entity_sim.py](../server/entity_sim.py): em vez de reproduzir bytes de sessão alheia (que
piorava, porque referenciam IDs inexistentes aqui), o servidor agora **gera** os pacotes com
estado próprio -- 12 entidades com ids 9000-9011, perambulando em torno de âncoras perto de
onde o personagem nasce (8920, 6520, 627 -- posição que o próprio client anunciou).

### Resultado

**O client parou de desconectar.** Antes caía sozinho em ~14s; agora ficou 67s+ sem nenhuma
desconexão registrada, e as quedas seguintes foram o usuário fechando o jogo. As entidades
geradas **renderizam como mobs de verdade** no mundo (o template usado era de um NPC real).

### Testado e revertido

Dividir o stream em 2 fases (segurando a fase 2 até o client mandar `04 00`) criava um
intervalo de ~11s sem tráfego de entidades. Revertido: o que segura a conexão é o fluxo
contínuo começar **imediatamente**. O envio pausado frame a frame (`entity_sim.enviar_pausado`)
foi mantido, por ser mais fiel ao servidor real.

### Pendente

A tela "AGORA CARREGANDO 100%" ainda não some, mesmo com o mundo renderizado por trás e mobs
visíveis. Investigado sem sucesso até agora:
- o stream replicado JÁ contém o spawn do próprio personagem (`04 02` com ent_id 0x299e,
  frame 26) e o `04 04` CMoveZonePacket
- o servidor real **nunca responde** ao `06 5c` que o client manda 28x (não é poll travado)
- além dos 500 frames replicados só há eventos de gameplay (chat/ataque/drop), nada de
  "loading completo"
- a classe do overlay é `CGuiLoadingEx` (RTTI em `0x008acd58`, vtable `0x0080ea5c`), mas os
  métodos da vtable são construção de widgets, não a lógica de dispensa

### RESOLVIDO -- a tela de carregando sai

A comparação de opcodes mostrou o desvio exato:

| | real | nosso (antes) |
|---|---|---|
| `02 04` entrar | 1 | 1 |
| `08 09` | 2 | 1 |
| `04 00` posição | 1 | 1 |
| `0a 0a` | 1 | **0** |
| `06 5c` | 28 | **0** |
| `04 01` movimento próprio | 139 | **0** |

O `04 01` zerado é a assinatura de "o personagem não anda" -- o client parava logo após
mandar a própria posição.

**A causa apareceu ao reler o `C:\RoshHook	raffic.log` ORIGINAL** (3.5MB), que tem SEND e
RECV **intercalados na mesma ordem em que aconteceram** -- ao contrário dos arquivos
`hook_parsed/sock_*_SEND.bin` / `_RECV.bin`, que já vinham separados por sentido e por isso
escondiam o encadeamento pergunta/resposta. No fluxo real:

    11:32:06.586  client   -> 04 00  (a propria posicao)
    11:32:06.765  SERVIDOR -> 04 02  ent_id 0x299e  <<< CMoveAppear do PROPRIO personagem

Esse pacote é o sinal de **"você chegou no mundo"**. Nós já mandávamos ele (frame 26 do
stream), mas **cedo demais** -- antes do client anunciar a posição, então era descartado.

**Fix**: `world_server.py` agora responde ao `04 00` do client com o `CMoveAppearPacket` do
próprio personagem, extraído do traffic.log (`captures/self_appear.bin`, 270 bytes).

**Resultado confirmado em jogo**: a tela "AGORA CARREGANDO" some, o personagem aparece com
nome/HP/MP, minimapa mostra "Berneo", barra de skills e inventário carregam, e os mobs
gerados pelo `entity_sim.py` aparecem nomeados ("Aranha pequena") ao redor. **O jogo está
jogável.**

### Lição de método

Separar uma captura bidirecional por sentido destrói a informação de encadeamento. Os
arquivos `hook_parsed/*` foram úteis pra estatística (frequência de opcodes), mas o
diagnóstico só saiu do log original intercalado. Vale manter os dois.


## Combate: por que a barra de HP do mob nao descia (23/08)

Sintoma: o numero de dano aparecia na tela, mas a barra do mob ficava em 100% e ele nunca
morria. Duas conclusoes minhas anteriores estavam erradas e sao corrigidas aqui.

### Erro 1 -- offset do alvo

Eu lia o alvo em `[12:16]` tanto do `05 00` quanto do `05 01`. Esse campo e o **id do tipo de
ataque / skill**, nao o alvo:

  - no `05 00` basico ele vale `00 00 03 fc` (0x3fc)
  - no `05 01` ele vale `00 00 2a f9` (0x2af9 = 10999), o mesmo id que reaparece no
    `05 0a` (CCoolTimeSkillList) e na lista de skills `05 05`

O alvo de verdade e o **BE u32 no fim do pacote**:

    05 00 (55 bytes)  ->  alvo em [33:37]
    05 01 (38 bytes)  ->  alvo em [34:38]   (um byte adiante: tem um flag a mais em [33])

Na captura, os dois dao `00 00 05 11` = 1297 -- exatamente o id que reaparece no `08 06`
seguinte. Como eu mandava o HP para a entidade errada (10999, que nao existe), o client
descartava o pacote em silencio.

### Erro 2 -- eu tinha descartado o `08 06`

Eu havia concluido que o servidor real nao manda pacote de HP no combate e que o HP restante
teria que estar dentro do proprio `05 02`. Errado. O `08 06` **e** o pacote da barra de HP; o
que me confundiu foi que ele tambem roda como sync ambiente a cada ~1s para uma entidade
proxima (1285 @ 27/100 na captura), e eu tinha olhado so para essas amostras periodicas.

Layout confirmado (frames 2971 / 2975 / 5757 / 5799 do traffic.log):

    [7:11]   entidade      (BE u32)
    [15:19]  HP atual      (BE u32)
    [19:23]  HP maximo     (BE u32)
    [23:27]  quem causou   -> id do atacante no golpe, ffffffff no sync ambiente

### Sequencia real de um golpe

    client SEND 05 00  ->  servidor ecoa 05 00 (identico, so troca a seq em [2:6])
    client SEND 05 01  ->  servidor manda, nesta ordem:
                             04 01  recuo do alvo
                             08 06  com o id do atacante em [23:27]
                             08 06  com ffffffff em [23:27]
                             05 02  numero de dano flutuante

O dano acontece **so no `05 01`**. Eu aplicava dano nos dois, o que dobrava o golpe.

### `05 02` (CAttackDamagePacket, 51 bytes)

    [2:6]    seq            (BE u32)
    [7:11]   atacante       (BE u32)
    [12:16]  id da skill    (BE u32)
    [22:24]  alvo           -- u16 LITTLE-endian (mais uma inversao de endianness da familia)
    [24:28]  DANO           (BE u32)
    [35:47]  posicao onde o numero flutua = posicao do ALVO, que so chega no `05 00[37:49]`

Ele so desenha o numero. Quem move a barra e o `08 06`.

### Validacao

`entity_sim.build_hp` e `build_dano_skill`, alimentados com o `05 00`/`05 01` reais da captura,
reproduzem os dois `08 06` **byte a byte identicos** ao servidor oficial, e o `05 02` identico
exceto pelos floats de posicao -- diferenca esperada, porque o servidor real conhece a posicao
viva do mob e nos so temos a ultima que o client reportou.


## HP maximo e escala da barra (23/08)

Depois de acertar os offsets do combate a barra passou a se mexer, mas marcava 120% e depois
40%. E depois, ja com o dano em 12, marcou 176% no primeiro golpe. Os dois numeros dizem a
mesma coisa: o client **nao usa** o HP maximo que vai no `08 06`. Ele usa o que veio no
`CMoveAppearPacket` (04 02) daquela entidade.

    04 02: [8:12] entidade (LE u32)   [28:32] HP atual   [32:36] HP maximo   (LE u32)

No mapa: 36 aranhas declaram maximo **50**, o jogador **186**, dois NPCs grandes **1450**.

    120% = 60/50   (eu tirava 40 de um total de 100)
    176% = 88/50   (eu tirava 12 de um total de 100)

Foram DOIS bugs no mesmo campo, em caminhos diferentes:

1. O default global era 100. Corrigido lendo o maximo por entidade do proprio 04 02.
2. As entidades do simulador continuavam nascendo com 100 mesmo depois disso, porque
   `Entidade.__init__` rodava **antes** do `build_appear` -- e e o `build_appear` que registra
   o maximo. Corrigido sincronizando o HP depois de montar os pacotes de spawn.

O primeiro teste offline nao pegou o bug 2 porque exercitou so o caminho das entidades vindas
do replay, que ja chegam registradas pelo stream de entrada.


## EXP e nivel: CCharInfoHeroGagePacket (08 01), 109 bytes (23/08)

O traffic.log contem um LEVEL UP ao vivo, o que amarra todos os campos de uma vez.

    [7:11]   jogador          BE u32
    [12:16]  HP atual         BE u32     [16:20] HP maximo  BE u32
    [20:24]  MP atual         BE u32     [24:28] MP maximo  BE u32
    [66:70]  EXP atual        BE u32
    [77:81]  EXP necessaria   LE u32   <- endianness diferente do resto do pacote
    [90:94]  nivel            BE u32

Conferencia aritmetica na propria captura:

    EXP  24 -> 96 -> 168        (72 por aranha morta)
    proximo golpe levaria a 240, necessaria era 216
    pacote seguinte: nivel 2, EXP 24 = 240 - 216, HP max 186 -> 223, MP max 100 -> 110

Nao existe pacote de "voce ganhou EXP". O servidor real so reenvia o `08 01` com o numero
novo -- foi assim que o level up apareceu.

Implementado em `entity_sim.Heroi` + `build_hero_gage`, disparado na morte do mob. Rodando a
simulacao com 72 por kill a partir do estado inicial da captura, ela reproduz exatamente a
sequencia observada, level up incluido.

Limite conhecido: so temos dois degraus reais de EXP necessaria (nivel 1 = 216, nivel 2 = 520).
Acima do nivel 2 e extrapolacao (`520 * 1.6 ** (nivel - 2)`) ate termos captura de niveis
maiores. Os campos `[45]` (+8 por kill) e `[54:58]` (+12 por kill) tambem sobem a cada morte e
continuam sem identificacao.


## Persistencia: SQL Server (23/08)

Ate aqui nada era salvo: o progresso vivia em memoria e sumia a cada reconexao e a cada
restart. Agora existe o banco `Rosh` no SQL Server 2022 local.

    dbo.conta        conta_id, usuario, senha_hash/salt, bloqueada, ultimo_login_em, ultimo_ip
    dbo.personagem   personagem_id, conta_id, entity_id, nome, nivel, exp,
                     hp/hp_max, mp/mp_max, campo43, contador45, contador54, pos_x/y/z
    dbo.salvar_progresso   procedure de UPDATE por entity_id

Autenticacao do Windows (`Trusted_Connection`): nenhuma senha fica em arquivo.

`db.py` falha em SILENCIO se o SQL estiver fora -- avisa no log e o servidor segue com o
estado em memoria. Perder progresso e ruim; derrubar o jogador no meio de uma luta e pior.

O login grava a conta (`garantir_conta`) mas ainda NAO valida senha: qualquer credencial
entra, como antes. A coluna `senha_hash` existe para quando isso mudar.

### Armadilha do Python desta maquina

A instalacao esta partida ao meio: `C:\Python314` tem o executavel, e
`AppData\Local\Programs\Python\Python314` tem a biblioteca. Com isso o `sys.prefix` cai no
DIRETORIO ATUAL e o site-packages vira `<cwd>\Lib\site-packages` -- o pyodbc some dependendo
de onde voce roda. Por isso os servidores tem que subir por `iniciar.ps1`, que define
`PYTHONHOME`. Sem isso o banco simplesmente nao existe para o processo.


## As "tabelas" do client sao arquivos .lod (23/08)

O client nao consulta banco nenhum. Tudo que ele sabe por conta propria esta em 157 tabelas
em `ResUnpak/Interface/IE_Lod`, cifradas com o algoritmo quebrado em 22/08.

Ferramenta: `tools/ler_lod.py` (--listar, --buscar).

Nomes uteis: npcInfo, itemBase, itemAttr, skillBase, skillLevel, skillData, classInfo,
classRace, classChange, npcShop, npcShopItem, npcDialogue, npcPosition, quest, mapWarp,
guildLevel, petBase, petLevel, uniqueOptionDrop, rvrDrop, string, IE_Maplist,
e 38 SkillTree_XXX (um por classe/especializacao).

Formato: nao e texto tabulado como o string.lod. Sao registros binarios de tamanho fixo com
strings embutidas. Em npcInfo: cabecalho de 4 bytes com a contagem (1227 NPCs), registros de
234 bytes, nome em [132]. O tamanho do registro se descobre medindo a distancia entre
ocorrencias de nomes consecutivos -- e o que o --buscar faz.

IMPORTANTE, e limita o que da para tirar dai: `npcInfo` NAO tem HP nem EXP dos mobs. So dados
de exibicao (nome, modelo, escala). Faz sentido -- o client nao precisa saber a vida do mob,
o servidor manda. Ou seja, o balanceamento de combate e server-side e NAO esta no client.

Os valores que usamos continuam sendo os medidos na captura do servidor real:
HP 50 da aranha (declarado no 04 02) e 72 de EXP por abate. Sao evidencia empirica direta,
nao chute -- mas vieram de observacao, nao de uma tabela oficial.

`skillBase`, `skillLevel` e `itemBase` comecam com o u32 20101118 (um carimbo de data, nao
contagem) e contem texto legivel de skills e itens. Ainda nao vasculhados -- sao o caminho
mais provavel para achar coeficientes de dano de skill, que o client precisa para desenhar
as tooltips.


## Autenticacao real (23/08)

O login passou a validar senha contra `dbo.conta`. PBKDF2-HMAC-SHA256, 200k iteracoes, salt de
16 bytes por conta. O client manda a senha em texto claro no CLoginReqPacket -- nao da pra
evitar isso sem mexer no client -- mas em DISCO nunca fica texto claro.

`db.autenticar(usuario, senha, ip)` devolve: ok | senha_errada | bloqueada | desconhecida |
sem_banco.

Duas decisoes que valem registro:

- AUTO_REGISTRAR=True: o primeiro login de um usuario desconhecido cria a conta com a senha
  informada. E o padrao de servidor privado, que nao tem pagina de cadastro. Desligar fecha
  o servidor a quem ja tem conta.
- Sem banco = RECUSA. E o OPOSTO da regra do resto do db.py. La, falhar em silencio e o certo
  (o risco e perder progresso); aqui, deixar passar quando o banco cai transforma
  indisponibilidade em porta aberta.

Contas criadas antes disso existir (senha_hash NULL) adotam a senha do proximo login.

### O byte que decide o login: 02 00 [13]

`captures/real_server_reject_01.log` tem uma tentativa RECUSADA contra o servidor oficial
(client mostrou "A sua autorizacao expirou"). O traffic.log da sessao boa tem as DUAS: a
primeira tentativa foi recusada e a segunda passou. Comparando as duas respostas `02 00`:

    [13] = 0x01   aceito
    [13] = 0x0a   recusado

O resto diverge so porque na recusa o servidor manda lixo de memoria (strings de boost) em vez
do token.

ARMADILHA ENCONTRADA NO CAMINHO: o nosso `build_middle_frame()` esta UM BYTE DESALINHADO em
relacao a captura -- falta um 0x00, e o codigo de resultado cai em [12] em vez de [13]. Ou
seja, ha semanas mandamos o cabecalho de RECUSA e o login passa mesmo assim. Isso prova que o
client NAO valida esse frame no nosso fluxo (provavelmente porque empurramos o 02 07 de forma
proativa e pulamos o caminho normal). Deixei como esta: funciona, e realinhar arriscaria o
login que custou a fechar. O `build_recusa_frame()` novo usa o alinhamento correto.

Como a recusa nao pode depender do client honrar esse frame, a negativa de verdade e feita
FECHANDO a conexao. Autenticacao tem que ser aplicada no servidor, nao pedida ao client.

Ferramenta: `contas.py listar | criar | senha | bloquear | liberar`.

### Relogio

O relogio LOCAL desta maquina e UTC-3; o banco grava em UTC (SYSUTCDATETIME). Os logs dos
servidores sao local. Uma conta com criada_em 09:47 UTC aparece como 06:47 nos logs.


## Comparacao com o schema do MuOnline

Existe um banco `MuOnline` (de outro emulador) nesta mesma instancia, com 60+ tabelas. Serve
como CHECKLIST do que um servidor de MMO completo precisa, nao como fonte de dados:

    MEMB_INFO (26 col)  -> nossa dbo.conta
    MEMB_STAT           -> quem esta online (nao temos)
    Character (60 col)  -> nossa dbo.personagem (hoje com 9 campos de progresso)
    warehouse           -> bau (nao temos)
    Guild/GuildMember   -> guildas (nao temos)
    T_FriendList/Mail   -> amigos e correio (nao temos)
    ItemLog             -> auditoria de itens (nao temos)
    QuestKillCount      -> progresso de quest (nao temos)

O `Character` do Mu guarda Inventory e MagicList como BLOB binario. Vale copiar a IDEIA: o
nosso `06 05 CItemInvenListPacket` ja e um blob que reproduzimos, entao guardar o inventario
como bytes crus e um caminho viavel antes de decodificar campo a campo.

O que NAO transfere: colunas e valores. Strength/Dexterity/Vitality/Energy, ResetCount,
PkLevel etc. sao design do Mu. O Karos tem 56 classes (classInfo.lod), outros atributos e
outro formato de item. Copiar as colunas do Mu seria embutir o jogo errado no nosso banco.


## Pagina de cadastro e lista de personagens vinda do banco (23/08)

Conta e personagem de teste apagados; o banco comecou do zero.

### Cadastro em PHP no XAMPP

`C:/xampp/htdocs/axion/index.php`. O Apache ja estava de pe na 80/443 e o PHP ja tinha
`pdo_sqlsrv` habilitado, entao nao foi preciso subir outro servidor nem instalar nada.

Autenticacao do banco: o Apache roda como LocalSystem, entao criei o login
`NT AUTHORITY\SYSTEM` -> usuario `axion_web` no banco Rosh, com permissao SO de
SELECT e INSERT em `dbo.conta`. Nada de personagem, nada de UPDATE/DELETE, e nenhuma senha de
banco em arquivo.

O hash TEM que bater com o db.py, senao a conta criada na pagina nao entra no jogo.
Conferido byte a byte com salt e senha fixos -- PHP e Python dao o mesmo digest:

    hash_pbkdf2('sha256', $senha, hex2bin($salt), 200000, 64, false)   [PHP]
    hashlib.pbkdf2_hmac("sha256", senha, bytes.fromhex(salt), 200000)  [Python]

Testado ponta a ponta: cadastro pela pagina -> `db.autenticar` aceita a senha certa, recusa a
errada. Duplicata e senha curta rejeitadas.

Com a pagina no ar, `AUTO_REGISTRAR` foi DESLIGADO: com um caminho proprio de cadastro,
deixar o login criar conta sozinho so encheria o banco de contas nascidas de erro de digitacao.

### Lista de personagens

Antes era fixa (sempre o personagem capturado). Agora sai de `db.personagens_da_conta()`,
usando o usuario do login de reautenticacao do hop3.

    com personagem : 3 slots vazios (sem flag) + o personagem POR ULTIMO (com a flag)
    sem personagem : 4 slots vazios, todos com a flag

A regra do byte "ultimo" veio da decompilacao do FUN_00651331: o client decide entre "abre a
tela de criacao" e "entra com o personagem" olhando o character_id DO REGISTRO que carrega a
flag. Vazio com a flag => tela de criacao.

Decodificado de quebra, do molde `_POPULATED_0201_HEX`:

    [6]      flag de ultimo registro
    [7:11]   character_id (BE u32)
    [15:19]  classe (LE u32) -- 600 = Maga no molde
    fim      nome: contagem (LE u32) + caracteres em UTF-32LE (4 bytes por letra)

Ou seja, da pra montar a lista com nome e classe arbitrarios a partir do banco.

### O que ainda falta pra criacao funcionar de verdade

O `02 02 CLoginCreateCharPacket` NAO tem captura -- a sessao gravada ja tinha personagem. O
world_server agora despeja os bytes crus dele no log e salva em
`captures/criar_personagem_02_02.bin` no primeiro que chegar. Depois de decodificado, ainda
falta montar o `02 05 CLoginCharInfoPacket` (1812 bytes), hoje reproduzido da captura.


## Limite de 3 computadores por conta: hash1 e hash2

O CLoginReqPacket carrega cinco strings: id, pw, hash1, locale, hash2. Os dois hashes sao MD5
(32 hex) e sao os candidatos obvios a impressao digital da maquina, que e como o Karos limita
a conta a 3 computadores.

NAO da pra afirmar ainda. Todas as capturas vieram da MESMA maquina (a VPS), entao os valores
sao iguais por construcao:

    hash1 = a755441959f0fb071dda530f086cc58c
    hash2 = 0bb79b04cf60d1a2a223df6212ed6bde

Um login vindo de outro PC decide: se mudarem, sao impressao de maquina e da pra construir o
limite de 3 em cima deles; se nao mudarem, sao constantes do client (versao/build) e a
impressao esta em outro lugar. `db.registrar_dispositivo` ja loga os dois a cada login.

RISCO A OBSERVAR: logs antigos mostram o client mandando id NUMERICO com pw VAZIO
(ex: id='67467150677465118', pw=''), o que parece token de sessao em vez de usuario/senha. Se
isso acontecer no hop1 agora que existe autenticacao, o login vai ser RECUSADO. Se der erro de
login, e o primeiro lugar pra olhar.


## Criacao de personagem capturada do servidor oficial (23/08)

Captura feita apontando o ServerList.ten da VPS pro servidor oficial e injetando o hook.
Arquivo: `captures/criacao_personagem_0823.log`.

### CLoginCreateCharPacket (02 02) -- 49 bytes

    [ 0: 2]  opcode                   02 02
    [ 2: 6]  seq                      BE u32
    [ 6: 8]  CLASSE                   LE u16   (500 no teste; 600 = Maga no personagem antigo)
    [ 8:16]  zeros
    [16:17]  aparencia?               u8       (0x11 = 17 -- SEM CONFIRMACAO, ver abaixo)
    [17:19]  zeros
    [19:21]  tamanho do nome          LE u16   (6)
    [21:45]  NOME                     UTF-32LE, 4 bytes por caractere ("Testee")
    [45:49]  zeros

O par "tamanho LE u16 + UTF-32LE" e o MESMO do nome no 02 01 populado, o que confirma os dois
de uma vez.

O byte [16] mudou de 0 pra 17 e e o unico candidato a aparencia (cabelo/rosto/sexo). NAO da pra
afirmar o que ele codifica com UMA amostra so -- precisaria criar personagens variando uma
opcao de cada vez.

### CLoginDeleteCharPacket (02 03) -- 10 bytes

    [0:2] opcode  [2:6] seq (BE u32)  [6:10] character_id (BE u32)

Duas amostras: 10654 (0x299e) e 10826 (0x2a4a). O 10654 e exatamente o personagem que a gente
vinha emulando -- foi apagado na conta real durante esta captura.

### LIMITACAO DESTA CAPTURA: so tem SEND

12 frames SEND, ZERO RECV. Temos o que o client pediu, nao o que o servidor respondeu. Falta:

  - a resposta ao 02 02 (o 02 01 populado com o personagem novo)
  - o 02 05 CLoginCharInfoPacket de um personagem NOVO (nivel 1, sem equipamento)

Esse segundo e o que interessa de verdade: comparado com o 02 05 que ja temos (nivel 2,
equipado), ele separaria os campos de progresso do resto -- que e o que trava tanto o calculo
de dano real quanto carregar um personagem criado por nos.

Causa provavel: o hook foi injetado com o jogo JA CONECTADO. A captura de 22/08, feita desde o
inicio do processo, tem RECV normalmente. Alguma coisa no caminho de recepcao (WSARecv
sobreposto / completion port) nao e pega quando a injecao acontece no meio da sessao.

Caminho melhor pra proxima: `server/real_server_proxy.py`, que e um proxy TCP e loga os DOIS
sentidos por construcao, sem depender de hook nenhum. Aponta o ServerList pra 127.0.0.1:20000
e o proxy encaminha pro oficial.

### Nota sobre o injetor

Lancar `injector.exe` ANTES do jogo NAO funciona -- ele sai sem injetar (perde a corrida com o
karos.exe subindo o AMo.exe). O que funciona e `tools/vigiar_e_injetar.ps1`: espera o processo
existir, da 5s pra rede subir, injeta e CONFERE se o traffic.log foi criado.


## 02 05 CLoginCharInfoPacket decodificado (23/08)

Captura `captures/mundo_nivel1_0823.log`: personagem NOVO (id 10827, classe 500, "Testee")
entrando no mundo no servidor oficial, com os DOIS sentidos gravados (43 RECV / 21 SEND).

Comparado com o 02 05 da captura de ontem (id 10654, classe 600, "teste"), so 198 dos 1812
bytes diferem -- 11%. A maior parte do pacote e igual entre personagens diferentes.

    [ 6:10]  id do personagem   BE u32    (10827 / 10654)
    [10:14]  CLASSE             LE u32    (500 / 600)
    [18:22]  ?                  LE u32    65536 nos dois
    [22:26]  HP MAXIMO          LE u32    (200 / 186)
    [30:34]  MP MAXIMO          LE u32    (78 / 100)
    [50:52]  02 00 nos dois
    [52]     0x24 / 0x08        difere

O NOME NAO ESTA no 02 05 -- procurei "Testee" em UTF-32LE e nao existe. Ele vive so no 02 01.

Atencao: os dois 02 05 que temos sao de personagem NIVEL 1. O de ontem foi capturado as
11:32, antes do level up das 11:34. Entao a comparacao separa classe/equipamento, NAO
progresso. Para isolar os campos de nivel/EXP dentro do 02 05 ainda falta uma captura de
personagem em nivel mais alto.

### 02 01 CLoginCharViewInfoPacket -- confirmado como variavel

    415 bytes com o nome "teste"  (5 letras)
    419 bytes com o nome "Testee" (6 letras)

Diferenca de 4 bytes = uma letra em UTF-32. Layout confirmado:

    [6]      flag de ultimo registro
    [7:11]   id do personagem   BE u32
    [15:19]  classe             LE u32
    fim      tamanho LE u16 + nome UTF-32LE

### Validacao independente do 08 01

O 08 01 desta captura nova, de um personagem que nunca tocamos, le corretamente com os offsets
que tinhamos deduzido ontem:

    jogador=10827  HP=200/200  MP=78/78  EXP=116/216  nivel=1

EXP 116 de 216 necessarios no nivel 1 -- exatamente o mesmo limiar de 216 que a aritmetica de
ontem tinha inferido do level up. Confirmacao em dado novo, nao no dado de onde a regra saiu.

### Impressao de maquina: hash1/hash2 NAO variaram

Os dois hashes do CLoginReqPacket sao IGUAIS aos de ontem:

    hash1=a755441959f0fb071dda530f086cc58c
    hash2=0bb79b04cf60d1a2a223df6212ed6bde

Mas isso ainda nao decide nada: as duas capturas sao da MESMA maquina (a VPS). O que da pra
dizer e que eles sao ESTAVEIS entre sessoes, contas e personagens diferentes na mesma maquina
-- consistente com impressao digital de hardware, e tambem consistente com constante do client.
So um login vindo de OUTRO PC separa as duas hipoteses.

### Higiene

A senha da conta oficial usada nas capturas foi apagada de todos os logs (substituida por
asteriscos, no hexdump e no texto) e a ausencia foi conferida. O usuario permanece visivel.


## CORRECAO: o campo [90:94] do 08 01 e NIVEL MENOS UM (23/08)

Descoberto com a ficha de atributos de um personagem novo capturado do servidor oficial:
a tela mostrava "nivel 1" e "Experiencia 0/48", enquanto o pacote trazia [90:94] = 0 e
[77:81] = 48.

Ou seja, o campo guarda nivel-1, nao o nivel. Isso desloca tudo que eu tinha concluido:

    ERRADO (22-23/08)          CERTO
    nivel 1 -> 216 de EXP      nivel 1 ->  48
    nivel 2 -> 520             nivel 2 -> 216
                               nivel 3 -> 520

E o "level up" da captura de ontem, que eu li como 1 -> 2, era 2 -> 3.

Confirmacao independente pelo HP maximo da Maga: 150 (nv1, capturado hoje) -> 186 (nv2) ->
223 (nv3). Progressao coerente do MESMO personagem, com ganho de +36 e +37. Antes eu lia
esses 150 e 186 como dois personagens distintos de nivel 1, o que nunca fechou.

Licao: [77:81] (EXP necessaria) estava certo desde o inicio -- a aritmetica do level up
confirmou 216. O erro foi assumir que o nivel ao lado dele era 1-based. Um unico valor de
tela ("nivel 1, 0/48") derrubou a interpretacao inteira.


## Atributos base no 02 05: bloco de 5 u16 em [1030]

Amarrados com a ficha do personagem na tela (Maga nivel 1):

    [1030] LE u16  Forca         6
    [1032] LE u16  Destreza      8
    [1034] LE u16  Vitalidade    6
    [1036] LE u16  Inteligencia 11
    [1038] LE u16  Sabedoria     9

Cinco valores consecutivos, na ordem exata em que a ficha os desenha. Nao e coincidencia de
numeros soltos: a sequencia inteira bate.

    [22:26] LE u32  HP maximo  150   (ficha: Saude 150)
    [30:34] LE u32  MP maximo   90   (ficha: Mana 90)

NAO estao no 02 05: ataque fisico/magico, protecao, chance de critico, velocidade de ataque.
Procurei 11, 15, 54, 30, 35, 19 e nenhum aparece. Sao DERIVADOS -- o client calcula a partir
dos atributos base e do equipamento. Isso e bom: nosso servidor nao precisa mandar nada disso.

### Tabela de classes ate agora

    classe 500  HP 200  MP 78   (personagem "Testee")
    classe 600  HP 150  MP 90   Maga -- CONFIRMADO pelo 02 02 da criacao

Faltam 100, 200, 300, 400, 800. Os sete ids saem dos arquivos SkillTree_XXX do client
(100/200/300/400/500/600/800 -- nao existe 700).

Cuidado: o molde antigo da classe 600 dizia HP 186 porque veio de um personagem de NIVEL 2.
Molde de classe tem que ser tirado de personagem recem-criado.


## Tabela das 7 classes -- COMPLETA (23/08)

Capturada do servidor oficial criando um personagem de cada classe e conferindo cada uma
contra a ficha de atributos na tela. As sete batem, sem divergencia.

      cl  nome         HP   MP   For Des Vit Int Sab
     100  Guerreiro   190   60    10   7  10   7   6
     200  Paladina    190   91     9   7  10   7   7
     300  Vagabundo   150   80     7  11   6   8   8
     400  Feiticeira  160   90     5   9   7  10   9
     500  Arqueira    160   70     9  10   7   8   7
     600  Maga        150   90     6   8   6  11   9
     800  Artilheiro  189  117    10   5   9   7   9

Nao existe classe 700 -- confirmado pelos arquivos SkillTree_XXX e agora pela captura.

Os numeros se comportam como o esperado do design: Guerreiro lidera Forca/Vitalidade com o
maior HP e a menor Mana; Maga e o oposto exato; Vagabundo tem a maior Destreza; Artilheiro a
maior Mana. Isso nao prova nada sozinho, mas nenhuma leitura destoa.

### Moldes salvos

Para cada classe: `captures/classes/charinfo_classe_XXX.bin` (02 05, 1812 bytes) e
`charlist_classe_XXX.bin` (02 01). Sao personagens RECEM-CRIADOS, nivel 1, sem progresso.

Isso e mais importante que a tabela: servindo o molde da classe escolhida, o emulador entrega
aparencia e equipamento inicial corretos SEM decodificar os ~1798 bytes que continuam sem
identificacao.

ARMADILHA JA PISADA: o primeiro molde da classe 600 dizia HP 186 porque veio do personagem
"teste", que estava no NIVEL 2. Molde de classe so vale se vier de personagem recem-criado --
a ferramenta sobrescreve quando chega um novo, entao capturas antigas de personagem
progredido corrompem a tabela em silencio.

### O que continua desconhecido no 02 05

Ataque fisico/magico, protecao, chance de critico, velocidade de ataque e multiplicador de
dano NAO estao no pacote. Procurei os valores da ficha (11, 15, 54, 30, 35, 19...) e nenhum
aparece. Sao DERIVADOS: o client calcula a partir dos 5 atributos base e do equipamento. Boa
noticia -- o servidor nao precisa mandar nada disso.


## Decodificacao do 02 05 (23/08) -- o que os 1812 bytes sao

Metodo: comparar os moldes das 7 classes byte a byte e casar cada valor contra a ficha de
atributos que o jogo desenha na tela, exigindo acerto nas SETE classes. Com sete amostras,
coincidencia e praticamente impossivel. Ferramenta: `tools/casar_ficha.py`.

### Mapa de constancia entre as 7 classes

    zerados em todas          ~70% dos bytes
    iguais mas nao-zero       ~18%
    VARIAM entre classes      ~11%

Ou seja: quase todo o pacote e molde. O que carrega informacao real cabe em poucas faixas.

### Cabecalho

    [ 6:10]  id do personagem    BE u32
    [10:14]  classe              LE u32
    [22:26]  HP maximo           LE u32
    [30:34]  MP maximo           LE u32
    [1030]   atributos base      5 x LE u16: Forca, Destreza, Vitalidade, Inteligencia, Sabedoria

### Bloco de combate em [1056..1200] -- floats LE

CONFIRMADOS nas 7 classes:

    [1056] HP maximo                 (190.4, 150.4, ... -- o mesmo valor de [22], em float)
    [1060] Regeneracao de HP
    [1068] MP maximo
    [1120] Protecao fisica           21.0 em TODAS -- a diferenca que a ficha mostra
                                     (67, 72, 63...) vem inteira do equipamento
    [1144] Protecao magica
    [1152] Velocidade de invocacao   100.0 em todas
    [1156] Multiplicador de critico  1.2 em todas
    [1188] Chance de Critico

NAO identificados, mesmo variando por classe: [1072], [1084], [1088], [1108], [1112], [1116],
[1128], [1136], [1140], [1148], [1160].

Alguns chegam perto de campos da ficha mas nao fecham nas 7. Exemplos honestos:

    [1136]  8.86  9.34  11.36  18.92  10.88  22.46  10.30
            parece ATQ Magico minimo, mas so casa se a Feiticeira arredondar e o Guerreiro
            truncar -- regra inconsistente, entao NAO afirmo.
    [1084]  6.85  6.85  7.05  6.95  7.00  6.90  6.75
            parece Chance de evasao, erra em duas classes.

Duas explicacoes possiveis, nenhuma verificada: a tela pode combinar base + equipamento com
uma formula que eu nao reproduzo, ou eu posso ter lido errado digitos pequenos nas capturas
de tela. Ficam como candidatos, nao como fatos.

### A estrutura aparece DUAS vezes

O bloco [1056..1200] se repete IDENTICO em [1436..1580] -- distancia de 380 bytes, 144 de 144
bytes iguais. Hipotese (nao verificada): uma copia e o valor atual e a outra o base, iguais
num personagem recem-criado porque nao ha buff nem progressao. Um personagem equipado ou com
buff separaria as duas.

### O que NAO esta no pacote

Ataque fisico e magico exibidos, evasao e bloqueio finais, velocidade de ataque: sao
DERIVADOS. O client calcula a partir dos 5 atributos base e do equipamento. O servidor nao
precisa mandar.


## A "segunda camada" nao existia: era o modo errado da cifra (23/08)

34 das 157 tabelas .lod pareciam ter uma camada extra de compressao -- entropia maxima, quase
sem zeros, e nenhum formato conhecido (zlib, deflate cru, bzip2, lzma) reconhecia. Eram
justamente as maiores: itemBase, itemBase_Name, itemAttr, skillData, skillLevel e as
SkillTree de nivel 2 e 3.

Nao havia camada nenhuma. Decompilando o leitor do client (FUN_007210d7 no AMo.exe), o laco
de decodificacao tem um flag:

    if ((saida == 0) && (leitor[0x24] != 0))  le um byte extra;

Esse [0x24] liga ou desliga o consumo de um byte apos cada ZERO decodificado. O nosso
decrypt_lod_stream pulava SEMPRE. Nas tabelas de texto isso passa (quase nao ha zeros), mas
nas binarias grandes desalinha tudo a partir do primeiro zero -- e o resultado com entropia
alta me fez concluir "esta comprimido". Testei tres algoritmos de descompressao contra um
problema que nao era de compressao.

`decrypt_lod_stream` agora aceita `pular_zero`. Regra pratica: se sair com entropia alta e
quase sem zeros, tente o outro modo.

### itemBase decodificado

    [0:4]   versao 20101118 (a mesma constante que o client compara em 14 lugares)
    [4:8]   contagem de itens (19806)
    depois: registros de tamanho variavel

    registro: 156 bytes de campos numericos, comecando pelo id, e 13 strings
              com o tamanho em LE u32 na frente
        string 0  nome         string 1  icone
        string 3  modelo       string 11 descricao

Os 19806 registros foram lidos em sequencia sem sobrar um byte, com id crescente.

Validacao independente: os ids do inventario que CAPTURAMOS do jogo resolvem para nomes
coerentes -- 16001 = "Bengala" (o cajado que a Maga tem equipado), 42001 = "Armadura do
novato", 54006 = "Pocao de saude do novato", 23001 = "Canhao vanisian".

### No banco

    dbo.item              19806 itens: id, nome, icone, modelo, descricao e os 156 bytes crus
    dbo.vw_inventario     junta personagem_item com o catalogo

Os 156 bytes numericos ficam guardados crus porque sabemos que carregam tipo, nivel minimo,
dano, defesa e preco, mas ainda nao qual campo e qual. Assim nao sera preciso reimportar
quando cada um for identificado.

PENDENTE: os ids 3971 e 34873 aparecem no inventario capturado do jogo mas NAO existem no
itemBase. Podem estar em outra tabela ou ser id de instancia em vez de tipo.

PENDENTE: as outras 33 tabelas opacas devem abrir com o mesmo `pular_zero=False` -- itemAttr,
skillData, skillLevel, skillLevel_Name e as SkillTree de nivel 2 e 3.

## Atributos de item decodificados (23/08)

Motivo: o `ItemList.xml` do MuOnline (referencia que o usuario mandou) tem 47 atributos por
item -- `ReqLevel`, `DamageMin/Max`, `AttackRate`, `Durability`, precos, permissao por classe.
O nosso `dbo.item` tinha nome, icone, modelo, descricao e um blob `campos VARBINARY(160)`.
Faltavam justamente os numeros.

### Os 156 bytes do itemBase sao 39 u32 LE

Nao havia estrutura escondida: e um vetor de inteiros de 32 bits, alinhado.

     0  id                          21  preco de compra
     2  codigo de classe/linha      25  preco de venda
     4  tipo (manipulador)          26  linha do icone na folha .dds
     5  categoria                   27  coluna do icone
     7  linha de arma               31  duracao em segundos (itens temporarios)
     9  slot de equipamento         34  grupo de modelo
    11  nivel minimo
    14  empilhamento maximo

Colunas 3, 10, 23 e 24 sao sempre zero; 1 e sempre 1. As restantes (6, 8, 12, 13, 15-20, 22,
28, 29, 30, 32, 33, 35-38) continuam sem nome -- ficam no blob `campos`, que foi mantido.

Como cada um foi confirmado:

- **col2 = classe**: os valores dividem 100 e caem exatamente nos nossos 7 ids -- 100, 200,
  300, 400, 500, 600, 800, e nenhum 700. A dezena e a linha de arma: 110 espadao e 120 lanca
  do Guerreiro, 310 adaga e 320 espada longa do Vagabundo, 810/820 do Artilheiro. Onde a
  classe so tem uma linha (Maga), arma e armadura dividem o mesmo 600.
- **col9 = slot**: 1 corpo, 2 pernas, 3 luvas, 4 botas, 7 arma, 8 escudo, 21 colar, 0 nao
  equipa. Bate item a item com o `container 0` dos kits iniciais que ja tinhamos capturado.
- **col11 = nivel minimo**: a serie de armaduras do Guerreiro sai 1, 5, 9, 14, 19, 25.
- **col14 = empilhamento**: 1 em equipamento, 100 nas pocoes.
- **col31 = duracao**: "Status VIP (3/7/30 dias)" da 259200 / 604800 / 2592000. Sao os
  segundos exatos. E o campo que vai controlar a expiracao do VIP e das platinas de EXP.
- **col26/col27 = posicao do icone**: 0..15 nos dois, e a serie 1001/1002/1003 anda
  (0,0) (0,1) (0,2). E uma grade 16x16 dentro do .dds.

### itemAttr: os numeros de combate

Registro de tamanho variavel, uma entrada por (item, refino):

    id u32, refino u32 (0..16), estrela u32, 13 doubles, 2 strings (u32 tam + bytes)

34336 entradas, 4262 itens distintos -- so o que e equipavel. Os doubles:

    d0/d1  dano fisico min/max      d6  defesa fisica
    d2/d3  dano magico min/max      d7  defesa magica
    d4     velocidade de ataque     d8..d12 bonus, ainda sem nome
    d5     alcance

`d5` fecha a identificacao sozinho: 10.0 em tudo que e corpo-a-corpo e 160.0 no canhao do
Artilheiro. `d4` acompanha -- 0,913 no espadao, 0,847 no cajado, 0,616 no canhao (arma
pesada, mais lenta). A primeira string e o arquivo de efeito visual do refino
(`HAM_BLADE_11._EF`); a segunda esta sempre vazia.

O refino cresce linear: espadao 3,53-9,83 em +0 e 4,01-10,31 em +1.

### No banco

    dbo.item            +14 colunas nomeadas, 19806 linhas preenchidas
    dbo.item_atributo   34336 linhas, chave (item_id, refino)

Importador: `tools/importar_atributos.py`. Parser: `tools/itembase.py`.

### O combate agora usa isso

`entity_sim.dano_do_golpe(entity_id)` sorteia entre o min e o max da arma equipada, lendo
`dbo.item_atributo` com cache em memoria. Antes era `DANO_SKILL = 12` igual pras 7 classes.
Com o equipamento inicial:

    Canhao (Artilheiro) 10-19      Arco grande (Arqueira) 8-14
    Bengala (Maga)       8-13      Adaga (Vagabundo)      4-6

Cajado usa o dano magico quando ele e maior -- a Bengala e 4-9 fisico contra 8-13 magico.
Sem arma equipada, ou com o banco fora do ar, cai em `DANO_POR_GOLPE = 25`.

PENDENTE: o refino ainda nao e persistido -- todo item entra como +0, porque ainda nao
sabemos onde ele mora nos 147 bytes do registro de inventario. `defesa_total()` ja soma a
defesa do que esta equipado, mas nada consome esse numero ainda: o mob ainda nao bate.

## A ficha (08 02) e calculada, nao copiada (23/08)

O jogador reclamou de duas coisas depois do primeiro teste: velocidade de ataque igual pra
todo mundo, e o cajado da Maga sem ataque magico. E completou: "o que manda e os atributos do
personagem". Estava certo -- a ficha vinha inteira do molde capturado, entao nao reagia nem
as caracteristicas nem ao equipamento.

### O molde oficial provou que a leitura do itemAttr esta certa

Procurando os numeros da arma inicial de cada classe dentro do molde 08 02 capturado do
servidor OFICIAL, tres offsets bateram exato nas SETE classes:

    [62]  alcance             10 / 10 / 10 / 12 / 140 / 10 / 160
    [94]  velocidade ataque   0,913 / 1,1 / 1,573 / 0,847 / 0,781 / 0,847 / 0,616
    [102] defesa + 21         46+21 / 51+21 / 42+21 / 35+21 / ... (soma do kit equipado)

Sao exatamente os valores que tiramos do itemAttr.lod. O servidor oficial manda no fio o
mesmo numero que a tabela do client guarda -- entao o d4 e velocidade, o d5 e alcance e o
d6 e defesa, sem duvida.

### Onze campos sao funcao das cinco caracteristicas

Ajustados sobre as classes 100, 200, 300, 400 e 800. Cada formula reproduz as cinco com erro
ZERO e coeficiente redondo:

    [42]  Vit * 0,75  + 6,25      [122] Int * 0,475 + 2,5
    [54]  Sab * 0,55  - 1,75      [126] Sab * 0,3   + 17
    [66]  Des * 0,05  + 6,5       [130] Des * 0,008 + Sab * 0,1 + 3,42
    [70]  Des * 0,1   + 8         [142] Vit * 0,6   + 2,275
    [98]  Des * 0,5   + 4         [170] Des * 0,452 + 2,08
    [110] Des * 0,35

### ERRO NOSSO: dois moldes nao eram de nivel 1

As classes 500 e 600 ficaram FORA do ajuste porque os moldes delas estao contaminados. O
teste e comparar o HP/MP do 08 02 com o do 02 05 da mesma captura:

    classe   08 02 [38]/[50]     02 05 hp_max/mp_max
    100      190,4 / 60          190 / 60     ok
    200      190,4 / 91          190 / 91     ok
    300      150,4 / 80          150 / 80     ok
    400      160,4 / 90          160 / 90     ok
    800      189,4 / 117         189 / 117    ok
    500      200,8 / 78          160 / 70     NAO BATE
    600      223,2 / 110         150 / 90     NAO BATE

223 de HP e 110 de MP sao exatamente os numeros da Maga em NIVEL 3, que ja tinhamos medido
(150 -> 186 -> 223 e 90 -> 100 -> 110). Ou seja: as capturas da Arqueira e da Maga foram
feitas com personagens que ja tinham subido de nivel. Todo Maga e toda Arqueira nossa entrava
no mundo com a ficha de um personagem nivel 3.

Enquanto nao recapturarmos as duas em nivel 1, `build_stats` sobrescreve HP/MP com o valor do
banco, o que corrige o sintoma sem depender do molde.

### Ainda vem do molde

`[38]` HP maximo, `[50]` MP maximo, `[90]` e `[118]`. Os quatro tem uma base POR CLASSE que
nao sai das caracteristicas. Nao esta no classInfo.lod (registro de 8414 bytes por classe,
oito registros incluindo um vazio pra 700) -- procurei os valores exatos la dentro e nao
aparecem.

Sobre `[118]`: nas quatro classes sem arma magica ele fecha em `Int * 1,54 + Sab * 0,48 - 4,8`.
A Feiticeira (400), que tem cajado, da 4,00 a mais. E o candidato a ataque magico, mas com uma
amostra magica so nao da pra afirmar de onde vem esse 4,00.

### As sete fichas do oficial em nivel 1 (23/08)

O jogador mandou captura de tela da ficha das SETE classes em nivel 1 no servidor oficial.
E a melhor referencia que temos: fecha o significado dos campos e da pra validar formula
contra numero de verdade, nao contra molde.

Confirmados campo por campo (o jogo TRUNCA inteiro e ARREDONDA decimal ao desenhar):

    [38] Saude          [90]  ATQ Fisico (base)    [126] Magico. protecao
    [42] Regen. de HP   [94]  Velocidade de ataque [134] Velocidade de invocacao (100 fixo)
    [50] Mana           [102] Protecao fisica      [138] Multiplicador de critico (1,2 fixo)
    [54] Regen. de MP   [118] ATQ Magico (base)    [170] Chance de Critico

Os campos ficam de 4 em 4 bytes a partir de 38 (varios zerados no meio).

**O "(+N)" da ficha e a arma, somada pelo CLIENT.** Os offsets 90 e 118 levam so a BASE; o
que aparece entre parenteses vem do itemAttr.lod que o proprio client tem. Bate nas sete:

    Guerreiro   19 (+3)  25 (+9)    espadao 3,53-9,83
    Artilheiro  20 (+10) 28 (+18)   canhao 10,17-18,97
    Feiticeira  27 (+8)  39 (+20)   cajado magico 8,26-20,91
    Maga        30 (+8)  35 (+13)   Bengala magica 8,37-13,32

E o mesmo vale pra protecao fisica: `[102] = 21 + soma das defesas`, e o (+46) do Guerreiro
e exatamente a soma do kit dele.

### Ataque magico: o coeficiente de Inteligencia DOBRA nas classes magicas

    normal (100,200,300,500,800):  Int * 1,54 + Sab * 0,48 -  4,80
    magica (400,600):              Int * 3,08 + Sab * 0,48 - 16,20

3,08 e o dobro exato de 1,54. A Maga (Int 11, Sab 9) da 22,00 de base, e com o cajado da
30 e 35 -- os numeros da ficha dela, casa na virgula.

### Ataque fisico base: e por classe, nao sai das caracteristicas

Nao existe combinacao linear de For/Des/Vit/Int/Sab que ajuste as cinco classes limpas E
sobreviva as duas retidas (500 e 600). Testei todos os subconjuntos de 2, 3 e 4 variaveis:
cinco ajustam com erro zero, e os cinco erram feio nas retidas -- e overfit, nao formula.
Entao guardamos o valor: `dbo.classe.atq_fisico_base`.

    100 16,075   200 11,900   300 10,950   400 6,400   800 9,875   (float exato do molde)
    500 11,500   600 6,975                             (do intervalo deduzido da ficha)

### Placar

Das 91 conferencias contra as sete fichas, 88 batem. As tres que sobram sao "Chance de
evasao" e "Chance de bloqueio":

    evasao mostrada:  6 6 7 8 7 6 5    para Des 7 7 11 9 10 8 5

Nao e monotonica em Destreza (Des 9 da 8, Des 10 e Des 11 dao 7), entao `Des * 0,05 + 6,5`
acerta cinco das sete e nao pode ser a formula inteira -- ou tem uma parcela do equipamento,
ou li errado algum digito da captura. Fica anotado como NAO RESOLVIDO em vez de arredondado
pra parecer certo.

## Dois bugs achados no teste da Maga (23/08)

### "Experiencia adquirida 24" no instante de logar

O jogador entrou com a Maga e a mensagem vermelha apareceu sem ele ter matado nada -- e com
o valor exato do abate da aranha.

A causa nao era a EXP: era o `_loop_sender`. Essa funcao existe desde 22/08 pra manter volume
de rede depois da rajada de entrada (sem trafego continuo o client fica com a carta de
"carregando" na frente). Ela reenvia em loop os 6000 frames de `enterworld_loop_v1.bin` --
trafego real gravado ao vivo, de OUTRO jogador, o personagem 10654.

Dentro desses 6000 frames vem tambem o estado DELE:

    18x 08 01  gage do heroi (com EXP 24, e um com 96)
     4x 08 02  a ficha de atributos
     1x 02 05  charinfo
     5x 06 03 / 06 04 / 06 06 / 06 28  inventario
     2x 08 00

Estavamos reescrevendo o jogador com o personagem capturado, em loop, o tempo todo. A
mensagem de EXP era so o sintoma mais visivel.

Agora o loop so deixa passar o que e ambiente de verdade -- `04 01` movimento, `04 02`
entidade aparece, `04 03` entidade sai, `08 06` sincronia de HP. Sao 5895 dos 6000 frames,
entao o volume de rede que a funcao existe pra manter nao mudou; os 105 descartados eram
exatamente os que nao podiam ser reenviados.

### Conta com 3 personagens mostrava 1

`montar_lista` montava `personagens[0]` mais tres slots vazios, FIXO, desde que foi escrita.
A conta mu6020 tem tres personagens no banco (Maga 600, arqw 500, tess 300) e a tela mostrava
so o primeiro. Nao era perda de dado: os outros dois nunca chegavam a ser enviados.

Agora monta um frame por personagem (ate os 4 slots), vazios primeiro, e a flag de ULTIMO no
ultimo personagem de verdade -- que e o que o FUN_00651331 le pra decidir entre abrir a tela
de criacao e entrar no jogo. A ordem e por entity_id, fixa: o handler do `02 04` move o
escolhido pro inicio da lista, e sem ordenar a tela embaralharia ao voltar.

De quebra, a lista tambem levava nivel, HP e MP do MOLDE da classe. Todo personagem aparecia
como nivel 1 na selecao. Agora vem do banco:

    [26:28] BE u16  HP maximo      [32:36] BE u32  MP maximo      [52:56] BE u32  nivel


### Correcao da lista de personagens: tres tentativas, dois erros de leitura e um de metodo

Historico honesto, porque o erro de metodo e o mais caro:

1. `montar_lista` montava `personagens[0]` + tres vazios, FIXO. Corrigido pra montar N frames.
   Testei a funcao isolada, deu certo, reiniciei -- a tela continuou mostrando um personagem.
2. Fui ao log do OFICIAL (classes_100_200_300_800_0823.log, conta com quatro personagens) e
   achei o formato de verdade: UM `02 0b` de 23 bytes, N frames de `02 01` colados, flag so no
   ultimo, e NENHUM vazio de enchimento. A gente repetia o 02 0b antes de cada frame. Corrigi,
   testei a funcao isolada de novo, reiniciei -- a tela CONTINUOU mostrando um personagem.
3. So entao fui ler o log do NOSSO servidor, que dizia:

       [hop3] conta 'mu6020': personagem 'Maga' (id=20101 classe=600 nivel=2) enviado

   Singular. Existia uma SEGUNDA copia da montagem escrita direto no `handle_client`, e era ela
   que rodava na conexao. A `montar_lista` so era chamada depois de criar ou apagar personagem.
   Corrigir a montar_lista duas vezes nao mudou nada na tela porque o caminho da conexao nunca
   passava por ela.

As duas primeiras correcoes estavam certas e continuam valendo (o formato do oficial e mesmo
esse). O erro foi ter validado a FUNCAO isolada duas vezes seguidas em vez de olhar o log do
servidor rodando -- que dizia "personagem" no singular desde a primeira tentativa.

Ja tinhamos levado esse mesmo golpe no `db.py`, com `criar_personagem` e `personagens_da_conta`
definidas duas vezes e a versao velha vencendo em silencio. Vale procurar outras duplicatas
antes que a terceira apareca.

## Velocidade de ataque e protecao fisica: o pacote esta certo, o efeito nao (23/08)

Comparando a ficha do Vagabundo lado a lado -- oficial contra o nosso -- duas linhas divergem:

    campo                  oficial      nosso
    Protecao fisica        63 (+42)     21
    Velocidade de ataque   1,57         0,87

Tudo o mais bate: Saude 150, Mana 80, ATQ Fisico 15 (+4) / 17 (+6), ATQ Magico 11, Magico
protecao 19, Chance de Critico 7,1, regeneracoes, evasao e bloqueio.

Repare que 0,87 e 21 sao exatamente os valores do BLOCO 2 do 08 02 (offsets 474 e 482), e
1,57 e 63 sao os do BLOCO 1 (offsets 94 e 102).

### O que o oficial faz: manda o 08 02 DUAS vezes

Extraindo todos os 08 02 dos logs do oficial, o padrao aparece nas sete classes:

    #1  [94]=0,872  [102]=21   |  [474]=0,872  [482]=21     <- antes do inventario
    #2  [94]=1,573  [102]=63   |  [474]=0,872  [482]=21     <- depois do inventario

Ou seja: **bloco 1 = com equipamento, bloco 2 = sem equipamento**, e o servidor reenvia a
ficha depois de mandar o 06 05. A ordem oficial e:

    08 02 (sem) -> 08 01 -> 08 00 -> 06 05 -> 06 28 -> 08 02 (com) -> 08 01 -> 08 00

O nosso stream de entrada tem exatamente essa ordem, e os dois 08 02 sao trocados pelo
build_stats, que ja escreve os valores COM equipamento nos dois.

### O que foi conferido byte a byte, e bate

Gravei o que o servidor realmente mandou (`captures/saida_servidor.log`) e comparei com o log
do oficial pro MESMO personagem (Vagabundo nivel 1, mesmo kit):

    08 02   794 bytes: 4 bytes diferentes -- 2 sao o numero de sequencia, e os outros 2 sao
            o HP em [38] (150,0 nosso contra 150,4 do oficial). [94]=1,573 e [102]=63,0 nos
            DOIS.
    06 05   os cinco registros equipados sao IDENTICOS byte a byte, cabecalho incluso
    06 28   identico fora o numero de sequencia
    04 02   proprio personagem: os bytes 60 a 246 sao IDENTICOS, e e ali que ficam os ids do
            equipamento visivel (a1 8c = 36001, 29 23 = 9001, etc.)

Tambem foi descartado:
  - o `_loop_sender` nao reenvia 08 02 (o filtro esta ativo -- so 04 01/04 02/04 03/08 06),
    e alias nem chega a ser chamado: e codigo morto hoje;
  - o `06 0c` que o client manda 17 vezes NAO tem resposta no oficial, entao ignorar esta certo.

### Conclusao honesta

Mandamos praticamente os mesmos bytes do oficial e o client mostra outra coisa. Nao da pra
resolver por deducao em cima dos pacotes que ja temos: falta ver a sessao NOSSA pelo lado do
client. Proximo passo e capturar com a hook.dll enquanto se joga no nosso servidor e comparar
o stream inteiro, nao so os pacotes que eu escolhi olhar -- foi assim que cairam o bug do
nivel, o da barra de HP e o da lista de personagens.

### O id do item no inventario tem QUATRO bytes, nao dois (23/08)

Ficava anotado como pendente: "os ids 3971 e 34873 aparecem no inventario capturado mas NAO
existem no itemBase". Nao existiam porque nao eram ids -- eram a metade baixa deles.

    registro do item: 01 03 39 88 8b 3b 00 00 ...
                            ^^^^^^^^^^^ [2:6] u32 LE = 999000121

    34873 = 0x8839 e a metade baixa de 999000121 = "Um presente de principiante (NV.1)"
     3971 = 0x0F83 e a metade baixa de     69507 = "Bala magica"

Os dois nomes fazem sentido total pro conteudo: um presente de novato e a municao do
Artilheiro (o unico personagem que tinha o 3971 era o Kabooom, classe 800).

`build_inventario` escrevia `<H` nesse campo. Nao quebrou ate agora so por sorte: a gente
copia o registro cru do banco e reescrevia os dois bytes baixos com o mesmo valor truncado,
entao os quatro bytes continuavam certos. Qualquer item novo acima de 65535 -- e o catalogo
vai ate 1000101726 -- entraria errado. Corrigido pra `<I`, e os ids no banco foram acertados
nas duas tabelas.

### Conjunto (set): as tabelas existem, e nao estao no banco (23/08)

O jogador levantou que a ficha nao soma os atributos de conjunto. Duas coisas separadas:

**A armadura ESTA registrada.** `dbo.item_atributo` tem 1583 itens de categoria 2 com 19237
linhas carregando defesa. As quatro pecas do Vagabundo dao 14 + 13 + 8 + 7 = 42, que e
exatamente o "(+42)" que o oficial mostra. Nao e por falta desse dado que a ficha trava em 21.

**Mas o sistema de conjunto e um andar inteiro que falta.** As tabelas estao no client:

    itemMixedSetEffect   364 linhas de 12 bytes: (id do conjunto, pecas, id do efeito)
                         182 conjuntos, sempre em par -- uma linha pra 3 pecas e outra pra 4
                         exemplo: (1, 3, 60204) e (1, 4, 60205)
    itemMixedOption     1176 linhas de 16 bytes: (item, flag 0/1, id do efeito, valor 1/2)
                         exemplo: (700153, 0, 60188, 1)

Os ids de efeito (60188..60244 ate agora) NAO estao no String.lod, entao ha pelo menos mais
uma tabela descrevendo o que cada efeito faz numericamente. Candidatas ainda nao abertas:
`uniqueOptionType`, `uniqueOptionDrop`, `skillOptionMaxValue`, `ItemEnchant`,
`itemOptionExtract`, `itemMixedExchangeOption`, `fletaItemEnchant`, `blessingEnchant`.

Tambem falta achar onde o item declara a QUAL conjunto pertence: os ids de conjunto (1..9028)
nao batem com nenhuma das 39 colunas do itemBase.

## Ghidra headless funcionando, e o mapa de classes do protocolo (23/08)

O plugin GhidraMCP nao aparece na lista de plugins desta instalacao, entao parei de brigar com
a interface: copiei o projeto pra `D:\RoshEmulator\ghidra` (a copia evita o lock do projeto
aberto na GUI) e passei a rodar tudo por `analyzeHeadless` com script proprio. Scripts em
`D:\RoshEmulator\ghidra\scripts`:

    DumpSymbols.java   lista simbolos por substring
    Decomp.java        decompila enderecos passados como argumento + quem chama
    Xrefs.java         so as referencias
    ExploreEquip0823.java / ExploreInven0823.java   RTTI -> vtable -> decompila

O AMo.exe tem **1264 classes com RTTI**, e as de pacote seguem o padrao
`CXxxPacket@AMOPacket@@`. Isso e o mapa do protocolo inteiro, com nome, de graca.

### Opcodes novos, tirados do binario

O `vtable[1]` de cada classe de pacote e o matcher: confere `payload[0]` e `payload[1]` e
mede o tamanho minimo. Lendo esses matchers:

    06 00  CItemWearPacket             EQUIPAR      (min 0x11 = 17 bytes)
    06 01  CItemPutoffPacket           DESEQUIPAR   (min 0x10 = 16)
    06 05  CItemInvenListPacket        lista de inventario
    06 06  CItemInvenListPacket        segunda variante (min 0x9d = 157)
    06 3f  CItemInvenListPacket        terceira variante (min 0x17)
    06 28  CItemEquipslotStatePacket   estado dos slots (min 0x86 = 134)

O 0x86 do `06 28` casa exatamente com os 134 bytes que capturamos, o que confirma a leitura.

### O portao do handler de inventario

`FUN_00657e08` processa o `06 05`, e antes de fazer qualquer coisa:

    uVar3 = ntohl(*(u_long *)(payload + 6));   // id no pacote, big-endian
    uVar4 = <id do jogador local>;
    if (uVar3 == uVar4) { ...processa... }      // senao, DESCARTA calado

Ou seja: o inventario inteiro e ignorado em silencio se o id em `[6:10]` nao for o do
personagem local. O nosso manda 20103 e o personagem e o 20103, entao esse portao passa --
mas fica anotado, porque e o tipo de conferencia que explica "sumiu e ninguem avisou".

## Equipar e desequipar, com o item gravado no banco (23/08)

### Os pacotes

Do RTTI do AMo.exe: `06 00` = CItemWearPacket (equipar), `06 01` = CItemPutoffPacket
(desequipar). O formato saiu de duas fontes que se confirmam:

  - a CONFIRMACAO, nas capturas do servidor oficial (uma troca de arma real);
  - o PEDIDO, capturando o nosso proprio client enquanto o jogador arrastava a Adaga pra fora
    do slot. Ele mandou DUAS vezes, com 0,8s de intervalo, porque a gente nao respondia.

        06 01 00 00 00 4b | 00 00 4e 88 | 00 | 07 | 07 | 00 00 23 29
                    seq      id 20104          slot      item 9001 (Adaga)

    [6:10] entity_id BE   [11] e [12] o slot (repetido)   [13:17] id do item BE u32
    o 06 00 tem um byte 0 a mais no fim (18 bytes contra 17)

**Pedido e confirmacao tem o MESMO formato**: aceitar e devolver o mesmo pacote. Foi conferido
byte a byte -- a confirmacao que montamos sai identica ao pedido que o client mandou.

Nenhum dos dois diz PRA ONDE o item vai na mochila: quem escolhe e o servidor. Por isso
desequipar procura o primeiro slot livre do container 1, e depois reenviamos o `06 05` inteiro
pra nao deixar o client adivinhando.

Na troca de arma o oficial manda os tres juntos, no mesmo milissegundo:

    06 01 (tira o antigo) -> 06 00 (poe o novo) -> 08 00 (HP/MP)

E **nao manda 08 02**. Mais uma confirmacao de que a ficha e recalculada pelo client.

### No banco

`dbo.personagem_item` ja tinha (entity_id, container, slot, item_id), entao equipar e mover a
linha do container 1 pro 0 no slot pedido, e desequipar e o contrario. Grava na hora: o item
nao some ao relogar, e nao existe estado de equipamento fora do banco.

    db.pode_equipar(personagem, item_id)   confere slot, classe_req e nivel_req do dbo.item
    db.equipar(entity_id, item_id, slot)   troca o que ja estava no slot, mandando pra mochila
    db.desequipar(entity_id, item_id, slot)
    db.primeiro_slot_livre(entity_id)

Testado o ciclo inteiro no Vagabundo tesess:

    tirar a Adaga     -> mochila slot 3, dano da arma vira None (cai no DANO_POR_GOLPE)
    por de volta      -> slot 7, dano 4-6 de novo
    tirar a armadura  -> defesa 42 -> 28 (exatamente os 14 da peca)
    por de volta      -> 42
    Bengala na mao do Vagabundo -> recusado: "'Bengala' e da classe 600"

### O campo que diz "estou vestido" e o `[6:8]`, nao o container (23/08)

Depois de desequipar a Adaga e relogar, ela aparecia na mochila -- certo, o banco guardou --
mas a ficha continuava mostrando o "(+4)" dela no ATQ Fisico. Item na mochila, bonus ativo.

O registro de 147 bytes tem DOIS campos de posicao:

    [0]     container   0 equipado, 1 mochila
    [1]     slot dentro do container
    [6:8]   SLOT DE EQUIPAMENTO, u16 LE, 0 quando o item nao esta vestido

O `bruto` guardado no banco e o registro ORIGINAL, capturado com o item vestido. Ao montar o
`06 05` a gente reescrevia so `[0]` e `[1]`; o `[6:8]` continuava dizendo 7. O client mostra o
item na mochila (olhando `[0]`/`[1]`) mas soma os atributos dele (olhando `[6:8]`).

Corrigido no `build_inventario`: `[6:8]` recebe o slot quando o container e 0, e zero quando
nao e. Com isso o banco volta a ser a unica fonte da verdade -- o `bruto` nao mente mais.

Outros campos do registro, ja identificados de passagem:

    [2:6]   id do item, u32 LE
    [8:12]  id da INSTANCIA, u32 LE (unico por copia: 0x17A411, 0x18A411, ...)
    [16]    quantidade (1 em equipamento, 100 nas pocoes)

## A sequencia real de equipar, capturada em vez de adivinhada (23/08)

O jogador cortou o meu ciclo de tentativa e erro com a pergunta certa: "nao e melhor capturar
no server original ao inves de tentar adivinhar?". Estava certo -- eu tinha mandado o 06 05
inteiro, tirado, botado 04 02, tirado de novo, tudo por deducao. A captura respondeu de
primeira.

O que faltava era um caso especifico: a unica captura que tinhamos era de um ARRASTAR, e o que
esta quebrado e o CLIQUE-DIREITO. No arrastar o client ja sabe o slot de destino; no
clique-direito quem escolhe e o servidor.

### Cinco pacotes por clique, nesta ordem

    desequipar:  06 01 (eco) -> 06 06 antes -> 06 06 depois -> 08 02 -> 08 01
    equipar:     06 06 antes -> 06 06 depois -> 06 00 -> 08 02 -> 08 01

### O 06 06 e o par "antes/depois"

`CItemInvenListPacket` tem uma variante de UM item so, com 158 bytes:

    [0:2] 06 06   [2:6] seq   [6:10] entity_id BE   [10] 01 (contagem)
    [11:158] o MESMO registro de 147 bytes do 06 05

E o truque esta na QUANTIDADE, em `registro[16]` (u32 LE):

    1o pacote: posicao ANTIGA, quantidade ZERO   -> o client tira o item dali
    2o pacote: posicao NOVA,   quantidade real   -> o client poe o item la

Eu tinha lido esse 0/1 como um byte de indice no fim do pacote, e por isso o meu 06 06 saia
com 159 bytes. E quantidade, e fica no meio do registro.

### O pedido de equipar NAO e eco

No desequipar sim, mas no equipar as duas pontas sao diferentes:

    pedido    [10]=01 [11]=03 [12]=ff    container 1, slot 3 da mochila, ff = "escolhe voce"
    resposta  [10]=00 [11]=01 [12]=01    container 0, slot 1: a posicao FINAL

`[10]` e o CONTAINER (nao um zero fixo, como eu tinha assumido). No desequipar quem escolhe o
slot da mochila e o client -- ele pega o primeiro livre, o mesmo que o nosso `db.desequipar`
ja calculava -- e o par de 06 06 confirma onde ficou.

### Conferido

Os pacotes que montamos saem byte a byte iguais aos do oficial, mudando so o id do personagem
e o do item:

    nosso   06 06 00 00 02 2f 00 00 4e 88 01 00 07 29 23 00 00 07 00 11 a4 16 00 52 ff 8a 6a 00
    oficial 06 06 00 00 02 2f 00 00 2a 80 01 00 07 81 3e 00 00 07 00 11 a4 0f 00 57 1b 8b 6a 00

### De quebra: a soma do set existe e e do client

A ficha da Maga no oficial marca `Protecao fisica 54 (+33)`, e 33 = 11+10+6+6, as quatro pecas
dela. Some com 21 de base. Ou seja: o client SOMA o conjunto -- so nao estava somando pra nos
porque nunca recebeu os eventos que registram o equipamento do jeito que ele espera.

### Mover item dentro do inventario: 06 08 (23/08)

Do RTTI, com o script headless:

    06 08  CItemInvenSwapPacket      mover / trocar de lugar   (min 0x15 = 21 bytes)
    06 0a  CItemInvenPutoutPacket                              (min 0x17)
    06 06  CItemInvenPutinPacket                               (min 0x9d)

O `06 06` ser "Putin" fecha o sentido do par antes/depois: sao dois "poe o item aqui", um com
quantidade zero na posicao velha (tira) e outro com a quantidade real na nova (poe).

O formato do 06 08 saiu de um pedido REAL do proprio jogador, que ja estava numa captura de
hoje de manha -- ele arrastou uma pocao dentro da mochila:

    06 08 00 00 00 40 | 00 00 4e 88 | 01 | 00 | 00 00 d2 f6 | 01 | 04 | 00 00 00 00
                         id 20104    cont slot   item 54006  cont slot
                                     --origem--              -destino-

ATENCAO: aqui o id do item fica em `[12:16]`, e nao em `[13:17]` como no 06 00 / 06 01.

Se o destino estiver ocupado, os dois itens TROCAM de lugar, e a resposta leva o par de 06 06
de CADA um (quatro pacotes), mais o eco do 06 08.

### Bug latente que so apareceu aqui: o slot temporario era -1

Pra tirar um item do caminho antes de por o outro, eu marcava o slot como -1. A coluna `slot`
e `tinyint`: o SQL Server devolvia "arithmetic overflow error for data type tinyint, value =
-1" e a troca falhava calada, deixando o item onde estava.

Nao tinha aparecido antes porque so acontece quando o destino ESTA OCUPADO -- e ate agora so
tinhamos testado equipar em slot vazio. O mesmo bug estava no `db.equipar`: trocar uma peca
por outra ja equipada teria falhado do mesmo jeito. Agora o marcador e 255.

### A pilha de 100 pocoes virando 1: a coluna quantidade nunca foi preenchida (23/08)

O jogador moveu uma pocao e ela apareceu com 1 unidade em vez de 100. Nada foi perdido -- o
banco estava certo, a pocao de mana estava no slot 4 e a de vida no 1. O erro estava no
pacote.

A quantidade fica em `registro[16:20]` (u32 LE). A coluna `dbo.personagem_item.quantidade`
nunca tinha sido preenchida a partir do registro: valia 1 pra TUDO, inclusive pras pocoes que
o registro capturado dizia 100. Enquanto a gente so repassava o `bruto` inteiro isso nao
aparecia; no `06 06` eu escrevo a quantidade no registro, e escrevi o 1.

Corrigido em tres lugares:

  - `dbo.personagem_item.quantidade` preenchida a partir do `bruto` (52 linhas)
  - `dbo.classe_item_inicial` ganhou a coluna, preenchida do mesmo jeito (57 linhas), e o
    `dar_itens_iniciais` passou a copiar -- senao personagem novo nasceria com 1 pocao
  - `registro_item` agora ESCREVE a quantidade do banco no registro, entao o banco virou a
    fonte da verdade tambem pra isso, do mesmo jeito que ja e pra posicao

## A protecao fisica: o par de 08 02 do login (23/08)

Fechado. A ficha nao mostrava a soma do set porque o client usa os DOIS `08 02` do login como
um par base -> total, e a gente mandava o mesmo valor nos dois.

Capturado do oficial, Maga nivel 1:

    08 02 #1   [102] = 21,0   [94] = 0,866      sem equipamento
    08 02 #2   [102] = 54,0   [94] = 0,847      com equipamento

O client desenha o total e mostra a DIFERENCA como o "(+33)" verde. Com os dois iguais a
diferenca da zero e ele cai pra base -- os 21 que o jogador via.

O bloco 2 do pacote (offset + 380) ja guardava o valor sem equipamento; agora o `build_stats`
tem o parametro `com_equipamento`, e a substituicao no stream manda o primeiro `08 02` com ele
desligado e os seguintes ligado.

### Isso tambem explica o teste das sentinelas

Eu tinha concluido, com 18 valores absurdos que nao apareceram na tela, que a ficha nao lia o
08 02. Errado: eu escrevi os MESMOS valores absurdos nos DOIS pacotes, entao nao havia par
valido e o client ignorou os dois. Os offsets `[94]` e `[102]` estavam certos desde o comeco.

Licao: um teste que quebra os dois lados de um par nao testa o par -- testa o tratamento de
erro. Se eu tivesse posto a sentinela em UM so, a resposta teria vindo na primeira rodada.

### De quebra: as bases da 500 e da 600 no bloco 2

O bloco 2 vem do molde, e os moldes dessas duas classes sao de personagem fora do nivel 1 (a
Maga marcava 21,9 de base em vez de 21,0). O `build_stats` agora sobrescreve com o
`atq_fisico_base` do `dbo.classe` e com o `BASE_DEFESA`, entao os seis campos saem identicos
aos do oficial.

## Troca de item: as remocoes tem que vir todas antes das insercoes

O jogador arrastou a pocao de mana em cima da de vida e a de vida sumiu da tela. O banco
estava intacto -- era a ORDEM dos pacotes:

    sai 54006 do slot 13 -> entra 54006 no slot 5 -> sai 54206 do slot 5 -> entra 54206 no 13
                                                              ^ apagou o slot recem-ocupado

O client remove pela POSICAO, nao pelo id do item. Agora vao todas as remocoes primeiro.

## Por que a arma somava e o set nao: o bloco 2 estava incoerente (23/08)

O jogador fez a pergunta que fechou o caso: "tem o que voce fez pra somar a arma, porque ela
esta somando certinho". A resposta apareceu no diff COMPLETO do 08 02, oficial contra nosso,
mesmo personagem e mesmo kit -- 42 bytes diferentes, e quase todos no BLOCO 2:

    [418]  oficial 150,4   nosso 223,2      HP base
    [422]  oficial  10,75  nosso  12,45
    [434]  oficial   3,20  nosso   3,44
    [498]  oficial  22,46  nosso  23,12
    [506]  oficial  19,70  nosso  20,00

O bloco 2 sai 380 bytes depois do bloco 1 e guarda os mesmos campos SEM equipamento. Ate agora
ele vinha INTEIRO do molde -- e os moldes da 500 e da 600 sao de personagem fora do nivel 1.
A Maga mandava HP base 223,2 (o HP dela em nivel 3) no bloco 2 enquanto o bloco 1 dizia 186.
Base maior que o total. O client via o par incoerente e descartava, desenhando so a base: os
21 de protecao que nao saiam do lugar.

**E o ataque escapava porque [90] e [470] sao IGUAIS nos dois blocos** -- ali nao havia
contradicao pra detectar, entao ele somava a arma normalmente. Era essa a assimetria que me
custou a tarde inteira: eu procurava algo especifico da armadura, e o problema era a coerencia
do pacote como um todo.

Agora o bloco 2 e copiado do bloco 1 (`p[418:554] = p[38:174]`) e so os quatro campos que o
equipamento mexe voltam pro valor base: alcance, ataque fisico, velocidade e defesa.

### O que me atrasou, pra nao repetir

1. Testei as sentinelas nos DOIS pacotes do par ao mesmo tempo. Isso nao testa o par, testa o
   tratamento de erro -- e me fez concluir que a ficha nao lia o 08 02, que era falso.
2. Comparei varias vezes os pacotes que EU escolhia olhar, achei todos iguais e concluí
   "mandamos os mesmos bytes". So o diff mecanico dos 794 bytes mostrou os 42.

## Protecao fisica: onde a investigacao parou (23/08)

NAO fechado. O que ficou provado, pra quem retomar nao repetir:

**1. A ficha e calculada pelo CLIENT, e ele le o BLOCO 2 como base.**
Sentinela em UM lado so do par (bloco 1 com 202, bloco 2 intacto com 21): a tela mostrou 21.
O mesmo com a velocidade -- mandamos 3,33 no `[94]` e a tela mostrou 0,87, que e o `[474]`.
Entao a tela desenha `[482]`/`[474]` (bloco 2) e SOMA o equipamento por conta propria.

**2. O `[102]` e o campo certo no protocolo, mesmo sem ser o exibido.**
Entre os pacotes de 54 e de 43 do oficial (uma peca a menos) mudam TRES bytes: o numero de
sequencia e os do float em `[102]`. O servidor oficial calcula e manda; o client ignora pra
desenhar, mas alguem usa (provavelmente o calculo de combate).

**3. O client soma a ARMA e nao soma a ARMADURA -- com pacotes iguais aos do oficial.**
Comparado byte a byte contra captura do oficial, mesmo personagem e mesmo kit:

    08 02   so diferem sequencia, id e o que muda por nivel (nossa Maga e nivel 2)
    06 05   so diferem o id de instancia [8:12] e o timestamp [12:16]
    06 28   identico
    04 02   identico no vetor de equipamento (bytes 60..246)
    02 05   so diferencas legitimas (nivel) mais [978:1003], 25 bytes de dados que o nosso
            molde tem zerados -- parecem uma permutacao (0e 27 1b 17 2f 29 ...), candidatos a
            layout de quickslot, nao a atributo

E o MESMO client, com as MESMAS tabelas .lod, recebendo bytes equivalentes, se comporta
diferente. Isso so fecha se a diferenca estiver em algo ainda nao comparado.

**4. Pista do jogador:** a "Reputacao" da ficha vai de 8 pra 48 quando ele tira uma peca. Um
campo reagindo ao equipamento no lugar errado -- e o melhor fio pra puxar.

### O que ja foi mapeado no binario, pra continuar

    itemAttr.lod carregado por FUN_00426e92 -> FUN_00636a55 -> FUN_006368b5
    entrada da tabela em memoria: 483 bytes (0x1e3), acessada por FUN_0062a596
    a ficha e a classe CGuiHeroCharInfoEx, rtti@0089b5dc, vtable@00802354
    o parser do 06 05 e FUN_00657e08, e ele DESCARTA calado se o id em [6:10] nao for o local

Falta achar a funcao que soma a defesa das pecas. Ela nao passa pelo `FUN_0062a596` (so o
parser usa), entao o client copia os atributos pro objeto do item na hora de receber o
inventario -- e o caminho e achar essa copia.

### Nota lateral: por que o plugin GhidraMCP nunca aparece

O log do headless mostra "Module manifest file error on line 2 of file:
...\Ghidra\Extensions\GhidraMCP\Module.manifest". O manifesto esta malformado, por isso o
plugin nao entra na lista de plugins da GUI. Nao atrapalha o headless.

## Reputacao identificada: era o "contador45" (23/08)

O jogador reparou que a Reputacao da ficha ia de 8 pra 48 e explicou o que o campo e: e a
MORAL do personagem -- sobe a cada mob abatido, o teto e 51200 (50 * 1024), e fica NEGATIVA
quando se mata outro jogador (o personagem vira PK).

Isso identificou um campo que estava anotado ha dias como "contador45, +8 por abate, sem
identificacao". Confirmado no fio: `08 01` `[45:49]` u32 LE. O personagem novo do oficial manda
0 ali; a nossa Maga mandava 48, que sao exatamente os 6 abates dela vezes 8.

    [45:49]  u32 LE   REPUTACAO      +8 por abate, teto 51200, negativa em PK
    [57:61]  u32 LE   ainda sem nome, +12 por abate
    [77:81]  u32 LE   EXP para subir de nivel (oficial nivel 1 = 48, nossa nivel 2 = 216)

A coluna `dbo.personagem.contador45` virou `reputacao`, a procedure `salvar_progresso` foi
atualizada junto, e o teto passou a ser aplicado no ganho.

O 8 -> 48 que ele viu, entao, nao era o equipamento mexendo em coisa errada: era o valor real
da reputacao dele aparecendo. Uma pista boa que acabou identificando outra coisa.


## Protecao fisica: a captura de um personagem NOVO fechou a conta (23/08)

Ideia do jogador, e foi a certa: as capturas que tinhamos do oficial eram de personagem com
historico. Criar um NOVO la e o estado mais proximo do que o nosso servidor produz -- nivel 1,
kit inicial, nada mais -- e anotar a tela ANTES e DEPOIS de tirar uma peca da o antes/depois
do fio e da tela ao mesmo tempo.

### O mecanismo, agora sem duvida

Vagabundo nivel 1 recem-criado, no oficial:

    08 02 #1  [102]=21  [482]=21     (login, antes do inventario)
    08 02 #2  [102]=63  [482]=21     tela: "Protecao fisica 63 (+42)"
    08 02 #3  [102]=49  [482]=21     tela: "Protecao fisica 49 (+28)"

O client desenha o `[102]` e mostra `[102] - [482]` entre parenteses. 63-21=42, 49-21=28.
Bate nos dois. Os offsets estavam certos desde o comeco.

### A diferenca que sobrava eram 6 bytes, e 4 deles eram o HP

Comparando o nosso 08 02 com o do oficial, mesmo Vagabundo nivel 1 e mesmo kit:

    [4:6]      numero de sequencia         (tem que diferir)
    [8:10]     id do personagem            (tem que diferir)
    [38:40]    HP: oficial 150,4, nosso 150,0
    [418:420]  o mesmo HP no bloco 2

**O HP tem parte fracionaria no jogo.** O oficial manda 190,4 / 150,4 / 160,4 / 189,4 --
sempre com o ,4, em todas as classes. O molde ja trazia o valor certo e EU o destruia: pra
consertar os moldes contaminados da 500 e da 600, eu sobrescrevia `[38]` com o inteiro do
banco, e de quebra matava a fracao em todas as outras.

Agora a sobrescrita so acontece quando a parte INTEIRA discorda do molde. Com isso o nosso
08 02 ficou diferindo do oficial em exatamente 2 bytes: a sequencia e o id.

### O que isso ensina sobre o metodo

Passei a tarde comparando pacotes e concluindo "sao iguais" -- e eram, em tudo que eu escolhia
olhar. A diferenca estava num campo que eu mesmo tinha mexido por um bom motivo (moldes
contaminados) e nunca reconferi contra o oficial depois de mexer.

## FECHADO: a protecao fisica era o ID DO PERSONAGEM faltando no 08 02 (23/08)

Dois bytes. O `build_stats` nunca escrevia `[6:10]`, entao o pacote saia com o id do
personagem que foi CAPTURADO -- 10860 no molde da classe 300, 10654 no da 600 -- e o client
descartava tudo em silencio.

E a conferencia estava documentada aqui desde a tarde, no handler do inventario:

    if (ntohl(*(u_long *)(payload + 6)) == <id do jogador local>) { ...processa... }

Eu tinha achado o portao, escrito "fica anotado, porque e o tipo de conferencia que explica
'sumiu e ninguem avisou'" -- e nao fui conferir se os NOSSOS pacotes passavam por ele.

### Como caiu

Ideia do jogador: "nao quer fazer um backup e retirar os moldes e tentar corrigir sem eles?".
Isso sugeriu o teste que separou as hipoteses de uma vez -- mandar os bytes CRUS capturados do
oficial, so trocando sequencia e id. A ficha mostrou 63 (+42) na hora. Com o pacote do oficial
funcionando e o nosso nao, a diferenca tinha que estar entre os dois; o diff A/B deu 2 bytes,
e eram o id.

### Por que os outros pacotes nao tinham o problema

`build_inventario` e `build_gage` recebem como molde o FRAME DO STREAM (`q[4:]`), e o stream
passa por um patch de id antes da substituicao. `build_charlist`, `build_charinfo` e
`build_self_appear` escrevem o id explicitamente. So o `build_stats` usava molde de classe
sem reescrever.

### Tambem corrigido nesta rodada

  - **Velocidade de ataque**: fica em `[39:43]` do `08 01` E do `08 00`, nao no 08 02. O
    oficial manda a base da classe no primeiro gage e a velocidade da ARMA em todos os
    seguintes. O nosso mandava 0,866 sempre -- a base da Maga, herdada do molde do gage.
  - **Fracao do HP**: o jogo usa 190,4 / 150,4 / 160,4 / 189,4. Eu sobrescrevia com o inteiro
    do banco e matava o ,4. Agora so sobrescreve quando a parte inteira discorda.
  - **Kit do Vagabundo**: faltava a Espada Longa (11001) na mochila, que o oficial da. O
    registro foi copiado da captura.

### A licao que fica

Passei horas comparando pacotes e concluindo "sao iguais", porque comparava os campos que EU
escolhia olhar. O diff mecanico dos 794 bytes contra uma captura do MESMO caso (Vagabundo
nivel 1, mesmo kit) foi o que achou -- e so foi possivel porque o jogador propos criar um
personagem novo no oficial em vez de usar as capturas antigas.

## Corrigido junto: defesa zerada voltava pro molde (23/08)

Tirando as pecas uma a uma a defesa baixava certinho -- 42 -> 28 -> 15 -> 7 -- e a ULTIMA
fazia saltar de volta pra 63 (+42). Causa: `if defesa:` antes da escrita. Com zero a escrita
era pulada e o campo ficava com o valor do MOLDE, que na classe 300 e justamente 63.

Agora escreve sempre. Sem equipamento os campos voltam pro valor base do bloco 2. O mesmo
valia pra velocidade: sem arma ela mantinha a da arma anterior, agora volta pra base da classe.

Padrao que vale generalizar: **guardar a escrita com `if valor:` num campo que vem de molde e
o mesmo que herdar o valor de outro personagem.** Melhor escrever sempre, com o valor base
explicito.

## Senha em texto puro no NOSSO log (23/08)

Os tres servidores faziam `print(f"LOGIN de {addr}: {req}")`, e o dataclass `LoginRequest`
imprimia todos os campos -- senha inclusive -- em `_login_stdout.log`, em disco, em claro.

O client manda a senha em texto puro e isso a gente nao controla. Gravar, controla. O
`__repr__` do LoginRequest agora troca a senha por `<oculta N chars>`.

## O que falta pra dois jogadores se verem

O jogador entrou de dois PCs e nao viu o proprio outro personagem. Nao e bug: nao existe.

Cada conexao roda isolada -- `handle_client` por thread, `entity_sim` com registro proprio de
entidades. O que aparece no mundo sao os mobs que a gente simula mais o trafego de ambiente
replicado da captura. Nenhuma conexao sabe da outra.

Pra existir multiplayer de verdade falta:

  - registro GLOBAL de quem esta no mundo, em vez de estado por conexao
  - ao entrar: mandar um 04 02 de cada jogador presente pra quem chega, e um 04 02 de quem
    chega pra todos os presentes
  - retransmitir movimento (04 01), ataque (05 xx), HP (08 06) e saida (04 03) entre conexoes

Os pacotes ja sao todos conhecidos, e o 04 02 ja sai com o equipamento certo do banco. O que
falta e arquitetura, nao protocolo.

### Trocar arma: faltava avisar o client sobre o item DESLOCADO (23/08)

Equipar a Espada Longa por cima da Adaga punha a espada no slot 7 e a adaga SUMIA da tela. O
banco estava certo o tempo todo (adaga na mochila slot 3, dano indo de 4-6 pra 5-8) -- o que
faltava era contar pro client.

Numa troca DOIS itens se mexem, e eu so mandava o par `06 06` de um: o que entra. O que sai do
slot nunca era anunciado, entao o client escrevia a espada por cima e perdia a adaga de vista.

Agora vai o par de cada um, com as REMOCOES todas antes das INSERCOES -- mesmo motivo da troca
dentro da mochila: o client remove pela posicao, entao um "sai" depois de um "entra" apaga o
slot recem-ocupado.

E o mesmo padrao aparece pela terceira vez hoje: o estado no banco certo, e o bug so no que a
gente conta pro client.

### O golpe basico usa o dano FISICO, mesmo em cajado (23/08)

Pergunta do jogador: "por que o Vagabundo bate menos que a Maga, sendo que a Maga tinha que
bater mais nas skills e nao no ataque basico?". Estava certo, e o erro era meu.

O `dano_da_arma` pegava `max(fisico, magico)`, entao a Maga batia 8-13 no basico -- o dano
MAGICO da Bengala -- em vez dos 4-9 fisicos. A propria ficha do jogo separa os dois campos,
ATQ Fisico e ATQ Magico: o basico e o fisico, o magico e das skills. A funcao agora tem o
parametro `magico`, que as skills vao usar.

Com o dano certo, o desenho do jogo aparece -- e a conta e por SEGUNDO, nao por golpe:

    classe        fisico      magico      vel     dano/seg
    Vagabundo    4,2- 6,4       -        1,573     8,34
    Maga         4,0- 9,0    8,4-13,3    0,847     5,53
    Arqueira     8,3-13,7       -        0,781     8,57
    Artilheiro  10,2-19,0       -        0,616     8,98

O Vagabundo bate fraco e rapido, o Artilheiro forte e lento, e a forca da Maga esta no magico.

PENDENTE: a velocidade nao e imposta pelo nosso servidor. O dano sai a cada `05 01` que o
CLIENT manda, entao quem dita o ritmo e a animacao dele. Com a velocidade certa no gage o
ritmo sai correto, mas um client modificado poderia mandar mais rapido. Validacao de ritmo no
servidor fica pro trabalho de anti-cheat.

## Multiplayer: as conexoes passam a se conhecer (23/08)

Ate agora o world_server era uma ilha por conexao -- `handle_client` numa thread, `entity_sim`
com registro proprio, nada compartilhado. Dois jogadores logados eram duas sessoes solo no
mesmo processo.

### server/mundo.py

Registro global de quem esta no mundo, com trava propria. Um `Jogador` guarda a conexao, o
send_lock e o frame `04 02` pronto -- que e o que os OUTROS recebem pra desenhar aquele
personagem. Se um `sendall` falha numa retransmissao, o destinatario sai do registro na hora,
senao a lista enche de fantasmas e todo broadcast fica mais lento.

### Os tres momentos

    entrar   as aparicoes sao trocadas nos DOIS sentidos: cada um que ja estava vai pra tela
             de quem chega, e quem chega vai pra tela de todos
    andar    o client anuncia a propria posicao em 04 00 a cada passo; retransmitimos como
             04 01 pros outros
    sair     04 03 pros que ficaram, senao o personagem fica parado na tela deles

Pacotes decodificados da captura:

    04 00 (28b, client -> servidor)  [7:11] id BE  [11:15] x  [15:19] y  [19:23] z  [23:27] rot
    04 01 (42b)  [7:11] id  [11] estado  [12:24] origem  [24:36] destino  [36:40] velocidade
    04 03 (11b)  [7:11] id

Conferido com os bytes reais do client: `id=20104 x=9010 y=6430 z=627 rot=3,1067`.

### O heroi era um singleton, e o multiplayer expos isso

`entity_sim.HEROI = Heroi()` era estado de MODULO. Com dois conectados os dois escreviam no
mesmo objeto: nivel, EXP, HP e reputacao se misturavam, e cada um salvava o progresso por cima
do outro. Nao foi o multiplayer que criou o bug -- so o tornou visivel.

Agora `iniciar_heroi()` DEVOLVE uma instancia, e cada `handle_client` tem a sua.
`salvar_estado`, `carregar_estado` e `build_hero_gage` passaram a receber o heroi.

### E o `ENTIDADES.clear()`

O `rodar` limpava o registro global de entidades ao comecar -- entao o segundo jogador a
entrar APAGAVA as aranhas do primeiro, que passava a bater em entidades que o servidor nao
conhecia mais. Agora cada conexao reserva a propria faixa de ids (9000, 9012, 9024...) e o
registro so recebe update.

PENDENTE: o certo num MMO e o mob ser do MUNDO, nao do jogador -- um bando so, que todos veem
e que morre pra quem matar primeiro. Isso muda o `rodar` de "thread por conexao" pra "thread
do mundo".

## Soco desarmado batia 25 (23/08)

Reparo do jogador: "sem arma bate 25 na aranha". Era o `DANO_POR_GOLPE = 25`, o valor de
reserva de quando o dano ainda nao saia do equipamento -- e batia MAIS que a adaga (4-6) e
quase o dobro do golpe da Maga.

Agora `DANO_DESARMADO = (1, 2)`. **Nao medido no oficial**: e um chute plausivel pra soco, nao
um numero capturado. Fica anotado pra quando alguem socar uma aranha no servidor de verdade.

## O dano do golpe, medido no oficial (23/08)

Pedido do jogador: monitorar o dano no oficial pra comparar com o nosso. E a observacao que
guiou tudo: "nem todos os ataques sao um valor so, tem hora que o mob bloqueia, tem hora que
da falha, tem hora que crita".

Captura: Vagabundo nivel 1, canal VAZIO (ideia dele -- sem outros jogadores a captura fica so
com os golpes que interessam), 64 golpes com a arma e depois de maos vazias.

### O [24:28] do 05 02 nao e o dano: e `bandeira << 8 | dano`

Os valores "263, 267, 527, 1024, 1536" que eu vinha lendo como dano sao 0x0107, 0x010B,
0x020F, 0x0400, 0x0600. O byte BAIXO e o dano -- e bate exatamente com a queda do HP do alvo
no 08 06 -- e o ALTO e o tipo do golpe:

    0x01  golpe normal        82,8%
    0x02  CRITICO             9,4%    dano x1,2
    0x04  falha, dano 0       1,6%
    0x05  bloqueio parcial    3,1%    dano reduzido (~1/3)
    0x06  esquiva, dano 0     3,1%

O x1,2 do critico e o mesmo "Multiplicador de critico" que a ficha mostra, e fecha nos dois
casos medidos: 7 -> 8 de maos vazias, 13 -> 16 com a adaga.

As TAXAS sao de 64 golpes: valem como ordem de grandeza. A ficha promete critico 7,1% e
bloqueio 9%; medimos 9,4% e 3,1%. Com mais amostra da pra amarrar isso nos campos da ficha
([170] critico, [70] bloqueio) em vez de constantes.

### A conta do dano

    ataque = base da classe + sorteio na faixa da arma
    dano   = ataque - defesa do mob

E a VARIACAO vem da arma, nao de um fator aleatorio. A prova: de maos vazias o oficial deu 7
CRAVADO, 18 golpes seguidos sem variar um ponto. Com a adaga (4,18-6,43) espalhou por 10..14.
Sem faixa de arma, sem variacao.

    de maos vazias   ataque 10,95 -> 10   menos 3 = 7      oficial: 7 sempre       BATE EXATO
    com a adaga      ataque 15..17        menos 3 = 12..14 oficial: 10, 11, 13, 14

O 3 de defesa do mob nao aparece em pacote nenhum: e o que sobra da conta nos dois casos.
Guardado em `dbo.mob.defesa`.

PENDENTE: o oficial mostrou 10 e 11, abaixo dos nossos 12..14. Com 64 golpes nao da pra
separar se e o personagem tendo subido de nivel no meio da captura ou outro fator.

### Duas correcoes que sairam junto

  - **A aranha tem 100 de HP, nao 50.** O `dbo.mob` estava errado desde que foi criado.
  - **O dano usa o ATAQUE TOTAL do personagem**, nao o numero cru da arma. A ficha mostra
    "15 (+4)": 15 e o que importa, o (+4) e so de onde veio.
  - **Soco desarmado**: era 25 (mais que a adaga), virou meu chute de 1-2, e o medido e 7.

## O mob virou do MUNDO, nao do jogador (23/08)

Depois que os personagens passaram a se ver, o jogador reparou: "so mostra outro personagem
parado, nao mostra ele em movimento nem atacando outro mob". Duas coisas diferentes.

### O movimento: eu escutava o pacote errado

O client manda `04 00` UMA vez, ao chegar no mundo. Quem carrega cada passo e o **`04 01`** --
58 deles contra 1 do `04 00` na captura. E o id dele fica em `[8:12]`, nao em `[7:11]`.

    04 01 (42b)  [6:8] estado (00 01 andando, 00 02 parado)   [8:12] id BE
                 [12:24] origem   [24:36] destino   [36:40] velocidade   [40:42] cauda

Com a leitura certa as posicoes fazem sentido: origem (8883, 6545, 629,7), destino
(8795,7, 6588,3, 629,7), velocidade 2,03. E o pacote que montamos sai identico ao do client.

### O mob: uma thread do mundo no lugar de uma por conexao

Cada conexao tinha o proprio bando de 12 aranhas, em faixas de id separadas. O jogador via o
outro personagem, mas nao os mobs dele -- e portanto nunca o via lutando.

Agora `entity_sim.iniciar_mundo()` sobe UMA thread no processo inteiro, dona dos mobs, que
transmite o movimento pra todos por `mundo.transmitir_todos`. Quem entra recebe as aparicoes
por `aparicoes_atuais()`.

E o combate passou a ser broadcast: o eco do `05 00` (a animacao do golpe), o `08 06` (a barra
de HP) e o `04 03` da morte vao pra todos. Sem isso um veria o outro parado enquanto a barra
do mob caia sozinha.

PENDENTE: o mob ainda nao bate em ninguem, e a EXP vai so pra quem deu o golpe -- nao ha
disputa nem divisao. E o respawn e por tempo fixo, sem relacao com quem matou.
