"""
Servidor de teste pro "HOP 3" (o "servidor de mundo" de verdade, alcancado depois do pedido de
troca de canal "02 07" respondido pelo channel_server.py). Formato descoberto em 21/08 via captura
real completa (captures/real_flow_channel_enter_dedup.txt), indo do login ate a tela de
personagem.

Sequencia observada real (hop3, servidor->client):
    handshake (19 bytes, mesmo formato de sempre)
    <espera eco do client>
    frame do meio (294 bytes, sig 02 00 -- mas dessa vez TODO ZERADO, diferente do hop1/hop2 que
        tinham strings de memoria vazada; nao sabemos se isso importa, replicamos como veio)
    <espera ack vazio do client>
    pacote NOVO sig 02 0b (23 bytes de payload) -- formato ainda nao entendido, replicado
        byte a byte da captura real
    pacote NOVO sig 02 01 (395 bytes de payload, "provavel lista de personagens" -- nesse teste
        veio TODO ZERO exceto o cabecalho, porque a conta usada nao tem personagem nenhum ainda)

Uso:
    python world_server.py [porta]
"""

from __future__ import annotations

import os
import socket
import struct
import random as _random
import time as _tempo
import sys
import threading
import time as _time

_orig_print = print
def print(*args, **kwargs):  # noqa: A001
    ms = int(_time.time() * 1000) % 1000
    _orig_print(f"[{_time.strftime('%H:%M:%S')}.{ms:03d}]", *args, **kwargs)

import channel_list_packet
import data_files
import db
import entity_sim
import mundo
from login_packet import encode_frame, read_frame, FrameError, parse_login_request

# Stream de entrada no mundo. Trocado em 23/08: o anterior (enterworld_stream_v2.bin) era a
# sessao gravada do personagem "teste" no NIVEL 2, e cada pacote dele falava daquele
# personagem -- nivel, HP, MP, inventario. Cada um que a gente descobria virava mais um
# remendo, e ainda apareceu um setimo (o 08 00, com o nivel em [43]).
#
# Este veio de um personagem RECEM-CRIADO, nivel 1, capturado do servidor oficial. Assim o
# ponto de partida ja e o certo e sobra menos coisa pra reescrever.
CAMINHO_STREAM = r"D:\RoshEmulator\captures\enterworld_nivel1.bin"
ID_STREAM_NIVEL1 = 10880          # o personagem "Amaga" da captura

# EXPERIENCIA DE 30/08 -- o fluxo de entrada do OFICIAL, cru.
#
# Caminho para um .bin gerado por tools/extrair_stream_entrada.py, ou "" para desligar.
# Com isto ligado o servidor manda o fluxo do oficial TAL E QUAL, trocando so a identidade,
# sem nenhuma das substituicoes que fazemos normalmente.
#
# PARA QUE SERVE: a velocidade de movimento ficou por resolver depois de catorze hipoteses.
# O client nunca passa da base no nosso servidor e passa no oficial, com o MESMO executavel
# e as MESMAS tabelas, e tudo o que se comparou pacote a pacote bate. Isto responde a
# pergunta que sobra -- se o fluxo do oficial, inteiro e em ordem, faz o client correr.
#
# O personagem vai aparecer com a identidade do capturado (nome, nivel, inventario). E de
# proposito: e um diagnostico, nao um modo de jogo. DESLIGAR depois do teste.
STREAM_CRU = ""
ID_DO_STREAM_CRU = 11762          # a entidade do jogador na captura de 30/08 15:05
CAMINHO_SELF_APPEAR = r"D:\RoshEmulator\captures\self_appear.bin"

# id do personagem usado em TODAS as capturas de entrada no mundo. O stream reproduzido
# referencia esse id o tempo todo; trocamos pelo id real do personagem que esta entrando.
ID_CAPTURADO = 10654

# diario dos bytes de aparencia mandados pelo client na criacao de personagem
CAMINHO_APARENCIAS = r"D:\RoshEmulator\captures\aparencias.log"

HOST = "0.0.0.0"
DEFAULT_PORT = 20299


def build_handshake_frame() -> bytes:
    payload = bytes([0x0b, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00]) + b"\x48\x2e\x9b\x76\x01\xe5\x00" + b"\x00"
    return encode_frame(payload)


def build_middle3_frame() -> bytes:
    # 290 bytes de payload interno (294 com o prefixo de frame), sig 02 00 -- quase todo zerado
    # na captura real dessa vez (so um resto de string "15259767" no final, lixo de memoria sem
    # relevancia, que ignoramos e deixamos zerado tambem).
    head = bytes.fromhex("0200000000020000000000000001002a00010000000000")
    payload = head + (b"\x00" * (290 - len(head)))
    return encode_frame(payload)


def build_keepalive_frame(seq: int) -> bytes:
    # CORRIGIDO (22/08, validado no channel_server.py): sem isso o client reseta a conexao
    # sozinho depois de alguns segundos parado numa tela (ex: lista de personagens esperando o
    # usuario clicar). Mesma tecnica: frame de 19 bytes, formato do handshake, seq incrementando.
    payload = bytes([0x0b, 0x00, 0x00, 0x00, 0x00, seq & 0xFF, 0x00]) + b"\x11\x22\x33\x44\x55\x66\x77" + b"\x00"
    return encode_frame(payload)


# CLoginSecondPasswdCheckPacket -- o pacote da SEGUNDA SENHA (PIN de 6 digitos).
#
# Identificado em 01/09 no Ghidra: a classe .?AVCLoginSecondPasswdCheckPacket@AMOPacket@@
# tem o parser em FUN_00648358, que confirma os bytes 02 0B e le uma string a partir do
# byte 20. Ver docs/segunda-senha.md -- os outros tres pacotes da familia sao 02 08
# (registar), 02 09 (alterar) e 02 0A (apagar).
#
# Estes 23 bytes vieram de uma captura do oficial (packet #124101) e estao no estado
# "senha desactivada", que e como o oficial corre hoje. Que campo a liga NAO SE SABE, e nao
# ha captura possivel do outro estado -- o oficial tem a funcao desligada.
#
# BYTE_ESTADO existe para o experimentar sem reescrever isto de cada vez. Vale 0x01 na
# captura; se um outro valor fizer o client abrir o ecra "Verify second password", esta
# achado. Testar aqui e seguro: e um pacote de dados, nao um modelo -- no pior caso o ecra
# nao muda.
# O QUE JA SE TESTOU (01/09), para nao se repetir:
#   byte 14: 0x01 -> 0x00        nada mudou, o ecra nao apareceu
#   byte 20: "En" -> "123456"   nada mudou (o pacote foi de 23 para 31 bytes)
#
# A seguir vai a CADEIA do byte 20, que e o unico campo que o parser do client de facto le:
# FUN_00648358 poe o offset 0x14 e copia dali uma string terminada em NUL (ate 38 chars).
# Na captura do oficial esta la "En", com a funcao desligada.
DEPOIS_DA_LISTA = False          # a ordem nao era o problema (testado 02/09)
OFF_ESTADO_020B = 14
BYTE_ESTADO_020B = 0x01          # como veio do oficial
OFF_BA_020B = 15
BYTE_BA_020B = 0xba              # como veio do oficial
# Os tres u32 a ZERO sao os candidatos que faltam: um campo que ja traz valor (o 0x01 do
# byte 14, o 0xba do 15) esta definido; um a zero e que pode ser o "desactivado".
# TESTE 5: os dois u32 que faltam, ao mesmo tempo. Se o ecra aparecer, isola-se depois.
OFFS_U32_020B = (10, 16)
VAL_U32_020B = 0                 # reposto ao original do oficial
CADEIA_020B = b"En"              # como veio do oficial
OFF_PIN_020B = 20                # onde o PIN vem no 02 0b DO CLIENT -- medido em 02/09


OFF_TEM_PIN_020B = 9             # o byte por onde se diz ao client qual janela abrir


def build_0x020b_frame(tem_pin: bool = False) -> bytes:
    """O 02 0b que vai para o client. `tem_pin` decide a janela que ele abre.

    O client de 2019 nao tem como saber sozinho se a conta ja cadastrou PIN -- o codigo
    que decidia isso foi arrancado com o resto da funcionalidade. Por isso o byte 9
    deste pacote passa a ser o sinal, e um code cave no parser (FUN_00648358) copia-o
    para um byte nosso na .AMoShar; o Update da tela de personagem le esse byte e abre
    a janela certa. Ver tools/patch_segunda_senha.py, monta_cave_sinal.

        byte 9 == 0   sem PIN  -> abre a janela de CADASTRO (30037), dois campos
        byte 9 != 0   com PIN  -> abre a de CONFERIR (30039), um campo

    Na captura do oficial este byte vinha a zero. Nao se sabe o que ele significava la,
    e como la a funcionalidade esta desligada tambem nao ha com que comparar -- o
    client sem o nosso patch ignora-o de qualquer maneira.
    """
    cabeca = bytearray.fromhex("020b00000003000000000000000001ba00000000")
    cabeca[OFF_TEM_PIN_020B] = 1 if tem_pin else 0
    cabeca[OFF_ESTADO_020B] = BYTE_ESTADO_020B & 0xFF
    cabeca[OFF_BA_020B] = BYTE_BA_020B & 0xFF
    for _o in OFFS_U32_020B:
        struct.pack_into("<I", cabeca, _o, VAL_U32_020B & 0xFFFFFFFF)
    return encode_frame(bytes(cabeca) + CADEIA_020B + bytes(1))


def build_0x0201_frame_empty(last: bool = True) -> bytes:
    # "lista de personagens" VAZIA -- veio TODO ZERO nessa captura (conta sem personagem), so o
    # cabecalho (sig + 5 bytes) tem dado de verdade.
    #
    # `last` = byte 6 do payload -- CONFIRMADO via decompilacao (FUN_00651331, handler desse
    # opcode) que esse byte sinaliza "esse e' o ULTIMO registro da lista, pode finalizar a tela".
    # Na captura real de uma conta 100% vazia, os 4 registros (1 por slot) vinham TODOS com essa
    # flag ligada -- mas pra conta com personagem real + slots vazios, só o ÚLTIMO registro da
    # sequencia deve vir com ela ligada (os anteriores dizem "ainda tem mais vindo").
    head = bytearray(bytes.fromhex("0201000000040100000000"))
    head[6] = 1 if last else 0
    payload = bytes(head) + (b"\x00" * (395 - len(head)))
    return encode_frame(payload)


_POPULATED_0201_HEX = "020100000009010000299e0000000058020000000000000000010096000000000000005a00000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000011a40000a1a5000031a70000c1a800000000000000000000813e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500740000006500000073000000740000006500000000000000"


def build_0x0201_frame_populated(last: bool = True) -> bytes:
    # "lista de personagens" POPULADA -- bytes EXATOS capturados de verdade depois de criar um
    # personagem real (nome "teste", classe Maga/600) -- usamos como molde direto, sem entender
    # 100% todos os campos intermediarios (mesma estrategia que ja funcionou a noite toda).
    #
    # TENTAMOS reescrever o byte de sequencia (offset 5, originalmente 9 na captura) pra um valor
    # "continuando" a nossa propria conexao (4) -- PIOROU (nem renderizou a tela de personagem
    # dessa vez, contra "renderiza mas falha ao clicar" de antes). Revertido pro valor original
    # capturado (9) -- a hipotese do contador de sequencia nao parece ser a causa real.
    #
    # `last` = byte 6, mesma flag de "ultimo registro" -- ver comentario em build_0x0201_frame_empty.
    payload = bytearray(bytes.fromhex(_POPULATED_0201_HEX))
    payload[6] = 1 if last else 0
    return encode_frame(bytes(payload))


_LOOP_STREAM_PATH = r"D:\RoshEmulator\captures\enterworld_loop_v1.bin"
_loop_frames_cache = None


def _tirar_npcs_que_nos_spawnamos(frames):
    """Tira do stream gravado os NPCs que o nosso proprio mundo faz nascer.

    O loop e trafego REAL gravado do oficial, e traz as entidades que estavam na tela de quem
    gravou -- entre elas o Domingo e a Caixa de correio, que sao os dois NPCs mais perto do
    ponto de entrada. Desde que o mundo passou a spawnar NPC de verdade (25/08) os dois
    chegavam DUAS vezes: um do replay e um nosso, no mesmo lugar, um por cima do outro.

    O filtro e por npc_id lido do proprio frame, nao por uma lista escrita aqui. Assim ele
    acompanha sozinho quando mais NPC entrar no NpcSpawn.ini -- uma lista fixa daria certo
    hoje e voltaria a duplicar no dia seguinte, calada.

    Tira TAMBEM o movimento (04 01) e o sumico (04 03) dessas entidades. So tirar a aparicao
    deixaria o client recebendo passo de uma entidade que ele nunca desenhou.
    """
    nossos = {int(r["npc_id"]) for r in db.data_files.tabela("NpcSpawn")}         if hasattr(db, "data_files") else set()
    if not nossos:
        return frames, 0
    # 1a passada: descobre os ids de entidade do stream que carregam um npc_id nosso
    ids = set()
    for f in frames:
        c = f[4:]
        if c[:2] == bytes([4, 2]) and len(c) >= 74:
            if struct.unpack_from("<I", c, 70)[0] in nossos:
                ids.add(struct.unpack_from("<I", c, 8)[0])
    if not ids:
        return frames, 0
    # 2a passada: corta aparicao, movimento e sumico dessas entidades
    saida = []
    for f in frames:
        c = f[4:]
        if c[:2] in (bytes([4, 1]), bytes([4, 2]), bytes([4, 3])) and len(c) >= 12:
            ent = struct.unpack_from("<I", c, 8)[0]
            if ent in ids:
                continue
        saida.append(f)
    return saida, len(frames) - len(saida)


def _load_loop_frames():
    """Carrega e faz cache (uma vez so, primeiro uso) dos frames do stream de loop -- 6000 frames
    de trafego real capturado ao vivo (ambiente/NPCs se movendo), pra tentar simular volume
    continuo de rede em vez de silencio total depois da rajada inicial de entrar no mundo."""
    global _loop_frames_cache
    if _loop_frames_cache is None:
        with open(_LOOP_STREAM_PATH, "rb") as f:
            data = f.read()
        frames = []
        off = 0
        while off + 4 <= len(data):
            (plen,) = struct.unpack_from(">I", data, off)
            if off + 4 + plen > len(data):
                break
            frames.append(data[off:off + 4 + plen])
            off += 4 + plen
        antes = len(frames)
        frames = [f for f in frames if f[4:6] in OPCODES_AMBIENTE]
        frames, dobrados = _tirar_npcs_que_nos_spawnamos(frames)
        _loop_frames_cache = frames
        if dobrados:
            print(f"[hop3] {dobrados} frames retirados do loop: NPCs que o nosso "
                  f"mundo tambem spawna (apareciam DOBRADOS na tela)")
        print(f"[hop3] stream de loop carregado: {len(frames)} frames de ambiente "
              f"({antes - len(frames)} descartados por carregarem estado do heroi capturado)")
    return _loop_frames_cache


# Opcodes que o stream de loop pode reenviar. O stream e trafego real de OUTRO jogador (o
# personagem 10654), gravado ao vivo. Dentro dele vem tambem o estado DELE: 18 frames de
# 08 01 (gage do heroi, com EXP 24 e 96), 4 de 08 02 (a ficha), 1 de 02 05 e o inventario
# em 06 xx. Reenviar isso escrevia o personagem capturado por cima do jogador -- foi o que
# fez aparecer "Experiencia adquirida 24" no instante em que a Maga entrou no mundo, sem ter
# matado nada. So o trafego de AMBIENTE passa:
#
#     04 01  movimento de entidade      04 03  saida de entidade
#     04 02  entidade aparece           08 06  sincronia de HP/MP de entidade
#
# Sao 5895 dos 6000 frames, entao o volume de rede que a funcao existe pra manter nao muda.
OPCODES_AMBIENTE = {b"", b"", b"", b""}


def _loop_sender(conn: socket.socket, addr, send_lock: threading.Lock, stop_event: threading.Event):
    """TESTE (22/08 tarde): depois da rajada inicial de entrar no mundo, o client fica com a carta
    de "carregando" na frente mesmo com o cenario renderizando atras -- suspeita: o client espera
    volume CONTINUO de trafego (o servidor real manda 100+ pacotes/segundo de sincronia de
    ambiente/NPCs desde o primeiro segundo), nao so uma rajada finita que depois fica em silencio.
    Essa funcao roda numa thread separada e fica reenviando (em loop) o trafego real capturado,
    em lotes pequenos com pausas curtas, pra manter um fluxo constante -- mesmo sem entender o
    significado de cada pacote, so testando se PRESENCA de trafego destrava a UI."""
    frames = _load_loop_frames()
    idx = 0
    batch_size = 8
    try:
        while not stop_event.is_set():
            batch = b"".join(frames[idx:idx + batch_size])
            idx += batch_size
            if idx >= len(frames):
                idx = 0
            with send_lock:
                conn.sendall(batch)
            stop_event.wait(0.03)
    except OSError as e:
        print(f"[hop3] loop sender parou (conexao fechada?) pra {addr}: {e}")


# ---------------------------------------------------------------------------
# Personagens montados a partir do BANCO (moldes capturados do servidor oficial 23/08)
# ---------------------------------------------------------------------------
# Ate 23/08 a lista de personagens e o CLoginCharInfo eram bytes fixos da captura. Com a
# captura de um personagem NOVO no servidor oficial (id 10827, classe 500, "Testee") deu pra
# separar os campos que dependem do personagem dos que sao molde. Ver docs/findings.md.
#
#   02 01 (variavel):  [6] flag ultimo  [7:11] id BE  [15:19] classe LE
#                      fim: [389:391] tamanho LE u16 + nome UTF-32LE + 4 bytes de cauda
#   02 05 (1812 fixo): [6:10] id BE  [10:14] classe LE  [22:26] HP max LE  [30:34] MP max LE
#
# O que NAO sabemos: onde ficam nivel e EXP dentro do 02 05. As duas capturas que temos sao de
# personagem nivel 1, entao a comparacao nao isolou esses campos. O 08 01 cobre isso em jogo.

# Moldes POR CLASSE, capturados do servidor oficial (personagem recem-criado de cada uma das
# 7 classes). Servir o molde da classe certa entrega aparencia e equipamento inicial corretos
# sem decodificar os ~1798 bytes do 02 05 que continuam sem identificacao.
DIR_CLASSES = r"D:\RoshEmulator\captures\classes"
CAMINHO_MOLDE_LISTA = r"D:\RoshEmulator\captures\charlist_02_01_nivel1.bin"
CAMINHO_MOLDE_INFO = r"D:\RoshEmulator\captures\charinfo_02_05_nivel1.bin"
_molde_lista = None
_molde_info = None
_moldes_classe = {}


def molde_da_classe(classe: int, tipo: str):
    """Molde 02 01 ou 02 05 da classe pedida. None se nao tivermos capturado essa classe."""
    chave = (classe, tipo)
    if chave not in _moldes_classe:
        nome = {"info": "charinfo", "lista": "charlist",
                "selfappear": "selfappear", "stats": "stats",
                "inven": "inven"}[tipo]
        caminho = os.path.join(DIR_CLASSES, f"{nome}_classe_{classe}.bin")
        try:
            with open(caminho, "rb") as f:
                _moldes_classe[chave] = f.read()
        except OSError:
            _moldes_classe[chave] = None
    return _moldes_classe[chave]

_TAM_NOME_OFF = 389      # onde comeca o tamanho do nome no molde de 419 bytes
_CAUDA_LISTA = 4         # bytes depois do nome


def _ler_molde(caminho, cache_attr):
    global _molde_lista, _molde_info
    atual = _molde_lista if cache_attr == "lista" else _molde_info
    if atual is None:
        try:
            with open(caminho, "rb") as f:
                atual = f.read()
        except OSError as e:
            print(f"[hop3] nao consegui ler o molde {caminho}: {e}")
            return None
        if cache_attr == "lista":
            _molde_lista = atual
        else:
            _molde_info = atual
    return atual


def _achar_campo_nome(molde: bytes):
    """Onde comeca o campo de nome neste molde.

    O 02 01 termina em: [tamanho LE u16][nome UTF-32LE][4 bytes de cauda]. Como cada molde de
    classe foi capturado com um nome de tamanho diferente, esse offset MUDA de molde pra molde
    -- fixa-lo em 389 (o valor do primeiro molde) gerava pacote de tamanho errado.
    """
    for base in range(len(molde) - 10, max(0, len(molde) - 120), -1):
        n = struct.unpack_from("<H", molde, base)[0]
        if 1 <= n <= 20 and base + 2 + n * 4 + _CAUDA_LISTA == len(molde):
            return base
    return None


def build_charlist(personagem: dict, last: bool = True, seq: int = 4) -> bytes | None:
    """Monta o 02 01 com os dados de um personagem do banco."""
    molde = molde_da_classe(personagem["classe"], "lista") or _ler_molde(CAMINHO_MOLDE_LISTA, "lista")
    if molde is None:
        return None
    base = _achar_campo_nome(molde)
    if base is None:
        return None
    cabeca = bytearray(molde[:base])
    struct.pack_into(">I", cabeca, 2, seq)
    cabeca[6] = 1 if last else 0
    struct.pack_into(">I", cabeca, 7, personagem["entity_id"])
    # Mesma armadilha do 02 05: a classe tem 2 bytes, e escrever 4 apagava o cabelo em [18].
    # A tela de SELECAO de personagem desenha o preview a partir daqui -- por isso o visual
    # continuava padrao mesmo com o 02 05 e o 04 02 ja corretos.
    struct.pack_into("<H", cabeca, 15, personagem["classe"] & 0xFFFF)
    _escrever_aparencia(cabeca, personagem, base_classe=15)
    # [25] = ESCONDER O FATO, na tela de seleccao de personagem. 1 esconde, 0 mostra.
    #
    # Medido em 29/08 com o controlo que faltava: a mesma personagem do oficial, o mesmo
    # equipamento (armadura + fantasia + asas), fotografada duas vezes na tela de seleccao,
    # so com o botao mudado entre as duas. Os dois 02 01 diferem em TRES bytes -- [4] e [5],
    # que sao a sequencia, e o [25]. Mais nada.
    #
    # O nosso vinha a 1 do molde, ou seja mandavamos "esconder" a cada entrada, e a fantasia
    # nunca aparecia na tela de personagem por mais certo que o vector estivesse. Andei a
    # procurar uma bandeira no sitio errado durante muito tempo: cheguei a compara-la entre
    # personagens DIFERENTES, onde mudam classe, nivel e HP ao mesmo tempo, e o [25] escapou
    # duas vezes por acompanhar a classe por acaso.
    #
    # [25] = ESCONDER O FATO na tela de personagem, e o 06 46 usa a MESMA convencao:
    #
    #     0 = mostrar     1 = esconder
    #
    # Andei a virar isto ao contrario duas vezes por deduzir em vez de medir. As duas
    # medicoes que mandam, e que concordam:
    #
    #   a) o oficial, mesma personagem e mesmo equipamento, duas fotos da tela de seleccao
    #      so com o botao mudado: [25]=0 com a fantasia a vista, [25]=1 sem ela;
    #   b) o nosso log, quando o jogador clicou para ACTIVAR a Forma o client mandou um
    #      06 46 com [10]=0.
    #
    # Portanto o valor guardado vai tal e qual para os dois sitios, sem negar.
    cabeca[25] = 1 if db.ler_preferencias(
        personagem.get("entity_id", 0)).get("mostrar_fato") else 0
    _escrever_equipamento(cabeca, personagem.get("entity_id", 0), BASE_EQUIP["lista"])
    # nivel, HP e MP da tela de selecao: vinham do molde da classe, entao todo personagem
    # aparecia como nivel 1 na lista, mesmo ja tendo subido.
    # HP E MP DA TELA DE SELECCAO: u32 LITTLE-endian em [27] e [35].
    #
    # Estavam em >H@26 e >I@32 -- offset e ordem de bytes errados nos dois, e a escrever por
    # cima dos vizinhos. Medido em 29/08 nos tres 02 01 do oficial, tres niveis diferentes:
    #
    #     classe 410 nivel 30 -> HP 2045  MP 686
    #     classe 600 nivel  6 -> HP  332  MP 140
    #     classe 300 nivel  3 -> HP  185  MP  80
    #
    # O 2045 e o mesmo numero da ficha da Feiticeira medida na seccao 10, o que fecha a
    # identificacao. E explica o byte [36], que eu andei a olhar como candidato a bandeira
    # de "mostrar fantasia": e o byte alto do MP.
    if personagem.get("hp_max"):
        struct.pack_into("<I", cabeca, 27, int(personagem["hp_max"]) & 0xFFFFFFFF)
    if personagem.get("mp_max"):
        struct.pack_into("<I", cabeca, 35, int(personagem["mp_max"]) & 0xFFFFFFFF)
    struct.pack_into(">I", cabeca, 52, max(1, personagem.get("nivel", 1)))
    nome = personagem["nome"].encode("utf-32-le")
    cauda = molde[-_CAUDA_LISTA:]
    p = bytes(cabeca) + struct.pack("<H", len(personagem["nome"])) + nome + cauda
    return encode_frame(p)


# Vetor de equipamento VISIVEL. Os tres pacotes de aparencia carregam a mesma tabela: 32
# posicoes de u32 LE, indexadas pelo slot de equipamento, com o ID DO ITEM em cada uma.
#
#     02 01 (lista de personagens)  base 134
#     02 05 (charinfo)              base 129
#     04 02 (aparecer no mundo)     base  74
#
# slot N fica em base + 4N. Fora as posicoes do kit, o vetor e todo zero nos moldes.
#
# Achado em 23/08: o jogador desequipou a Adaga, ela foi pra mochila no banco e sumiu do slot,
# MAS o personagem continuou com ela na mao e o "(+4)" continuou na ficha. Motivo: estes tres
# pacotes saiam do MOLDE, com o kit inicial fixo. O client acredita neles.
OFF_POS_CHARINFO = 391
"""Onde a POSICAO do personagem vai dentro do 02 05: tres floats LE, convencao do chao.

Medido em 27/08 comparando o 02 05 com o 04 02 que vem logo a seguir, em 17 capturas do
oficial: dx e dy dao ZERO exato nas 17, e dz da +5,0000 exato nas 17. Nao e coincidencia de
tres floats que calham no intervalo do mundo -- e o mesmo campo, com um deslocamento de
altura constante entre os dois pacotes.

O nosso molde do 02 05 foi capturado de personagens RECEM-CRIADOS, que ainda nao tinham
entrado no mundo, e por isso trazia (0, 0, 0) ali. Como nada reescrevia esses bytes, todo
login mandava zero e o personagem voltava sempre ao mesmo ponto, por mais longe que tivesse
parado -- o "ponto fixo" que o jogador relatou em 27/08. As capturas mostram isto de forma
limpa: das 36 entradas lidas, as 18 que trazem (0,0,0) no 02 05 sao exatamente as dos
personagens novos, e nenhuma outra.
"""

ALTURA_APARICAO = 5.0
"""O 04 02 leva a posicao 5 unidades ACIMA da que o 02 05 e o movimento do client usam.

O client manda os proprios passos (04 01) com o mesmo z do 02 05 -- conferido no mesmo
instante da entrada: 02 05 z=201,69, primeiro 04 01 z=201,69, 04 02 z=206,69. Ou seja o
banco guarda o z do CHAO, e so a aparicao e que sobe.
"""

BASE_EQUIP = {"lista": 134, "info": 129, "selfappear": 74}
SLOTS_EQUIP = 32


def _escrever_equipamento(p: bytearray, entity_id: int, base: int) -> None:
    """Preenche o vetor de equipamento visivel com o que esta EQUIPADO no banco."""
    if not entity_id or base + 4 * SLOTS_EQUIP > len(p):
        return
    equipado = db.equipamento_do_personagem(entity_id)
    for slot in range(SLOTS_EQUIP):
        item = equipado.get(slot)
        struct.pack_into("<I", p, base + 4 * slot, item["item_id"] if item else 0)


def _escrever_aparencia(p: bytearray, personagem: dict, base_classe: int) -> None:
    """Grava cabelo, cor e rosto no bloco de aparencia.

    Descoberto em 23/08 criando um personagem com cabelo 4, rosto 4 e cor (120,122,86,255)
    no servidor oficial: a MESMA estrutura de 8 bytes aparece logo depois da classe, tanto no
    02 05 quanto no 04 02.

        base+0  classe   LE u16
        base+2  0
        base+3  cabelo
        base+4  cor RGBA (4 bytes, alfa vem 255 quando o jogador escolhe cor)
        base+8  rosto

    Ou seja: 02 05 usa base 10, e 04 02 usa base 246.
    """
    p[base_classe + 3] = personagem.get("cabelo", 0) & 0xFF
    for i, campo in enumerate(("cor_r", "cor_g", "cor_b", "cor_a")):
        p[base_classe + 4 + i] = personagem.get(campo, 0) & 0xFF
    p[base_classe + 8] = personagem.get("rosto", 0) & 0xFF


OFF_BARRA_CHARINFO = 471
"""Onde a BARRA DE ATALHOS vive dentro do 02 05. Trinta e seis ranhuras de doze bytes.

    [0:4]  id, LE     [4:8]  tipo, LE     [8:12]  extra, LE

    ranhura vazia:  00 00 00 00 | ff ff ff ff | ff ff ff ff

ACHADA EM 30/08, e o caminho ate la vale a pena. O jogador reparou que a barra voltava ao
princípio ao relogar. O `05 0c` parecia a candidata -- e nao era: so tem 15 ranhuras e ele
usava a 32, e comparando dois logins do oficial, um antes de mexer e outro depois, sai byte
a byte IGUAL e vazio. Procurou-se ficheiro na pasta do jogo, AppData e registo: nada.

O que resolveu foi procurar os IDS que ele tinha posto (11001 e 11002) em TODOS os pacotes
recebidos. Apareceram no 02 05 -- o pacote de 1812 bytes que ate hoje era quase todo molde
por decifrar. A distancia entre os dois (168 bytes para 14 ranhuras) da os 12 bytes exactos.

Bate campo a campo com o `06 0b` que o client manda: ranhura 12 = 11001 tipo 9, ranhura 26 =
11002 tipo 9, ranhura 20 = 1 tipo 9.
"""
N_RANHURAS_BARRA = 36
TAM_RANHURA_BARRA = 12
RANHURA_VAZIA = bytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255])


def escrever_barra(p: bytearray, entity_id: int) -> int:
    """Poe os atalhos guardados dentro do 02 05. Devolve quantos foram postos."""
    fim = OFF_BARRA_CHARINFO + N_RANHURAS_BARRA * TAM_RANHURA_BARRA
    if len(p) < fim:
        return 0
    for i in range(N_RANHURAS_BARRA):
        o = OFF_BARRA_CHARINFO + i * TAM_RANHURA_BARRA
        p[o:o + TAM_RANHURA_BARRA] = RANHURA_VAZIA
    postos = 0
    for ranhura, (valor, tipo, extra) in db.atalhos_do_personagem(entity_id).items():
        if not (0 <= ranhura < N_RANHURAS_BARRA) or not valor:
            continue
        o = OFF_BARRA_CHARINFO + ranhura * TAM_RANHURA_BARRA
        struct.pack_into("<I", p, o, valor & 0xFFFFFFFF)
        struct.pack_into("<I", p, o + 4, tipo & 0xFFFFFFFF)
        struct.pack_into("<I", p, o + 8, extra & 0xFFFFFFFF)
        postos += 1
    return postos


def build_charinfo(personagem: dict, seq: int = 5) -> bytes | None:
    """Monta o 02 05 (1812 bytes) com os dados de um personagem do banco."""
    molde = molde_da_classe(personagem["classe"], "info") or _ler_molde(CAMINHO_MOLDE_INFO, "info")
    if molde is None:
        return None
    p = bytearray(molde)
    struct.pack_into(">I", p, 2, seq)
    struct.pack_into(">I", p, 6, personagem["entity_id"])
    _nb = escrever_barra(p, personagem["entity_id"])
    if _nb:
        print("[hop3] barra: %d atalho(s) no 02 05" % _nb)
    # RELOGIO DO SERVIDOR em [949:953], u32 LE. Vinha CONGELADO do molde: todos os moldes
    # foram capturados em 23/08/2026 e era essa a hora que o servidor anunciava, seis dias
    # atrasada e a envelhecer sozinha.
    #
    # Que o campo e um relogio esta medido: nos dois 02 05 do oficial ele vale 1788031857,
    # que e a hora real da captura. O que NAO esta medido e o que o client faz com ele --
    # mandar a hora certa e de qualquer forma melhor do que mandar uma de ha seis dias.
    struct.pack_into("<I", p, 949, int(_time.time()) & 0xFFFFFFFF)
    # A classe ocupa 2 bytes em [10:12]. Escrever 4 aqui (como eu fazia) apagava o cabelo
    # em [13] e o primeiro byte da cor. Ver o mapa de aparencia abaixo.
    struct.pack_into("<H", p, 10, personagem["classe"] & 0xFFFF)
    _escrever_aparencia(p, personagem, base_classe=10)
    _escrever_equipamento(p, personagem.get("entity_id", 0), BASE_EQUIP["info"])
    # POSICAO em [391:403]. Ver OFF_POS_CHARINFO -- e este campo, e nao o do 04 02, que
    # decide onde o personagem nasce ao entrar no mundo. O client anuncia a propria posicao
    # (SEND 04 00) ANTES de o 04 02 chegar, portanto quando o 04 02 sai o lugar ja foi
    # escolhido; o unico pacote que o precede com a posicao dentro e este.
    if personagem.get("pos_x") is not None and len(p) >= OFF_POS_CHARINFO + 12:
        struct.pack_into("<3f", p, OFF_POS_CHARINFO, float(personagem["pos_x"]),
                         float(personagem["pos_y"]), float(personagem["pos_z"]))
    struct.pack_into("<I", p, 22, personagem["hp_max"])
    struct.pack_into("<I", p, 30, personagem["mp_max"])
    # EXP em [76:84], u64 LE. Sem isto o client entra no mundo achando que tem ZERO de EXP.
    #
    # O sintoma era estranho: ao entrar aparecia "Experiencia adquirida: 20" com o valor
    # TOTAL do personagem, e no oficial nao aparece nada. A causa esta no handler do 08 01
    # dentro do AMo.exe, que compara a EXP do pacote com a que ele ja tem guardada em
    # 0x828 da struct do personagem e so anuncia quando diferem. Quem devia ter guardado o
    # valor antes e este 02 05 -- e o nosso mandava zero ali.
    #
    # Conferido byte a byte: no 02 05 do oficial o offset 76 traz d4 00 00 00, que e 212, a
    # EXP daquele personagem. No nosso, dezesseis zeros.
    #
    # A mensagem em si esta certa e deve continuar existindo: ela e o que aparece ao matar
    # um monstro, onde a diferenca e real.
    struct.pack_into("<Q", p, 76, int(personagem.get("exp") or 0) & 0xFFFFFFFFFFFFFFFF)
    # atributos base em [1030], cinco LE u16 -- amarrados com a ficha da tela em 7 classes
    for i, campo in enumerate(("forca", "destreza", "vitalidade", "inteligencia", "sabedoria")):
        if campo in personagem:
            struct.pack_into("<H", p, 1030 + i * 2, personagem[campo] & 0xFFFF)
    return encode_frame(bytes(p))


def build_self_appear(personagem: dict, seq: int = 31) -> bytes | None:
    """Monta o CMoveAppear (04 02) do PROPRIO jogador a partir do banco.

    E este pacote que carrega o NOME e a APARENCIA que o jogo mostra em volta do personagem --
    nao o 02 05. Ate 23/08 mandavamos o capturado inteiro, e por isso o personagem entrava no
    mundo chamando "teste" com o visual de outro.

    Layout decodificado do molde (266-273 bytes, tamanho varia com o nome):

        [  8:12]  entidade        LE u32
        [ 12:24]  posicao         3 floats LE
        [ 28:32]  HP atual        [32:36] HP maximo   (LE u32)
        [ 36:40]  MP atual        [40:44] MP maximo   (LE u32)
        [ 78:96]  ids de equipamento
        [246:248] classe          LE u16
        [258:260] tamanho do nome BE u16
        [260:   ] nome            latin-1 (1 byte por letra -- diferente do 02 01, que usa UTF-32)
    """
    molde = molde_da_classe(personagem["classe"], "selfappear")
    if molde is None:
        return None
    cabeca = bytearray(molde[:258])
    struct.pack_into(">I", cabeca, 2, seq & 0xFFFFFFFF)
    struct.pack_into("<I", cabeca, 8, personagem["entity_id"])
    struct.pack_into("<I", cabeca, 28, personagem["hp"])
    struct.pack_into("<I", cabeca, 32, personagem["hp_max"])
    struct.pack_into("<I", cabeca, 36, personagem["mp"])
    struct.pack_into("<I", cabeca, 40, personagem["mp_max"])
    struct.pack_into("<H", cabeca, 246, personagem["classe"] & 0xFFFF)
    # POSICAO em [12:24], tres floats. Sem escrever isso, o personagem nascia onde EU estava
    # quando capturei o molde daquela classe -- e como eu tinha andado com a Arqueira antes de
    # capturar, ela aparecia longe de todas as outras.
    if personagem.get("pos_x") is not None:
        struct.pack_into("<3f", cabeca, 12, float(personagem["pos_x"]),
                         float(personagem["pos_y"]),
                         float(personagem["pos_z"]) + ALTURA_APARICAO)
    _escrever_aparencia(cabeca, personagem, base_classe=246)
    _escrever_equipamento(cabeca, personagem.get("entity_id", 0), BASE_EQUIP["selfappear"])
    # [56:58] e o NIVEL, e e daqui que o painel do canto superior o le. Confirmado em 7
    # moldes: os cinco personagens recem-criados trazem 1, e os dois que ja tinham subido
    # trazem 2 -- coerente com o [90:94] do 08 01, que guarda nivel-1 pelo outro caminho.
    struct.pack_into("<H", cabeca, 56, max(1, personagem.get("nivel", 1)) & 0xFFFF)
    nome = personagem["nome"].encode("latin-1", "replace")[:60]
    return encode_frame(bytes(cabeca) + struct.pack(">H", len(nome)) + nome + molde[-1:])


# Skills que TODA a gente tem, medidas comparando o 05 05 de duas classes diferentes: as
# mesmas dez a frente e as mesmas cinco no fim, so muda o punhado do meio.
SKILLS_COMUNS = (1, 2, 3, 4, 5, 6, 11, 12, 13, 14)
SKILLS_FINAIS = (2100000001, 2100000002, 2100000003, 2100000004, 2100000005)


OP_ATALHO = bytes([6, 0x0b])        # o client grava uma ranhura da barra
OP_BARRA = bytes([5, 0x0c])         # o servidor devolve a barra inteira
CAB_BARRA = 12                      # cabecalho antes das ranhuras
TAM_RANHURA = 13                    # 12 + 15*13 = 207, o tamanho do pacote


# O `build_barra` (05 0c) FOI REMOVIDO em 30/08. Parecia a barra -- 15 ranhuras de 13
# bytes, com id e tipo -- e nao e: o jogador usa ate a ranhura 35, e comparando dois logins
# do oficial, um antes de mexer na barra e outro depois, o 05 0c sai byte a byte IGUAL e
# vazio nos dois. A barra vive no 02 05; ver OFF_BARRA_CHARINFO.


def build_skills(molde: bytes, entity_id: int, classe: int, nivel: int = 1) -> bytes | None:
    """CSkillListPacket (05 05) com as skills DA CLASSE.

        [0:2] 05 05   [2:6] seq   [6:10] entidade BE   [10:12] contagem BE
        [12:]  N ids u32 LE, e a seguir N niveis u32 LE

    Medido em 30/08 comparando dois 05 05:

        classe 400   1..6 11..14  7001 7002 7003 7004  2100000001..5   -> 19
        o stream     1..6 11..14  11001                2100000001..5   -> 16

    ISTO VINHA DO STREAM, ou seja toda a gente recebia as skills da personagem gravada, que
    e uma Maga. O jogador deu por isso: "so ta soltando skill da maga".

    DESVIO CONHECIDO: mandam-se TODAS as skills da arvore da classe. O oficial manda so as
    APRENDIDAS -- o personagem de classe 400 tinha 4 das 6 -- mas nos nao guardamos que
    skills o jogador aprendeu, que e a mesma tabela em falta que ja obriga o skill_para a
    assumir nivel 1. Enquanto ela nao existir, dar a arvore toda e melhor do que dar a de
    outra classe.
    """
    # A LISTA VEM DA TABELA DO PERSONAGEM, e nao da arvore da classe filtrada na hora.
    #
    # db.sincronizar_skills acerta o que ele tem ao nivel em que esta -- pela coluna
    # `nivel_req` do SkillLod, que e a regra medida -- e devolve {skill_id: nivel}. Assim o
    # NIVEL de cada skill tambem vai no pacote, em vez de ir 1 em todas.
    # SO O QUE ELE APRENDEU. A sincronizacao garante a skill inicial da classe e mais nada --
    # o resto entra pelo 05 06, que e o clique no "+". Ver db.sincronizar_skills.
    try:
        aprendidas = db.sincronizar_skills(int(entity_id), int(classe), int(nivel))
    except Exception as e:                                          # noqa: BLE001
        print("[hop3] sincronizar skills falhou: %s" % str(e)[:110])
        aprendidas = {}
    arv = sorted(aprendidas)
    ids = list(SKILLS_COMUNS) + arv + list(SKILLS_FINAIS)
    p = bytearray(12 + len(ids) * 8)
    p[0:12] = (molde[:12] if molde and len(molde) >= 12 else bytes(12))
    p[0:2] = bytes([5, 5])
    struct.pack_into(">I", p, 6, int(entity_id))
    struct.pack_into(">H", p, 10, len(ids))
    for k, s in enumerate(ids):
        struct.pack_into("<I", p, 12 + k * 4, s & 0xFFFFFFFF)
        struct.pack_into("<I", p, 12 + len(ids) * 4 + k * 4, aprendidas.get(s, 1))
    return encode_frame(bytes(p))


def build_inventario(molde: bytes, itens: list, entity_id: int = 0) -> bytes | None:
    """Monta o 06 05 a partir das linhas do banco.

    Cabecalho de 12 bytes, depois um registo de 147 bytes por item. Confirmado contra o
    06 05 do oficial (4275 bytes = 12 + 29*147): mesma aritmetica, e o byte [10] vale 5 nos
    dois.

    O ID DO PERSONAGEM EM [6:10] vinha do molde, ou seja o do personagem CAPTURADO. O
    oficial poe la o id de quem recebe. Nao se sabe o que o client faz com o campo -- os
    itens apareciam na mesma -- mas mandar o id de outra pessoa nao pode estar certo.
    """
    if not itens:
        return None
    cab = bytearray(molde[:12])
    if entity_id:
        struct.pack_into(">I", cab, 6, int(entity_id))
    cab[11] = min(len(itens), 255)
    corpo = bytearray()
    for it in itens:
        corpo += registro_item(it)
    return encode_frame(bytes(cab) + bytes(corpo))


TAM_REGISTRO = 147


def registro_item(it: dict, container: int | None = None, slot: int | None = None) -> bytes:
    """Os 147 bytes de um item, com a posicao reescrita a partir do banco.

    O `bruto` guardado e o registro ORIGINAL da captura; aqui a posicao e corrigida. Da pra
    forcar outro container/slot (usado pelo 06 06, que precisa do estado ANTIGO do item)."""
    if True:
        r = bytearray(it["bruto"] or bytes(TAM_REGISTRO))
        if len(r) != TAM_REGISTRO:
            r = bytearray(bytes(r[:TAM_REGISTRO]).ljust(TAM_REGISTRO, bytes(1)))
        cont = it["container"] if container is None else container
        slo = it["slot"] if slot is None else slot
        r[0] = cont & 0xFF
        r[1] = slo & 0xFF
        # O id do item ocupa QUATRO bytes em [2:6], nao dois. Escrever u16 aqui truncava
        # qualquer item acima de 65535 -- e sao muitos: o catalogo vai ate 1000101726.
        # Foi assim que nasceram os dois "itens fantasma" que nao existiam no itemBase:
        #     34873 era o low half de 999000121 = "Um presente de principiante (NV.1)"
        #      3971 era o low half de     69507 = "Bala magica"
        struct.pack_into("<I", r, 2, it["item_id"] & 0xFFFFFFFF)
        # [6:8] = SLOT DE EQUIPAMENTO, 0 quando o item nao esta vestido. E por este campo que
        # o client decide se conta os atributos do item na ficha -- nao pelo container em [0].
        #
        # Achado em 23/08: depois de desequipar a Adaga e relogar, ela aparecia na mochila
        # (certo) mas o ATQ Fisico continuava com o "(+4)" dela (errado). O motivo e que o
        # `bruto` guardado no banco e o registro ORIGINAL, capturado com o item vestido, e a
        # gente so reescrevia o container e o slot -- o [6:8] continuava dizendo 7.
        # UM BYTE, nao dois. Escrever u16 aqui punha zero em [7] tambem, e [7] e o REFINO:
        # com o item na mochila, toda peca refinada perdia o "+N" no caminho para o client.
        # O jogador mandou uma Bengala +13 pela ferramenta web e ela chegou normal -- o banco
        # tinha 13 em [7] e o `registro_item` devolvia 0.
        #
        # Que sao dois campos de um byte esta MEDIDO, e nao suposto: no 06 06 do oficial que
        # transporta a "[+7 arma osso de dragao]" (item 21210), o registo comeca em +11 e
        # traz [6]=00 e [7]=07. O comentario do tools/dar_item.py ja o suspeitava do binario
        # -- FUN_00657e08 le `_Src[6]` e `_Src[7]` como campos SEPARADOS -- e a captura
        # confirma. O outro sentido tambem bate: a Bengala equipada que veio da captura
        # original tem [6]=7 (o slot) e [7]=0 (sem refino).
        r[6] = (slo & 0xFF) if cont == db.CONTAINER_EQUIPADO else 0
        # A QUANTIDADE fica em [16:20], u32 LE. Ate 23/08 a coluna `quantidade` do banco valia
        # 1 pra tudo, enquanto o registro capturado dizia 100 nas pocoes -- e o 06 06 mandava
        # o 1, entao a pilha de 100 virava 1 na tela do jogador. Agora a coluna e preenchida a
        # partir do registro e passou a ser a fonte da verdade.
        if it.get("quantidade"):
            struct.pack_into("<I", r, 16, int(it["quantidade"]) & 0xFFFFFFFF)
        # [76:80] = O EFEITO DA FANTASIA, e tem de ser um dos DOIS que o item admite.
        #
        # Medido no 06 06 do oficial em 29/08: cada peca leva o efeito da SUA ranhura --
        # torso 60189, pernas 60193, maos 60197 -- e nunca o de outra. O nosso `bruto` vem do
        # molde que o dar_item copiou de outra peca, e por isso as quatro pecas do jogador
        # levavam 60189, que e o do torso: certo numa e errado nas outras tres.
        #
        # Corrigido AQUI, como o [6], e nao so no dar_item: assim as pecas que ja estao no
        # banco tambem saem certas, sem ser preciso mexer no que la esta.
        # O DOUBLE DO TEMPO RESTANTE E RECALCULADO A CADA ENVIO, e nao guardado a descer no
        # banco. O oficial manda-o sempre actualizado; guardar um numero que envelhece so daria
        # o mesmo defeito da posicao de entrada em 27/08. A conta esta em db.segundos_restantes.
        _resta = db.segundos_restantes(it["item_id"], r)
        if _resta is not None and db.duracao_do_item(it["item_id"], so_horas=True):
            struct.pack_into("<d", r, db.OFF_TEMPO_RESTANTE, max(0.0, _resta))
        if db.efeito_de_fantasia(it["item_id"]):
            struct.pack_into("<I", r, 76, db.efeito_da_peca(it["item_id"], bytes(r)))
        _escrever_opcoes_da_instancia(r, it.get("instancia"))
        return bytes(r)


TIPOS_DE_GRAU_PERCENTUAL = frozenset((13, 14, 27))
# JA NAO E USADA. Ficou obsoleta em 31/08, quando db.indice_de_opcao passou a resolver o
# indice pelo skillData: a unidade vem de la (`valor` e plano, `valor2` e percentagem) e nao
# precisa de uma lista a mao. Fica pelo que a nota abaixo explica.
"""Tipos cujo GRAU no uniqueOptionType ja e uma percentagem.

A regra geral -- "grau e valor, nao percentagem" -- vale para forca, saude, proteccao. Para
a velocidade e falsa, e o 27 esta PROVADO: no FantasiaEfeito o `velocidade_movimento` so
aparece em `pct`, nunca em `plano`, e os graus do grupo 427 sao [2, 4, 6, 8, 10] -- os
mesmos numeros dos efeitos 42702, 42704, 42706, 42708 e 42710, que dao +2% a +10%. Duas
tabelas, a mesma escala.

O 13 e o 14 NAO ESTAO MEDIDOS assim: nao ha linha deles no FantasiaEfeito para cruzar. Vao
juntos porque o proprio servidor ja os aplica como percentagem (ver percentuais_de_opcoes e
o calculo da velocidade de ataque) -- se fossem absolutos o erro estaria no calculo, nao
aqui. Se uma medicao no oficial disser o contrario, tiram-se destes tres.

Sem isto uma opcao de velocidade CONTA no combate e fica calada na caixa do item, e nao ha
como o jogador confirmar na tela que ela existe."""


def _escrever_opcoes_da_instancia(r: bytearray, instancia) -> int:
    """Poe as opcoes desta copia do item no fim do registo, onde o client as le.

    Devolve quantas foram escritas. Ver db.OFF_OPCOES_NO_REGISTO para a medicao.

    UMA OPCAO SO ENTRA SE TIVER REPRESENTACAO. O client nao le um numero: le um indice do
    uniqueOptionType, e o valor sai do GRAU dessa linha. Os graus sao poucos, entao um
    "multiplicador de critico +0,02" nao tem como ser escrito -- essa opcao continua a contar
    no combate e fica calada na caixa de texto, que e melhor do que a caixa anunciar um
    numero que o servidor nao aplica.
    """
    if not instancia:
        return 0
    try:
        opcoes = db.bonus_das_instancias([instancia]).get(int(instancia)) or []
    except Exception as e:                                          # noqa: BLE001
        print(f"[hop3] opcoes da instancia {instancia} falharam: {str(e)[:120]}")
        return 0
    n = 0
    for tipo, valor, unidade in opcoes:
        if n >= db.N_OPCOES_NO_REGISTO:
            break
        # A UNIDADE VAI JUNTO. O catalogo distingue plano de percentagem -- ver
        # db.catalogo_de_opcoes -- e sem ela procurava-se "27 valor 10" e podia calhar
        # uma linha de outra unidade. Nao ha mais lista de tipos a mao: quem tem
        # representacao e quem esta no catalogo, e o catalogo sai do skillData.
        idx = db.indice_de_opcao(tipo, valor, unidade)
        if idx is None:
            continue
        off = db.OFF_OPCOES_NO_REGISTO + n * 2
        if off + 2 <= len(r):
            struct.pack_into("<H", r, off, idx & 0xFFFF)
            n += 1
    return n


OP_EQUIPAR = bytes([6, 0])
OP_DESEQUIPAR = bytes([6, 1])


SEGUNDOS_SALVAR_POS = 20   # de quanto em quanto a posicao vai pro banco

OP_VOLTAR_CANAL = bytes([2, 6])        # "voltar" na tela de personagem
# Mesmo endereco que o hop2 anuncia. Precisa bater: e pra ca que o client reconecta
# depois de escolher o canal de novo.
CANAL_HOST, CANAL_PORT = "144.217.65.115", 20299
CANAL_SERVIDOR, CANAL_INDICE = 42, 1


def build_lista_canais(seq: int) -> bytes:
    """CChannelListPacket (02 06) -- devolve a tela de "Selecao do canal".

    Descoberto em 24/08 rastreando um travamento: na tela de personagem, "voltar" faz o
    client mandar um 02 06 de 16 bytes e ESPERAR a lista de canais na MESMA conexao. Nos
    nao tratavamos, entao ele ficava em keepalive pra sempre com a tela congelada.

    Que a lista vem por aqui, e nao por uma conexao nova, esta na captura oficial: o socket
    0x50C do traffic_0823_1819 carrega tanto os 04 02 do mundo quanto um 02 06.

    O layout ja estava resolvido em channel_list_packet.py, usado pelo hop2 -- a mesma
    classe serve, so muda quem manda. Cuidado pra nao confundir os dois sentidos do 02 06:
    no hop2 o 02 06 vindo do client quer dizer "entrar no canal" e a resposta e um
    redirecionamento; aqui quer dizer "me devolve a lista".
    """
    pacote = channel_list_packet.ChannelListPacket(
        server_ids=[CANAL_SERVIDOR],
        channels=[channel_list_packet.ChannelEntry(
            server_id=CANAL_SERVIDOR, channel_idx=CANAL_INDICE, status_flag=1,
            ip=CANAL_HOST, port=CANAL_PORT)])
    p = bytearray(pacote.encode_payload())
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    return encode_frame(bytes(p))


OP_ARMA = bytes([3, 2])                # sacar/guardar a arma, e MONTAR
TAM_ARMA = 17
MODO_MONTADO = 2                       # o [10] do 03 02: 0 normal, 1 arma sacada, 2 montado
OP_MONTOU = bytes([3, 0x16])           # o servidor confirma a montada; corpo todo a zero
TAM_MONTOU = 10


def build_arma(seq: int, entity_id: int, sacada: bool, modo: int = -1) -> bytes:
    """CWeaponDrawPacket (03 02, 17 bytes) -- poe a arma na MAO do personagem.

    Capturado do oficial (traffic_0823_1819.log, dois jogadores no mesmo mapa):

        17:49:44.101 RECV  03 02 00 00 4d d7 00 00 13 be 00 00 00 00 00 00 00
        17:51:20.172 RECV  03 02 00 00 54 99 00 00 0e ba 01 00 00 00 00 00 00

    Os ids em [6:10] sao de OUTROS jogadores, nao do nosso: o servidor real retransmite
    este pacote. E o toggle -- [10] = 01 saca, 00 guarda. O resto e zero.

    Era isto que faltava: sem o 03 02, o client alheio anima o golpe com a arma embainhada
    (adaga na cintura, rifle nas costas), que foi exatamente o que apareceu na tela.
    """
    p = bytearray(TAM_ARMA)
    p[0:2] = OP_ARMA
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 6, entity_id)
    # O [10] E UM MODO, nao um booleano: 0 normal, 1 arma sacada, 2 MONTADO. O modo 2 foi
    # medido em 30/08 -- ver o bloco MONTARIA no entity_sim. Quando se monta, o oficial poe
    # a entidade outra vez em [12:16]; a desmontar manda la zeros.
    p[10] = MODO_MONTADO if modo == MODO_MONTADO else (1 if sacada else 0)
    if p[10] == MODO_MONTADO:
        struct.pack_into(">I", p, 12, entity_id)
    return encode_frame(bytes(p))


OP_MOVIMENTO_PROPRIO = bytes([4, 0])   # o client anuncia a chegada, UMA vez
OP_MOVIMENTO = bytes([4, 1])           # entidade se move -- o client manda a CADA passo
OP_SAIU = bytes([4, 3])                # entidade sai do mundo

# Onde se ressuscita. Lido do 04 02 que o servidor oficial mandou depois da morte, em 25/08:
# (7466, 9173, 629), que o jogo mostra como 74,7 - 91,7 e que e a Pedra do retorno de Berneo.
PONTO_DE_RESSURREICAO = (7466.0, 9173.0, 629.0)

TAM_MOVIMENTO = 42


def ler_movimento(body: bytes) -> tuple[int, tuple, tuple, float] | None:
    """(entity_id, origem, destino, velocidade) de um 04 01 vindo do client.

    Layout (42 bytes), conferido contra a captura do oficial com o jogador andando:

        [6:8]   estado/acao (00 02 parado, 00 01 andando)
        [8:12]  id da entidade  BE u32
        [12:24] posicao de ORIGEM   (3 floats LE)
        [24:36] posicao de DESTINO  (3 floats LE)
        [36:40] velocidade
        [40:42] cauda

    ERRO ANTERIOR: eu escutava o `04 00` e lia o id em [7:11]. O `04 00` o client manda UMA
    vez, ao chegar no mundo -- quem carrega cada passo e o `04 01`, e o id dele fica em [8:12].
    Por isso o outro personagem aparecia parado: o servidor nunca via o movimento.
    """
    if len(body) < 40:
        return None
    eid = struct.unpack_from(">I", body, 8)[0]
    origem = struct.unpack_from("<3f", body, 12)
    destino = struct.unpack_from("<3f", body, 24)
    (vel,) = struct.unpack_from("<f", body, 36)
    return eid, origem, destino, vel


def repassar_movimento(body: bytes, seq: int) -> bytes:
    """Retransmite o 04 01 do client aos outros, trocando SO o numero de sequencia.

    Antes eu remontava o pacote campo a campo com build_movimento, e escrevia [41]=5 fixo.
    Contando os 22337 movimentos da captura oficial, [41] nao e constante nenhuma -- vai de
    0 a 9 (75% deles com [40]=[41]=0) -- e [40] tambem varia. Chutar essa cauda era o que
    deixava os personagens andando errado na tela dos outros.

    Remontar so faz sentido pra pacote que a gente ORIGINA. Este ja chega pronto e correto
    do client; reescrever campo a campo so cria oportunidade de errar um deles.
    """
    p = bytearray(body)
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    return encode_frame(bytes(p))


def aparicao_na_posicao(jogador) -> bytes:
    """A aparicao guardada, com a posicao ATUAL do jogador em [12:24].

    O 04 02 e montado uma vez, com a posicao que veio do banco (a do login). Quem entra
    depois desenhava o outro no ponto onde ele tinha logado, e so o primeiro passo dele
    corrigia. A posicao real vem sendo guardada no registro a cada 04 01.
    """
    if jogador.x == 0.0 and jogador.y == 0.0 and jogador.z == 0.0:
        return jogador.aparicao
    p = bytearray(jogador.aparicao)
    # +4 por causa do prefixo de tamanho do frame
    struct.pack_into("<3f", p, 4 + 12, jogador.x, jogador.y, jogador.z)
    return bytes(p)


def build_saiu(seq: int, entity_id: int) -> bytes:
    """CMoveDisappearPacket (04 03, 11 bytes) -- tira a entidade da tela dos outros."""
    p = bytearray(11)
    p[0:2] = OP_SAIU
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    p[6] = 1
    struct.pack_into(">I", p, 7, entity_id)
    return encode_frame(bytes(p))


# O client PERGUNTA com um 08 09 logo depois de receber a ficha, e espera resposta.
# Medido no oficial: pergunta em t+0,901 e resposta em t+1,176 -- um 08 0f de 10 bytes
# seguido do proprio 08 09 de volta, byte a byte, so com sequencia nova.
#
# Ate 24/08 esse par vinha dentro do stream de entrada, ou seja, ANTES da pergunta. O
# client perguntava, nao recebia nada e ficava esperando -- era a travada leve que sobrava
# depois de tirar as 139 entidades fantasma do stream.
OP_PERGUNTA_08_09 = bytes([8, 9])


def responder_08_09(body: bytes, seq: int) -> bytes:
    """O par 08 0f + 08 09 que o oficial devolve. `body` e a pergunta do client."""
    zero = bytearray(10)
    zero[0:2] = bytes([8, 0x0f])
    struct.pack_into(">I", zero, 2, seq & 0xFFFFFFFF)
    eco = bytearray(body)
    struct.pack_into(">I", eco, 2, (seq + 1) & 0xFFFFFFFF)
    return encode_frame(bytes(zero)) + encode_frame(bytes(eco))


# ---------------------------------------------------------------------------
# NPC e missao
# ---------------------------------------------------------------------------
# Tudo aqui foi medido no servidor OFICIAL em 25/08, clicando no Domingo e aceitando a
# missao 19003101 (Um grito de Socorro) com a captura ligada. Ver Data/Protocol/Opcode.ini.
#
# Os tres pacotes de missao sao ECO PURO: o oficial devolve byte a byte o que recebeu,
# trocando so a sequencia. Nao ha pacote proprio de "aceitei" nem de "recompensa" -- o
# aceite e o eco, e a recompensa chega pelos caminhos que ja existem (08 01 pra EXP).

OP_CLICAR_NPC = bytes([6, 0x0d])
OP_MISSAO_ACEITAR = bytes([9, 0])
OP_MISSAO_ENTREGAR = bytes([9, 1])
OP_MISSAO_OBJETIVO = bytes([9, 3])

# O oficial trocou este campo de 9 (ida) para 44 (volta) no unico 06 0d que temos.
# NAO SEI o que significa -- com uma amostra nao da pra distinguir "codigo de resultado" de
# "estado da conversa" de "quantidade de opcoes do menu". Ecoamos o valor medido porque e o
# que o client viu funcionar; se um clique em outro NPC devolver outro numero, isto vira
# tabela em vez de constante.
RESPOSTA_CLIQUE_NPC = 44


def responder_clique_npc(body: bytes, seq: int) -> bytes:
    """Eco do 06 0d com [18:20] trocado, que e o que o oficial faz.

        pedido   06 0d | seq | jogador | entidade | ????? | 9
        resposta 06 0d | seq | jogador | entidade | ????? | 44
    """
    eco = bytearray(body)
    struct.pack_into(">I", eco, 2, seq & 0xFFFFFFFF)
    if len(eco) >= 20:
        struct.pack_into(">H", eco, 18, RESPOSTA_CLIQUE_NPC)
    return encode_frame(bytes(eco))


def eco_missao(body: bytes, seq: int) -> bytes:
    """Devolve o pacote igual, so com a sequencia nova. Serve pro 09 00, 09 01 e 09 03."""
    eco = bytearray(body)
    struct.pack_into(">I", eco, 2, seq & 0xFFFFFFFF)
    return encode_frame(bytes(eco))


def ler_pedido_missao(body: bytes):
    """(entity_do_jogador, quest_id, alvo) de um 09 00 / 09 01. None se nao der pra ler.

        09 00 00 00 03 4a | 00 00 2b 2f | 01 21 f6 dd | 00 00 01 72 | 00 51
                             jogador       quest_id      entidade do NPC
    """
    if len(body) < 18:
        return None
    return (struct.unpack_from(">I", body, 6)[0],
            struct.unpack_from(">I", body, 10)[0],
            struct.unpack_from(">I", body, 14)[0])


def build_objetivo_missao(seq: int, entity_id: int, quest_id: int,
                         progresso: int = 0) -> bytes | None:
    """Monta o 09 03 a partir do Quest.ini. Nao precisa de captura pra missao nenhuma.

    [22:26] E PROGRESSO, NAO A QUANTIDADE ALVO. Corrigido em 25/08 depois que o jogador
    testou: a missao aparecia CONCLUIDA no instante em que era aceita.
    A causa foi minha. Eu tinha posto ali o `quantidade` do catalogo e conferido contra a
    missao do Domingo, onde quantidade = 1; como o progresso completo dela tambem e 1, os
    dois numeros coincidiram e o meu "os quatro campos batem" passou por confirmacao sendo
    coincidencia. Mandar 1 na aceitacao e dizer ao client "1 de 1 feito".

    O que desfaz a duvida e a missao de COLETAR 19003136 (cinco dentes de lagarto), onde os
    dois numeros sao diferentes -- o campo sobe 1, 2, 3, 4, 5 conforme os itens entram no
    inventario, enquanto a quantidade alvo fica 5 o tempo todo. Nas de conversa o oficial
    manda 0 e depois 1.

    Vale registrar que a documentacao de 24/08 ja dizia "progresso" neste campo, e eu a
    contradisse hoje com uma checagem mais fraca. A anotacao antiga estava certa.
    """
    ficha = db._ficha_missao(quest_id)
    if not ficha:
        return None
    p = bytearray(28)
    p[0:2] = OP_MISSAO_OBJETIVO
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 6, int(entity_id) & 0xFFFFFFFF)
    struct.pack_into(">I", p, 10, int(quest_id) & 0xFFFFFFFF)
    struct.pack_into(">I", p, 14, int(ficha.get("tipo") or 0) & 0xFFFFFFFF)
    struct.pack_into(">I", p, 18, int(ficha.get("alvo") or 0) & 0xFFFFFFFF)
    struct.pack_into(">I", p, 22, max(0, int(progresso)) & 0xFFFFFFFF)
    # [26:28] veio 81 nas duas medicoes, no 09 00 e no 09 03. Nao sei o que e; repito o
    # valor observado em vez de mandar zero, porque zero tambem seria um chute e este ao
    # menos foi visto funcionando.
    struct.pack_into(">H", p, 26, 81)
    return encode_frame(bytes(p))


# Recompensa de EXP acima disto NAO e paga. Existe porque 127 das 730 missoes trazem
# exp_paga acima de 100 milhoes -- a "Doces" traz 1.000.002.626 -- e o limiar medido do
# nivel 68 e 49,7 milhoes. Pagar isso levaria um personagem de nivel 1 ao teto numa missao.
#
# Sao numeros do proprio quest.lod, entao ou o offset 1148 nao e so EXP, ou o campo carrega
# outra coisa junto. Ate saber, o teto protege o progresso de quem joga; o log avisa toda
# vez que ele corta, pra isso nao virar silencio.
TETO_EXP_MISSAO = 5_000_000


def avancar_missoes_por_abate(heroi, npc_id, conn, send_lock, jogador=None):
    """Soma 1 no progresso das missoes de MATAR cujo alvo e este monstro.

    Foi o que faltou pro teste de 25/08: a missao "Protecao da fazenda" pede duas aranhas
    pequenas, e nada no servidor ligava a morte do mob ao contador -- o db.avancar_missao
    existia e nunca era chamado.

    Manda um 09 03 novo a cada avanco, que e como o oficial faz: na captura da missao de
    cinco dentes chegou um 09 03 por item, com o campo de progresso indo 1, 2, 3, 4, 5.
    Nao ha pacote de "objetivo cumprido" -- o client compara o progresso com a quantidade
    do proprio catalogo dele.
    """
    tipo_matar = 1
    for m in db.missoes_do_personagem(heroi.id):
        if m["estado"] != db.MISSAO_ACEITA:
            continue
        ficha = db._ficha_missao(m["quest_id"]) or {}
        if ficha.get("tipo") != tipo_matar or int(ficha.get("alvo") or 0) != int(npc_id):
            continue
        prog, completou = db.avancar_missao(heroi.id, m["quest_id"], 1)
        quadro = build_objetivo_missao(_proxima_seq(), heroi.id, m["quest_id"], prog)
        if quadro:
            if jogador is None:
                with send_lock:
                    conn.sendall(quadro)
            else:
                jogador.enviar(quadro)
        alvo = ficha.get("quantidade") or 1
        print(f"[hop3] missao {m['quest_id']} ({ficha.get('nome')}): "
              f"{prog}/{alvo}" + ("  <<< OBJETIVO CUMPRIDO" if completou else ""))


_seq_missao = [900000]


def _proxima_seq():
    """Sequencia propria pros pacotes de missao, longe do contador de ataque."""
    _seq_missao[0] += 1
    return _seq_missao[0]


def recompensa_de(quest_id):
    """(exp, itens) devidos, com o teto aplicado."""
    ficha = db._ficha_missao(quest_id) or {}
    exp = int(ficha.get("exp_paga") or 0)
    if exp > TETO_EXP_MISSAO:
        print(f"[hop3] missao {quest_id} pede {exp} de EXP -- CORTADO em "
              f"{TETO_EXP_MISSAO}. Ver TETO_EXP_MISSAO.")
        exp = TETO_EXP_MISSAO
    itens = [r for r in data_files.tabela("QuestReward")
             if int(r.get("quest_id") or 0) == int(quest_id)]
    return exp, itens


OP_MOVER_ITEM = bytes([6, 8])      # CItemInvenSwapPacket, minimo 21 bytes


def ler_pedido_mover(body: bytes) -> tuple[int, int, int, int, int, int] | None:
    """(entity_id, cont_origem, slot_origem, item_id, cont_destino, slot_destino) de um 06 08.

    Formato decodificado de um pedido REAL do client (o jogador arrastou uma pocao dentro da
    mochila enquanto a captura rodava):

        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-

    Note que o id do item fica em [12:16] aqui, e nao em [13:17] como no 06 00/06 01.
    """
    if len(body) < 21:
        return None
    entity_id = struct.unpack_from(">I", body, 6)[0]
    item_id = struct.unpack_from(">I", body, 12)[0]
    return entity_id, body[10], body[11], item_id, body[16], body[17]


OP_ITEM_MOVIDO = bytes([6, 6])
CONTAINER_AUTO = 0xFF


OFF_QUANTIDADE = 16      # dentro do registro de 147 bytes, u32 LE


def build_item_movido(seq: int, entity_id: int, registro: bytes, quantidade: int,
                     sozinho: bool = False) -> bytes:
    """CItemInvenListPacket na variante de UM item (06 06), 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

    Capturado do servidor OFICIAL em 23/08: a cada clique-direito num item ele manda ESTE par
    -- e nao o 06 05 inteiro, nem o 04 02. E o par que diz ao client pra onde a peca foi.

    O truque esta na QUANTIDADE, em registro[16]: o primeiro pacote traz a posicao ANTIGA com
    quantidade ZERO (o client tira o item dali) e o segundo traz a posicao NOVA com a
    quantidade real (o client poe o item la). Eu tinha lido esse 0/1 como um byte de indice no
    fim do pacote -- por isso o meu 06 06 saia com 159 bytes em vez de 158.
    """
    p = bytearray(11 + TAM_REGISTRO)
    p[0:2] = OP_ITEM_MOVIDO
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 6, entity_id)
    # O BYTE [10] SEPARA PACOTE SOLTO DE PAR, e nao "apagar" de "mover" como escrevi antes.
    #
    #   1  faz parte de um PAR (mover, equipar, reorganizar): sai da posicao antiga com
    #      quantidade 0 e entra na nova com a real
    #   0  vem SOZINHO: apagar, e tambem APANHAR DO CHAO
    #
    # Medido no fio do oficial: os 76 de mover trazem 1, o de apagar traz 0, e os de apanhar
    # tambem trazem 0. Nos mandavamos 1 no apanhar, e a mensagem da tela saia sem a
    # quantidade -- "Voce comprou: Carat." em vez de dizer quantos.
    p[10] = 0 if sozinho else 1
    r = bytearray(registro[:TAM_REGISTRO])
    struct.pack_into("<I", r, OFF_QUANTIDADE, max(0, quantidade))
    p[11:11 + TAM_REGISTRO] = r
    return encode_frame(bytes(p))


def build_item_removido(seq: int, entity_id: int, registro: bytes) -> bytes:
    """O 06 06 que TIRA um item do inventario do client. 158 bytes, como o outro.

    Medido no fio do oficial em 29/08 (traffic.log, t=74570.58), quando o jogador arrastou
    um item para o caixote. O oficial manda DOIS pacotes, por esta ordem:

        06 06   com o registo do item, quantidade 0, e o byte [10] a ZERO
        06 41   o eco do pedido

    NOS SO MANDAVAMOS O ECO. Era essa a razao de o item continuar na tela depois de apagado:
    a linha saia do banco e o client nunca era avisado, por isso desenhava o que tinha em
    memoria. O mesmo defeito atingia os itens expirados, que tambem saem do banco caladinhos.

    O BYTE [10] E O QUE SEPARA APAGAR DE MOVER, e isso mede-se comparando os 77 pacotes
    06 06 do mesmo log:

        [10] = 1   os 76 de mover/reorganizar (o par posicao antiga qtd 0, posicao nova)
        [10] = 0   o unico de apagar

    Nao se sabe o que o campo SIGNIFICA; sabe-se o valor que o oficial poe em cada caso, e e
    esse que se copia.
    """
    p = bytearray(11 + TAM_REGISTRO)
    p[0:2] = OP_ITEM_MOVIDO
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 6, entity_id)
    p[10] = 0
    r = bytearray(registro[:TAM_REGISTRO])
    struct.pack_into("<I", r, OFF_QUANTIDADE, 0)
    p[11:11 + TAM_REGISTRO] = r
    return encode_frame(bytes(p))


SEGUNDOS_VER_EXPIRADOS = 15


def limpar_expirados(conn, send_lock, entity_id: int, seq_base: list) -> int:
    """Apaga os itens que expiraram e AVISA o client, item a item.

    O jogador descreveu o comportamento do oficial: "ja some dentro do jogo mesmo, do mesmo
    jeito que quando deleto um item". Ou seja o mesmo 06 06 de remocao do caixote -- e por
    isso reaproveita-se build_item_removido em vez de inventar pacote nenhum.

    Sem isto a peca so desaparecia no relogin, porque a limpeza corria so na entrada.
    """
    if not entity_id:
        return 0
    try:
        fora = db.apagar_itens_expirados(entity_id)
    except Exception as e:                                          # noqa: BLE001
        print("[hop3] limpar expirados falhou: %s" % str(e)[:110])
        return 0
    for it in fora:
        seq_base[0] += 1
        try:
            with send_lock:
                conn.sendall(build_item_removido(seq_base[0], entity_id, registro_item(it)))
        except OSError:
            break
    if fora:
        print("[hop3] %d item(ns) expiraram e sairam da tela" % len(fora))
    return len(fora)


# ---------------------------------------------------------------------------
# APANHAR DO CHAO
# ---------------------------------------------------------------------------
# O entity_sim decide QUANDO um saco e apanhado (ele e que tem as posicoes e o ciclo do
# mundo); quem sabe mexer no inventario e no banco e aqui. O gancho ENTREGAR_ITEM liga os
# dois sem que o entity_sim tenha de importar este modulo -- que seria ciclo, porque este ja
# importa aquele.
_seq_apanhar = [90000]
TIPO_MISSAO_COLETAR = 3


def consumir_itens_da_missao(jogador, quest_id: int) -> int:
    """Tira do inventario os itens que a missao de recolha pedia. Devolve quantos saiu.

    O jogador corrigiu em 29/08: entregue a missao, os itens somem. Faz sentido -- foram
    entregues ao NPC. Sem isto ficavam na mochila para sempre e a proxima repeticao da mesma
    missao ficava cumprida de borla, com o inventario a encher de dentes de cao.

    O 06 06 com QUANTIDADE ZERO e que apaga o item na tela do client: nao ha opcode proprio
    de remocao, e a descoberta ja esta anotada no Opcode.ini, no 06 06.
    """
    ficha = db._ficha_missao(quest_id) or {}
    if ficha.get("tipo") != TIPO_MISSAO_COLETAR:
        return 0
    item_id = int(ficha.get("alvo") or 0)
    if not item_id:
        return 0
    pedidos = int(ficha.get("quantidade") or 1)
    saiu = 0
    for it in db.inventario_do_personagem(jogador.entity_id):
        if saiu >= pedidos:
            break
        if it.get("item_id") != item_id:
            continue
        # o pacote tem de sair ANTES do apagar: depois de apagado o registo de 147 bytes
        # deixa de existir e nao ha com que dizer ao client qual slot esvaziar
        _seq_apanhar[0] += 1
        quadro = build_item_movido(_seq_apanhar[0], jogador.entity_id,
                                   registro_item(it), 0)
        if db.apagar_item(it["instancia"]):
            jogador.enviar(quadro)
            saiu += 1
    if saiu:
        print("[missao] %d item(ns) %d saiu(ram) da mochila de %s na entrega da missao %s"
              % (saiu, item_id, jogador.nome, quest_id))
    return saiu


def entregar_itens_de_missao(heroi, npc_id: int) -> None:
    """Poe na mochila os itens de missao que este abate rendeu, se houver missao para eles.

    NAO passa pelo chao. O jogador corrigiu em 29/08: o item de missao entra direto no
    inventario, e nao ha saco para apanhar. Por isso isto nao chama o entity_sim.largar --
    so o sorteio, e a entrega e a mesma do saque.
    """
    try:
        missoes = db.missoes_do_personagem(heroi.id)
    except Exception as e:                                          # noqa: BLE001
        print("[missao] missoes_do_personagem falhou: %s" % str(e)[:150])
        return
    aceites = {m["quest_id"] for m in missoes if m["estado"] == db.MISSAO_ACEITA}
    itens = entity_sim.sortear_drop_missao(int(npc_id), aceites)
    if not itens:
        return
    jogador = mundo.jogador(heroi.id)
    if jogador is None:
        return                    # saiu do mundo entre o golpe e a morte
    for item_id, qtd in itens:
        entregar_no_inventario(jogador, item_id, qtd)


def avancar_missoes_por_coleta(jogador, item_id: int, quantidade: int) -> None:
    """Soma progresso nas missoes de RECOLHA quando o item entra na mochila.

    O progresso conta ao APANHAR e nao ao matar. E o que a captura mostra: na missao dos
    cinco dentes chegou um 09 03 por ITEM, com o contador a ir 1, 2, 3, 4, 5. Um monstro que
    morra sem largar nada nao avanca a missao.

    Fica separado do avancar_missoes_por_abate, que trata as de MATAR: sao dois gatilhos
    diferentes (a morte e o saque) e juntar os dois numa funcao so obrigaria a passar por
    ela nos dois sitios com um parametro a dizer qual e qual.
    """
    heroi = getattr(jogador, "heroi", None)
    if heroi is None:
        return
    try:
        missoes = db.missoes_do_personagem(jogador.entity_id)
    except Exception as e:                                          # noqa: BLE001
        print("[drop] missoes_do_personagem falhou: %s" % str(e)[:150])
        return
    for m in missoes:
        if m["estado"] != db.MISSAO_ACEITA:
            continue
        ficha = db._ficha_missao(m["quest_id"]) or {}
        if ficha.get("tipo") != TIPO_MISSAO_COLETAR:
            continue
        if int(ficha.get("alvo") or 0) != item_id:
            continue
        prog, completou = db.avancar_missao(jogador.entity_id, m["quest_id"], quantidade)
        quadro = build_objetivo_missao(_proxima_seq(), jogador.entity_id, m["quest_id"], prog)
        if quadro:
            jogador.enviar(quadro)
        print("[drop] missao %s (%s): %s/%s%s"
              % (m["quest_id"], ficha.get("nome"), prog, ficha.get("quantidade") or 1,
                 "  <<< OBJETIVO CUMPRIDO" if completou else ""))


def entregar_no_inventario(jogador, item_id: int, qtd: int) -> bool:
    """Poe na mochila um item apanhado do chao e desenha-o la.

    Devolve False quando NAO conseguiu -- mochila cheia, banco em baixo, item fora do
    catalogo. Nesse caso o entity_sim deixa o saco no chao de proposito: sumir com o saco
    sem dar o item e a pior das saidas, porque o jogador ve o saque acontecer e fica sem
    nada, sem nada na tela que explique.
    """
    try:
        r = db.dar_item(jogador.entity_id, int(item_id), int(qtd))
    except Exception as e:                                          # noqa: BLE001
        print("[drop] dar_item rebentou para %s: %s" % (jogador.nome, str(e)[:150]))
        return False
    if not r.get("ok"):
        print("[drop] %s nao levou %dx%d: %s"
              % (jogador.nome, qtd, item_id, r.get("erro")))
        return False
    avancar_missoes_por_coleta(jogador, int(item_id), int(qtd))
    # o 06 06 precisa do registo de 147 bytes, e esse so existe depois de gravado
    for it in db.inventario_do_personagem(jogador.entity_id):
        if it.get("instancia") == r.get("instancia"):
            _seq_apanhar[0] += 1
            # sozinho=True -> [10]=0, que e o que o oficial manda ao apanhar do chao
            jogador.enviar(build_item_movido(_seq_apanhar[0], jogador.entity_id,
                                             registro_item(it), it["quantidade"],
                                             sozinho=True))
            break
    else:
        # entrou no banco mas nao apareceu na releitura: o item esta la e aparece no
        # proximo login. Vale mais avisar do que fingir que correu tudo bem.
        print("[drop] %s levou %dx%d mas o 06 06 nao saiu (instancia %s nao relida)"
              % (jogador.nome, qtd, item_id, r.get("instancia")))
    return True


def _reenviar_ficha(j):
    """Monta e manda o 08 02 deste jogador. Gancho do entity_sim -- ver rodar_fichas.

    O `build_stats` precisa da linha do BANCO, nao do objecto do mundo, e por isso o
    reenvio periodico nao podia viver no entity_sim: aquele modulo nao fala com o banco.
    """
    # A VIVA PRIMEIRO. A do banco so serve de rede: e a base, sem bonus de equipamento.
    p = getattr(j, "ficha_viva", None) or db.personagem_por_entidade(j.entity_id)
    if not p:
        return
    enviar_velocidade(j, p)
    _seq_apanhar[0] += 1
    quadro = build_stats(p, _seq_apanhar[0])
    if quadro:
        j.enviar(quadro)
    # E O 08 01, QUE E QUEM MOVE O BONECO.
    #
    # Sem isto a velocidade nao voltava ao normal quando um buff EXPIRAVA: mandava-se a
    # ficha com o valor certo e o client continuava a andar pelo antigo. O jogador viu o
    # MV travado a zero depois de a pocao acabar.
    #
    # Este e o gancho por onde passam o reenvio periodico E o fim dos buffs (ver
    # entity_sim.rodar_buffs_de_item, que chama `enviar_ficha` ao apagar os que acabaram),
    # entao chega poe-lo aqui uma vez.
    _h = getattr(j, "heroi", None)
    if _h is not None:
        _g = entity_sim.build_hero_gage(_seq_apanhar[0] + 1, _h)
        if _g:
            _seq_apanhar[0] += 1
            j.enviar(_g)


def enviar_velocidade(j, personagem) -> None:
    """Manda o `08 00` deste jogador. Ver entity_sim.build_velocidade.

    ANTES DA FICHA, que e a ordem do oficial: nas quatro mudancas de velocidade da captura
    das 18:43 a sequencia e sempre `05 09 -> 08 00 -> 08 02 -> 08 01`.

    Sem este pacote a velocidade nao aplica, por mais que o 08 02 a declare. A ficha e o
    mostrador; este e o motor.
    """
    if not personagem:
        return
    try:
        ent = int(personagem.get("entity_id") or 0)
        if not ent:
            return
        _seq_apanhar[0] += 1
        j.enviar(entity_sim.build_velocidade(
            _seq_apanhar[0], ent,
            entity_sim.velocidade_de_movimento(ent),
            hp=int(personagem.get("hp") or 0),
            hp_max=int(personagem.get("hp_max") or 0),
            mp=int(personagem.get("mp") or 0),
            mp_max=int(personagem.get("mp_max") or 0),
            nivel=int(personagem.get("nivel") or 1)))
    except Exception as e:                                          # noqa: BLE001
        print("[hop3] enviar_velocidade falhou: %s" % str(e)[:110])


entity_sim.ENVIAR_FICHA[0] = _reenviar_ficha
entity_sim.ENTREGAR_ITEM[0] = entregar_no_inventario


def ler_pedido_equipamento(body: bytes) -> tuple[int, int, int, int] | None:
    """(entity_id, slot, item_id) de um 06 00 ou 06 01. None se o tamanho nao bater.

    Formato levantado em 23/08, capturando o client do nosso proprio servidor enquanto o
    jogador arrastava a Adaga pra fora do slot -- e ele repetiu o pedido, porque a gente
    ainda 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

    O PEDIDO e a CONFIRMACAO tem o mesmo formato: aceitar e devolver o mesmo pacote.
    """
    if len(body) < 17:
        return None
    entity_id = struct.unpack_from(">I", body, 6)[0]
    container = body[10]
    slot = body[11]
    item_id = struct.unpack_from(">I", body, 13)[0]
    return entity_id, container, slot, item_id


def build_equipamento(op: bytes, seq: int, entity_id: int, container: int, slot: int,
                     item_id: int, refino: int = 0) -> bytes:
    """Confirmacao de equipar (06 00, 18 bytes) ou desequipar (06 01, 17 bytes).

    ATENCAO: no EQUIPAR o pedido e a resposta sao DIFERENTES -- foi o meu erro antes de ir na
    captura. Comparando os dois no servidor oficial:

        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

    No DESEQUIPAR a resposta e eco exato do pedido: quem escolhe o slot da mochila e o client
    (pega o primeiro livre), e o par de 06 06 confirma onde ficou.
    """
    tam = 18 if op == OP_EQUIPAR else 17
    p = bytearray(tam)
    p[0:2] = op
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 6, entity_id)
    p[10] = container & 0xFF
    p[11] = slot & 0xFF
    p[12] = slot & 0xFF
    struct.pack_into(">I", p, 13, item_id & 0xFFFFFFFF)
    # [17] = REFINO, e so existe no equipar -- e por isso que o 06 00 tem 18 bytes e o 06 01
    # so 17. Medido em 28/08 no oficial, ao equipar a "[+7 arma osso de dragao]":
    #
    #     06 00 00 00 00 45  00 99 06 c2  00 07 07  00 00 52 da  07
    #                 seq    entidade     cont/slot  item 21210  refino
    #
    # Bate com o que o binario ja dizia: FUN_00652797 enche o campo +0xf3 do objeto item com
    # o byte 17 deste pacote, e o handler da lista de inventario enche o MESMO +0xf3 com o
    # `_Src[7]` do registo. Ou seja os dois caminhos alimentam o mesmo campo, e nos so
    # tinhamos o primeiro -- o item chegava refinado na mochila e perdia o refino ao vestir.
    if op == OP_EQUIPAR and tam >= 18:
        p[17] = max(0, min(db.REFINO_MAX, int(refino))) & 0xFF
    return encode_frame(bytes(p))


def build_gage(molde: bytes, personagem: dict) -> bytes:
    """Reescreve o CCharInfoGage (08 00) com o nivel e o HP/MP do personagem.

    Descoberto em 23/08 comparando o 08 00 de um personagem nivel 2 com um nivel 1:

        [15:19] HP maximo    [19:23] HP maximo (de novo)
        [23:27] MP maximo    [27:31] MP maximo (de novo)
        [43]    NIVEL

    Era o setimo pacote do stream a carregar dado do personagem capturado -- e o que fazia o
    jogo mostrar "subiu de nivel" e Lv2 por alguns segundos ao entrar no mundo.
    """
    p = bytearray(molde)
    # LITTLE-endian. Escrevi big-endian ate 23/08 e o client lia HP maximo ZERO -- comparando
    # o nosso 08 00 com o do servidor oficial, os quatro campos apareciam com os bytes
    # trocados. O resto do pacote (nivel em [43]) ja estava certo.
    for off in (15, 19):
        struct.pack_into("<I", p, off, personagem["hp_max"])
    for off in (23, 27):
        struct.pack_into("<I", p, off, personagem["mp_max"])
    p[43] = max(1, personagem.get("nivel", 1)) & 0xFF
    # [39:43] e a velocidade de ataque, mesmo campo do 08 01 -- ver escrever_velocidade()
    entity_sim.escrever_velocidade(p, personagem.get("entity_id", 0))
    return encode_frame(bytes(p))


# Campos da ficha (08 02) que sao FUNCAO das cinco caracteristicas.
#
# Levantados em 23/08 ajustando os moldes capturados do servidor OFICIAL das classes 100,
# 200, 300, 400 e 800 -- as cinco cujo HP/MP no 08 02 bate com o do 02 05, ou seja, as cinco
# que foram mesmo capturadas em nivel 1. Cada uma das onze formulas abaixo reproduz as cinco
# classes com erro ZERO, e os coeficientes sao redondos (0,35 / 0,5 / 0,6 / 0,475...), o que
# nao acontece por acaso em cinco pontos.
#
# As 500 e 600 ficaram de fora do ajuste de proposito: os moldes delas vieram de personagens
# que NAO estavam em nivel 1 (a Maga marca HP 223,2 e MP 110 na ficha contra 150/90 no 02 05,
# que sao os numeros dela em nivel 3). Enquanto nao recapturarmos, calcular e melhor que
# copiar -- e por isso que a ficha agora e montada, nao herdada do molde.
#
#   offset: (coeficiente por caracteristica, constante)
CAMPOS_DERIVADOS = {
    42:  ({"vitalidade": 0.75}, 6.25),
    54:  ({"sabedoria": 0.55}, -1.75),
    66:  ({"destreza": 0.05}, 6.5),
    70:  ({"destreza": 0.1}, 8.0),
    98:  ({"destreza": 0.5}, 4.0),      # "Classificacao de ataque". Validado em 27/08 contra a
                                    # ficha de um Desperado nivel 90: Destreza 20 da 14,0 e o
                                    # 08 02 do oficial trazia 14,000 exatos.
    110: ({"destreza": 0.35}, 0.0),
    122: ({"inteligencia": 0.475}, 2.5),
    126: ({"sabedoria": 0.3}, 17.0),
    130: ({"destreza": 0.008, "sabedoria": 0.1}, 3.42),
    142: ({"vitalidade": 0.6}, 2.275),
    170: ({"destreza": 0.452}, 2.08),
}

CAMPOS_SO_DO_OFICIAL = {
    36: 26752.0, 48: -2.0, 100: 72.129, 154: 20.0,
    180: 4.094, 310: 20.0, 428: -2.0, 480: 72.128,
}
"""EXPERIENCIA de 31/08: os oito campos que o oficial SEMPRE preenche e nos SEMPRE zeramos.

Sao o que sobrou depois de o par 78/82 nao explicar a velocidade. Achados comparando as 23
fichas do oficial com as 108 nossas, lendo float em TODOS os offsets (sem alinhamento -- foi
o alinhamento que escondeu o 78 na primeira passagem).

O 100 e o 480 sao o mesmo campo nos dois blocos (100 + 380 = 480) e valem 72,13, que cai
bem no meio da faixa de velocidade. O 48 e o 428 sao outro par, a -2.

OS VALORES SAO DO PERSONAGEM DELE, nao do nosso -- e por isso isto e uma EXPERIENCIA e nao
uma correccao: nao se sabe o que cada campo significa, e copiar o numero de outra pessoa so
serve para responder "e na ficha ou nao e". Se o personagem andar, bissecta-se para achar
qual dos oito; se nao andar, a ficha deixa de ser suspeita e o alvo passa a ser o que chega
por fora dela.

DESLIGAR depois do teste, seja qual for o resultado."""

EXPERIENCIA_CAMPOS_OFICIAL = False
"""Interruptor da experiencia acima. Falso = comportamento normal."""

OFF_VELOCIDADE_2 = 78
"""O outro campo de velocidade. Ver o bloco que o escreve em build_stats.

Sobe sempre na mesma proporcao que o 82 -- medido em tres velocidades da mesma sessao do
oficial. Qual dos dois o client usa para mover o boneco nao esta separado: os dois andam
juntos la, e por isso nao ha captura que os distinga. O que se sabe e que escrever so o 82
nao move nada."""

PASSO_BLOCO2 = 380
"""Distancia entre o bloco 1 (com equipamento) e o bloco 2 (base). Ja estava implicita no
PAR_BLOCO2; fica com nome porque o calculo da velocidade precisa dela."""

OFF_ALCANCE = 62      # igual ao alcance da arma equipada, sem nenhuma conta pelo meio
OFF_ATQ_FISICO = 90   # ataque fisico BASE -- o client soma o dano da arma por cima
OFF_VEL_ATAQUE = 94   # idem para a velocidade de ataque
OFF_VEL_INVOCACAO = 134
"""Velocidade de invocacao. Achado em 31/08 procurando, nas 23 fichas do oficial, os offsets
que valem SEMPRE 100,00 -- que e o valor base que a ficha mostra. Sobraram dois: o 134 e o
514, que e o par dele no bloco 2 (134 + 380). E a contraparte magica do 94: *"Maga nao tem
velocidade de att e sim de invocacao"*."""
OFF_DEFESA = 102      # soma das defesas do equipamento + 21
OFF_ATQ_MAGICO = 118  # ataque magico BASE -- idem, o client soma o dano magico da arma
# CAMPOS NOVOS, medidos em 27/08 comparando dois 08 02 do oficial -- o mesmo personagem
# (Desperado nivel 90) com e sem a mesma arma, uma "[+7 A poderosa arma osso de dragao]"
# (item 21210, refino 7). A caixa de texto dela declara quatro caracteristicas especiais, e
# tres apareceram como diferenca limpa entre os dois pacotes:
#
#     Forca de Ataque Fisico +5,00   ->  offset  90   55,225 -> 60,225
#     Classificacao de ataque +2,00  ->  offset  98   14,000 -> 16,000   <- ESTE era novo
#     Poder magico. ataques +5,00    ->  offset 118   56,110 -> 61,110
#     Vitalidade +3,00               ->  offset  28   u16, 0 -> 3        <- e ESTE
#
# O offset 28 e u16 e guarda o BONUS de Vitalidade que o equipamento da, nao o total: a ficha
# do jogo mostrava "Vitalidade 24 (+3)" e o campo tinha 3.
OFF_CLASSIF_ATAQUE = 98   # "Classificacao de ataque" -- float, somado pelo equipamento

# OS TRES CAMPOS QUE A FANTASIA MEXE E QUE NINGUEM ESTAVA A SOMAR. Sao campos DERIVADOS --
# saem das caracteristicas pelo CAMPOS_DERIVADOS -- e por isso passavam despercebidos: a
# ficha mostrava um numero certo para um personagem nu e ignorava o equipamento por completo.
#
#   102  proteccao fisica   medido em 29/08 com as "Exemplo das asas do Caos": +50,00
#   126  proteccao magica   a mesma medicao: +8,00
#   170  chance de critico  destreza * 0,452 + 2,08, a formula do db.chance_critico
OFF_PROTECAO_MAGICA = 126
OFF_CHANCE_CRITICO = 170
OFF_CLASSIFICACAO_ATAQUE = 98
"""Classificacao de ataque. Ja saia do CAMPOS_DERIVADOS (destreza * 0,5 + 4, validado em
27/08 contra a ficha de um Desperado nivel 90); ganhou nome quando as opcoes percentuais do
Arena passaram a poder mexer nela."""

# MULTIPLICADOR DE CRITICO. Medido em 27/08 numa Feiticeira de nivel 48 do oficial, com e sem
# o equipamento dela: o campo passa de 1,200 para 1,300 e a ficha do jogo mostra exatamente
# "Multiplicador de critico 1.2" e depois "1.3". E a PROVA de que um item pode aumenta-lo --
# a duvida que ficou em aberto quando so se tinham fichas com 1,2.
#
# O par de bloco 2 fica em 518, ou seja 138 + 380, o mesmo desvio dos outros campos: la o
# valor mantem-se em 1,200, que e o do personagem nu.
OFF_MULT_CRITICO = 138

# VELOCIDADE DE MOVIMENTO -- OS DOIS CANDIDATOS CAIRAM (30/08).
#
# Em 28/08 varreu-se a ficha 02 05 do oficial a procura de floats plausiveis e ficaram dois:
# [1068] valia 90,0 e [1100] valia 65,0, que sao exactamente os DOIS MODOS da velocidade do
# jogador medida no fio. Pareciam andar e correr. Nao sao:
#
#     [1100] vale 65,000 em TODAS as 30 capturas oficiais que existem, de todas as classes e
#            niveis, sem uma excepcao. Nao e um campo do personagem.
#     [1068] varia (60, 70, 78, 80, 90, 98, 100, 117, 134, 140, 242, 260) e e A MANA. Na
#            captura de 30/08 vale 98,0 e a ficha do jogador mostra "Mana 98"; varrendo a
#            zona 900-1200 a procura dos oito numeros da ficha dele, foi a UNICA batida.
#
# O 90 que fez o [1068] parecer velocidade era so a mana de varios personagens calhar 90.
#
# O 02 05 SO VAI NO LOGIN e nao e reenviado ao trocar de equipamento -- a captura de 30/08
# tem os dois unicos 02 05 no minuto da entrada e a fantasia foi vestida 70 segundos depois.
# Para ver o campo mudar e preciso entrar JA COM o bonus vestido e comparar com uma entrada
# sem ele; nao chega equipar com a sessao aberta.
#
# QUE O ATRIBUTO EXISTE, existe, e o valor ate se sabe: a Phoenix (item 1000008860) diz na
# propria descricao "Aumenta a velocidade de movimento em 90 e a proteccao fisica em 70", e
# o +70 esta medido na ficha. O que falta e o OFFSET.
MULT_CRITICO_BASE = 1.2       # o que a ficha de um personagem sem equipamento mostra

# BONUS DE CARACTERISTICA que o equipamento da, cinco u16 seguidos pela mesma ordem das
# caracteristicas base (que estao em 12 + i*2): Forca, Destreza, Vitalidade, Inteligencia,
# Sabedoria. Guardam o (+N) e nao o total -- a ficha mostrava "Destreza 21 (+6)" com 6 no
# offset 26, e "Vitalidade 24 (+3)" com 3 no offset 28 na captura do Desperado.
OFF_BONUS_CARACTERISTICAS = 24
OFF_BONUS_VITALIDADE = OFF_BONUS_CARACTERISTICAS + 2 * 2   # 28, mantido pelo nome
BASE_DEFESA = 21.0

# Liga o teste de sentinela do build_stats (ver o bloco no fim da funcao). Deixar SEMPRE False
# fora de um teste: com isso ligado a ficha mostra numeros inventados.
SENTINELA = False

# offset -> valor unico. Escolhidos pra nao se confundirem entre si nem com numero plausivel.
SENTINELAS = {
    38: 111.0,   # HP maximo?          98:  101.0,
    42: 22.0,    # regeneracao de HP?  102: 202.0,   # protecao fisica?
    50: 333.0,   # MP maximo?          110: 303.0,
    54: 44.0,    # regeneracao de MP?  118: 404.0,   # ataque magico?
    62: 55.0,    # alcance?            122: 505.0,
    66: 66.0,    # chance de evasao?   126: 60.0,    # protecao magica?
    70: 77.0,    # chance de bloqueio? 130: 707.0,
    90: 88.0,    # ataque fisico?      142: 808.0,
    94: 9.99,    # velocidade ataque?  170: 90.9,    # chance de critico?
    98: 101.0, 102: 202.0, 110: 303.0, 118: 404.0, 122: 505.0,
    126: 60.0, 130: 707.0, 142: 808.0, 170: 90.9,
}

# Ataque magico base. As sete fichas do oficial em nivel 1 mostram que o coeficiente de
# Inteligencia DOBRA nas classes magicas (Feiticeira e Maga), e so nelas:
#
#     normal:  Int * 1,54 + Sab * 0,48 -  4,80
#     magica:  Int * 3,08 + Sab * 0,48 - 16,20
#
# Conferido nas sete: a Maga da 30 e 35 (Int 11, Sab 9, cajado 8,37-13,32), exatamente o que
# a ficha dela mostra. 3,08 e o dobro exato de 1,54 -- nao e ajuste forcado.
ATQ_MAGICO = {False: (1.54, 0.48, -4.80), True: (3.08, 0.48, -16.20)}


# Os campos que o equipamento altera tem um par no BLOCO 2 do pacote (offset + 380), que
# guarda o valor SEM equipamento. O client usa os dois 08 02 do login como base -> total:
#     08 02 #1  bloco 1 == bloco 2   (sem equipamento)
#     08 02 #2  bloco 1 com o equipamento somado, bloco 2 intacto
# e mostra a diferenca como o "(+33)" verde. Mandando o MESMO valor nos dois, a diferenca da
# zero e ele desenha so a base -- foi exatamente o que aconteceu com a nossa protecao fisica
# travada em 21. Conferido na captura do oficial: #1 manda 21,0 e #2 manda 54,0.
PAR_BLOCO2 = {OFF_ALCANCE: 442, OFF_ATQ_FISICO: 470, OFF_VEL_ATAQUE: 474, OFF_DEFESA: 482,
              OFF_MULT_CRITICO: 518}
# O OFF_CLASSIF_ATAQUE nao entra aqui: na captura de 27/08 o seu par de bloco 2 (478)
# NAO mudou quando a arma entrou, ao contrario dos outros quatro. Ou o campo nao tem
# par, ou o par guarda outra coisa -- nao se assumiu nenhuma das duas.


# DIAGNOSTICO: manda o 08 02 CRU capturado do servidor oficial (Vagabundo novo, nivel 1,
# mesmo kit), trocando so o numero de sequencia e o id do personagem.
#
# Existe pra separar duas hipoteses que a comparacao byte a byte nao separou: ou a nossa
# construcao tem um defeito que o diff nao pegou, ou o pacote nao e a causa. Se com os bytes
# do proprio oficial a ficha mostrar 63, o problema e nosso; se continuar 21, o 08 02 esta
# descartado como causa.
#
# So vale pra classe 300 -- e so pra teste.
ESPELHO_OFICIAL = False


def bonus_de_opcoes(entity_id) -> dict:
    """Bonus ABSOLUTOS que as CARACTERISTICAS ESPECIAIS do equipamento dao a ficha.

    Devolvia sempre vazio ate 28/08, com a justificacao de que as caracteristicas especiais
    pertencem a INSTANCIA do item -- sorteadas quando ele cai -- e que os itens do nosso
    servidor nao tem nenhuma. Isso esta certo para as opcoes SORTEADAS, e continua a ser
    verdade; mas nao cobria o outro caminho, que e o que o jogador tinha a frente.

    As "[Asas de anjo (24 horas)]" mostram tres linhas na caixa de texto -- Saude +1004,00,
    Mana +328,00 e Poder de ataque de habilidade +3% -- e o `bruto` do item na nossa base vem
    todo a zero. Ou seja: nao veio do servidor, quem as desenha e o CLIENT, a partir das suas
    proprias tabelas. Sao FIXAS do item, iguais em toda copia dele, e por isso extraiveis.
    Ver tools/importar_bonus_de_item.py para a cadeia (itemAttr -> skillLevel -> skillData).

    PERCENTUAIS FICAM DE FORA. A mesma tabela traz opcoes em percentagem, e o percentual so
    tem sentido contra a base a que se aplica -- que e conhecida no sitio onde o campo e
    escrito, nao aqui. Devolver "+3" misturado com os absolutos era garantir que alguem o
    somasse como se fossem pontos. Quem precisar deles chama `percentuais_de_opcoes`.
    """
    if not entity_id:
        return {}
    return {nome: absoluto
            for nome, (absoluto, _pct) in db.bonus_do_equipamento(entity_id).items()
            if absoluto}


def percentuais_de_opcoes(entity_id) -> dict:
    """A outra metade: {nome: percentagem}. Aplicar sempre contra a base do proprio campo."""
    if not entity_id:
        return {}
    return {nome: pct
            for nome, (_abs, pct) in db.bonus_do_equipamento(entity_id).items()
            if pct}


def maximos_com_equipamento(personagem: dict) -> tuple[int, int]:
    """(hp_max, mp_max) com as caracteristicas especiais ja somadas.

    NAO GRAVAR ISTO NO BANCO. O hp_max da tabela e a base do personagem; o bonus e do
    equipamento e recalcula-se a cada leitura. Guardar o total daria o mesmo defeito que a
    posicao de entrada teve em 27/08 -- ler o valor ja somado e torna-lo a somar no login
    seguinte, subindo um degrau de cada vez.
    """
    b = bonus_de_opcoes(personagem.get("entity_id"))
    return (int(personagem.get("hp_max") or 0) + int(b.get("saude", 0)),
            int(personagem.get("mp_max") or 0) + int(b.get("mana", 0)))


ESPELHO_RAPIDO = False
"""EXPERIENCIA de 31/08: mandar a ficha do OFICIAL em que ele andava a 156, byte a byte.

PORQUE. Ja cairam cinco hipoteses para a velocidade -- o buff 05 09, o id de skill nele, o
par de campos 78/82, os oito campos que so ele preenchia, e o pacote 08 00. Em todas se
mandou o numero certo e o client andou a 65.

Esta experiencia responde a pergunta que sobra, e responde-a de vez: se o client receber a
FICHA DELE, sem uma unica alteracao nossa alem da identidade, anda a 156?

    ANDA  -> o problema esta em alguma coisa que nos escrevemos, e bissecta-se campo a campo
    NAO   -> a ficha nao move o boneco, e o alvo passa a ser tudo o que chega por fora dela

Nao ha meio-termo, e e por isso que vale a pena: e o unico teste do dia que nao pode dar
"talvez".

Os stats na tela vao sair errados (sao os do personagem dele, nivel 94). Isso e o esperado
e nao interessa -- so a velocidade interessa.

RESPOSTA (31/08 17:56): NAO ANDA. Mandaram-se 26 fichas com a dele -- 156,08 no [82],
108,53 no [78], a base 65,00/45,20 no bloco 2 -- e o jogador andou a p50 64,9. A ficha nao
move o boneco, e o 08 02 sai da lista de suspeitos de vez.

Fica o interruptor, desligado, porque a experiencia e repetivel e a conclusao e forte demais
para se perder num comentario."""

CAMINHO_ESPELHO_RAPIDO = r"D:\RoshEmulator\captures\classes\oficial_rapido_156.bin"


def build_stats_rapido(personagem: dict, seq: int) -> bytes | None:
    """A ficha do oficial a 156, so com a identidade trocada. Ver ESPELHO_RAPIDO."""
    try:
        with open(CAMINHO_ESPELHO_RAPIDO, "rb") as f:
            p = bytearray(f.read())
    except OSError:
        return None
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 6, int(personagem["entity_id"]))
    return encode_frame(bytes(p))


def build_stats_espelho(personagem: dict, seq: int, com_equipamento: bool) -> bytes | None:
    if personagem.get("classe") != 300:
        return None
    nome = "oficial_stats_300_2.bin" if com_equipamento else "oficial_stats_300_1.bin"
    try:
        with open(os.path.join(DIR_CLASSES, nome), "rb") as f:
            p = bytearray(f.read())
    except OSError:
        return None
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    struct.pack_into(">I", p, 6, personagem["entity_id"])
    return encode_frame(bytes(p))


def build_stats(personagem: dict, seq: int = 0, com_equipamento: bool = True) -> bytes | None:
    if ESPELHO_OFICIAL:
        espelho = build_stats_espelho(personagem, seq, com_equipamento)
        if espelho is not None:
            return espelho
    """Monta o 08 02 (794 bytes) -- e ESTE que a ficha de atributos do jogo desenha.

    Descoberto em 23/08 depois de o Guerreiro entrar no mundo mostrando os atributos da Maga:
    o 02 05 e o 08 01 ja iam corretos, mas a ficha nao le nenhum dos dois. Le daqui.

        [12] [14] [16] [18] [20]  LE u16
        Forca, Destreza, Vitalidade, Inteligencia, Sabedoria -- mesma ordem do 02 05[1030]

    Os floats sao ataque, defesa e taxas. Onze deles saem das caracteristicas (ver
    CAMPOS_DERIVADOS) e tres saem do EQUIPAMENTO:

        [62]  alcance da arma            10 corpo-a-corpo, 140 arco, 160 canhao
        [94]  velocidade de ataque       0,616 canhao ... 1,573 adaga
        [102] defesa + 21                bate exato com a soma do itemAttr nas cinco classes

    Esses tres sao a prova de que a leitura do itemAttr.lod esta certa: o servidor oficial
    manda nesses offsets exatamente o numero que a tabela do proprio client tem.

    O que AINDA vem do molde: HP/MP maximo (38 e 50), e os offsets 90 e 118 -- os tres tem
    uma base por classe que ainda nao achamos (nao esta no classInfo.lod, ja conferido).
    """
    if ESPELHO_RAPIDO:
        _r = build_stats_rapido(personagem, seq)
        if _r:
            return _r
    molde = molde_da_classe(personagem["classe"], "stats")
    if molde is None:
        return None
    p = bytearray(molde)
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    # [6:10] = ID DO PERSONAGEM, big-endian. Faltava, e era ISSO.
    #
    # O molde traz o id do personagem que foi capturado (10911 na classe 300). O client
    # DESCARTA o pacote em silencio quando esse id nao e o do jogador local -- a mesma
    # conferencia que eu ja tinha achado no handler do inventario (FUN_00657e08):
    #
    #     if (ntohl(*(u_long *)(payload + 6)) == <id do jogador local>) { ...processa... }
    #
    # Por isso a ficha ignorava tudo o que a gente mandava no 08 02 e desenhava o que o
    # proprio client tinha. Provado por eliminacao: reenviando os bytes CRUS capturados do
    # oficial, so com sequencia e id trocados, a Protecao fisica apareceu na hora -- e a
    # diferenca entre aquele pacote e o nosso eram exatamente estes dois bytes.
    if personagem.get("entity_id"):
        struct.pack_into(">I", p, 6, personagem["entity_id"])

    caracteristicas = ("forca", "destreza", "vitalidade", "inteligencia", "sabedoria")
    for i, campo in enumerate(caracteristicas):
        if campo in personagem:
            struct.pack_into("<H", p, 12 + i * 2, personagem[campo] & 0xFFFF)

    # OS CAMPOS DERIVADOS CONTAM O BONUS DAS CARACTERISTICAS, e nao so a base.
    #
    # Medido em 30/08 em 1178 fichas do oficial que tinham bonus de Destreza: a chance de
    # evasao e a classificacao de ataque saem de (base + bonus) em 1178 casos e de so a base
    # em ZERO. O critico confirma-se pelo outro lado: os sete valores seguidos da Arqueira
    # (7,17 a 7,53, de 0,06 em 0,06) dao niveis inteiros com base+bonus e 9,4 so com a base.
    #
    # Foi o jogador que perguntou: "com a destreza aumentada, aumentou a chance de critico?".
    # Na nossa ficha nao aumentava -- a passiva dava-lhe Destreza 13 (+2) e o critico ficava
    # nos 7,1 da destreza 11.
    _bon_car = {}
    try:
        _b_car = bonus_de_opcoes(personagem.get("entity_id"))
        _bon_car = {c: float(_b_car.get(c, 0.0)) for c in caracteristicas}
    except Exception as _xc:                                        # noqa: BLE001
        print("[hop3] bonus de caracteristicas falhou: %s" % str(_xc)[:90])
    if all(c in personagem for c in caracteristicas):
        for off, (pesos, base) in CAMPOS_DERIVADOS.items():
            valor = base + sum(k * (personagem[c] + _bon_car.get(c, 0.0))
                               for c, k in pesos.items())
            # O TERMO DO NIVEL, que o CAMPOS_DERIVADOS nao sabe exprimir: ele so multiplica
            # caracteristicas, e este termo depende do nivel. Sem ele a ficha dizia 11,70 e o
            # combate usava 12,12 no mesmo personagem (destreza 8, nivel 8) -- medido em
            # 31/08. A formula com o termo esta em db.chance_critico e foi medida em 27/08
            # com duas Feiticeiras do oficial, nivel 1 e nivel 68; sem ele a de nivel 68
            # falhava por quatro pontos.
            if off == OFF_CHANCE_CRITICO:
                valor += 0.0604 * max(0, int(personagem.get("nivel", 1)) - 1)
            struct.pack_into("<f", p, off, valor)

    # HP/MP maximo. Os moldes da 500 e da 600 sao de personagem fora do nivel 1, entao o valor
    # do banco vale mais -- MAS so quando o molde discorda de verdade.
    #
    # O HP tem PARTE FRACIONARIA no jogo: o oficial manda 190,4 / 150,4 / 160,4 / 189,4,
    # sempre com o ,4. Eu sobrescrevia com o inteiro do banco e matava essa fracao, e era a
    # unica diferenca que sobrava entre o nosso 08 02 e o do oficial num Vagabundo nivel 1
    # com o mesmo kit. Agora, se a parte inteira ja bate, o molde fica como esta.
    for off, campo in ((38, "hp_max"), (50, "mp_max")):
        valor = personagem.get(campo)
        if not valor:
            continue
        do_molde = struct.unpack_from("<f", p, off)[0]
        if int(do_molde) != int(valor):
            struct.pack_into("<f", p, off, float(valor))

    # BONUS DAS OPCOES ESPECIAIS DO EQUIPAMENTO, nos dois campos descobertos em 27/08.
    #
    # Medido no oficial equipando uma "[+7 A poderosa arma osso de dragao]" (item 21210), cuja
    # caixa de texto declara quatro caracteristicas especiais. Tres apareceram como diferenca
    # limpa entre o 08 02 com e sem a arma:
    #
    #     Classificacao de ataque +2,00  ->  offset 98, float   14,000 -> 16,000
    #     Vitalidade +3,00               ->  offset 28, u16          0 -> 3
    #     (as outras duas caem em 90 e 118, que ja eram tratados)
    #
    # O offset 28 guarda o BONUS e nao o total: a ficha mostrava "Vitalidade 24 (+3)" e o
    # campo tinha 3.
    #
    # Hoje isto soma sempre ZERO, e esta certo assim: as caracteristicas especiais nao sao do
    # item, sao da INSTANCIA -- sorteadas quando ele cai, de uma das 97 tabelas do
    # uniqueOptionDrop.lod -- e o nosso servidor ainda nao gera itens com opcoes proprias.
    # Quando gerar, basta `bonus_de_opcoes` passar a devolve-las e a ficha fica certa sem
    # mais nenhuma alteracao. Ver docs/opcoes-de-item.md.
    bonus = bonus_de_opcoes(personagem.get("entity_id"))
    if bonus.get("classificacao_ataque"):
        atual = struct.unpack_from("<f", p, OFF_CLASSIF_ATAQUE)[0]
        struct.pack_into("<f", p, OFF_CLASSIF_ATAQUE,
                         atual + float(bonus["classificacao_ataque"]))
    for i, campo in enumerate(("forca", "destreza", "vitalidade",
                               "inteligencia", "sabedoria")):
        struct.pack_into("<H", p, OFF_BONUS_CARACTERISTICAS + i * 2,
                         int(bonus.get(campo, 0)) & 0xFFFF)
    # O multiplicador de critico e o unico destes que tem um valor BASE proprio, e nao zero:
    # a ficha de um personagem nu mostra 1,2. O bonus soma-se por cima, e o par do bloco 2
    # guarda sempre a base -- foi assim que se viu 1,3 no bloco 1 e 1,2 no 518.
    base_mult = struct.unpack_from("<f", p, PAR_BLOCO2[OFF_MULT_CRITICO])[0] or MULT_CRITICO_BASE
    struct.pack_into("<f", p, PAR_BLOCO2[OFF_MULT_CRITICO], base_mult)
    struct.pack_into("<f", p, OFF_MULT_CRITICO,
                     base_mult + float(bonus.get("multiplicador_critico", 0.0)))

    # Ataque fisico e magico: os offsets 90 e 118 levam so a BASE. O "(+3)" que o jogo mostra
    # ao lado e o dano da arma, que o client soma sozinho lendo o itemAttr.lod dele.
    base_classe = db.classe_base(personagem["classe"])
    if base_classe:
        if base_classe.get("atq_fisico_base"):
            struct.pack_into("<f", p, OFF_ATQ_FISICO, float(base_classe["atq_fisico_base"]))
        if all(c in personagem for c in caracteristicas):
            k_int, k_sab, const = ATQ_MAGICO[bool(base_classe.get("magica"))]
            magico = (personagem["inteligencia"] * k_int
                      + personagem["sabedoria"] * k_sab + const)
            struct.pack_into("<f", p, OFF_ATQ_MAGICO, magico)

    # BLOCO 2 = os mesmos campos do bloco 1, 380 bytes adiante, com o valor SEM equipamento.
    #
    # Ate agora ele saia 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 (nivel 3) no bloco 2 enquanto o bloco 1 dizia
    # 186. Base maior que o total. Comparando o nosso 08 02 com o do oficial byte a byte, 42
    # bytes diferiam e quase todos eram bloco 2 -- e o client, vendo o par incoerente,
    # descartava e desenhava so a base. Era por isso que a soma do set nunca aparecia.
    #
    # O ataque escapava porque [90] e [470] sao iguais nos dois blocos: nao havia contradicao.
    #
    # Agora o bloco 2 e COPIADO do bloco 1 e so os campos que o equipamento mexe voltam pro
    # valor base.
    vel_base = struct.unpack_from("<f", p, PAR_BLOCO2[OFF_VEL_ATAQUE])[0]
    alcance_base = struct.unpack_from("<f", p, PAR_BLOCO2[OFF_ALCANCE])[0]
    p[418:418 + 136] = p[38:38 + 136]
    struct.pack_into("<f", p, PAR_BLOCO2[OFF_VEL_ATAQUE], vel_base)
    struct.pack_into("<f", p, PAR_BLOCO2[OFF_ALCANCE], alcance_base)
    struct.pack_into("<f", p, PAR_BLOCO2[OFF_DEFESA], BASE_DEFESA)
    if base_classe and base_classe.get("atq_fisico_base"):
        struct.pack_into("<f", p, PAR_BLOCO2[OFF_ATQ_FISICO],
                         float(base_classe["atq_fisico_base"]))

    if not com_equipamento:
        # a versao "pelado": cada campo do bloco 1 recebe o valor do seu par no bloco 2
        for off, par in PAR_BLOCO2.items():
            struct.pack_into("<f", p, off, struct.unpack_from("<f", p, par)[0])
        return encode_frame(bytes(p))

    ent = personagem.get("entity_id")
    if ent:
        equipado = db.equipamento_do_personagem(ent)
        arma = equipado.get(db.SLOT_ARMA)
        defesa = sum(d.get("defesa", 0.0) for d in equipado.values())

        # SEMPRE escrever, mesmo com zero. Antes havia um `if defesa:` aqui, e quando o
        # jogador tirava a ULTIMA peca a escrita era pulada -- o campo ficava com o valor do
        # MOLDE, que pra classe 300 e justamente 63. Na tela: tirar as pecas uma a uma baixava
        # certinho (42 -> 28 -> 15 -> 7) e a ultima fazia saltar de volta pra 63 (+42).
        #
        # Sem equipamento os campos voltam pro valor base, que e o mesmo do bloco 2.
        struct.pack_into("<f", p, OFF_DEFESA, BASE_DEFESA + defesa)
        # SEM ARMA, A BASE E A DA CLASSE -- e nao o bloco 2.
        #
        # Lia-se aqui o par do bloco 2, que tem 0,860 a 0,872 em todas as classes. Parecia a
        # base "sem equipamento", e nao e: o jogador mandou as fichas das SETE classes de
        # nivel 1 do oficial e elas dizem 0,91 / 1,10 / 1,57 / 0,85 / 0,78 / 0,85 / 0,62 --
        # os valores do BLOCO 1, que e o que entity_sim.BASE_VEL_ATAQUE guarda.
        #
        # No nosso a Paladina marcava 0,86 onde a dele marca 1,10.
        #
        # A base sai da MESMA funcao que o combate usa (entity_sim.velocidade_de_ataque), e e
        # esse o ponto: enquanto a ficha e o 08 01 tiverem cada um a sua conta, voltam a
        # divergir na primeira vez que alguem mexer numa delas.
        struct.pack_into(
            "<f", p, OFF_VEL_ATAQUE,
            entity_sim.velocidade_de_ataque(
                ent, base=(float(arma["vel_ataque"])
                           if arma and arma.get("vel_ataque") else None)))
        # E A DE INVOCACAO, pela mesma conta. Ficava sempre no 100 do molde porque o tipo 14
        # nem existia no TIPO_DE_BONUS -- ver la.
        _pinv = percentuais_de_opcoes(ent).get("velocidade_invocacao", 0.0)
        if _pinv:
            struct.pack_into(
                "<f", p, OFF_VEL_INVOCACAO,
                struct.unpack_from("<f", p, OFF_VEL_INVOCACAO)[0]
                * (1.0 + _pinv / 100.0))
        struct.pack_into(
            "<f", p, OFF_ALCANCE,
            float(arma["alcance"]) if arma and arma.get("alcance")
            else struct.unpack_from("<f", p, PAR_BLOCO2[OFF_ALCANCE])[0])

        # ATAQUE COM AS OPCOES DO EQUIPAMENTO, depois da copia do bloco 2 -- de proposito.
        #
        # O bloco 2 guarda o valor SEM equipamento, e ele ja foi copiado do bloco 1 la em
        # cima. Escrever aqui deixa o par coerente: bloco 1 com o bonus, bloco 2 com a base.
        # Ao contrario, os dois levariam o bonus e a ficha mostraria o mesmo numero nos dois
        # lados, como se o equipamento nao valesse nada.
        #
        # A percentagem incide sobre a BASE, que e o unico numero que este campo carrega --
        # o dano da arma o client soma sozinho, lendo o itemAttr.lod dele, e nao passa por
        # aqui. Se o oficial aplicar os 10% sobre base+arma em vez de so sobre a base, a
        # diferenca aparece num item com arma forte; nao ha captura que decida, e esta e a
        # leitura que nao inventa numero nenhum.
        b_abs = bonus_de_opcoes(ent)

        # PROTECCAO E CRITICO vindos das caracteristicas especiais.
        #
        # A proteccao fisica ja levava a soma das defesas das PECAS (o `defesa` acima); o que
        # faltava era o bonus das opcoes, que e por onde a fantasia entra. As outras duas nao
        # levavam nada: saiam do CAMPOS_DERIVADOS e ficavam nos numeros do personagem nu.
        #
        # Escrito DEPOIS da copia do bloco 2, como o ataque, para o par ficar coerente: o
        # bloco 1 com o bonus e o bloco 2 com a base.
        # O ABSOLUTO SOMA, O PERCENTUAL MULTIPLICA -- e o percentual nao entrava.
        #
        # Ate 31/08 so o absoluto era escrito. Nao dava para ver porque no client de 2019
        # quase nao ha opcoes percentuais nestes atributos: a proteccao vinha sempre em
        # pontos. As 104 opcoes que vieram do client do Arena SAO percentuais -- proteccao,
        # saude e classificacao de +1% a +5% -- e o jogador pos-as numa armadura e numa
        # espada: o servidor somava +8% de proteccao fisica e a ficha nao mexia.
        #
        # A ordem importa: primeiro o absoluto, depois a percentagem sobre o total. E o que
        # a fantasia ja fazia em bonus_da_fantasia, e mantem as duas contas iguais.
        _pcts = percentuais_de_opcoes(ent)
        for off, chave in ((OFF_DEFESA, "protecao_fisica"),
                           (OFF_PROTECAO_MAGICA, "protecao_magica"),
                           (OFF_CHANCE_CRITICO, "chance_critico"),
                           (OFF_CLASSIFICACAO_ATAQUE, "classificacao_ataque")):
            if off is None:
                continue
            base = struct.unpack_from("<f", p, off)[0] + float(b_abs.get(chave, 0.0))
            pct = float(_pcts.get(chave, 0.0))
            if b_abs.get(chave) or pct:
                struct.pack_into("<f", p, off, base * (1.0 + pct / 100.0))

        # VELOCIDADE DE MOVIMENTO. O molde ja traz 65, que e a base certa, mas nunca mudava
        # -- nem com a montaria nem com o +10% das botas do fato. Ver o bloco MONTARIA no
        # entity_sim: a montaria SUBSTITUI a base e as percentagens entram por cima.
        if len(p) >= entity_sim.OFF_VELOCIDADE + 4:
            _vel = entity_sim.velocidade_de_movimento(ent)
            # EXPERIENCIA TEMPORARIA de 30/08 -- ver entity_sim.FORCAR_VELOCIDADE.
            if entity_sim.FORCAR_VELOCIDADE:
                _vel = float(entity_sim.FORCAR_VELOCIDADE)
            struct.pack_into("<f", p, entity_sim.OFF_VELOCIDADE, _vel)
            # O SEGUNDO CAMPO DE VELOCIDADE, achado em 31/08, e a razao de o cliente ter
            # ficado preso em 65 o dia inteiro.
            #
            # Nao ha UM campo de velocidade: ha DOIS, em 78 e 82, e no oficial eles sobem
            # sempre juntos, na mesma proporcao. Na captura das 18:43 a velocidade muda tres
            # vezes na mesma sessao e os dois acompanham, ate a terceira casa:
            #
            #     [78]   46,78    67,83   108,53      x1,450   x2,320
            #     [82]   67,28    97,55   156,08      x1,450   x2,320
            #
            # A razao entre eles e constante -- 45,2/65 = 0,6954 -- e e a mesma que o MOLDE
            # ja traz no bloco 2 (458 e 462). Nos escreviamos so o 82 e deixavamos o 78 no
            # valor base, quebrando a proporcao: mandavamos 45,20/71,50, uma razao de 0,632
            # que nao existe no oficial.
            #
            # E 45,20 e exactamente o 78 que corresponde a 65 no 82 -- o tecto em que o
            # personagem andava por mais que a ficha declarasse 165,88.
            #
            # A CONTA SAI DO PROPRIO MOLDE, e nao de constantes cravadas: le-se a base dos
            # dois no bloco 2 e aplica-se a mesma proporcao. Confere nas tres fichas do
            # oficial sem erro:  45,2 * 67,28/65 = 46,78 / 97,55 -> 67,83 / 156,08 -> 108,53
            _b78 = struct.unpack_from("<f", p, OFF_VELOCIDADE_2 + PASSO_BLOCO2)[0]
            _b82 = struct.unpack_from("<f", p, entity_sim.OFF_VELOCIDADE + PASSO_BLOCO2)[0]
            if _b82:
                struct.pack_into("<f", p, OFF_VELOCIDADE_2, _b78 * _vel / _b82)
            # EXPERIENCIA -- ver CAMPOS_SO_DO_OFICIAL.
            if EXPERIENCIA_CAMPOS_OFICIAL:
                for _o, _v in CAMPOS_SO_DO_OFICIAL.items():
                    if _o + 4 <= len(p):
                        struct.pack_into("<f", p, _o, _v)

        mult_fis, mult_mag = db.multiplicadores_de_ataque(ent)
        for off, chave, mult in ((OFF_ATQ_FISICO, "atq_fisico", mult_fis),
                                 (OFF_ATQ_MAGICO, "atq_magico", mult_mag)):
            base = struct.unpack_from("<f", p, off)[0]
            struct.pack_into("<f", p, off, (base + b_abs.get(chave, 0.0)) * mult)


    if SENTINELA:
        # DIAGNOSTICO TEMPORARIO -- mapeamento da ficha inteira numa rodada so.
        #
        # A primeira rodada de sentinela derrubou duas conclusoes minhas: mandamos 3,33 em [94]
        # e 333 em [102] (e 7,77/777 nos equivalentes do bloco 2, offsets 474 e 482) e a ficha
        # continuou mostrando 0,87 e 21. Conferido no que o servidor gravou. Ou seja: nenhum
        # dos quatro offsets alimenta "Velocidade de ataque" nem "Protecao fisica".
        #
        # Entao agora vai um valor UNICO e reconhecivel em cada campo que eu acho que conheco.
        # O que aparecer na tela prova o offset; o que nao aparecer, nao vem daqui.
        # DIAGNOSTICO: sentinela em UM LADO SO do par. Da primeira vez marquei os dois e o
        # teste nao respondeu nada -- com os dois quebrados o client so exercita o tratamento
        # de erro. Aqui o bloco 1 recebe um valor impossivel e o bloco 2 fica intacto:
        #     tela mostra 202 -> [102] alimenta "Protecao fisica"
        #     tela mostra  21 -> nao alimenta, e a conta e toda do client
        # Rodada anterior: marquei o BLOCO 1 ([102]=202, [94]=3,33) e a tela nao mudou --
        # continuou 21 e 0,87. Agora marco o BLOCO 2 e deixo o bloco 1 com o valor certo:
        #     tela mostra 777 / 7,77  -> a ficha desenha o bloco 2
        #     tela mostra  54 / 0,85  -> desenha o bloco 1, e a rodada anterior mentiu
        #     tela mostra  21 / 0,87  -> nao vem de NENHUM dos dois, e a base e do proprio client
        struct.pack_into("<f", p, PAR_BLOCO2[OFF_DEFESA], 777.0)
        struct.pack_into("<f", p, PAR_BLOCO2[OFF_VEL_ATAQUE], 7.77)
    return encode_frame(bytes(p))


# DIAGNOSTICO temporario: grava tudo que o servidor manda na entrada no mundo, pra descobrir
# qual pacote declara nivel 2 num personagem nivel 1. A simulacao offline dizia nivel 1 em
# todos os campos conhecidos, entao o valor vem de um campo que ainda nao identifiquei -- e
# adivinhar em cima de molde ja falhou tres vezes hoje.
CAMINHO_SAIDA = r"D:\RoshEmulator\captures\saida_servidor.log"
_gravando_saida = False


# Opcodes do stream de entrada a NAO reproduzir. Ver cortar_do_stream.
#
# Comeca vazio de proposito: cortar as cegas parte a entrada no mundo, e a entrada e o
# caminho mais fragil que temos -- ja se descobriu em 22/08 que sem o CMoveAppear no momento
# certo o overlay "AGORA CARREGANDO" nunca sai e o personagem nao anda.
OPCODES_A_CORTAR = {
    # OS SEIS QUE O OFICIAL NAO MANDA NA ENTRADA.
    #
    # Medido em 28/08 contra karos_20260828_1756.log, uma entrada no mundo gravada no
    # oficial nesse dia. Os primeiros CATORZE quadros batem com o nosso stream ao opcode e
    # ao byte de tamanho -- 02 05, 0a 0f, 04 04, 0f 11, 16 01, 08 07, 08 08, 09 02, 09 04,
    # 05 05, 05 0c, 08 02, 08 01, 08 00 -- e a partir dai divergem. O oficial segue para
    # 0b 00, 08 0f, 08 09, 16 0d, 16 09 e os 04 02 do mundo; o nosso segue para estes seis,
    # que vieram de MAIS TARDE na sessao da "Amaga" e ficaram colados ao ficheiro.
    #
    # Dois deles levam nomes em texto de gente da sessao dela ("AsgarD", "cNonStop"), o que
    # nao deixa duvidas sobre a origem.
    (0x06, 0x28),
    (0x0d, 0x0f),
    (0x0e, 0x0c),
    (0x06, 0x25),
    (0x0a, 0x0c),
}


def cortar_do_stream(stream: bytes) -> bytes:
    """Tira do stream os quadros cujo opcode esta em OPCODES_A_CORTAR.

    PARA QUE SERVE. Dos 25 quadros do stream de entrada, 9 sao montados por nos e 16 sao
    reproducao crua da sessao da "Amaga". Cada um desses 16 pode estar a levar dados de outra
    pessoa -- foi assim que cinco estados de LESAO dela apareceram como uma aura a volta de
    todo o personagem que entrava, em 28/08.

    Decifrar os 16 a forca e caro e provavelmente desnecessario: muitos hao-de ser guilda,
    arena, correio -- sistemas que nao temos. O barato e descobrir de quais o client precisa
    mesmo, cortando um de cada vez e vendo se a entrada no mundo aguenta. O que sobreviver ao
    corte nao precisa de ser decifrado: precisa de ser apagado.

    Este interruptor existe para essa busca. Poe o opcode em OPCODES_A_CORTAR, reinicia,
    entra no jogo. Se entrar bem, o pacote nao era preciso.
    """
    if not OPCODES_A_CORTAR:
        return stream
    saida, cortados = [], []
    for f in entity_sim.separar_frames(stream):
        if len(f) >= 6 and (f[4], f[5]) in OPCODES_A_CORTAR:
            cortados.append("%02x %02x" % (f[4], f[5]))
            continue
        saida.append(f)
    if cortados:
        print(f"[hop3] stream: {len(cortados)} quadro(s) cortados: {sorted(set(cortados))}")
    return b"".join(saida)


# LICAO DE 28/08, guardada para nao se repetir o erro.
#
# Ao ver que o 05 05 do nosso stream anunciava cinco "skills" com texto de lesao --
# "Voce tropecou durante a corrida e torceu o tornozelo", "Voce ouviu o estrondo, e o seu
# joelho" -- concluiu-se que eram estados activos da personagem capturada, a serem entregues
# a toda a gente, e escreveu-se um filtro para os tirar.
#
# ERRADO. A captura do oficial de 28/08 (karos_20260828_1756.log) mostra o MESMO 05 05, com
# os mesmos 2100000001..05, para um personagem completamente diferente. Sao parte da lista
# normal de qualquer um -- animacoes de tropecao que o jogo pode tocar, registadas como
# disponiveis -- e nao um estado ligado. O filtro tirava coisa legitima e nao resolvia nada:
# a aura continuou.
#
# O que no 05 05 esta REALMENTE errado e outra coisa, e pequena: a nossa lista traz a 11001,
# uma skill de Arqueira, onde o oficial traz uma da classe do proprio personagem (3001,
# golpe de escudo, no caso dele). Fica assim ate haver sistema de skills de onde montar a
# lista a serio -- tirar sem por deixaria a lista mais curta que a do oficial, que e outro
# tipo de errado.



def registrar_saida(dados: bytes, rotulo: str = "") -> None:
    if not _gravando_saida:
        return
    try:
        with open(CAMINHO_SAIDA, "ab") as f:
            f.write(("=== " + rotulo + " " + str(len(dados)) + " bytes" + chr(10)).encode())
            f.write(dados.hex(" ").encode())
            f.write(chr(10).encode())
    except OSError:
        pass


SLOTS_PERSONAGEM = 4


def montar_lista(personagens: list, tem_pin: bool = False) -> bytes:
    """Rajada de 4 slots que o client espera na tela de personagens.

    Regra vinda da decompilacao do FUN_00651331: o client decide entre "abrir a tela de
    criacao" e "entrar com o personagem" olhando o character_id do registro que carrega a
    flag de ULTIMO. Por isso os vazios vao primeiro e os personagens de verdade depois, com
    a flag no ultimo deles.

    FORMATO REAL, extraido do log do servidor OFICIAL (classes_100_200_300_800_0823.log, uma
    conta com quatro personagens):

        02 0b   uma vez so, 23 bytes
        02 01   [6]=0
        02 01   [6]=0
        02 01   [6]=0
        02 01   [6]=1     <- so o ultimo leva a flag

    Ou seja: N personagens -> N frames, colados, e NENHUM frame vazio de enchimento. Conta sem
    personagem nenhum recebe um unico 02 01 vazio, esse sim com a flag.

    DOIS ERROS ATE 23/08, os dois nesta funcao:
      1. montava `personagens[0]` e mais tres vazios, FIXO -- quem tinha tres personagens via
         um so, e os outros dois nunca eram enviados;
      2. repetia o 02 0b antes de CADA 02 01 e enchia com frames vazios. Depois de corrigir o
         (1) a tela continuou mostrando so um personagem por causa disso.
    """
    # ORDEM DO 02 0B: antes ou depois da lista?
    #
    # A captura do oficial poe-no ANTES (02 0b, depois os 02 01) e e assim que sempre saiu.
    # Mas o jogador descreveu o Arena em 02/09: a janela da segunda senha aparece NA TELA DE
    # PERSONAGEM e bloqueia a seleccao ate se escrever a senha. Se o pacote chega antes de a
    # tela existir, o client nao tem onde a por -- que bate com o que se mediu: ele recebe o
    # 02 0B e nao reage a campo nenhum (cinco testados em 01/09).
    #
    # DEPOIS_DA_LISTA experimenta a outra ordem sem perder a original.
    f0b = build_0x020b_frame(tem_pin)
    if not personagens:
        return f0b + build_0x0201_frame_empty(last=True)
    # Ordem estavel por entity_id: quando o jogador escolhe um personagem, o handler do 02 04
    # move o escolhido pra frente da lista. Sem ordenar aqui, voltar pra tela de selecao
    # embaralharia os slots.
    usados = sorted(personagens, key=lambda p: p["entity_id"])[:SLOTS_PERSONAGEM]
    saida = b"" if DEPOIS_DA_LISTA else f0b
    for i, personagem in enumerate(usados):
        ultimo = (i == len(usados) - 1)
        saida += (build_charlist(personagem, last=ultimo, seq=4 + i)
                  or build_0x0201_frame_populated(last=ultimo))
    return saida + f0b if DEPOIS_DA_LISTA else saida


# Codigos de falha do 01 00, decodificados em 23/08 correlacionando 20 tentativas de criacao
# no servidor oficial com o que de fato virou personagem:
#
#     criacao BEM-SUCEDIDA -> NENHUM 01 00; o servidor manda so a lista atualizada
#     codigo 5 -> nome invalido   (as tres recusas com 13 caracteres, e uma comecando com digito)
#     codigo 6 -> nome ja em uso  (o jogador tentando nome atras de nome num servidor cheio)
#
# "Feiticeira10", com 12 caracteres, passou -- entao o limite e 12.
FALHA_NOME_INVALIDO = 5
FALHA_NOME_EM_USO = 6
NOME_MAX = 12


def build_falha(seq: int, codigo: int) -> bytes:
    """Pacote 01 00 (16 bytes) de falha, no formato exato do servidor oficial."""
    p = bytearray(16)
    p[0:2] = bytes([1, 0])
    struct.pack_into(">I", p, 2, seq & 0xFFFFFFFF)
    p[7] = codigo & 0xFF
    return encode_frame(bytes(p))


# Lista de nomes proibidos do proprio client (IE_NameFiltering.lod_<idioma>). E texto simples,
# nao cifrado: primeira linha com a contagem, depois um termo por linha. Em portugues a lista
# esta VAZIA; a inglesa tem 177 termos. Lemos varios idiomas e juntamos -- proibido num, proibido
# em todos, senao bastaria trocar de client pra escapar.
CAMINHO_FILTRO_NOME = r"C:\Games\Karos\ResUnpak\Interface\IE_Lod"
_proibidos = None


def nomes_proibidos() -> set:
    global _proibidos
    if _proibidos is None:
        _proibidos = set()
        for idioma in ("_pt", "_en", "_es", "_rus"):
            try:
                with open(os.path.join(CAMINHO_FILTRO_NOME,
                                       "IE_NameFiltering.lod" + idioma), "rb") as f:
                    linhas = f.read().decode("latin-1").replace(chr(13), "").split(chr(10))
            except OSError:
                continue
            for l in linhas[1:]:          # a primeira linha e a contagem
                l = l.strip().lower()
                if l:
                    _proibidos.add(l)
        print(f"[hop3] filtro de nomes: {len(_proibidos)} termos proibidos carregados")
    return _proibidos


# Letras e digitos ASCII, e mais nada. A lista e explicita de proposito -- ver nome_valido.
LETRAS_PERMITIDAS = frozenset(
    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")


def nome_valido(nome: str) -> bool:
    """Validacao de nome no SERVIDOR.

    O client barra simbolo no proprio campo de texto, mas isso e conveniencia, nao
    seguranca: um client modificado manda o que quiser. A regra que vale e esta.

    MEDIDO contra o servidor oficial (26 tentativas capturadas em 23/08):
      - no maximo 12 caracteres  (todo nome de 13 levou o codigo 5; "Feiticeira10", com 12, passou)
      - nao pode comecar com digito  ("01MAGA" levou codigo 5 com apenas 6 letras)

    DECISAO DO DONO DO SERVIDOR (25/08): simbolo e caractere especial sao RECUSADOS.

    O `isalnum()` que estava aqui nao cumpria isso, e e uma armadilha facil de nao ver: em
    Python ele e Unicode, entao "Acao" com cedilha passa, e nomes em grego ou em letras de
    largura dupla tambem. Todos sao "alfanumericos" pelo padrao Unicode. A lista explicita
    de caracteres nao tem esse problema.

    E nao e so estetica. O 04 02 codifica o nome em latin-1, um byte por letra, enquanto o
    02 01 usa UTF-32: um nome com caractere fora do latin-1 apareceria na tela de selecao e
    ESTOURARIA na entrada do mundo, que e o pior lugar pra descobrir. Restringir a ASCII faz
    os dois caminhos concordarem por construcao.

    Nome de ADMINISTRADOR nao passa por aqui: e escrito direto no banco. Foi assim que
    [ADM]-NoLosT foi criado, e e o que separa "o jogador nao pode" de "ninguem pode".
    """
    if not nome or len(nome) > NOME_MAX:
        return False
    if nome[0].isdigit():
        return False
    if not all(c in LETRAS_PERMITIDAS for c in nome):
        return False
    return nome.lower() not in nomes_proibidos()


def parse_criar_personagem(body: bytes):
    """Le o CLoginCreateCharPacket (02 02). Formato capturado do oficial em 23/08.

        [6:8] classe LE u16   [16] aparencia u8   [19:21] tamanho LE u16   [21:] nome UTF-32LE

    O byte [16] e o unico candidato a aparencia e veio 0x11 na unica amostra que temos --
    guardamos o valor sem saber o que ele codifica.
    """
    if len(body) < 21:
        return None
    classe = struct.unpack_from("<H", body, 6)[0]
    aparencia = body[16]
    n = struct.unpack_from("<H", body, 19)[0]
    if n == 0 or n > 20 or 21 + n * 4 > len(body):
        return None
    try:
        nome = body[21:21 + n * 4].decode("utf-32-le")
    except UnicodeDecodeError:
        return None
    return {"classe": classe, "aparencia": aparencia, "nome": nome,
            "cabelo": body[9], "rosto": body[14], "cor": tuple(body[10:14])}


def parse_apagar_personagem(body: bytes):
    """CLoginDeleteCharPacket (02 03), 10 bytes: [6:10] id do personagem BE u32."""
    if len(body) < 10:
        return None
    return struct.unpack_from(">I", body, 6)[0]


def handle_client(conn: socket.socket, addr):
    print(f"[hop3] conexao de {addr}")
    send_lock = threading.Lock()
    loop_stop = threading.Event()
    # inicializada aqui porque o `finally` la embaixo tira o jogador do mundo,
    # e ele roda mesmo se a conexao morrer antes da lista ser carregada.
    personagens: list = []
    # o heroi DESTA conexao. Antes era `entity_sim.HEROI`, um singleton de modulo que os
    # jogadores compartilhavam -- ver o comentario em entity_sim onde ele existia.
    heroi = entity_sim.Heroi()
    try:
        body = read_frame(conn)
        print(f"[hop3] frame recebido de {addr}: {len(body)} bytes: {body[:64]!r}")
        usuario_conectado = None
        # SEGUNDA SENHA. Duas variaveis, e nao uma: `conta_tem_pin` diz ao client QUAL
        # janela abrir (byte 9 do 02 0b) e nao muda ao longo da ligacao; `segunda_senha_ok`
        # diz se ja conferiu nesta ligacao, e e o que tranca o 02 04. Com uma so, depois
        # de conferir o sinal virava zero e o client passava a abrir o CADASTRO.
        segunda_senha_ok = True
        conta_tem_pin = False
        try:
            req = parse_login_request(body)
            print(f"[hop3] LOGIN (reautenticacao) de {addr}: {req}")
            usuario_conectado = req.id or None
            db.registrar_dispositivo(req.id, req.hash1, req.hash2)
        except FrameError as e:
            print(f"[hop3] frame recebido nao e um login request valido: {e}")

        if not usuario_conectado:
            # O client manda id='' aqui (usa token no lugar de usuario/senha). Recupera quem
            # autenticou no hop1 pelo IP -- ver db.usuario_por_ip.
            usuario_conectado = db.usuario_por_ip(addr[0] if addr else "",
                                                  getattr(req, "hash1", None),
                                                  getattr(req, "hash2", None))
            if usuario_conectado:
                print(f"[hop3] conta recuperada da sessao do hop1: '{usuario_conectado}'")
                if db.tem_segunda_senha(usuario_conectado):
                    conta_tem_pin = True
                    segunda_senha_ok = False
                    print(f"[2asenha] '{usuario_conectado}' tem PIN -- entrada no mundo "
                          f"trancada ate um 02 0b conferir")
            else:
                # RECUSA. Ate 25/08 a conexao seguia e o servidor mandava uma lista de
                # personagens VAZIA, que o client desenha como "crie seu personagem".
                #
                # Isso e pior do que parece. Quem chega aqui nao provou ser dono de conta
                # nenhuma -- e recebia a tela de criacao DENTRO de uma conta que nao e dele.
                # Um personagem criado ali entra na conta errada, e o dono legitimo depois
                # ve um personagem que nao fez.
                #
                # A porta certa e esta, antes de mandar qualquer coisa. Quem tem conta
                # valida passou pelo hop1 e deixou sessao registrada; quem nao passou nao
                # tem o que ver aqui.
                #
                # O jogador levantou isso vendo o proprio caso: entrou da maquina dele, o IP
                # nao bateu, e a tela ofereceu criar personagem em vez de recusar.
                print(f"[hop3] RECUSADO: nao identifiquei a conta de {addr}. "
                      "Sem sessao do hop1 registrada pra este IP.")
                try:
                    conn.close()
                except OSError:
                    pass
                return

        handshake = build_handshake_frame()
        conn.sendall(handshake)
        print(f"[hop3] handshake ({len(handshake)}) enviado pra {addr}")

        conn.settimeout(10)
        try:
            echo = read_frame(conn)
            print(f"[hop3] eco do handshake recebido de {addr}: {len(echo)} bytes")
        except (FrameError, socket.timeout) as e:
            print(f"[hop3] nao recebeu eco do handshake: {e}")

        middle = build_middle3_frame()
        conn.sendall(middle)
        print(f"[hop3] frame do meio ({len(middle)}) enviado pra {addr}")

        # REMOVIDO (22/08): esperar 10s aqui por um "ack" que nunca usamos so atrasava a lista de
        # personagens sem necessidade -- a captura real manda tudo em sequencia rapida.

        # TESTE (22/08 tarde, achado novo): na captura real de uma conta SEM personagem
        # (real_flow_channel_enter_dedup.txt, pacotes #124101-#124108), o servidor manda o par
        # "02 0b + 02 01" QUATRO VEZES SEGUIDAS (idênticas, uma por slot de personagem -- a conta
        # tem 4 slots), não uma vez só. A gente só mandava 1 par (so o personagem "teste"). Testando
        # a hipotese de que o client espera receber os 4 slots -- mandando o populado (slot 1, o
        # "teste") + o vazio 3x (slots 2-4), igual uma conta real com 1 personagem receberia.
        # CORRIGIDO DE NOVO: relendo o handler (FUN_00651331) com calma -- o bloco que decide
        # "mostra tela de criar personagem" X "finaliza com personagem carregado" olha o
        # character_id DO PRoPRIO registro que carrega a flag de "ultimo", nao um acumulado geral.
        # Colocar o vazio por ultimo (mesmo com char_id=0) SEMPRE disparava "criar personagem",
        # não importa quantos personagens reais vieram antes. Corrigido: personagem de VERDADE por
        # ultimo (carregando a flag), vazios ANTES dele (sem flag, viram no-op inofensivo).
        # A lista de personagens agora vem do BANCO, nao mais fixa. Conta sem personagem
        # recebe os 4 slots VAZIOS -- e o que faz o client abrir a tela de criacao.
        #
        # A regra do byte "ultimo" saiu da decompilacao do FUN_00651331: o client decide entre
        # "abre a tela de criar" e "entra com o personagem" olhando o character_id DO REGISTRO
        # que carrega essa flag. Vazio com a flag => tela de criacao. Por isso, quando ha
        # personagem, ele tem que vir POR ULTIMO.
        # ERRO ATE 23/08: aqui havia uma SEGUNDA copia da montagem da lista, escrita direto no
        # handler, que mandava so `personagens[0]` mais tres vazios. A `montar_lista` existia,
        # mas so era chamada depois de criar ou apagar personagem -- entao corrigir a
        # montar_lista duas vezes nao mudou nada na tela: o caminho da CONEXAO nunca passava
        # por ela. Agora e uma funcao so.
        personagens = db.personagens_da_conta(usuario_conectado) if usuario_conectado else []
        burst = montar_lista(personagens, tem_pin=conta_tem_pin)
        if personagens:
            print(f"[hop3] conta '{usuario_conectado}': {len(personagens)} personagem(ns) "
                  + ", ".join(f"'{p['nome']}' (id={p['entity_id']} classe={p['classe']} "
                              f"nivel={p['nivel']})" for p in personagens)
                  + f" enviados pra {addr}")
        else:
            print(f"[hop3] conta '{usuario_conectado}' SEM personagem -- slot vazio "
                  f"enviado pra {addr} (o client deve abrir a tela de criacao)")
        conn.sendall(burst)

        # CORRIGIDO (22/08): keepalive periodico, mesma tecnica validada no channel_server.py --
        # sem isso o client reseta a conexao sozinho poucos segundos depois de ficar parado na
        # tela de personagem esperando o usuario clicar. Timeout mais curto aqui (0.5s) porque
        # nessa etapa o reset aconteceu em ~1.1s, mais rapido que os ~3.8s do hop 2.
        conn.settimeout(0.5)
        keepalive_seq = 10
        ultimo_exp = 0.0        # ultima vez que se procuraram itens expirados
        idle_rounds = 0
        stream_parte2 = b""
        sim_iniciado = False
        self_appear_enviado = False
        ataque_seq = 20000   # tambem usado no finally -- ver o 04 03 da saida
        ultimo_salvo_pos = 0.0
        MAX_IDLE_ROUNDS = 240  # 240 * 0.5s = 120s de tolerancia total
        while True:
            try:
                body = read_frame(conn)
                idle_rounds = 0
                # O MESMO TIQUE, para quem NAO esta parado. O ramo do keepalive so corre
                # quando o socket expira, ou seja com o jogador quieto; quem esta a andar ou
                # a bater nunca la chega, e sem isto a peca so sumia ao parar.
                if personagens and _time.time() - ultimo_exp >= SEGUNDOS_VER_EXPIRADOS:
                    ultimo_exp = _time.time()
                    limpar_expirados(conn, send_lock,
                                     personagens[0]["entity_id"], _seq_apanhar)
                print(f"[hop3] frame adicional de {addr}: {len(body)} bytes: {body[:64]!r}...")

                if len(body) >= 2 and body[0] == 2 and body[1] in (8, 9, 0x0A, 0x0B):
                    # SEGUNDA SENHA -- so log, por enquanto.
                    #
                    # A janela 30039 ja abre no client (ver tools/patch_segunda_senha.py) e da
                    # para escrever o PIN. O que ela ENVIA nunca foi capturado do oficial,
                    # porque la a funcao esta desligada e nao ha nada com que comparar. Por
                    # isso mede-se aqui, em vez de se adivinhar o formato pelo parser.
                    #
                    # As quatro classes existem no binario do client:
                    #   02 08  CLoginSecondPasswdRegisterPacket
                    #   02 09  CLoginSecondPasswdRevisionPacket
                    #   02 0a  CLoginSecondPasswdDeletePacket
                    #   02 0b  CLoginSecondPasswdCheckPacket
                    _nome = {8: "registar", 9: "alterar", 0x0A: "apagar",
                             0x0B: "verificar"}[body[1]]
                    print(f"[2asenha] {_nome} -- {len(body)} bytes")
                    print(f"[2asenha] hex: {body.hex(' ')}")

                    # O PIN vem em texto claro a partir do offset 20, terminado em NUL.
                    # Medido em 02/09: o client mandou
                    #   02 0b 00 00 00 13 00 00 00 01 00 00 00 00 01 ba 00 00 00 00
                    #   32 32 32 32 32 32 00        <- "222222"
                    # que e exactamente onde FUN_00648358 le (poe o offset 0x14 e copia
                    # dali uma cadeia de ate 38 chars).
                    _pin = body[OFF_PIN_020B:].split(b"\x00", 1)[0].decode("ascii", "ignore")
                    if body[1] == 0x0A:
                        db.apagar_segunda_senha(usuario_conectado)
                        segunda_senha_ok = True
                    elif _pin:
                        if not db.tem_segunda_senha(usuario_conectado):
                            # PRIMEIRA VEZ = CADASTRO. No oficial ha uma janela propria para
                            # isso (a 30037, "Criar uma segunda senha"), que ainda nao esta
                            # ligada -- so ligamos a 30039. Ate la, o primeiro PIN que a
                            # conta enviar passa a ser o dela. Vale para o 02 08 tambem.
                            db.definir_segunda_senha(usuario_conectado, _pin)
                            segunda_senha_ok = True
                            conta_tem_pin = True
                            print(f"[2asenha] '{usuario_conectado}' nao tinha PIN -- "
                                  f"cadastrado o que veio agora")
                        elif body[1] in (8, 9):
                            db.definir_segunda_senha(usuario_conectado, _pin)
                            segunda_senha_ok = True
                        else:
                            segunda_senha_ok = db.conferir_segunda_senha(usuario_conectado, _pin)
                            print(f"[2asenha] '{usuario_conectado}': "
                                  f"{'CONFERE' if segunda_senha_ok else 'NAO CONFERE'}")
                    with send_lock:
                        conn.sendall(build_0x020b_frame(conta_tem_pin))

                if len(body) >= 2 and body[0:2] == bytes([2, 2]):
                    # CRIAR PERSONAGEM. Formato decodificado da captura do servidor oficial
                    # em 23/08 -- ver parse_criar_personagem e docs/findings.md.
                    pedido = parse_criar_personagem(body)
                    if pedido is not None:
                        # DIARIO DE APARENCIA: registra todo 02 02 que chega, mesmo os que
                        # serao recusados. E assim que vamos decodificar o byte de aparencia --
                        # o client monta o pacote, entao basta variar UMA opcao por vez na tela
                        # e comparar. Usar sempre o mesmo nome faz o servidor recusar sem
                        # gastar slot, e o registro acontece do mesmo jeito.
                        ap = pedido["aparencia"]
                        # Registra o PACOTE INTEIRO, nao so o byte [16]. Em 23/08 tres
                        # tentativas com rosto e cabelo diferentes deram todas 0x11 -- ou seja
                        # a escolha visual esta em OUTRO campo, e olhando so um byte eu nunca
                        # acharia qual.
                        linha = (f"classe={pedido['classe']:<4} [16]=0x{ap:02x} "
                                 f"nome={pedido['nome']!r} pacote={body.hex(' ')}")
                        print(f"[APARENCIA] {linha}")
                        try:
                            with open(CAMINHO_APARENCIAS, "a", encoding="utf-8") as af:
                                af.write(_time.strftime("%H:%M:%S ") + linha + chr(10))
                        except OSError:
                            pass
                    if pedido is None:
                        print(f"[hop3] 02 02 malformado ({len(body)} bytes): {body.hex(' ')}")
                    elif not usuario_conectado:
                        print("[hop3] 02 02 sem conta identificada -- ignorado")
                    else:
                        print(f"[hop3] criando personagem '{pedido['nome']}' "
                              f"classe={pedido['classe']} aparencia=0x{pedido['aparencia']:02x} "
                              f"na conta '{usuario_conectado}'")
                        ataque_seq += 1
                        if not nome_valido(pedido["nome"]):
                            print(f"[hop3] nome {pedido['nome']!r} invalido "
                                  f"(max {NOME_MAX}, sem digito no inicio)")
                            with send_lock:
                                conn.sendall(build_falha(ataque_seq, FALHA_NOME_INVALIDO))
                            novo_p = None
                        else:
                            novo_p = db.criar_personagem(
                                usuario_conectado, pedido["nome"], pedido["classe"],
                                pedido["aparencia"], pedido["cabelo"], pedido["rosto"],
                                pedido["cor"])
                            if novo_p:
                                n_it = db.dar_itens_iniciais(novo_p["entity_id"],
                                                             novo_p["classe"])
                                # A BARRA TAMBEM VEM POSTA. Sem isto o personagem novo
                                # entrava com a barra vazia e o jogador tinha de arrastar
                                # a skill a mao -- no oficial ela ja la esta.
                                n_at = db.dar_atalhos_iniciais(novo_p["entity_id"],
                                                               novo_p["classe"])
                                print(f"[hop3] kit inicial da classe {novo_p['classe']}: "
                                      f"{n_it} itens, {n_at} atalho(s)")
                        if novo_p is None and nome_valido(pedido["nome"]):
                            # A MENSAGEM ERA MENTIRA. Dizia "nome em uso" para qualquer
                            # falha, incluindo um NameError dentro do db -- e foi assim que
                            # um bug de constante em falta passou por "esse nome ja existe"
                            # em todos os nomes que o jogador tentou. O motivo real fica no
                            # log do db; aqui diz-se so o que se sabe.
                            print("[hop3] criacao RECUSADA -- ver o motivo no [db] acima; "
                                  "avisando o client")
                            with send_lock:
                                conn.sendall(build_falha(ataque_seq, FALHA_NOME_EM_USO))
                        if novo_p is not None:
                            personagens = [novo_p]
                            quadro = build_charlist(novo_p, last=True)
                            if quadro:
                                with send_lock:
                                    conn.sendall(build_0x020b_frame(conta_tem_pin)
                                                 + quadro)
                                print(f"[hop3] personagem criado e lista devolvida "
                                      f"(id={novo_p['entity_id']})")

                if len(body) >= 2 and body[0:2] == bytes([2, 1]):
                    # VOLTAR PARA A SELECAO DE PERSONAGEM ("Escolha um personagem" no menu
                    # Sistema). Capturado do servidor oficial em 23/08:
                    #
                    #   client SEND 02 01 (395 bytes, corpo praticamente vazio)
                    #   servidor    02 0b + 02 01 populado
                    #
                    # O 02 01 e o MESMO opcode da lista, mas aqui vindo do client como PEDIDO.
                    # Como so o tratavamos como resposta, o pedido caia no vazio e o jogo
                    # ficava preso na tela de carregando.
                    # Para a simulacao e troca por um evento NOVO: reusar o mesmo depois de
                    # set() deixaria a proxima entrada no mundo sem simulacao nenhuma, porque a
                    # thread nova ja nasceria com o sinal de parada ligado.
                    loop_stop.set()
                    loop_stop = threading.Event()
                    sim_iniciado = False
                    self_appear_enviado = False
                    # O RELOGIO DO BUFF PARA AO SAIR DO MUNDO, nao so ao cair a ligacao.
                    #
                    # MEDIDO no oficial (karos_20260830_1433): o jogador voltou ao ecra de
                    # personagens aos +98,25 s e regressou ao mundo aos +339,63 s -- 241
                    # segundos la parado, com a ligacao viva e keepalives de 10 em 10 s. O
                    # buff tinha 1119 s aos +57,42 e voltou com 1079: gastou 41, que sao
                    # exactamente os que ele esteve no mundo. Os 241 no ecra nao contaram.
                    try:
                        if personagens:
                            entity_sim.pausar_buffs(personagens[0]["entity_id"])
                            entity_sim.pausar_buffs_de_item(personagens[0]["entity_id"])
                    except Exception as e:                         # noqa: BLE001
                        print(f"[hop3] pausar_buffs (ecra) falhou: {str(e)[:120]}")
                    if usuario_conectado:
                        personagens = db.personagens_da_conta(usuario_conectado)
                    print(f"[hop3] client pediu a selecao de personagem -- devolvendo "
                          f"{len(personagens)} personagem(ns)")
                    with send_lock:
                        registrar_saida(montar_lista(personagens, conta_tem_pin),
                                        "lista de personagens")
                        conn.sendall(montar_lista(personagens, conta_tem_pin))

                if len(body) >= 2 and body[0:2] == bytes([2, 3]):
                    alvo_id = parse_apagar_personagem(body)
                    # passa a conta: sem isso um client poderia mandar 02 03 com id alheio
                    if alvo_id and db.apagar_personagem(alvo_id, usuario_conectado):
                        # RELE do banco em vez de zerar a lista: a conta pode ter outros
                        # personagens, e mandar 4 slots vazios faria os sobreviventes
                        # sumirem da tela ate o proximo login.
                        personagens = db.personagens_da_conta(usuario_conectado)
                        print(f"[hop3] personagem {alvo_id} apagado -- restam "
                              f"{len(personagens)} na conta '{usuario_conectado}'")
                        # HIPOTESE (23/08): eco do proprio 02 03 como confirmacao, antes da
                        # lista. Nao temos captura da resposta do servidor oficial a um apagar
                        # -- as unicas que tem 02 03 sao de sentido unico. Mas este protocolo
                        # ecoa pedido em varios pontos (05 00 do ataque, 02 07 da troca de
                        # canal), e sem confirmacao o client mantinha o personagem na tela ate
                        # o relogin. Se nao resolver, o caminho certo e capturar um apagar no
                        # servidor oficial com o RECV funcionando.
                        eco = bytearray(body)
                        ataque_seq += 1
                        struct.pack_into(">I", eco, 2, ataque_seq)
                        with send_lock:
                            conn.sendall(encode_frame(bytes(eco)))
                            registrar_saida(montar_lista(personagens, conta_tem_pin),
                                        "lista de personagens")
                        conn.sendall(montar_lista(personagens, conta_tem_pin))
                    else:
                        print(f"[hop3] 02 03: nao apaguei {alvo_id} "
                              f"(nao e da conta '{usuario_conectado}'?)")
                if len(body) >= 10 and body[0:2] == bytes([2, 4]):
                    # CLoginStartCharPacket: [6:10] BE u32 diz QUAL personagem o jogador
                    # escolheu. Ate 23/08 usavamos sempre personagens[0] -- com mais de um
                    # personagem na conta, entrava sempre o errado.
                    escolhido = struct.unpack_from(">I", body, 6)[0]
                    for cand in personagens:
                        if cand["entity_id"] == escolhido:
                            if cand is not personagens[0]:
                                personagens.remove(cand)
                                personagens.insert(0, cand)
                                print(f"[hop3] jogador escolheu '{cand['nome']}' "
                                      f"(id={escolhido})")
                            break
                    else:
                        if personagens:
                            print(f"[hop3] 02 04 pediu o id {escolhido}, que nao esta na conta "
                                  f"-- usando '{personagens[0]['nome']}'")

                if (len(body) >= 2 and body[0:2] == bytes([2, 4])
                        and conta_tem_pin):
                    # A TRANCA DA SEGUNDA SENHA FICA AQUI, no servidor, e nao no client.
                    #
                    # O binario tem um bloqueio proprio (FUN_0043b905 recusa cliques fora da
                    # 30039 enquanto ela estiver visivel), mas trancar so na interface nao
                    # vale nada: basta editar o Axn.exe para passar por cima. O pedido de
                    # entrar no mundo e que tem de ser recusado.
                    print(f"[2asenha] 02 04 RECUSADO: '{usuario_conectado}' ainda nao "
                          f"conferiu o PIN nesta ligacao")
                    continue

                if len(body) >= 2 and body[0:2] == bytes([2, 4]):
                    # Stream de entrada no mundo, capturado ao vivo via hook.dll contra o servidor REAL
                    # com a nossa propria conta (char_id 0x299e). Enviado PAUSADO, frame a frame, como o
                    # servidor real entrega -- despejar tudo num sendall() so nao imita o real.
                    try:
                        _cru = bool(STREAM_CRU and os.path.exists(STREAM_CRU))
                        with open(STREAM_CRU if _cru else CAMINHO_STREAM, "rb") as sf:
                            stream = sf.read()
                        if _cru:
                            print("[hop3] EXPERIENCIA: fluxo CRU do oficial (%s, %d bytes)"
                                  % (os.path.basename(STREAM_CRU), len(stream)))
                        # O primeiro frame do stream e o 02 05 do personagem CAPTURADO. Trocamos
                        # pelo do personagem que esta entrando de verdade, senao o client carrega
                        # a identidade de outro (era o comportamento ate 23/08).
                        if personagens:
                            # O stream capturado fala do personagem 10654 do inicio ao fim:
                            # medidor de heroi, inventario, buffs. Se o client acabou de
                            # receber que ELE e o 20000, esses pacotes referenciam um
                            # desconhecido. Reescreve as referencias.
                            #
                            # RISCO ACEITO: troca cega de todas as ocorrencias de 0x299E, em
                            # BE e LE. Um float de coordenada ou id de item que por acaso valha
                            # 0x299E seria corrompido junto. E improvavel mas nao impossivel --
                            # se aparecer item ou posicao estranha no mundo, olhar aqui.
                            novo_id = personagens[0]["entity_id"]
                            _id_velho = ID_DO_STREAM_CRU if _cru else ID_STREAM_NIVEL1
                            for fmt in (">I", "<I"):
                                antigo_b = struct.pack(fmt, _id_velho)
                                novo_b = struct.pack(fmt, novo_id)
                                n_ocor = stream.count(antigo_b)
                                if n_ocor:
                                    stream = stream.replace(antigo_b, novo_b)
                                    print(f"[hop3] stream: {n_ocor} referencia(s) a "
                                          f"{_id_velho} trocadas por {novo_id} ({fmt})")
                            stream = cortar_do_stream(stream)
                            quadros = entity_sim.separar_frames(stream)
                            heroi = entity_sim.iniciar_heroi(personagens[0])
                            # UMA fonte de verdade. O 08 01 sai do HEROI (que le o banco na
                            # hora) e o 08 00 / 04 02 / 02 05 saiam de personagens[0], carregado
                            # quando a conexao abriu. Com o personagem ja tendo subido de nivel,
                            # um pacote dizia 2 e o outro 1 -- o client mostrava "Novo Nivel" e
                            # voltava. Alinhamos os dois aqui.
                            h = heroi
                            personagens[0].update({
                                "nivel": h.nivel, "exp": h.exp,
                                "hp": h.hp, "hp_max": h.hp_max,
                                "mp": h.mp, "mp_max": h.mp_max})
                            # A ficha e montada DEPOIS do alinhamento acima, nao antes.
                            # Ela carrega a EXP em [76:84], e essa e a linha de base que o
                            # client usa pra decidir se anuncia "Experiencia adquirida".
                            # Montada antes, ela levava a EXP velha de personagens[0] e a
                            # primeira barra de heroi virava um ganho falso.
                            proprio = build_charinfo(personagens[0])
                            trocados = 0
                            n_stats = 0
                            # NO MODO CRU nao se troca quadro nenhum -- e esse o ponto.
                            for i, q in ([] if _cru else list(enumerate(quadros))):
                                op = q[4:6]
                                if op == bytes([2, 5]) and proprio:
                                    quadros[i] = proprio
                                    trocados += 1
                                elif op == bytes([4, 2]) and len(q) > 16 and                                         struct.unpack_from("<I", q, 12)[0] == personagens[0]["entity_id"]:
                                    # O stream ja traz o 04 02 do proprio personagem, com o
                                    # NOME e o NIVEL do capturado. Ele chega antes do que
                                    # montamos no 04 00, entao e ele que o painel exibe.
                                    montado = build_self_appear(
                                        personagens[0], struct.unpack_from(">I", q, 6)[0])
                                    if montado:
                                        quadros[i] = montado
                                        trocados += 1
                                elif op == bytes([5, 5]):
                                    # LISTA DE SKILLS: montada para a CLASSE do jogador. Ate
                                    # aqui saia do stream, com as skills da Maga capturada.
                                    montado = build_skills(
                                        q[4:], personagens[0]["entity_id"],
                                        personagens[0]["classe"],
                                        personagens[0].get("nivel", 1))
                                    if montado:
                                        quadros[i] = montado
                                        trocados += 1
                                        print("[hop3] skills da classe %d enviadas"
                                              % personagens[0]["classe"])
                                elif op == bytes([6, 5]) and db.inventario_do_personagem(
                                        personagens[0]["entity_id"]):
                                    # EXPIRADOS SAEM ANTES DE MONTAR A LISTA. Estava a correr
                                    # so na entrada no mundo, que e DEPOIS disto -- o
                                    # inventario ja tinha sido desenhado com eles dentro.
                                    _exp5 = db.apagar_itens_expirados(
                                        personagens[0]["entity_id"])
                                    if _exp5:
                                        print(f"[hop3] {len(_exp5)} item(ns) expirado(s) "
                                              f"apagado(s) antes do inventario")
                                    itens = db.inventario_do_personagem(personagens[0]["entity_id"])
                                    montado = build_inventario(
                                        q[4:], itens, personagens[0]["entity_id"])
                                    if montado:
                                        quadros[i] = montado
                                        trocados += 1
                                        print(f"[hop3] inventario do banco: {len(itens)} itens")
                                elif op == bytes([6, 5]):
                                    # CItemInvenListPacket: o inventario INICIAL. Vinha do
                                    # stream capturado, ou seja todo personagem nascia com os
                                    # itens da Maga. Cada classe comeca com equipamento
                                    # proprio, entao usamos o molde da classe certa.
                                    inven = molde_da_classe(personagens[0]["classe"], "inven")
                                    if inven:
                                        novo_i = bytearray(inven)
                                        struct.pack_into(">I", novo_i, 2,
                                                         struct.unpack_from(">I", q, 6)[0])
                                        quadros[i] = encode_frame(bytes(novo_i))
                                        trocados += 1
                                elif op == bytes([8, 0]):
                                    quadros[i] = build_gage(q[4:], personagens[0])
                                    trocados += 1
                                elif op == bytes([8, 2]):
                                    # a ficha de atributos le DESTE pacote, nao do 02 05.
                                    # O PRIMEIRO vai sem equipamento e o segundo com: e o par
                                    # que o client usa pra achar o bonus do equipamento.
                                    n_stats += 1
                                    montado = build_stats(
                                        personagens[0], struct.unpack_from(">I", q, 6)[0],
                                        com_equipamento=(n_stats > 1))
                                    if montado:
                                        quadros[i] = montado
                                        trocados += 1
                                elif op == bytes([8, 1]):
                                    # medidor de heroi capturado: traz o NIVEL e a EXP do
                                    # personagem da captura (nivel 2). Sem trocar, o jogador
                                    # entra no mundo mostrando o nivel de outro.
                                    seq_orig = struct.unpack_from(">I", q, 6)[0]
                                    novo_g = entity_sim.build_hero_gage(seq_orig, heroi)
                                    if novo_g:
                                        quadros[i] = novo_g
                                        trocados += 1
                            if trocados:
                                stream = b"".join(quadros)
                                h = heroi
                                print(f"[hop3] stream: {trocados} pacote(s) trocados pelo "
                                      f"personagem '{personagens[0]['nome']}' "
                                      f"(nivel {h.nivel}, HP {h.hp_max}, MP {h.mp_max})")
                        global _gravando_saida
                        _gravando_saida = True
                        registrar_saida(stream, "stream de entrada")
                        n = entity_sim.enviar_stream(conn, send_lock, stream)
                        print(f"[hop3] recebido 02 04 -- stream enviado "
                              f"({n} frames, {len(stream)} bytes) pra {addr}")
                    except OSError as e:
                        print(f"[hop3] nao consegui ler o stream capturado: {e}")
                
                    # SIMULACAO DE ENTIDADES: substitui o replay cego (que reproduzia bytes de sessao
                    # alheia e PIORAVA). Comeca IMEDIATAMENTE -- testar segurar ate o 04 00 piorou,
                    # porque criava ~11s sem trafego de entidades.
                    # O heroi ja foi inicializado acima com o personagem do BANCO. Chamar
                    # iniciar_heroi() de novo sem argumento (como era ate 23/08) recarregava o
                    # molde e revertia a identidade pro personagem capturado 10654.
                    h0 = heroi
                    print(f"[hop3] heroi {h0.id}: nivel {h0.nivel} EXP {h0.exp}/"
                          f"{entity_sim.exp_necessaria(h0.nivel)} HP {h0.hp}/{h0.hp_max}")
                    # A thread do MUNDO, uma so no processo. Antes era uma por conexao,
                    # cada uma com o proprio bando de aranhas -- por isso um jogador nao via
                    # os mobs do outro, e portanto nao via o outro lutando.
                    entity_sim.iniciar_mundo(print)
                
                # O 06 08 TINHA DOIS TRATADORES, e o errado vinha primeiro.
                #
                # Este ramo procurava o item pelo ID e chamava db.mover_item. Isso parte
                # assim que o jogador tem DUAS UNIDADES DO MESMO ITEM: o id casa com as
                # duas, o UPDATE tenta pôr ambas no mesmo sitio e o banco recusa --
                #     Violation of UNIQUE KEY constraint 'uq_personagem_slot'
                # -- e o log dizia so "mover item 16001 ... nao aplicado". Foi exactamente o
                # que aconteceu ao jogador com duas Bengalas nos slots 1 e 2 (30/08).
                #
                # Mais abaixo ha o tratador que trabalha por (container, slot), que e o que o
                # client manda e e unico por definicao. Como este vinha antes na cadeia de
                # elif, apanhava o pacote e o outro nunca corria. Removido; o de baixo fica.
                #
                # `db.mover_item` e `ler_pedido_mover` continuam la, sem uso -- ficam para
                # quem precise de mover por id a partir de codigo nosso (a ferramenta web),
                # onde nao ha ambiguidade de duas unidades.

                elif len(body) >= 17 and body[0:2] in (OP_EQUIPAR, OP_DESEQUIPAR):
                    # EQUIPAR / DESEQUIPAR, copiado da captura do servidor OFICIAL (23/08).
                    # Cada clique-direito gera CINCO pacotes, 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
                    #
                    # Passei a tarde tentando adivinhar isto (mandei 06 05 inteiro, mandei
                    # 04 02, tirei os dois). O par de 06 06 e a resposta: e ele que diz ao
                    # client a posicao antiga e a nova do item.
                    pedido = ler_pedido_equipamento(body)
                    equipando = body[0:2] == OP_EQUIPAR
                    seq_pedido = struct.unpack_from(">I", body, 2)[0]
                    if pedido and personagens:
                        _, cont_orig, slot_orig, item_id = pedido
                        p_atual = personagens[0]
                        ent = p_atual["entity_id"]
                        # PELA POSICAO, nao pelo item_id. Um slot so tem um item, entao
                        # (container, slot) e sempre unico; o item_id nao e -- duas Bengalas
                        # tem o mesmo. Enquanto isto procurava por item_id, uma troca entre
                        # duas copias da mesma peca mexia na errada e a outra sumia da tela.
                        antes = next((i for i in db.inventario_do_personagem(ent)
                                      if i["container"] == cont_orig
                                      and i["slot"] == slot_orig), None)
                        if antes is None:      # o client mandou uma posicao que nao existe
                            antes = next((i for i in db.inventario_do_personagem(ent)
                                          if i["item_id"] == item_id), None)
                        if equipando:
                            pode, motivo = db.pode_equipar(p_atual, item_id)
                            # slot_orig desambigua quando ha duas copias do mesmo
                            # item na mochila -- sem ele o servidor equipava a outra.
                            r = (db.equipar(ent, item_id, de_slot=slot_orig)
                                 if pode else None)
                            if not pode:
                                print(f"[hop3] equipar recusado: {motivo}")
                        else:
                            r = db.desequipar(ent, item_id, slot_orig)
                        # A MESMA instancia, no lugar novo. Procurar por item_id devolvia
                        # a primeira copia que aparecesse na ordem do banco, que numa troca
                        # entre duas iguais e justamente a outra.
                        _inst = antes.get("instancia") if antes else None
                        depois = next((i for i in db.inventario_do_personagem(ent)
                                       if i.get("instancia") == _inst), None) if _inst else None
                        if r is not None and antes and depois:
                            seq_saida = seq_pedido
                            envio = []
                            conf = build_equipamento(
                                body[0:2], seq_saida, ent,
                                db.CONTAINER_EQUIPADO if equipando else cont_orig,
                                depois["slot"] if equipando else slot_orig, item_id,
                                refino=(depois.get("bruto") or bytes(8))[db.OFF_REFINO_BRUTO]
                                if depois.get("bruto") else 0)
                            # REGISTO DOS BYTES DO ECO, para comparar com o oficial.
                            #
                            # O espiao do fio nao apanha isto: ele engancha o `enviar` do
                            # mundo e o equipar escreve directo no socket. Sem estas linhas
                            # nao ha como pôr o nosso 06 00 ao lado do dele.
                            print("[hop3] eco de equipar: %s" % conf[4:].hex(" "))
                            # O par "antes/depois" de CADA item que se mexeu. Numa troca de
                            # arma sao DOIS: o que entra e o que foi deslocado pra mochila.
                            #
                            # Faltava o segundo: equipar a Espada Longa por cima da Adaga
                            # punha a espada no slot 7 e a adaga sumia da tela -- o banco
                            # estava certo, mas o client nunca sabia pra onde ela tinha ido.
                            #
                            # E as REMOCOES vao todas antes das insercoes, pelo mesmo motivo da
                            # troca dentro da mochila: o client remove pela POSICAO, entao um
                            # "sai" depois de um "entra" apaga o slot recem-ocupado.
                            # POR INSTANCIA. Com a chave em item_id, duas copias da mesma
                            # peca colapsavam numa entrada so: equipar a Bengala +13 por cima
                            # da +0 punha a +13 no slot e a +0 nunca recebia o pacote de "fui
                            # para a mochila" -- desaparecia da tela e so voltava no login
                            # seguinte, ou ao desequipar. Foi o que o jogador descreveu em
                            # 28/08: "o +0 simplesmente some".
                            inv_agora = {i["instancia"]: i
                                         for i in db.inventario_do_personagem(ent)}
                            mexidos = [(depois, antes["container"], antes["slot"])]
                            deslocado = (r.get("trocado_instancia")
                                         if isinstance(r, dict) else None)
                            if deslocado is not None and deslocado in inv_agora:
                                mexidos.append((inv_agora[deslocado],
                                                db.CONTAINER_EQUIPADO, depois["slot"]))
                            elif isinstance(r, dict) and r.get("trocado") is not None:
                                print(f"[hop3] AVISO: a peca deslocada (item "
                                      f"{r['trocado']}) nao foi achada no inventario; "
                                      f"ela vai sumir da tela ate ao proximo login")
                            tira, poe = [], []
                            for it, cont_ant, slot_ant in mexidos:
                                tira.append((it, cont_ant, slot_ant, 0))
                                poe.append((it, it["container"], it["slot"],
                                            it.get("quantidade") or 1))
                            par = []
                            for it, cont, slo, qtd in tira + poe:
                                seq_saida += 1
                                par.append(build_item_movido(
                                    seq_saida, ent,
                                    registro_item(it, container=cont, slot=slo), qtd))
                            envio = ([conf] + par) if not equipando else (par + [conf])
                            # O maximo de HP/MP muda com a peca: as "[Asas de anjo
                            # (24 horas)]" dao Saude +1004 e Mana +328. Recalcular ANTES de
                            # montar a ficha e a barra, ou os dois pacotes saem com o valor
                            # de antes da troca e o client so corrige no proximo login.
                            heroi.aplicar_bonus_de_equipamento()
                            p_atual["hp_max"], p_atual["mp_max"] = heroi.hp_max, heroi.mp_max
                            p_atual["hp"], p_atual["mp"] = heroi.hp, heroi.mp
                            # O BUFF DA PECA. Ver build_buff_de_item.
                            #
                            # ISTO AFASTA-SE DO OFICIAL, e fica dito: ele NAO manda 05 09 ao
                            # equipar fantasia -- so nas pocoes. Mas o mecanismo do buff esta
                            # decifrado e o pacote sai igual ao dele, e a velocidade da peca
                            # nao aplica no nosso client de nenhuma outra maneira que se
                            # tenha achado. Testa-se; se nao funcionar, sai.
                            #
                            # A DURACAO E INVENTADA: uma peca equipada nao expira, e nao ha
                            # amostra do oficial para copiar. Zero foi tentado primeiro e o
                            # client ignorou -- num campo de duracao, zero le-se como "ja
                            # acabou". Ver DURACAO_BUFF_DE_EQUIPAMENTO.
                            try:
                                if (entity_sim.ENVIAR_BUFF_DE_FANTASIA
                                        and db.efeito_de_fantasia(item_id)):
                                    seq_saida += 1
                                    # O ID DA SKILL, nao a instancia -- ver
                                    # db.skill_do_item. Mandava-se aqui o numero da copia
                                    # no inventario, o client procurava a skill, nao
                                    # achava, e nao aplicava nada.
                                    _skf = db.skill_do_item(
                                        item_id, depois.get("bruto"))
                                    envio.append(entity_sim.build_buff_de_item(
                                        seq_saida, ent, item_id, equipando,
                                        entity_sim.DURACAO_BUFF_DE_EQUIPAMENTO
                                        if equipando else 0,
                                        _skf))
                                    print("[hop3] buff de item %s para %d (%s) skill %d"
                                          % (item_id, ent,
                                             "liga" if equipando else "desliga", _skf))
                            except Exception as _xb:                # noqa: BLE001
                                print("[hop3] buff de item falhou: %s" % str(_xb)[:90])
                            # O 08 00 ANTES DA FICHA -- faltava, e e ele que move o boneco.
                            #
                            # Ao equipar mandava-se a ficha (08 02) e o 08 01, mas nao este.
                            # A ordem do oficial e `08 00 -> 08 02 -> 08 01`, a mesma que a
                            # entrada no mundo ja usava por _reenviar_ficha -- e por isso e
                            # que RELOGAR corrigia e equipar nao.
                            #
                            # O sintoma, descrito pelo jogador em 01/09: ao equipar a bengala
                            # +13 "minha velocidade ficou igual do flash", e voltava ao normal
                            # no login seguinte. Sem o 08 00 o client fica a andar pelo valor
                            # que ele proprio calculou da peca, e o servidor nunca o corrige:
                            # os dois lados ficam a discordar ate ao proximo login.
                            seq_saida += 1
                            _vel = entity_sim.build_velocidade(
                                seq_saida, ent,
                                entity_sim.velocidade_de_movimento(ent),
                                hp=int(p_atual.get("hp") or 0),
                                hp_max=int(p_atual.get("hp_max") or 0),
                                mp=int(p_atual.get("mp") or 0),
                                mp_max=int(p_atual.get("mp_max") or 0),
                                nivel=int(p_atual.get("nivel") or 1))
                            if _vel:
                                envio.append(_vel)
                            seq_saida += 1
                            ficha = build_stats(p_atual, seq_saida)
                            if ficha:
                                envio.append(ficha)
                            seq_saida += 1
                            gage = entity_sim.build_hero_gage(seq_saida, heroi)
                            if gage:
                                envio.append(gage)
                            with send_lock:
                                for frame in envio:
                                    conn.sendall(frame)

                            # MULTIPLAYER: os OUTROS precisam ver a troca ao vivo. Sem isto o
                            # visual alheio congela no que estava quando o personagem apareceu:
                            # tirar a armadura no outro PC nao mudava nada na tela de quem via.
                            #
                            # Retransmitir o proprio 06 00/06 01 e seguro -- conferido no
                            # handler do client (FUN_00652797 / FUN_00652959): ele busca a
                            # entidade pelo id de [6:10] e aplica o VISUAL em qualquer uma,
                            # e so mexe no inventario/UI quando esse id bate com o do jogador
                            # local. Nao ha classe de pacote dedicada a "mudou de roupa": o
                            # binario so tem CMoveAppear, CItemWear e CItemPutoff.
                            mundo.transmitir(ent, conf)
                            # e a aparicao GUARDADA tambem: quem logar depois monta a cena a
                            # partir dela, e ficaria com o equipamento antigo.
                            eu_reg = mundo.jogador(ent)
                            if eu_reg is not None:
                                seq_saida += 1
                                nova = build_self_appear(p_atual, seq_saida)
                                if nova:
                                    eu_reg.aparicao = nova
                                    # NAO SE REENVIA A APARICAO AO EQUIPAR.
                                    #
                                    # Cheguei a mandar aqui o 04 02 novo, por ter visto um na
                                    # captura logo a seguir ao equipar. Era coincidencia: o
                                    # 04 02 daquele momento e da entidade 11467 e o jogador e
                                    # a 11282 -- era outro personagem a aparecer por perto.
                                    #
                                    # O efeito de o mandar e visivel e mau: o client re-cria
                                    # o personagem e ele reaparece como se tivesse acabado de
                                    # entrar no mundo. Foi o que o jogador descreveu, "da a
                                    # impressao que o char acaba de entrar no jogo".
                                    #
                                    # Quem muda o visual e o proprio 06 00 / 06 01, que ja e
                                    # retransmitido acima -- ver o comentario do transmitir.

                            acao = "equipou" if equipando else "desequipou"
                            print(f"[hop3] {p_atual['nome']} {acao} o item {item_id}: "
                                  f"container {antes['container']}/slot {antes['slot']} -> "
                                  f"container {depois['container']}/slot {depois['slot']} "
                                  f"(dano da arma {db.dano_da_arma(ent)}, "
                                  f"defesa {db.defesa_total(ent)[0]})")
                        else:
                            print(f"[hop3] pedido de {'equipar' if equipando else 'desequipar'} "
                                  f"nao aplicado (item {item_id} slot {slot_orig})")

                elif len(body) >= 2 and body[0:2] == bytes([5, 4]):
                    # CANCELAMENTO DA SKILL. So o eco, tal como o 05 03.
                    #
                    # O jogador ficava preso na animacao de carregar quando girava a camara a
                    # meio do lancamento -- "tinha que ter cancelado ela". Nos ignoravamos o
                    # pacote por completo e o client ficava a espera da confirmacao.
                    #
                    # Isolado na captura de 29/08 as 15:10, feita de proposito com ele a
                    # cancelar tres vezes: aparecem exactamente tres 05 04 do client, cada um
                    # 1,0 a 1,8 s depois do 05 03 correspondente. Layout, 31 bytes:
                    #
                    #     05 04 | seq | 00 | jogador BE | skill BE | pos (3 floats) | rotacao
                    #
                    # O oficial ecoa-o DUAS vezes, com sequencias consecutivas -- e o mesmo
                    # padrao do 05 03, que tambem vai para quem esta perto.
                    _sk4 = struct.unpack_from(">I", body, 11)[0] if len(body) >= 15 else 0
                    print(f"[hop3] 05 04 cancelou a skill {_sk4}")
                    _eco4 = bytearray(body)
                    ataque_seq += 1
                    struct.pack_into(">I", _eco4, 2, ataque_seq)
                    try:
                        _q4 = encode_frame(bytes(_eco4))
                        with send_lock:
                            conn.sendall(_q4)
                        if personagens:
                            mundo.transmitir(personagens[0]["entity_id"], _q4)
                    except Exception as _e4:                        # noqa: BLE001
                        print(f"[hop3] eco do 05 04 falhou: {str(_e4)[:90]}")
                    # A RECUPERACAO NAO CONTA NUM LANCAMENTO CANCELADO: a skill nao chegou a
                    # sair, portanto nao ha o que recuperar. Sem isto o jogador cancelava e
                    # ficava impedido de a usar durante o tempo todo dela.
                    _RECUPERACAO.pop((heroi.id, _sk4), None)

                elif len(body) >= 10 and body[0:2] == bytes([6, 0x52]):
                    # BARRA DE SKILLS. O client manda a barra inteira e o servidor devolve-a
                    # igual, so com a sequencia trocada. Registos de 12 bytes a partir de [10]:
                    #
                    #     id LE u32 | tipo LE u32 (9 = skill) | slot LE u32
                    #     ff ff ff ff nos vazios
                    #
                    # Isolado na captura de 29/08 as 16:45. O jogador reparou que a barra
                    # voltava ao que estava: nos ecoavamos nada e nao guardavamos nada.
                    if personagens:
                        db.guardar_preferencia(personagens[0]["entity_id"],
                                               "barra_skills", bytes(body[10:]))
                    print(f"[hop3] barra de skills guardada ({len(body) - 10} bytes)")
                    _e52 = bytearray(body)
                    ataque_seq += 1
                    struct.pack_into(">I", _e52, 2, ataque_seq)
                    try:
                        with send_lock:
                            conn.sendall(encode_frame(bytes(_e52)))
                    except OSError:
                        pass

                elif len(body) >= 14 and body[0:2] == bytes([6, 0x51]):
                    # REORGANIZAR A MOCHILA. O client manda a ordem que quer:
                    #
                    #     06 51 | seq | jogador BE | contagem BE u32 | pares (container, slot)
                    #
                    # Isolado na captura de 29/08 as 16:45. Os pares sao as posicoes ACTUAIS
                    # dos itens, pela ordem em que o jogador os quer -- o primeiro par vai
                    # para o slot 0 do container dele, o segundo para o 1, e assim por diante.
                    #
                    # O oficial responde com um 06 06 por item que se mexeu, que e o mesmo
                    # pacote que ja usamos ao equipar.
                    #
                    # O container 2 e a "Bolsa especial", que ainda nao temos: os pares dela
                    # sao lidos e ignorados, e isso fica dito aqui para nao parecer esquecido.
                    # OS PARES COMECAM EM 16, NAO EM 14. Medido no 06 51 do oficial
                    # (traffic.log): o contador em [10:14] diz 18 e ha 19 pares de 2 bytes a
                    # partir de 14 -- um a mais. Comecando em 16 dao exactamente 18, e a
                    # ordem bate PAR A PAR com os 18 pacotes 06 06 que ele mandou a seguir.
                    # Os dois bytes em [14:16] valem 02 2a nos dois pedidos que temos, do
                    # oficial e do nosso: nao e um par, e um campo que fica por identificar.
                    _n51 = struct.unpack_from(">I", body, 10)[0]
                    _pares = [(body[16 + 2 * k], body[17 + 2 * k])
                              for k in range(min(_n51, (len(body) - 16) // 2))]
                    print(f"[hop3] reorganizar: {len(_pares)} posicoes {_pares[:6]}...")
                    if personagens and _pares:
                        _ent51 = personagens[0]["entity_id"]
                        _inv51 = {(i["container"], i["slot"]): i
                                  for i in db.inventario_do_personagem(_ent51)}
                        # OS SLOTS DOS ITENS QUE NAO ESTAO NA LISTA SAO INTOCAVEIS, e e por
                        # nao saber isso que o botao rebentava. Medido na captura de 29/08
                        # as 22:42: o client listou 20 itens e eles foram para os slots 4 a
                        # 19 -- comecou no 4 porque os slots 0 a 3 tinham itens fora da
                        # lista. Nos atribuiamos 0, 1, 2... a contar do zero e ia direito
                        # contra esses, o que dava
                        #
                        #     Violation of UNIQUE KEY constraint
                        #
                        # Ou seja: a lista nao e "arruma tudo", e "poe ESTES por esta ordem
                        # nas posicoes livres mais baixas".
                        _listados = {(c, s) for c, s in _pares}
                        _ocupados = {(i["container"], i["slot"])
                                     for i in _inv51.values()
                                     if (i["container"], i["slot"]) not in _listados}
                        _destino = {}
                        _proximo = {}
                        for cont, slot in _pares:
                            it = _inv51.get((cont, slot))
                            if it is None:
                                continue          # container 2 e o que o banco nao tem
                            novo_slot = _proximo.get(cont, 0)
                            while (cont, novo_slot) in _ocupados:
                                novo_slot += 1
                            _proximo[cont] = novo_slot + 1
                            if novo_slot != slot:
                                _destino[(cont, slot)] = novo_slot
                        # O PAR "SAI / ENTRA" POR ITEM, e as saidas todas antes das
                        # entradas -- a mesma regra do equipar: o client remove pela posicao,
                        # entao um "sai" depois de um "entra" apagaria o slot recem-ocupado.
                        _sai, _poe = [], []
                        for (cont, slot), novo_slot in _destino.items():
                            it = _inv51[(cont, slot)]
                            _sai.append((it, cont, slot, 0))
                            _poe.append((it, cont, novo_slot, it.get("quantidade", 1) or 1))
                        _quadros51 = []
                        for it, cont, slo, qtd in _sai + _poe:
                            seq_pedido += 1
                            _quadros51.append(build_item_movido(
                                seq_pedido, _ent51,
                                registro_item(it, container=cont, slot=slo), qtd))
                        try:
                            # DE UMA VEZ SO, por personagem_item_id, e sem slots temporarios.
                            #
                            # A primeira versao movia tudo para 100+slot e depois subtraia
                            # 100. Isso deixou nove itens do jogador presos nos slots 100 a
                            # 108 -- a coluna `slot` e TINYINT e a conexao faz commit por
                            # instrucao, entao a primeira metade ficou gravada e a segunda
                            # nao. Ele viu a mochila desarrumada e o item desequipado a nao
                            # aparecer, que e o mesmo defeito visto de fora.
                            #
                            # Mexer pelo id da linha nao colide com a chave unica de posicao,
                            # portanto nao ha fase intermedia nenhuma para falhar.
                            # NUM UPDATE SO, com CASE. Compactar slots COLIDE sempre:
                            # mover o item do slot 4 para o 0 rebenta na chave unica enquanto
                            # o slot 0 ainda estiver ocupado por outro que so se move a
                            # seguir. Foi isto que o jogador viu como "o botao de organizar
                            # parou de funcionar", e o log dizia-o por extenso:
                            #
                            #     Violation of UNIQUE KEY constraint
                            #
                            # Uma instrucao unica resolve-o porque o SQL Server so verifica o
                            # indice no FIM da instrucao. E nao ha alternativa comoda: a
                            # conexao e autocommit=True, entao um par de instrucoes nao e
                            # atomico -- foi assim que nove itens ficaram presos nos slots
                            # 100 a 108 na primeira tentativa, com metade gravada.
                            _movs = []
                            for (cont, slot), novo_slot in _destino.items():
                                it = _inv51[(cont, slot)]
                                _pid51 = it.get("personagem_item_id") or it.get("instancia")
                                if _pid51 is not None:
                                    _movs.append((int(_pid51), int(novo_slot)))
                            if _movs:
                                _sql51 = ("UPDATE dbo.personagem_item SET slot = CASE "
                                          + " ".join("WHEN personagem_item_id = ? THEN ?"
                                                     for _ in _movs)
                                          + " END WHERE personagem_item_id IN ("
                                          + ",".join("?" for _ in _movs) + ")")
                                _args51 = [x for m in _movs for x in m] + [m[0] for m in _movs]
                                with db._conectar() as _cn51:
                                    _cn51.cursor().execute(_sql51, *_args51)
                            with send_lock:
                                for q in _quadros51:
                                    conn.sendall(q)
                            # O ECO DO 06 51 NO FIM. O oficial devolve o pedido inteiro
                            # com a sua propria sequencia, depois de todos os 06 06 -- e nos
                            # nao mandavamos nada. Medido na mesma captura.
                            _e51 = bytearray(body)
                            seq_pedido += 1
                            struct.pack_into(">I", _e51, 2, seq_pedido)
                            with send_lock:
                                conn.sendall(encode_frame(bytes(_e51)))
                            print(f"[hop3] reorganizar: {len(_destino)} itens mudaram de slot")
                        except Exception as _x51:                   # noqa: BLE001
                            print(f"[hop3] reorganizar falhou: {str(_x51)[:110]}")

                elif len(body) >= 16 and body[0:2] == bytes([6, 0x56]):
                    # DESMONTAR ITEM. Vinte e seis bytes:
                    #
                    #     06 56 | seq | jogador BE | container | slot | item BE u32 | ...
                    #
                    # NAO ESTA IMPLEMENTADO, mas a razao que estava aqui deixou de valer.
                    #
                    # Dizia que "a tabela que diz QUE materiais cada item devolve nao foi
                    # encontrada em nenhuma das 909 do client". Foi encontrada em 30/08, ao
                    # procurar outra coisa: chama-se `itemDismantal` e tem 61 registos de 80
                    # bytes. Nao esta lida -- mas ja se sabe onde esta, e a frase antiga
                    # mandava quem retomasse isto procurar o que ja existe.
                    #
                    # Fica o registo para quem retomar saber o formato.
                    print(f"[hop3] 06 56 desmontar: container {body[10]} slot {body[11]} "
                          f"item {struct.unpack_from('>I', body, 12)[0]} -- NAO implementado")

                elif len(body) >= 6 and body[0:2] in (bytes([0x0a, 0x0a]), bytes([0x0a, 0x0b])):
                    # HORA DO SERVIDOR e ASSIDUIDADE, no mesmo pacote.
                    #
                    #   0a 0a  o client pede; a resposta traz o relogio E os premios de entrada
                    #   0a 0b  o client abre a janela; a resposta traz so os contadores
                    #
                    # Formato decifrado em 30/08 com duas capturas do oficial:
                    #
                    #   [6:10]  BE  relogio unix          (so no 0a 0a)
                    #   [10:14] BE  entradas CONSECUTIVAS
                    #   [14:18] BE  entradas do MES
                    #   [18:]       um byte por dia do mes em que se entrou
                    #
                    # Confere-se sozinho: [14:18] e sempre o comprimento da lista de dias.
                    # Uma captura deu (1, 2) com os dias 27 e 29; outra (1, 1) com o dia 30.
                    #
                    # EU TINHA ISTO CRAVADO, e foi o jogador que apanhou: liguei o 0a 0a com
                    # os bytes de uma captura sem os perceber, e o servidor anunciava a toda
                    # a gente a assiduidade da personagem capturada -- 27 e 29. Ele viu os
                    # dois dias circulados na janela e perguntou se era molde.
                    _e0a = personagens[0]["entity_id"] if personagens else 0
                    try:
                        _seg, _dias = db.registar_entrada(_e0a) if _e0a else (1, [])
                    except Exception as _x0a:                       # noqa: BLE001
                        print(f"[hop3] assiduidade falhou: {str(_x0a)[:90]}")
                        _seg, _dias = 1, []
                    _hora = body[0:2] == bytes([0x0a, 0x0a])
                    _p0a = bytearray(18 + (len(_dias) if _hora else 0))
                    _p0a[0:2] = body[0:2]
                    ataque_seq += 1
                    struct.pack_into(">I", _p0a, 2, ataque_seq)
                    if _hora:
                        # 0a 0a: relogio, consecutivas, quantas no mes, e a lista de dias
                        struct.pack_into(">I", _p0a, 6, int(_time.time()) & 0xFFFFFFFF)
                        struct.pack_into(">I", _p0a, 10, _seg)
                        struct.pack_into(">I", _p0a, 14, len(_dias))
                        for _k, _d in enumerate(_dias):
                            _p0a[18 + _k] = _d & 0xFF
                    else:
                        # 0a 0b: sem relogio e sem lista. A UNICA amostra que temos traz
                        # (1, 1, 30) num personagem com uma so entrada, no dia 30 -- os dois
                        # primeiros sao as consecutivas e as do mes, e o terceiro e o DIA DE
                        # HOJE. Com uma amostra so nao da para separar "dia de hoje" de
                        # "dias do mes"; escolhe-se o que a amostra mostra e diz-se aqui.
                        struct.pack_into(">I", _p0a, 6, _seg)
                        struct.pack_into(">I", _p0a, 10, len(_dias))
                        struct.pack_into(">I", _p0a, 14, _dias[-1] if _dias else 0)
                    print("[hop3] %s: %d consecutivas, %d no mes %s"
                          % ("hora+assiduidade" if _hora else "premios de entrada",
                             _seg, len(_dias), _dias))
                    try:
                        with send_lock:
                            conn.sendall(encode_frame(bytes(_p0a)))
                    except OSError:
                        pass

                elif len(body) >= 12 and body[0:2] == bytes([6, 0x46]):
                    # MOSTRAR FATO -- o botao da aba Forma. Doze bytes:
                    #
                    #     06 46 | seq | 00 | jogador BE | 01 ou 00 | 00
                    #                                     ^ ligar / desligar
                    #
                    # Isolado na captura de 29/08 as 16:43, feita de proposito: o jogador
                    # carregou duas vezes no botao e aparecem exactamente dois 06 46, um com
                    # 01 e outro com 00. O servidor devolve o mesmo pacote de volta.
                    #
                    # Sem o eco o client nao aplica a mudanca, tal como no 05 03 da skill.
                    _mostrar = body[10]
                    print(f"[hop3] mostrar fato: {'ligado' if _mostrar else 'desligado'}")
                    if personagens:
                        db.guardar_preferencia(personagens[0]["entity_id"],
                                               "mostrar_fato", int(_mostrar))
                    _e46 = bytearray(body[:12])
                    ataque_seq += 1
                    struct.pack_into(">I", _e46, 2, ataque_seq)
                    try:
                        _q46 = encode_frame(bytes(_e46))
                        with send_lock:
                            conn.sendall(_q46)
                        if personagens:
                            mundo.transmitir(personagens[0]["entity_id"], _q46)
                    except Exception as _x46:                       # noqa: BLE001
                        print(f"[hop3] eco do 06 46 falhou: {str(_x46)[:80]}")

                elif len(body) >= 16 and body[0:2] == bytes([6, 2]):
                    # USAR ITEM DA MOCHILA.
                    #
                    #   CLI  06 02 | seq | jogador BE | container | slot | item BE [12:16] | ...
                    #   srv  06 06 por cada item que ENTRA
                    #   srv  06 02  o eco
                    #   srv  06 06  o item usado, com quantidade ja descontada
                    #
                    # Medido em iniciante.log (30/08): o presente 999000121 deu cinco itens.
                    #
                    # SO ABRE O QUE ESTA MEDIDO. O efeito dos itens nao esta em tabela
                    # nenhuma do client -- nem o conteudo das caixas nem a cura das pocoes.
                    # Consumir sem dar nada e pior do que nao fazer nada, entao o que nao
                    # estiver no CaixaConteudo.ini fica por usar, com uma linha no log.
                    _it2 = struct.unpack_from(">I", body, 12)[0]
                    _c2, _s2 = body[10], body[11]
                    # A OPCAO ESCOLHIDA NA JANELA "Selecione Opcoes", em [17:21] BE. Ver
                    # db.dar_item: valida-se contra as opcoes do item, e por isso o valor
                    # que as caixas sem escolha ali poem (a propria entidade) cai fora
                    # sozinho. Ate 30/08 este campo era ignorado e a peca saia sempre com
                    # uma opcao ao acaso, desse o jogador a que desse.
                    _esc2 = struct.unpack_from(">I", body, 17)[0] if len(body) >= 21 else 0
                    print("[hop3] 06 02 usar: item %d em cont %d slot %d (opcao %d)"
                          % (_it2, _c2, _s2, _esc2))
                    _conteudo = data_files.agrupar("CaixaConteudo", "caixa").get(_it2, ())
                    # POCAO DE VELOCIDADE. O item nao abre nada -- da um bonus por tempo.
                    #
                    # O CAMINHO E O MEDIDO, e e o unico bonus de velocidade que se viu de
                    # ponta a ponta no oficial (31/08): usar -> 05 09 a ligar com a duracao
                    # -> 08 02 com o valor novo -> o client anda mesmo mais depressa. Ver
                    # entity_sim.build_buff_de_item.
                    #
                    # A DURACAO vem do proprio item (duracao_seg). Quando ela nao existe,
                    # 60 s, que e o que a Pocao do vento declarou no fio.
                    _pct_vel = 0.0
                    for _lb in data_files.agrupar("ItemBonus", "item_id").get(_it2, ()):
                        try:
                            if int(_lb.get("tipo") or 0) == 27:
                                _pct_vel = float(_lb.get("valor") or 0.0)
                        except (TypeError, ValueError):
                            pass
                    if _pct_vel and personagens:
                        _e3 = personagens[0]["entity_id"]
                        _orig3 = next((i for i in db.inventario_do_personagem(_e3)
                                       if i["container"] == _c2 and i["slot"] == _s2), None)
                        if _orig3 is not None and int(_orig3["item_id"]) == _it2:
                            # A DURACAO SAI DA SKILL -- ver db.buff_do_item. O
                            # `duracao_seg` da tabela Item e zero em todas as pocoes, e o
                            # 60 de reserva dava 1 minuto a uma pocao que promete 5.
                            _sk3, _dur3, _pct_sk = db.buff_do_item(_it2)
                            if not _dur3:
                                _fic = (data_files.indice("Item", "item_id")
                                        or {}).get(_it2) or {}
                                try:
                                    _dur3 = int(_fic.get("duracao_seg") or 0) or 60
                                except (TypeError, ValueError):
                                    _dur3 = 60
                            if _pct_sk and abs(_pct_sk - _pct_vel) > 0.01:
                                print("[hop3] pocao %d: ItemBonus diz %.0f%% e a skill %d "
                                      "diz %.0f%% -- fica a skill"
                                      % (_it2, _pct_vel, _sk3, _pct_sk))
                                _pct_vel = _pct_sk
                            entity_sim.usar_item_de_bonus(_e3, _it2, _pct_vel, _dur3)
                            _j3 = mundo.jogador(_e3)
                            if _j3 is not None:
                                _seq_apanhar[0] += 1
                                _j3.enviar(entity_sim.build_buff_de_item(
                                    _seq_apanhar[0], _e3, _it2, True, _dur3, _sk3))
                                print("[hop3] buff da pocao %d -> skill %d" % (_it2, _sk3))
                                _seq_apanhar[0] += 1
                                _f3 = build_stats(personagens[0], _seq_apanhar[0])
                                if _f3:
                                    _j3.enviar(_f3)
                                # O ECO DO 06 02, e o item a descer de quantidade.
                                #
                                # Faltavam os dois. Neste protocolo o client espera a
                                # confirmacao do que pediu -- o oficial responde ao 06 02
                                # com o eco e um 06 06 com a quantidade nova -- e sem ela
                                # nao ha razao para ele manter o efeito. Foi tambem por
                                # isto que a pocao nunca sumia da mochila: dava para a usar
                                # dez vezes seguidas.
                                _ec3 = bytearray(body)
                                ataque_seq += 1
                                struct.pack_into(">I", _ec3, 2, ataque_seq)
                                _j3.enviar(encode_frame(bytes(_ec3)))
                                _resta3 = max(0, int(_orig3.get("quantidade") or 1) - 1)
                                try:
                                    with db._conectar() as _cn3:
                                        _cu3 = _cn3.cursor()
                                        if _resta3:
                                            _cu3.execute(
                                                "UPDATE dbo.personagem_item SET quantidade = ? "
                                                "WHERE personagem_item_id = ?",
                                                _resta3, _orig3["instancia"])
                                        else:
                                            _cu3.execute(
                                                "DELETE FROM dbo.personagem_item "
                                                "WHERE personagem_item_id = ?",
                                                _orig3["instancia"])
                                except Exception as _x3:            # noqa: BLE001
                                    print("[hop3] gastar a pocao falhou: %s" % str(_x3)[:90])
                                _seq_apanhar[0] += 1
                                _j3.enviar(build_item_movido(
                                    _seq_apanhar[0], _e3,
                                    registro_item(_orig3, _c2, _s2), _resta3, sozinho=True))
                            print("[hop3] pocao %d: +%.0f%% de velocidade por %d s"
                                  % (_it2, _pct_vel, _dur3))
                    if not personagens:
                        pass
                    elif _pct_vel:
                        pass
                    elif not _conteudo:
                        print("[hop3] usar item %d: conteudo/efeito nao medido -- nao usado"
                              % _it2)
                    else:
                        _e2 = personagens[0]["entity_id"]
                        _orig = next((i for i in db.inventario_do_personagem(_e2)
                                      if i["container"] == _c2 and i["slot"] == _s2), None)
                        if _orig is None or int(_orig["item_id"]) != _it2:
                            # SEM ISTO O RAMO FICAVA MUDO. O jogador tentou abrir a caixa
                            # noutra personagem e nao apareceu linha nenhuma no log -- nao
                            # havia como saber se o pacote nem chegou ou se a posicao nao
                            # batia. Um ramo que falha em silencio nao se diagnostica.
                            print("[hop3] usar item %d: em cont %d slot %d esta %s"
                                  % (_it2, _c2, _s2,
                                     _orig["item_id"] if _orig else "nada"))
                        else:
                            _q2 = []
                            _cls2 = int(personagens[0].get("classe") or 0)
                            for _l in _conteudo:
                                # A PECA VEM DA CLASSE DE QUEM ABRE. O CaixaConteudo guarda
                                # o que se mediu, e mediu-se com uma classe so -- por isso a
                                # arqueira recebia o terno do vagabundo. Ver db.item_da_classe.
                                _item2 = db.item_da_classe(int(_l["item"]), _cls2)
                                if _item2 != int(_l["item"]):
                                    print("[hop3] caixa %d: %s -> %s (classe %d)"
                                          % (_it2, _l["item"], _item2, _cls2))
                                try:
                                    _r2 = db.dar_item(_e2, _item2,
                                                      int(_l["quantidade"] or 1),
                                                      efeito=_esc2)
                                except Exception as _x2:            # noqa: BLE001
                                    print("[hop3] caixa %d: %s" % (_it2, str(_x2)[:80]))
                                    continue
                                if not _r2.get("ok"):
                                    print("[hop3] caixa %d nao deu %s: %s"
                                          % (_it2, _l["item"], _r2.get("erro")))
                                    continue
                                for _n2 in db.inventario_do_personagem(_e2):
                                    if _n2.get("instancia") == _r2.get("instancia"):
                                        _seq_apanhar[0] += 1
                                        _q2.append(build_item_movido(
                                            _seq_apanhar[0], _e2, registro_item(_n2),
                                            _n2["quantidade"], sozinho=True))
                                        break
                            # a caixa gasta-se: uma unidade a menos
                            _resta = max(0, int(_orig.get("quantidade") or 1) - 1)
                            try:
                                with db._conectar() as _cn2:
                                    _cu2 = _cn2.cursor()
                                    if _resta:
                                        _cu2.execute(
                                            "UPDATE dbo.personagem_item SET quantidade = ? "
                                            "WHERE personagem_item_id = ?",
                                            _resta, _orig["instancia"])
                                    else:
                                        _cu2.execute(
                                            "DELETE FROM dbo.personagem_item "
                                            "WHERE personagem_item_id = ?", _orig["instancia"])
                            except Exception as _x2:                # noqa: BLE001
                                print("[hop3] gastar a caixa falhou: %s" % str(_x2)[:90])
                            _ec2 = bytearray(body)
                            ataque_seq += 1
                            struct.pack_into(">I", _ec2, 2, ataque_seq)
                            _q2.append(encode_frame(bytes(_ec2)))
                            _seq_apanhar[0] += 1
                            _q2.append(build_item_movido(
                                _seq_apanhar[0], _e2,
                                registro_item(_orig, _c2, _s2), _resta, sozinho=True))
                            with send_lock:
                                for _q in _q2:
                                    conn.sendall(_q)
                            print("[hop3] caixa %d aberta por %s: %d item(ns)"
                                  % (_it2, personagens[0].get("nome"), len(_conteudo)))

                elif len(body) >= 14 and body[0:2] == bytes([5, 6]):
                    # APRENDER OU EVOLUIR SKILL -- o clique no "+" da janela de habilidades.
                    #
                    #   CLI  05 06 | seq | jogador BE | skill BE | 00 24
                    #   srv  05 06 (eco) + 08 02 + 08 01 com os pontos ja descontados
                    #
                    # Medido na captura da Arqueira: cada 05 06 fez os pontos descerem
                    # exactamente 1 e a soma dos niveis do 05 05 subir exactamente 1.
                    _sk6 = struct.unpack_from(">I", body, 10)[0]
                    if personagens:
                        _p6 = personagens[0]
                        _ok6, _erro6, _pts6 = db.aprender_skill(
                            _p6["entity_id"], _p6["classe"], _p6.get("nivel", 1), _sk6)
                        if _ok6:
                            if heroi is not None:
                                heroi.pontos_skill = _pts6
                            _e6 = bytearray(body[:16])
                            ataque_seq += 1
                            struct.pack_into(">I", _e6, 2, ataque_seq)
                            _q6 = [encode_frame(bytes(_e6))]
                            _lista6 = build_skills(bytes(12), _p6["entity_id"],
                                                   _p6["classe"], _p6.get("nivel", 1))
                            if _lista6:
                                _q6.append(_lista6)
                            # A FICHA TEM DE VOLTAR. O oficial manda 08 02 e 08 01 depois do
                            # 05 06, e eu so mandava o eco e a lista -- por isso a passiva
                            # entrava no banco e a Destreza da tela ficava sem o "(+2)".
                            # Uma passiva so se ve na ficha, portanto sem isto ela nao existe
                            # para o jogador.
                            if heroi is not None:
                                heroi.aplicar_bonus_de_equipamento()
                                _p6["hp_max"], _p6["mp_max"] = heroi.hp_max, heroi.mp_max
                                _p6["hp"], _p6["mp"] = heroi.hp, heroi.mp
                            ataque_seq += 1
                            _f6 = build_stats(_p6, ataque_seq)
                            if _f6:
                                _q6.append(_f6)
                            if heroi is not None:
                                ataque_seq += 1
                                _g6 = entity_sim.build_hero_gage(ataque_seq, heroi)
                                if _g6:
                                    _q6.append(_g6)
                            with send_lock:
                                for _q in _q6:
                                    conn.sendall(_q)
                        else:
                            print("[hop3] aprender skill %d recusado: %s" % (_sk6, _erro6))

                elif len(body) >= 14 and body[0:2] == bytes([6, 3]):
                    # APANHAR DO CHAO. O 06 03 e BIDIRECCIONAL, e isso passou-me:
                    #
                    #   servidor -> client   o saco SAI (apanhado, expirou, saiu de vista)
                    #   client -> servidor   "quero APANHAR este saco"
                    #
                    # A nota antiga dizia que o 06 03 era so a saida de campo de visao. Era
                    # o que se via olhando so o lado do servidor; o client manda-o 645 vezes
                    # nas capturas. Medido no fio do oficial:
                    #
                    #   06 04  saco aparece
                    #   +9,3s  CLI 06 03 | seq | jogador BE | id do saco BE | item BE | 00 5a
                    #          srv 06 06  item para a mochila
                    #          srv 06 03  saco sai
                    #
                    # Os 9,3 segundos com o jogador em cima e que dizem tudo: no oficial o
                    # saco NAO vem sozinho. Nos apanhavamos por proximidade, e o jogador deu
                    # por isso -- "ta coletando o carat so de chegar perto".
                    _saco = struct.unpack_from(">I", body, 10)[0]
                    _j3 = mundo.jogador(personagens[0]["entity_id"]) if personagens else None
                    if _j3 is not None:
                        def _seq3():
                            _seq_apanhar[0] += 1
                            return _seq_apanhar[0]
                        # SEM ECO. O 06 03 de saida ja sai do _tirar_saco, agora com o id
                        # de quem apanhou. Ecoar aqui mandava um SEGUNDO 06 03 e o oficial so
                        # manda um -- ver build_saco_sai.
                        entity_sim.apanhar_saco(_saco, _j3, _seq3)

                elif len(body) >= 26 and body[0:2] == OP_ATALHO:
                    # BARRA DE ATALHOS: o client grava uma ranhura de cada vez.
                    #
                    #   [10:14] ranhura BE   [14:18] o que la fica, LE
                    #   [18:22] vale 9 em todas as amostras   [22:26] varia, por perceber
                    #
                    # Ate 30/08 isto era ignorado e o `05 0c` da entrada ia do molde -- a
                    # barra voltava sempre ao mesmo depois de relogar.
                    _ran = struct.unpack_from(">I", body, 10)[0]
                    _val = struct.unpack_from("<I", body, 14)[0]
                    _c18 = struct.unpack_from("<I", body, 18)[0]
                    _c22 = struct.unpack_from("<I", body, 22)[0]
                    _enta = personagens[0]["entity_id"] if personagens else 0
                    if _enta:
                        db.guardar_atalho(_enta, _ran, _val, _c18, _c22)
                    print("[hop3] atalho: ranhura %d = %d (campos %d/%d)"
                          % (_ran, _val, _c18, _c22))
                    # O ECO. O client manda e segue; nao se viu resposta do oficial a este
                    # pacote em captura nenhuma, portanto nao se inventa uma.

                elif len(body) >= 18 and body[0:2] == bytes([6, 8]):
                    # MOVER ITEM DE SLOT. Vinte e dois bytes:
                    #
                    #   06 08 | seq | jogador BE | cont_orig slot_orig | item BE | cont_dest slot_dest
                    #
                    # Medido no fio do oficial: ao arrastar um item de (2,1) para (1,3) ele
                    # responde com o par de 06 06 do costume -- a posicao antiga com
                    # quantidade 0 e a nova com a quantidade real -- e ecoa o 06 08.
                    #
                    # NAO TRATAVAMOS ISTO DE TODO, e era por isso que o jogador nao
                    # conseguia mudar itens de sitio dentro da mochila.
                    _co, _so = body[10], body[11]
                    _cd, _sd = body[16], body[17]
                    _ent8 = personagens[0]["entity_id"] if personagens else 0
                    _inv8 = {(i["container"], i["slot"]): i
                             for i in db.inventario_do_personagem(_ent8)} if _ent8 else {}
                    _de, _para = _inv8.get((_co, _so)), _inv8.get((_cd, _sd))
                    print("[hop3] mover item: (%d,%d) -> (%d,%d)  %s%s"
                          % (_co, _so, _cd, _sd,
                             _de["item_id"] if _de else "nada na origem",
                             " (troca com %d)" % _para["item_id"] if _para else ""))
                    if _de is not None:
                        try:
                            # TROCA OU MOVE, num UPDATE por linha mas com a origem a passar
                            # por um slot livre quando ha troca -- a chave unica e (entity,
                            # container, slot), a mesma pedra do reorganizar. Aqui sao duas
                            # linhas so, entao o CASE de uma instrucao chega.
                            with db._conectar() as _cn8:
                                _cu8 = _cn8.cursor()
                                if _para is not None:
                                    _cu8.execute(
                                        "UPDATE dbo.personagem_item SET container = CASE "
                                        "WHEN personagem_item_id = ? THEN ? ELSE ? END, "
                                        "slot = CASE WHEN personagem_item_id = ? THEN ? "
                                        "ELSE ? END WHERE personagem_item_id IN (?, ?)",
                                        _de["instancia"], _cd, _co,
                                        _de["instancia"], _sd, _so,
                                        _de["instancia"], _para["instancia"])
                                else:
                                    _cu8.execute(
                                        "UPDATE dbo.personagem_item SET container = ?, "
                                        "slot = ? WHERE personagem_item_id = ?",
                                        _cd, _sd, _de["instancia"])
                            # os "sai" antes dos "entra", como no equipar e no reorganizar
                            _q8 = []
                            _mex = [(_de, _co, _so, _cd, _sd)]
                            if _para is not None:
                                _mex.append((_para, _cd, _sd, _co, _so))
                            for _it, _c1, _s1, _c2, _s2 in _mex:
                                seq_pedido += 1
                                _q8.append(build_item_movido(
                                    seq_pedido, _ent8,
                                    registro_item(_it, container=_c1, slot=_s1), 0))
                            for _it, _c1, _s1, _c2, _s2 in _mex:
                                seq_pedido += 1
                                _q8.append(build_item_movido(
                                    seq_pedido, _ent8,
                                    registro_item(_it, container=_c2, slot=_s2),
                                    _it.get("quantidade", 1) or 1))
                            _e8 = bytearray(body)
                            seq_pedido += 1
                            struct.pack_into(">I", _e8, 2, seq_pedido)
                            _q8.append(encode_frame(bytes(_e8)))
                            with send_lock:
                                for _q in _q8:
                                    conn.sendall(_q)
                        except Exception as _x8:                    # noqa: BLE001
                            print(f"[hop3] mover item falhou: {str(_x8)[:110]}")

                elif len(body) >= 18 and body[0:2] == bytes([6, 0x41]):
                    # APAGAR ITEM (arrastar para o caixote). Trinta e oito bytes:
                    #
                    #     06 41 | seq | 00 | jogador BE | container BE u32 | slot BE u32 | ...
                    #
                    # Da mesma captura: um so 06 41, e o jogador apagou um so item.
                    _cont41 = struct.unpack_from(">I", body, 10)[0]
                    _slot41 = struct.unpack_from(">I", body, 14)[0]
                    _apagado = None
                    if personagens:
                        _ent41 = personagens[0]["entity_id"]
                        _apagado = next((i for i in db.inventario_do_personagem(_ent41)
                                         if i["container"] == _cont41
                                         and i["slot"] == _slot41), None)
                    print(f"[hop3] apagar item: container {_cont41} slot {_slot41} -> "
                          f"{_apagado['item_id'] if _apagado else 'nada nessa posicao'}")
                    if _apagado:
                        try:
                            with db._conectar() as _cn41:
                                _cu41 = _cn41.cursor()
                                _cu41.execute(
                                    "DELETE FROM dbo.personagem_item WHERE entity_id = ? "
                                    "AND container = ? AND slot = ?",
                                    _ent41, _cont41, _slot41)
                                _cn41.commit()
                            # AVISAR O CLIENT. Sem isto a linha sai do banco e o item fica na
                            # tela -- o jogador apagava uma peca e ela nao sumia. O oficial
                            # manda este 06 06 ANTES do eco; ver build_item_removido.
                            ataque_seq += 1
                            with send_lock:
                                conn.sendall(build_item_removido(
                                    ataque_seq, _ent41,
                                    registro_item(_apagado, _cont41, _slot41)))
                        except Exception as _x41:                   # noqa: BLE001
                            print(f"[hop3] apagar falhou: {str(_x41)[:100]}")
                    _e41 = bytearray(body)
                    ataque_seq += 1
                    struct.pack_into(">I", _e41, 2, ataque_seq)
                    try:
                        with send_lock:
                            conn.sendall(encode_frame(bytes(_e41)))
                    except OSError:
                        pass

                elif len(body) >= 2 and body[0:2] == bytes([5, 3]):
                    # ANUNCIO DA SKILL. So o eco -- o dano NAO acontece aqui.
                    #
                    # Medido no fio do oficial em 29/08 as 13:40, com o leitor de pacotes
                    # ja a separar os que vem colados no mesmo bloco:
                    #
                    #     +0.000 CLIENT 05 03          anuncia a skill
                    #     +0.318   serv 05 03          eco -- e isto que toca a animacao
                    #     +0.481 CLIENT 05 01          conecta
                    #     +0.729   serv 05 02 DANO=43  o dano, UMA vez
                    #     +0.867   serv 08 06          a barra do mob
                    #
                    # Cheguei a fazer o 05 03 entrar pelo mesmo ramo do 05 01, e o dano
                    # passou a ser aplicado duas vezes por skill -- o jogador viu logo,
                    # "ta dando 2 hit". O 05 03 anuncia; quem doi e o 05 01.
                    #
                    # O ECO SAI SEMPRE, mesmo com o alvo ja morto: o client comeca a carregar
                    # por conta propria e so termina quando o servidor confirma. Sem
                    # confirmacao fica a carregar para sempre, que foi o que prendeu a
                    # personagem quando o mob morreu a meio do lancamento.
                    _sk3 = entity_sim.skill_do_pedido(body)
                    _al3 = entity_sim.alvo_da_skill(body)
                    # A MUNICAO. Algumas skills gastam um item -- o client ja o dizia na
                    # dica ("Objeto utilizado : Bala magica (1)") e o servidor ignorava:
                    # dava para disparar sem balas nenhumas. Sao 53 skills, 29 delas a
                    # gastar Bala magica; a tabela vem do skillBase.lod do proprio client,
                    # em Data/Skills/SkillItem.ini.
                    #
                    # GASTA-SE NO ANUNCIO, nao no 05 01: e aqui que o client pergunta se
                    # pode, e e a resposta a este pacote que solta a animacao.
                    _mun = db.item_da_skill(_sk3) if _sk3 else None
                    if _mun and personagens:
                        _iid_m, _qtd_m = _mun
                        _gasto = db.gastar_do_inventario(
                            personagens[0]["entity_id"], _iid_m, _qtd_m)
                        if _gasto is None:
                            # SEM MUNICAO NAO HA ECO. O client fica a carregar ate o
                            # servidor confirmar, entao nao confirmar e o que o impede de
                            # disparar -- e a mesma via por onde o oficial recusa.
                            print("[hop3] 05 03 skill=%s RECUSADA: faltam %d do item %d"
                                  % (_sk3, _qtd_m, _iid_m))
                            continue
                        _inst_m, _cont_m, _slot_m, _resta_m = _gasto
                        _reg_m = next((i for i in db.inventario_do_personagem(
                            personagens[0]["entity_id"]) if i.get("instancia") == _inst_m),
                            None)
                        if _reg_m is None:
                            _reg_m = {"instancia": _inst_m, "item_id": _iid_m,
                                      "container": _cont_m, "slot": _slot_m,
                                      "quantidade": _resta_m, "bruto": b""}
                        _seq_apanhar[0] += 1
                        try:
                            conn.sendall(build_item_movido(
                                _seq_apanhar[0], personagens[0]["entity_id"],
                                registro_item(_reg_m, _cont_m, _slot_m), _resta_m,
                                sozinho=True))
                        except Exception as _xm:                    # noqa: BLE001
                            print("[hop3] aviso da municao falhou: %s" % str(_xm)[:80])
                        print("[hop3] skill %s gastou %d x item %d, restam %d"
                              % (_sk3, _qtd_m, _iid_m, _resta_m))
                    print(f"[hop3] 05 03 skill={_sk3} alvo={_al3} -- eco (sem dano)")
                    _eco3 = bytearray(body)
                    ataque_seq += 1
                    struct.pack_into(">I", _eco3, 2, ataque_seq)
                    try:
                        _q3 = encode_frame(bytes(_eco3))
                        with send_lock:
                            conn.sendall(_q3)
                        if personagens:
                            mundo.transmitir(personagens[0]["entity_id"], _q3)
                    except Exception as _e3:                        # noqa: BLE001
                        print(f"[hop3] eco do 05 03 falhou: {str(_e3)[:90]}")

                elif len(body) >= 2 and body[0:2] == bytes([5, 1]):
                    if len(body) >= 28:
                        try:
                            px, py, pz = struct.unpack_from("<3f", body, 16)
                            eu_j = mundo.jogador(heroi.id)
                            if eu_j is not None and (abs(px) + abs(py)) > 1.0:
                                eu_j.x, eu_j.y, eu_j.z = px, py, pz
                        except struct.error:
                            pass
                    alvo = entity_sim.alvo_da_skill(body)
                    atacante = entity_sim.atacante_do_pedido(body) or 0xFFFFFFFF
                    # dano vindo da ARMA EQUIPADA (itemAttr.lod -> dbo.item_atributo), nao mais
                    # da constante DANO_SKILL=12 que valia igual pras 7 classes.
                    # alvo_id e nivel_jogador entram porque a chance de falha/bloqueio
                    # depende do quanto o mob esta ACIMA do jogador -- ver taxas_do_golpe().
                    _npc_do_alvo = entity_sim.npc_id_de(alvo) if alvo else None
                    bandeira, dano = entity_sim.dano_do_golpe(
                        heroi.id,
                        personagens[0]["classe"] if personagens else 0,
                        personagens[0] if personagens else None,
                        alvo_id=_npc_do_alvo,
                        nivel_jogador=heroi.nivel)
                    # ---- A SKILL ENTRA AQUI ----
                    #
                    # O client diz em [12:16] qual skill usou; a tabela do proprio client
                    # (Data/Skills/SkillLod.ini, tirada do skillLevel.lod) diz quanto ela
                    # custa, quanto tempo recupera e que bonus de dano da.
                    #
                    # O BONUS SOMA-SE, nao substitui. A dica do client chama-lhe "Ataque
                    # fisico Bonus", e o fio confirma: o Golpe doloroso (7001) tem bonus 0~0
                    # na tabela e mediu-se 62 de dano no oficial -- o golpe basico e mais
                    # nada. So somando e que os dois casos batem.
                    _sk = entity_sim.skill_do_pedido(body)
                    if _sk and dano > 0:
                        # O NIVEL DA SKILL, e nao o do personagem. Vem da tabela por
                        # personagem; sem linha, cai em 1 -- que era o valor assumido para
                        # toda a gente ate 30/08.
                        _nsk = db.skills_do_personagem(heroi.id).get(_sk, 1)
                        # BUFF: se a skill tem duracao, liga-a em vez de contar dano.
                        _j_buff = mundo.jogador(heroi.id)
                        if _j_buff is not None:
                            def _seqb():
                                _seq_apanhar[0] += 1
                                return _seq_apanhar[0]
                            # O `body` VAI JUNTO porque o 05 02 de cada tique se monta a
                            # partir dele -- ver entity_sim.rodar_periodicos. O numero nao
                            # sai aqui: sairia duas vezes no lancamento, ja que o primeiro
                            # tique e nesse mesmo instante.
                            entity_sim.aplicar_buff(_j_buff, _sk, _nsk, _seqb,
                                                    pedido=body)
                            # DEBUFF: vai no ALVO, nao em quem lanca. MEDIDO em 30/08 -- o
                            # jogador lancou a 11002 num javali no oficial e o 05 09 saiu
                            # com a entidade DO JAVALI (952 e 957), nao com a dele (11277).
                            # Era esta a duvida que mantinha os debuffs desligados.
                            if alvo:
                                _tique = entity_sim.aplicar_debuff(
                                    alvo, _sk, _nsk, _seqb, mundo.transmitir_todos)
                                # O NUMERO DO EFEITO CONTINUO, uma vez, no lancamento. E o
                                # que o oficial faz: um 05 02 com o valor do tique e a
                                # bandeira 0x04, e dai em diante so a barra mexe.
                                if _tique:
                                    ataque_seq += 1
                                    _fd = entity_sim.build_dano_skill(
                                        ataque_seq, body, _tique, alvo,
                                        entity_sim.GOLPE_CONTINUO)
                                    if _fd:
                                        try:
                                            with send_lock:
                                                conn.sendall(_fd)
                                        except OSError:
                                            pass
                                        mundo.transmitir(heroi.id, _fd)
                        _bmin, _bmax, _mana, _rec = entity_sim.bonus_da_skill(_sk, _nsk)
                        # RECUPERACAO com folga de 10 por cento. Quem manda no tempo e o
                        # client, que tem o relogio dele; travar no valor exacto rejeitava
                        # golpes legitimos que chegassem uns milissegundos cedo. Skill que
                        # nao esteja na tabela nao e travada -- so 9 dos 28 ids vistos no
                        # fio existem la, e nao vou impedir os outros de funcionar.
                        _agora = _tempo.time()
                        _chave = (heroi.id, _sk)
                        _ult = _RECUPERACAO.get(_chave, 0.0)
                        if _rec > 0 and _agora - _ult < _rec * 0.9:
                            print(f"[hop3] skill {_sk} ainda a recuperar "
                                  f"({_agora - _ult:.1f}s de {_rec:.1f}s) -- golpe ignorado")
                            dano = 0
                        elif _mana > 0 and getattr(heroi, "mp", 0) < _mana:
                            print(f"[hop3] skill {_sk} sem mana "
                                  f"({getattr(heroi, 'mp', 0)} < {_mana:.0f})")
                            dano = 0
                        else:
                            _RECUPERACAO[_chave] = _agora
                            if _mana > 0 and hasattr(heroi, "mp"):
                                heroi.mp = max(0, heroi.mp - int(_mana))
                                # MANDA O GAGE JA. Sem isto o MP descia no servidor e a
                                # barra do client so mexia quando algum outro evento
                                # mandasse um 08 01 -- na pratica, so ao matar o mob, que e
                                # quando a EXP obriga a mandar um. O jogador viu exactamente
                                # isso: "so ta consumindo mp depois que mob morre". O
                                # servidor estava certo; o que faltava era contar ao client.
                                ataque_seq += 1
                                _g = entity_sim.build_hero_gage(ataque_seq, heroi)
                                if _g:
                                    try:
                                        with send_lock:
                                            conn.sendall(_g)
                                    except OSError:
                                        pass
                            if _bmax > 0:
                                _b = _random.uniform(_bmin, _bmax)
                                if entity_sim.skill_e_magica(_sk, _nsk):
                                    # SOMA AO DANO MAGICO DO EQUIPAMENTO. Ver
                                    # skill_e_magica() no entity_sim.
                                    try:
                                        _mg = db.dano_da_arma(heroi.id, magico=True)
                                    except Exception:               # noqa: BLE001
                                        _mg = None
                                    if _mg:
                                        _b += _random.uniform(_mg[0], _mg[1])
                                    # SUBSTITUI: refaz o golpe com o valor da skill no lugar
                                    # do da arma, para a defesa e as bandeiras correrem sobre
                                    # ele. Ver skill_substitui_ataque() no entity_sim.
                                    bandeira, dano = entity_sim.dano_do_golpe(
                                        heroi.id,
                                        personagens[0]["classe"] if personagens else 0,
                                        personagens[0] if personagens else None,
                                        alvo_id=_npc_do_alvo,
                                        nivel_jogador=heroi.nivel,
                                        ataque_base=_b,
                                        magico=True)
                                    print(f"[hop3] skill {_sk} nv{heroi.nivel}: magica "
                                          f"{_b:.0f} -> dano {dano}, mana -{_mana:.0f}")
                                else:
                                    dano += int(round(_b))
                                    print(f"[hop3] skill {_sk} nv{heroi.nivel}: "
                                          f"+{int(round(_b))} de bonus "
                                          f"({_bmin:.0f}~{_bmax:.0f}), mana -{_mana:.0f}")
                    # atacante= alimenta o livro que divide a EXP por dano.
                    r = (entity_sim.aplicar_dano(alvo, dano, atacante=heroi.id)
                         if alvo else None)
                    if alvo and r:
                        hp, morreu = r
                        # O mob e do MUNDO: a barra de HP e o numero de dano vao pra todos
                        # os jogadores, nao so pra quem bateu. Sem isto o outro ve o
                        # personagem atacando e a aranha sem tomar dano.
                        for quem in (atacante, 0xFFFFFFFF):
                            ataque_seq += 1
                            frame_hp = entity_sim.build_hp(ataque_seq, alvo, hp, atacante=quem)
                            with send_lock:
                                conn.sendall(frame_hp)
                            mundo.transmitir(heroi.id, frame_hp)
                        ataque_seq += 1
                        frame = entity_sim.build_dano_skill(ataque_seq, body, dano, alvo,
                                                           bandeira)
                        if frame:
                            with send_lock:
                                conn.sendall(frame)
                            mundo.transmitir(heroi.id, frame)
                        # A CHANCE DE CRITICO VAI JUNTO. Sem ela nao ha como distinguir
                        # "o critico nao esta ligado" de "nao calhou": e a mesma linha de
                        # log nos dois casos, e a diferenca so aparece ao fim de dezenas de
                        # golpes. Com a chance escrita, um golpe basta.
                        _crit = ""
                        try:
                            _p0 = personagens[0] if personagens else None
                            _cb = db.chance_critico(_p0) if _p0 else 0.0
                            _ce = db.bonus_do_equipamento(heroi.id).get(
                                "chance_critico", (0.0, 0.0))
                            _crit = " crit=%.1f%%" % (_cb + _ce[0] + _ce[1])
                        except Exception:                           # noqa: BLE001
                            pass
                        print(f"[hop3] golpe: alvo={alvo} atacante={atacante} "
                              f"dano={dano} "
                              f"{entity_sim.NOME_DO_GOLPE.get(bandeira, bandeira)}"
                              f"{_crit} "
                              f"HP={hp}/{entity_sim.hp_max_de(alvo)}")
                        if morreu:
                            ent = entity_sim.ENTIDADES.get(alvo)
                            if ent is not None:
                                ent.viva = False
                                ent.morta_em = _time.time()
                            ataque_seq += 1
                            morto_seq = ataque_seq

                            # DROP. Os sacos nascem ONDE O CORPO CAIU, e por isso a posicao
                            # sai da entidade e nao do jogador -- um mob que morreu a fugir
                            # larga longe de quem o matou, como no oficial.
                            #
                            # O DONO E ESCOLHA NOSSA, nao coisa medida. Do oficial so se sabe
                            # que o [6:10] do 06 04 traz ou 0xFFFFFFFF (livre) ou um id de
                            # jogador, e que na captura os dois casos aparecem misturados --
                            # 140 livres e 107 com dono, em 247 sacos. Qual a regra que
                            # decide entre os dois, e se a reserva expira, nao se mediu.
                            #
                            # Reservar a quem deu o golpe final e a escolha conservadora:
                            # ninguem rouba o abate de ninguem. Se se vier a medir que o
                            # oficial liberta ao fim de x segundos, muda-se aqui e no
                            # rodar_sacos, que ja compara o dono.
                            if ent is not None and _npc_do_alvo:
                                entity_sim.largar(_npc_do_alvo, ent.x, ent.y, ent.z,
                                                  dono=heroi.id,
                                                  nivel_jogador=heroi.nivel)
                                # ITENS DE MISSAO -- NAO caem no chao. Vao direto para a
                                # mochila de quem matou (corrigido pelo jogador em 29/08).
                                # Por isso passam ao lado do largar() e do saco: o chao nunca
                                # chega a saber que existiram.
                                entregar_itens_de_missao(heroi, _npc_do_alvo)

                            # SEM 04 03. O oficial nao manda desaparecimento nenhum quando
                            # um monstro morre -- medido em duas mortes na captura de 26/08:
                            # so os dois 08 06 com hp=0, e depois nada durante 15 s, ate o
                            # 04 02 do renascimento.
                            #
                            # Nos mandavamos o 04 03 a 2,5 s "pra dar tempo da animacao de
                            # morte rodar", o que era exatamente ao contrario: e o hp=0 que
                            # dispara a animacao, e o 04 03 apagava o corpo a meio dela. Por
                            # isso o jogador nao via animacao de morte nenhuma.
                            print(f"[hop3] alvo {alvo} MORREU -- corpo fica ate renascer")

                            # EXP pela morte. O servidor real nao manda pacote proprio de
                            # "ganhou exp": ele so reenvia o medidor do heroi (08 01) com o
                            # numero novo -- foi assim que o level up apareceu na captura.
                            # EXP vem do banco: dbo.mob da a base e dbo.exp_modificador
                            # aplica os bonus ativos. Ver db.calcular_exp.
                            # DIVISAO POR DANO. A EXP nao e do golpe final: cada atacante
                            # leva a fracao do HP que tirou. Quem tirou 100 por cento leva a
                            # EXP toda, quem tirou 10 por cento leva 10 por cento -- e so
                            # entao os bonus ativos entram, por jogador.
                            #
                            # A penalidade por diferenca de nivel tambem e por jogador, e por
                            # isso o calcular_exp e chamado uma vez para cada um, com o nivel
                            # DELE. Dividir um unico total ja calculado daria a todos a
                            # penalidade de quem matou.
                            fatias = entity_sim.participacao(alvo)
                            if not fatias:
                                # ninguem registrado (dano de ambiente, ou mob que ja estava
                                # ferido antes do livro existir): quem deu o golpe leva tudo
                                fatias = {heroi.id: 1.0}
                            for quem_id, fracao in fatias.items():
                                if quem_id == heroi.id:
                                    alvo_heroi, envia = heroi, None
                                else:
                                    j = mundo.jogador(quem_id)
                                    if j is None or j.heroi is None:
                                        continue          # saiu do mundo no meio da luta
                                    alvo_heroi, envia = j.heroi, j
                                # o bonus de EXP do equipamento entra como `extras`, que e
                                # exatamente o que esse parametro existe para receber:
                                # percentuais que nao estao ligados globalmente
                                # A ESPECIE, e nao o 1 fixo que aqui esteve ate 29/08.
                                #
                                # `calcular_exp` sempre soube ler a EXP e o nivel da especie
                                # e aplicar a penalizacao por diferenca de nivel -- so que
                                # recebia mob_id=1, que nao existe. Caia sempre no valor de
                                # recurso (24) e no nivel de mob 1, portanto TODA a fauna
                                # dava a mesma EXP e nunca havia penalizacao nenhuma. Medido
                                # no oficial, um Guardiao Estripador da 450 e uma Aranha
                                # pequena 24; aqui davam 24 os dois.
                                cheio = db.calcular_exp(
                                    _npc_do_alvo or 1, alvo_heroi.nivel,
                                    extras=(db.bonus_de_exp_do_equipamento(alvo_heroi.id),))
                                # ARREDONDA, nao trunca. A EXP por abate e pequena (24 no
                                # nivel 1), entao truncar cada fatia joga fora uma parte
                                # grande do total: numa divisao 70/30 saia 16 + 7 = 23 de 24,
                                # e com cinco no grupo a 20 por cento saia 20 de 24 -- um
                                # sexto da EXP evaporava so por causa do int().
                                ganho = round(cheio * fracao)
                                if ganho <= 0:
                                    continue
                                # O MESMO FACTOR DE NIVEL VAI PARA A FLETTA. Ver
                                # ganhar_exp() -- a EXP normal ja o traz embutido no `ganho`,
                                # a fletta precisa dele a parte porque tem base propria.
                                try:
                                    _fn = db.penalidade_nivel(max(0, alvo_heroi.nivel - int(
                                        (data_files.indice("Mob", "mob_id").get(
                                            int(_npc_do_alvo or 0)) or {}).get("nivel") or 1
                                    ))) / 100.0
                                except Exception:                   # noqa: BLE001
                                    _fn = 1.0
                                subiu = alvo_heroi.ganhar_exp(
                                    ganho, fator_nivel=_fn,
                                    npc_morto=int(_npc_do_alvo or 0))
                                ataque_seq += 1
                                gage = entity_sim.build_hero_gage(ataque_seq, alvo_heroi)
                                if gage:
                                    if envia is None:
                                        with send_lock:
                                            conn.sendall(gage)
                                    else:
                                        envia.enviar(gage)
                                # PROGRESSO DE MISSAO pelo abate. Vale pra quem participou
                                # do dano, nao so pra quem deu o golpe final -- e a mesma
                                # regra da EXP, e o jogador ja descreveu que membro de grupo
                                # ajuda a cumprir objetivo.
                                if _npc_do_alvo:
                                    avancar_missoes_por_abate(
                                        alvo_heroi, _npc_do_alvo, conn, send_lock, envia)
                                print(f"[hop3] +{ganho} EXP ({fracao:.0%} do dano) "
                                      f"-> {quem_id} nivel {alvo_heroi.nivel} "
                                      f"EXP {alvo_heroi.exp}/"
                                      f"{entity_sim.exp_necessaria(alvo_heroi.nivel)}"
                                      + ("  <<< SUBIU DE NIVEL" if subiu else ""))
                    else:
                        print(f"[hop3] 05 01 sem alvo utilizavel (len={len(body)})")

                elif len(body) >= 2 and body[0:2] == bytes([5, 0]):
                    # INICIO DO ATAQUE (CAttackStartPacket). O servidor real so devolve o mesmo
                    # pacote de volta, byte a byte, trocando o numero de sequencia em [2:6].
                    # Nenhum dano acontece aqui -- ele vem no 05 01. (Antes eu aplicava dano nos
                    # dois, o que dobrava o golpe e ainda usava o offset errado de alvo.)
                    eco = bytearray(body)
                    ataque_seq += 1
                    struct.pack_into(">I", eco, 2, ataque_seq)
                    try:
                        quadro_ataque = encode_frame(bytes(eco))
                        with send_lock:
                            conn.sendall(quadro_ataque)
                        # e os outros veem a ANIMACAO do golpe. Sem isto o personagem alheio
                        # fica parado enquanto a barra do mob cai sozinha.
                        if personagens:
                            mundo.transmitir(personagens[0]["entity_id"], quadro_ataque)
                        entity_sim.lembrar_pos_alvo(body)
                        alvo = entity_sim.alvo_do_ataque(body)

                        # A FICHA E A BARRA VAO COM O ECO, como no oficial.
                        #
                        # MEDIDO em 29/08, na sequencia completa de um ataque do jogador:
                        #
                        #     +0,000  SEND 05 00   o jogador ataca
                        #     +0,124  RECV 05 00 + 08 02 + 08 01   eco, ficha e barra
                        #     +0,339  RECV 08 06   barra do alvo
                        #     +0,506  SEND 05 01   o CLIENT confirma o golpe
                        #     +0,620  RECV 05 02   o dano
                        #
                        # Nos mandavamos SO o eco. E o client, que no oficial confirma o golpe
                        # 0,38 s depois do eco, connosco demorava 1,8 s -- medido em 8 golpes,
                        # mediana 1,819, p75 1,976. O jogador sentiu-o antes de eu o medir:
                        # "da dano muito delay, como se o server estivesse lagando".
                        #
                        # Nao esta provado que seja a ficha que ele espera; o que esta medido e
                        # que o oficial a manda aqui e nos nao, e que o mesmo client se comporta
                        # de maneira diferente nos dois casos.
                        if personagens:
                            try:
                                ataque_seq += 1
                                _ficha = build_stats(personagens[0], ataque_seq)
                                if _ficha:
                                    with send_lock:
                                        conn.sendall(_ficha)
                                # O 08 01 (barra do heroi) fica de fora: o build_gage precisa
                                # de um molde do fluxo gravado, que aqui nao existe. Manda-se a
                                # ficha, que e o pacote grande dos dois e o build_stats monta
                                # sozinho.
                            except Exception as _e:                       # noqa: BLE001
                                print(f"[hop3] ficha pos-ataque falhou: {_e}")

                        print(f"[hop3] ataque iniciado (05 00) contra {alvo} -- eco devolvido "
                              f"(seq={ataque_seq}) pra {addr}")
                    except OSError as e:
                        print(f"[hop3] falhou confirmando ataque: {e}")

                elif len(body) >= 10 and body[0:2] == OP_PERGUNTA_08_09:
                    ataque_seq += 2
                    with send_lock:
                        conn.sendall(responder_08_09(body, ataque_seq - 1))
                    print(f"[hop3] 08 09 do client respondido (08 0f + eco) pra {addr}")

                elif len(body) >= 20 and body[0:2] == OP_CLICAR_NPC:
                    # Clique num NPC. O oficial so ecoa; quem abre a janela e o client, a
                    # partir dos dados dele. Nao ha texto de dialogo no fio.
                    ataque_seq += 1
                    with send_lock:
                        conn.sendall(responder_clique_npc(body, ataque_seq))
                    _alvo = struct.unpack_from(">I", body, 10)[0]
                    print(f"[hop3] clique no NPC entidade {_alvo}")

                elif len(body) >= 18 and body[0:2] == OP_MISSAO_ACEITAR and personagens:
                    pedido = ler_pedido_missao(body)
                    if pedido:
                        _quem, _quest, _npc = pedido
                        ok, motivo = db.aceitar_missao(heroi.id, _quest)
                        _f = db._ficha_missao(_quest) or {}
                        if ok:
                            # eco do aceite, e logo depois o objetivo -- foi essa a ordem
                            # medida no oficial: 09 00 volta em 193 ms e o 09 03 em 287 ms.
                            ataque_seq += 1
                            quadros = [eco_missao(body, ataque_seq)]
                            ataque_seq += 1
                            # progresso ZERO: a missao acabou de ser aceita. Mandar o
                            # alvo aqui faria o client mostra-la ja concluida.
                            obj = build_objetivo_missao(ataque_seq, heroi.id, _quest, 0)
                            if obj:
                                quadros.append(obj)
                            with send_lock:
                                conn.sendall(b"".join(quadros))
                            print(f"[hop3] missao {_quest} ACEITA "
                                  f"({_f.get('nome')}) -- {_f.get('objetivo')}")
                        else:
                            # Sem eco: o oficial ecoa o que aceita, e ecoar uma recusa
                            # faria o client marcar como aceita uma missao que o banco
                            # nao gravou. As duas pontas ficariam discordando.
                            print(f"[hop3] missao {_quest} RECUSADA: {motivo}")

                elif len(body) >= 18 and body[0:2] == OP_MISSAO_ENTREGAR and personagens:
                    pedido = ler_pedido_missao(body)
                    if pedido:
                        _quem, _quest, _npc = pedido
                        ok, _ = db.concluir_missao(heroi.id, _quest)
                        _f = db._ficha_missao(_quest) or {}
                        if ok:
                            ataque_seq += 1
                            with send_lock:
                                conn.sendall(eco_missao(body, ataque_seq))
                            _exp, _itens = recompensa_de(_quest)
                            if _exp:
                                subiu = heroi.ganhar_exp(_exp)
                                ataque_seq += 1
                                _g = entity_sim.build_hero_gage(ataque_seq, heroi)
                                if _g:
                                    with send_lock:
                                        conn.sendall(_g)
                                print(f"[hop3] missao {_quest} ENTREGUE "
                                      f"({_f.get('nome')}) -- +{_exp} EXP"
                                      + ("  <<< SUBIU DE NIVEL" if subiu else ""))
                            else:
                                print(f"[hop3] missao {_quest} ENTREGUE "
                                      f"({_f.get('nome')}) -- sem EXP")
                            # OS ITENS DA RECOLHA SAEM DA MOCHILA. Foram entregues ao NPC.
                            _j = mundo.jogador(heroi.id)
                            if _j is not None:
                                consumir_itens_da_missao(_j, _quest)
                            if _itens:
                                print(f"[hop3] {len(_itens)} item(ns) de recompensa NAO "
                                      f"entregues: falta o caminho de dar item por missao")
                        else:
                            print(f"[hop3] missao {_quest} nao estava em aberto -- "
                                  f"entrega ignorada")

                elif len(body) >= 2 and body[0:2] == OP_MISSAO_OBJETIVO:
                    # O client tambem manda 09 03. O oficial ecoa e mais nada.
                    ataque_seq += 1
                    with send_lock:
                        conn.sendall(eco_missao(body, ataque_seq))

                elif len(body) >= 2 and body[0:2] == OP_VOLTAR_CANAL:
                    # VOLTAR pra tela de canal. Sem resposta o client congela mandando ping.
                    ataque_seq += 1
                    try:
                        with send_lock:
                            conn.sendall(build_lista_canais(ataque_seq))
                        print(f"[hop3] client pediu voltar pra selecao de canal (02 06) -- "
                              f"lista devolvida pra {addr}")
                    except OSError as e:
                        print(f"[hop3] falhou devolvendo a lista de canais: {e}")

                elif len(body) >= 11 and body[0:2] == OP_ARMA:
                    # SACAR/GUARDAR A ARMA. O oficial devolve o mesmo pacote pro dono (so a
                    # sequencia muda) e retransmite pros outros. Sem a retransmissao, o
                    # personagem alheio ataca com a arma embainhada.
                    # O [10] E UM MODO: 0 normal, 1 arma sacada, 2 MONTADO. Ate 30/08
                    # lia-se `bool(body[10])` e uma montada entrava como "arma sacada".
                    eid = struct.unpack_from(">I", body, 6)[0]
                    modo = body[10]
                    montou = (modo == MODO_MONTADO)
                    desmontou = (modo == 0 and entity_sim.esta_montado(eid))
                    sacada = (modo == 1)
                    if montou:
                        _mont = db.montaria_equipada(eid)
                        if _mont:
                            entity_sim.montar(eid, _mont)
                        else:
                            # sem nada na ranhura nao ha o que montar; o eco vai na mesma
                            # para o client nao ficar preso na animacao
                            print(f"[hop3] {eid} pediu para montar sem montaria equipada")
                            montou = False
                    elif desmontou:
                        entity_sim.desmontar(eid)
                    ataque_seq += 1
                    quadro_arma = build_arma(ataque_seq, eid, sacada,
                                             MODO_MONTADO if montou else -1)
                    try:
                        with send_lock:
                            conn.sendall(quadro_arma)
                    except OSError as e:
                        print(f"[hop3] falhou ecoando 03 02: {e}")
                    mundo.transmitir(eid, quadro_arma)
                    j = mundo.jogador(eid)
                    if j is not None:
                        j.arma_sacada = sacada
                    # GUARDAR A ARMA E O SINAL DE FIM DE COMBATE, e e dai que a regeneracao
                    # arranca -- um intervalo a frente, medido em 22 amostras do oficial.
                    if modo == 0:
                        entity_sim.sair_de_combate(eid)
                    if montou or desmontou:
                        # A FICHA TEM DE IR A SEGUIR, e e ela que leva a velocidade nova.
                        # O oficial manda 03 16 (corpo a zero), depois 08 02 e 08 01.
                        try:
                            if montou:
                                ataque_seq += 1
                                _c = bytearray(TAM_MONTOU)
                                _c[0:2] = OP_MONTOU
                                struct.pack_into(">I", _c, 2, ataque_seq)
                                with send_lock:
                                    conn.sendall(encode_frame(bytes(_c)))
                            if personagens:
                                ataque_seq += 1
                                _f = build_stats(personagens[0], ataque_seq,
                                                 com_equipamento=True)
                                if _f:
                                    with send_lock:
                                        conn.sendall(_f)
                                ataque_seq += 1
                                _g = entity_sim.build_hero_gage(ataque_seq, heroi)
                                if _g:
                                    with send_lock:
                                        conn.sendall(_g)
                        except OSError as e:
                            print(f"[hop3] falhou mandando a ficha da montaria: {e}")
                        print(f"[hop3] {eid} {'MONTOU' if montou else 'desmontou'} "
                              f"-- velocidade {entity_sim.velocidade_de_movimento(eid):g}")
                    else:
                        print(f"[hop3] arma {'SACADA' if sacada else 'guardada'} "
                              f"por id={eid}")

                elif (len(body) >= 40 and body[0:2] == OP_MOVIMENTO
                      and self_appear_enviado and personagens):
                    # O client manda um 04 01 a CADA passo, com a origem e o destino do
                    # movimento. Retransmitimos igual pros outros -- e o que os faz ver o
                    # personagem andando em vez de parado.
                    pos = ler_movimento(body)
                    if pos:
                        eid, origem, destino, vel = pos
                        # a posicao fica guardada pra quem entrar depois: senao o outro
                        # aparece no ponto onde logou ate dar o primeiro passo.
                        j = mundo.jogador(eid)
                        if j is not None:
                            # ---- MEDICAO DO ATRASO DA POSICAO (temporario) ----
                            # O mob decide se bate comparando a posicao DELE com a posicao do
                            # jogador que o servidor tem. Se essa estiver atrasada, o mob
                            # aparece colado na tela e a conta no servidor da longe. O client
                            # diz onde esta no campo `origem` de cada 04 01: e a referencia.
                            try:
                                _ag = _tempo.time()
                                _sv = j.pos(_ag)
                                _e = ((origem[0]-_sv[0])**2 + (origem[1]-_sv[1])**2) ** 0.5
                                _ATRASO_POS.append(_e)
                                if len(_ATRASO_POS) >= 200:
                                    _s = sorted(_ATRASO_POS)
                                    print("[atraso-pos] n=%d  p50=%.1f  p90=%.1f  max=%.1f"
                                          % (len(_s), _s[len(_s)//2], _s[int(len(_s)*.9)], _s[-1]))
                                    del _ATRASO_POS[:]
                            except Exception as _ex:
                                print("[atraso-pos] falhou: %s" % str(_ex)[:80])
                            # SEGMENTO, nao teleporte para o destino. Ver o comentario da
                            # posicao em mundo.Jogador: guardar o destino punha o jogador
                            # a frente de onde ele esta, e os monstros arrancavam antes.
                            j.mover_para(origem, destino, _tempo.time())
                            # persiste de vez em quando. Nao a cada passo: numa unica
                            # captura o client mandou 97 mil movimentos.
                            agora = _time.time()
                            if agora - ultimo_salvo_pos >= SEGUNDOS_SALVAR_POS:
                                ultimo_salvo_pos = agora
                                db.salvar_posicao(eid, *destino)
                        if mundo.quantos() > 1:
                            ataque_seq += 1
                            mundo.transmitir(eid, repassar_movimento(body, ataque_seq))

                elif (len(body) >= 28 and body[0:2] == bytes([4, 0])
                      and self_appear_enviado and heroi.hp <= 0):
                    # RESSURREICAO. O client ja mandou 04 00 uma vez, na entrada; este e o
                    # segundo, e vem depois da morte com a POSICAO DO PONTO DE RESSURREICAO
                    # ja escolhida por ele.
                    #
                    # Sequencia medida no oficial (25/08), da morte ate estar de pe:
                    #   t=0,00   08 01 com HP 0        -- caiu
                    #   t=16,90  SEND 04 00            -- o client diz pra onde vai
                    #   t=17,15  05 04                 -- resposta, com a posicao da queda
                    #   t=17,15  08 01 (duas vezes)    -- 20 por cento de HP e de MP
                    #
                    # A contagem de 2 minutos e do client, igual a do /esc. O servidor nao
                    # gere espera nenhuma -- so responde ao 04 00 quando ele chega.
                    # O DESTINO NAO VEM DO CLIENT. Ele manda no 04 00 o ponto de
                    # NASCIMENTO -- na captura do oficial mandou (9000, 6420), praticamente
                    # o mesmo (8980, 6380) que tinha mandado ao entrar no mundo, apesar de
                    # ter morrido a (4377, 4603). Eu usei esse valor e o jogador ressuscitou
                    # no sitio onde se nasce ao criar personagem.
                    #
                    # Quem manda o destino e o SERVIDOR, no 04 02 que vai a seguir. Na
                    # captura ele levava (7466, 9173, 629) -- 74,7 - 91,7 nas coordenadas do
                    # jogo, que e a Pedra do retorno de Berneo, ja registada no Opcode.ini
                    # como "190033 Berneo 7470, 9166".
                    #
                    # POR REGIAO, e nao por distancia: o corpo ficou a (4377, 4603), e a
                    # pedra geometricamente mais perto dai e a de Peltrock, nao a de Berneo.
                    # Enquanto o nosso mundo for um mapa so, fica a de Berneo cravada. Quando
                    # houver mapas, isto passa a ser a consulta que o /esc ja descreve:
                    # regiao nos digitos [3:5] do mapa_id, pedra no mapa 1900R3.
                    _ = struct.unpack_from("<3f", body, 11)   # o que o client acha, ignorado
                    destino = PONTO_DE_RESSURREICAO
                    eu_mundo = mundo.jogador(heroi.id)
                    # onde o corpo ficou: a ultima posicao que o mundo conhece
                    queda = ((eu_mundo.x, eu_mundo.y, eu_mundo.z) if eu_mundo is not None
                             else destino)
                    heroi.hp = max(1, int(heroi.hp_max * entity_sim.FRACAO_AO_RENASCER))
                    heroi.mp = max(1, int(heroi.mp_max * entity_sim.FRACAO_AO_RENASCER))
                    seq_res = entity_sim.proxima_seq_ressurreicao()
                    # A SEQUENCIA INTEIRA, e nao so o 05 04 e o gage.
                    #
                    # Mandar so esses dois repoe o HP -- o jogador ve 37/186 na barra -- e
                    # deixa o CORPO CAIDO no chao, com a placa do nome a flutuar ao lado. E
                    # o que aconteceu no primeiro teste: os numeros certos, o boneco morto.
                    #
                    # O que levanta o personagem e o par 04 03 + 04 02: apagar a entidade e
                    # desenha-la de novo. O oficial faz o mesmo, com um 08 02 pelo meio que
                    # nos ainda nao montamos.
                    quadros = [entity_sim.build_ressurreicao(seq_res, heroi.id, queda),
                               build_saiu(seq_res + 1, heroi.id)]
                    g = entity_sim.build_hero_gage(seq_res + 2, heroi)
                    if g:
                        quadros.append(g)
                    if personagens:
                        vivo = dict(personagens[0])
                        # as chaves sao pos_x/pos_y/pos_z, e o HP tem de ir o NOVO --
                        # o do banco ainda e o de antes de morrer
                        vivo["pos_x"], vivo["pos_y"], vivo["pos_z"] = destino
                        vivo["hp"], vivo["mp"] = heroi.hp, heroi.mp
                        vivo["hp_max"], vivo["mp_max"] = heroi.hp_max, heroi.mp_max
                        de_pe = build_self_appear(vivo, seq_res + 3)
                        if de_pe:
                            quadros.append(de_pe)
                    with send_lock:
                        conn.sendall(b"".join(quadros))
                    if eu_mundo is not None:
                        eu_mundo.x, eu_mundo.y, eu_mundo.z = destino
                    # grava os 20 por cento: quem sai depois de ressuscitar tem de voltar
                    # assim, e nao com a barra cheia
                    entity_sim.gravar_estado_com_folga(heroi, urgente=True)
                    print(f"[hop3] {personagens[0]['nome'] if personagens else '?'} ressuscitou "
                          f"com {heroi.hp}/{heroi.hp_max} HP em "
                          f"({destino[0]:.0f}, {destino[1]:.0f}, {destino[2]:.0f})")

                elif len(body) >= 2 and body[0:2] == bytes([4, 0]) and not self_appear_enviado:
                    # DESCOBERTO 22/08 (noite) relendo o traffic.log ORIGINAL do hook -- que tem SEND e
                    # RECV intercalados, ao contrario dos arquivos ja separados por sentido.
                    # No servidor real, no instante seguinte ao client mandar 04 00 (a propria posicao),
                    # o servidor responde com um CMoveAppearPacket DO PROPRIO PERSONAGEM (ent 0x299e).
                    # E o sinal de "voce esta no mundo": sem ele o overlay "AGORA CARREGANDO" nunca sai e
                    # o personagem nao anda (o client nao emite nenhum 04 01 de movimento proprio).
                    # Nos ja mandavamos esse pacote (frame 26 do stream), mas CEDO DEMAIS -- antes do
                    # client anunciar a posicao. Aqui ele vai no momento certo.
                    self_appear_enviado = True
                    try:
                        self_appear = None
                        if personagens:
                            self_appear = build_self_appear(personagens[0])
                            if self_appear:
                                print(f"[hop3] aparicao propria montada do banco: "
                                      f"'{personagens[0]['nome']}' id={personagens[0]['entity_id']} "
                                      f"classe={personagens[0]['classe']}")
                        if self_appear is None:
                            # sem molde da classe: cai no capturado (personagem vai aparecer
                            # com nome e visual errados, mas pelo menos entra no mundo)
                            with open(CAMINHO_SELF_APPEAR, "rb") as sf:
                                self_appear = sf.read()
                            print("[hop3] AVISO: sem molde de aparicao pra essa classe, "
                                  "usando o capturado")
                        registrar_saida(self_appear, "self_appear")
                        entity_sim.registrar_appear(self_appear[4:])
                        with send_lock:
                            conn.sendall(self_appear)

                        # MULTIPLAYER: o jogador entra no registro global e as aparicoes sao
                        # trocadas nos dois sentidos -- cada um que ja estava aqui vai pra tela
                        # dele, e ele vai pra tela de todos. Sem isto duas conexoes no mesmo
                        # processo sao duas sessoes solo, que era o caso ate 23/08.
                        if personagens:
                            eu = mundo.Jogador(personagens[0]["entity_id"],
                                               personagens[0]["nome"], conn, send_lock,
                                               self_appear, heroi)
                            # POSICAO DE PARTIDA, antes de qualquer coisa usar `eu`.
                            #
                            # O Jogador nascia em (0, 0) e so ganhava posicao no PRIMEIRO
                            # PASSO. Enquanto isso nao importava, porque nada lia x/y na
                            # entrada; desde que o campo de visao passou a filtrar por
                            # distancia (25/08), nascer na origem faz o mundo inteiro cair
                            # fora do raio e o jogador entra num mapa vazio -- sem NPC e sem
                            # mob, ate dar o primeiro passo.
                            #
                            # A posicao vem do proprio self_appear, que ja foi montado com
                            # ela em [12:24]: e a mesma que o client vai desenhar, entao nao
                            # ha como divergir.
                            # Vem do BANCO e nao do self_appear: desde que o 04 02
                            # passou a levar o z somado de ALTURA_APARICAO, ler de volta
                            # do pacote punha o jogador 5 unidades no ar aos olhos do
                            # servidor -- e essa altura ia crescendo a cada login, porque
                            # o que se le e o que se grava na saida.
                            if personagens and personagens[0].get("pos_x") is not None:
                                eu.x = float(personagens[0]["pos_x"])
                                eu.y = float(personagens[0]["pos_y"])
                                eu.z = float(personagens[0]["pos_z"])
                            elif self_appear and len(self_appear) >= 28:
                                try:
                                    eu.x, eu.y, eu.z = struct.unpack_from(
                                        "<3f", self_appear, 16)
                                    eu.z -= ALTURA_APARICAO
                                except Exception as e:
                                    print(f"[hop3] nao li a posicao do self_appear: {e}")
                            # A FICHA VIVA fica agarrada ao jogador, para o reenvio
                            # periodico a poder usar -- ver _reenviar_ficha. Sem isto ele ia
                            # buscar a linha FRESCA do banco, que nao tem os bonus do
                            # equipamento, e de 3 em 3 s mandava os numeros base por cima
                            # dos certos: a vida piscava entre 260 e 190 no ecra.
                            eu.ficha_viva = personagens[0]
                            # E O HEROI TAMBEM, pela mesma razao e para o mesmo sitio: o
                            # `_reenviar_ficha` precisa dele para montar o 08 01, que e
                            # quem carrega a velocidade. O Heroi vive na CONEXAO e o
                            # reenvio periodico so tem o objecto do mundo.
                            eu.heroi = heroi
                            ja_estavam = mundo.entrar(eu)
                            for outro in ja_estavam:
                                eu.enviar(aparicao_na_posicao(outro))
                                # o appear nao carrega o estado da arma: quem ja estava
                                # lutando aparece de arma guardada pra quem acabou de
                                # entrar. Reenviamos o 03 02 logo depois da aparicao.
                                if outro.arma_sacada:
                                    ataque_seq += 1
                                    eu.enviar(build_arma(ataque_seq, outro.entity_id, True))
                            mundo.transmitir(eu.entity_id, self_appear)
                            # e os mobs que ja estao no mundo vao pra tela dele
                            # so o que esta no campo de visao. O mundo tem 518
                            # entidades e mandar todas eram 136 KB numa rajada -- 23
                            # vezes a maior que o oficial ja mandou.
                            mobs = entity_sim.aparicoes_atuais(eu.x, eu.y)
                            # Anota o que ja foi mostrado. Sem isto o primeiro ciclo da
                            # visao dinamica acha que nada foi visto e reenvia as 42
                            # aparicoes, desenhando tudo em dobro.
                            eu.visiveis = entity_sim.ids_visiveis(eu.x, eu.y)
                            if mobs:
                                eu.enviar(mobs)
                                # Fica gravado o que foi pro fio. Foi assim que se resolveu,
                                # em 25/08, o caso dos dois Domingos: o log provou que o
                                # servidor mandava UM, e a segunda copia era o proprio client
                                # desenhando o NPC a partir dos dados dele. Ver o comentario
                                # de entidade_id no NpcSpawn.ini.
                                registrar_saida(mobs, "entidades do mundo")
                                _n = sum(1 for _f in entity_sim.separar_frames(mobs)
                                         if _f[4:6] == bytes([4, 2]))
                                print(f"[hop3] {_n} entidades do mundo enviadas")
                            if ja_estavam:
                                print(f"[hop3] {eu.nome} entrou no mundo com "
                                      f"{', '.join(o.nome for o in ja_estavam)}")
                            else:
                                print(f"[hop3] {eu.nome} entrou no mundo (sozinho)")
                            # A BARRA DE FLETTA PRECISA DE UM 08 01 DEPOIS DA ENTRADA.
                            #
                            # Os tres 08 01 que vao dentro do fluxo de login nao chegam ao
                            # mostrador: o jogador entrava com a barra a zero e ela so
                            # acordava ao equipar alguma coisa, que e o proximo evento que
                            # manda um. Confirmado no fio dos dois lados -- o nosso pacote
                            # de login ja levava os valores certos e o mostrador ficava a
                            # zero na mesma.
                            #
                            # O oficial manda o dele a +6,6 s do primeiro, ou seja depois da
                            # entrada, e a partir dai um a cada 1,5 s em media.
                            if personagens:
                                _g = entity_sim.build_hero_gage(_seq_apanhar[0] + 1, heroi)
                                if _g:
                                    _seq_apanhar[0] += 1
                                    eu.enviar(_g)

                            # O PAR DE FICHAS OUTRA VEZ, DEPOIS DA ENTRADA -- experiencia
                            # de 30/08 para a velocidade de movimento.
                            #
                            # Comparando a entrada no mundo lado a lado, o oficial manda os
                            # dois `08 02` (sem equipamento, e depois com) COLADOS e DEPOIS
                            # do inventario. No nosso fluxo -- que e uma gravacao, e por isso
                            # com a ordem dela -- o par fica separado, com o inventario pelo
                            # meio, e o primeiro chega antes de o client saber o que esta
                            # vestido.
                            #
                            # O comentario do proprio ramo do stream ja dizia que "e o par
                            # que o client usa pra achar o bonus do equipamento". Se ele o
                            # calcula uma vez, com o primeiro a chegar cedo demais, isso
                            # explica a velocidade presa em 65 mesmo com a ficha a dizer
                            # 71,5 -- e explica tambem por que forcar 200 nao mudou nada.
                            #
                            # ADITIVO DE PROPOSITO: nao se reordena o stream gravado, que ja
                            # partiu a entrada no mundo uma vez. Manda-se o par de novo, na
                            # ordem certa, quando o client ja tem tudo.
                            # E as pecas que ja vinham vestidas da entrada.
                            if personagens:
                                _ep = personagens[0]["entity_id"]
                                for _it in db.inventario_do_personagem(_ep):
                                    if _it["container"] != db.CONTAINER_EQUIPADO:
                                        continue
                                    try:
                                        if not (entity_sim.ENVIAR_BUFF_DE_FANTASIA
                                                and db.efeito_de_fantasia(
                                                    int(_it["item_id"]))):
                                            continue
                                        _seq_apanhar[0] += 1
                                        # A SKILL da peca -- ver db.skill_do_item.
                                        _ske = db.skill_do_item(int(_it["item_id"]),
                                                                _it.get("bruto"))
                                        eu.enviar(entity_sim.build_buff_de_item(
                                            _seq_apanhar[0], _ep, int(_it["item_id"]),
                                            True, entity_sim.DURACAO_BUFF_DE_EQUIPAMENTO,
                                            _ske))
                                        print("[hop3] buff de entrada: item %s skill %d"
                                              % (_it["item_id"], _ske))
                                    except Exception as _xe:        # noqa: BLE001
                                        print("[hop3] buff de entrada falhou: %s"
                                              % str(_xe)[:90])
                            if personagens:
                                # O MOTOR DA VELOCIDADE, antes das fichas -- ver
                                # enviar_velocidade.
                                enviar_velocidade(eu, personagens[0])
                                for _com in (False, True):
                                    _seq_apanhar[0] += 1
                                    _f2 = build_stats(personagens[0], _seq_apanhar[0],
                                                      com_equipamento=_com)
                                    if _f2:
                                        eu.enviar(_f2)
                                print("[hop3] par de fichas reenviado depois da entrada")
                                # E O 08 01 OUTRA VEZ, que e quem carrega a velocidade.
                                #
                                # O jogador em 31/08: *"a velocidade quando entra fica
                                # bugada, so atualiza quando eu removo a bota de fantasia se
                                # tiver equipada e ou se eu equipar se ela tiver
                                # desequipada"*.
                                #
                                # O 08 01 ja ia na entrada, mas ANTES das fichas e do
                                # equipamento -- e a velocidade que ele leva sai de
                                # `velocidade_de_movimento`, que precisa do equipamento
                                # carregado. Chegava com a base e so era corrigido quando
                                # equipar/desequipar mandava outro.
                                #
                                # Mesma cura do par de fichas logo acima, e pela mesma razao:
                                # nao se reordena a entrada (ja se partiu uma vez), manda-se
                                # de novo no fim, com tudo no sitio.
                                _gv = entity_sim.build_hero_gage(_seq_apanhar[0] + 1, heroi)
                                if _gv:
                                    _seq_apanhar[0] += 1
                                    eu.enviar(_gv)
                                    print("[hop3] 08 01 reenviado: velocidade %.2f"
                                          % entity_sim.velocidade_de_movimento(heroi.id))

                            # BUFFS QUE AINDA CONTAM voltam a ligar. Ver
                            # entity_sim.repor_buffs: manda o tempo QUE FALTA, nao a duracao
                            # cheia, para o relogio da tela continuar de onde estava.
                            if personagens:
                                _jb = mundo.jogador(personagens[0]["entity_id"])
                                if _jb is not None:
                                    def _seqbr():
                                        _seq_apanhar[0] += 1
                                        return _seq_apanhar[0]
                                    entity_sim.repor_buffs(_jb, _seqbr)
                                    # E AS POCOES -- ver entity_sim.repor_buffs_de_item.
                                    entity_sim.repor_buffs_de_item(_jb, _seqbr)

                            # ITENS EXPIRADOS SAEM NA ENTRADA. Ver db.apagar_itens_expirados.
                            if personagens:
                                _exp = db.apagar_itens_expirados(
                                    personagens[0]["entity_id"])
                                for _it_exp in _exp:
                                    # cada um leva o seu 06 06 de remocao: sem isso a linha
                                    # sai do banco e o item fica na tela ate ao relogin
                                    try:
                                        eu.enviar(build_item_removido(
                                            _seq_apanhar[0] + 1,
                                            personagens[0]["entity_id"],
                                            registro_item(_it_exp)))
                                        _seq_apanhar[0] += 1
                                    except Exception as _xe:        # noqa: BLE001
                                        print(f"[hop3] aviso de expirado falhou: "
                                              f"{str(_xe)[:80]}")
                                if _exp:
                                    print(f"[hop3] {len(_exp)} item(ns) expirado(s) apagado(s)")

                            # AS PREFERENCIAS VOLTAM COM ELE. Sem isto o "mostrar fato" e a
                            # barra de skills voltavam ao que estavam a cada login, que foi o
                            # que o jogador reparou. Ver db.guardar_preferencia.
                            if personagens:
                                _pref = db.ler_preferencias(personagens[0]["entity_id"])
                                _pid = personagens[0]["entity_id"]
                                try:
                                    # O 06 46 DE LOGIN, e o vai-e-vem que custou a acertar.
                                    #
                                    # O oficial NAO manda este pacote por iniciativa propria:
                                    # no fio dele os dois 06 46 do servidor sao ecos de
                                    # cliques. Tirei-o por isso, e o fato passou a aparecer.
                                    # Mas o jogador reparou logo a seguir: "forma desativada
                                    # de novo, nao ta ficando salvo". Ou seja, o client entra
                                    # com a Forma DESLIGADA por omissao e nao guarda nada.
                                    #
                                    # E isso fixa a polaridade do flag, que eu tinha ao
                                    # contrario: se o omissao e desligado, o clique que manda
                                    # 01 e o que LIGA. Portanto 01 = mostrar, 00 = esconder,
                                    # e o valor guardado vai tal e qual.
                                    #
                                    # Fica um DESVIO conhecido do oficial: ele ha-de repor
                                    # isto por outro caminho que ainda nao achei (o 09 02 da
                                    # entrada tem 4188 bytes e nao esta lido). Ate la, mandar
                                    # o eco e o que da ao jogador o comportamento certo.
                                    # MANDA SEMPRE, e nao so quando esta escondido. O
                                    # client entra com a Forma no estado dele, nao no nosso,
                                    # e sem esta linha a tela de personagem dizia uma coisa
                                    # (o [25] do 02 01) e o jogo dizia outra. O jogador
                                    # apanhou-o: "eu ativo a forma dentro do jogo, mas nao
                                    # ta salvando".
                                    _p46 = bytearray(12)
                                    _p46[0:2] = bytes([6, 0x46])
                                    struct.pack_into(">I", _p46, 6, _pid)
                                    _p46[10] = 1 if _pref.get("mostrar_fato") else 0
                                    with send_lock:
                                        conn.sendall(encode_frame(bytes(_p46)))
                                    _barra = _pref.get("barra_skills")
                                    if _barra:
                                        _p52 = bytearray(10) + bytearray(_barra)
                                        _p52[0:2] = bytes([6, 0x52])
                                        struct.pack_into(">I", _p52, 6, _pid)
                                        with send_lock:
                                            conn.sendall(encode_frame(bytes(_p52)))
                                    print(f"[hop3] preferencias repostas: fato="
                                          f"{_pref.get('mostrar_fato')} barra="
                                          f"{len(_barra) if _barra else 0} bytes")
                                except Exception as _xp:            # noqa: BLE001
                                    print(f"[hop3] repor preferencias falhou: {str(_xp)[:90]}")
                        print(f"[hop3] client mandou 04 00 -- respondido com o CMoveAppear do PROPRIO "
                              f"personagem ({len(self_appear)} bytes) pra {addr}")
                    except OSError as e:
                        print(f"[hop3] nao consegui ler o self_appear: {e}")
            except FrameError as e:
                print(f"[hop3] conexao fechada pelo client (ou erro de framing): {e}")
                break
            except socket.timeout:
                idle_rounds += 1
                if idle_rounds >= MAX_IDLE_ROUNDS:
                    print(f"[hop3] {MAX_IDLE_ROUNDS * 3}s sem nada do client -- desistindo")
                    break
                if personagens and _time.time() - ultimo_exp >= SEGUNDOS_VER_EXPIRADOS:
                    ultimo_exp = _time.time()
                    limpar_expirados(conn, send_lock,
                                     personagens[0]["entity_id"], _seq_apanhar)
                keepalive_seq += 1
                ka = build_keepalive_frame(keepalive_seq)
                try:
                    with send_lock:
                        conn.sendall(ka)
                    print(f"[hop3] keepalive (seq={keepalive_seq}) mandado pra {addr} "
                          f"(idle_round {idle_rounds}/{MAX_IDLE_ROUNDS})")
                except OSError as e:
                    print(f"[hop3] falhou mandando keepalive: {e}")
                    break
    except FrameError as e:
        print(f"[hop3] erro de framing com {addr}: {e}")
    except Exception as e:
        print(f"[hop3] erro com {addr}: {e}")
    finally:
        loop_stop.set()
        # MULTIPLAYER: tirar do registro e avisar os outros. Sem o 04 03 o personagem fica
        # parado na tela dos que continuaram, como um fantasma.
        if personagens:
            saiu = mundo.sair(personagens[0]["entity_id"])
            if saiu is not None:
                # onde ele parou, pra renascer aqui no proximo login
                if saiu.x or saiu.y or saiu.z:
                    db.salvar_posicao(saiu.entity_id, saiu.x, saiu.y, saiu.z)
                # SEQUENCIA CRESCENTE, nao 1 fixo. Era o mesmo erro do [41] da caminhada:
                # um numero inventado num campo real. Depois de milhares de pacotes com
                # sequencia alta, um 04 03 chegando com seq=1 e candidato a ser descartado
                # -- e o personagem que saiu ficava parado na tela dos outros, de pe, como
                # fantasma. Nos 2388 desconectes da captura oficial a sequencia sempre
                # acompanha o contador do servidor.
                try:
                    proxima = ataque_seq + 1
                except NameError:          # conexao morreu antes do laco principal
                    proxima = 1
                quantos = mundo.transmitir(saiu.entity_id,
                                           build_saiu(proxima, saiu.entity_id))
                restantes = mundo.nomes()
                print(f"[hop3] {saiu.nome} saiu do mundo (04 03 seq={proxima} "
                      f"avisado a {quantos})"
                      + (f" -- ainda no mundo: {', '.join(restantes)}" if restantes
                         else " -- mundo vazio"))
        # OS BUFFS PARAM DE CONTAR AQUI. Ver entity_sim.pausar_buffs: grava-se o que falta
        # a cada um, para o relogio nao correr com o jogador desligado.
        try:
            if personagens:
                entity_sim.pausar_buffs(personagens[0]["entity_id"])
                entity_sim.pausar_buffs_de_item(personagens[0]["entity_id"])
        except Exception as e:                                     # noqa: BLE001
            print(f"[hop3] pausar_buffs falhou: {str(e)[:120]}")
        # ULTIMA GRAVACAO. A folga de 3 s entre saves podia engolir o ultimo golpe recebido
        # se a ligacao caisse logo a seguir; aqui vai sem folga.
        try:
            if heroi is not None and heroi.id:
                entity_sim.gravar_estado_com_folga(heroi, urgente=True)
        except Exception as e:                                     # noqa: BLE001
            print(f"[hop3] nao consegui gravar o estado na saida: {type(e).__name__}: {e}")
        conn.close()
        print(f"[hop3] conexao {addr} fechada")


# Ultimo uso de cada (jogador, skill), para a recuperacao. Ver o bloco da skill no 05 01.
_RECUPERACAO = {}

_ATRASO_POS = []   # medicao temporaria, ver "[atraso-pos]"

SINAL_RECARGA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "recarregar.flag")


def vigiar_recarga():
    """Recarrega o codigo do mundo quando aparece o arquivo-sinal, sem derrubar ninguem.

    Uso:  type nul > server\recarregar.flag

    Por que existe: reiniciar o processo fecha o socket e o client fecha o jogo. Cada ajuste
    no movimento dos monstros custava fechar o jogo, rearmar o hook -- que so grava RECV se
    for injetado antes do jogo abrir -- e entrar de novo.

    O que E recarregado: `entity_sim` (o laco do mundo, o movimento, o combate) e as tabelas
    de `Data/`. O que NAO e: `mundo`, que guarda quem esta conectado, e este proprio modulo,
    que guarda as conexoes. E essa divisao que deixa o jogador de pe.

    Recarregar troca o conteudo do modulo mas mantem o MESMO objeto, entao os `entity_sim.X`
    espalhados pelo servidor passam a ver o codigo novo sozinhos -- ninguem aqui usa
    `from entity_sim import ...`, que guardaria a versao velha.
    """
    import importlib
    while True:
        try:
            if os.path.exists(SINAL_RECARGA):
                os.remove(SINAL_RECARGA)
                print("[recarga] pedido recebido")
                t = entity_sim.parar_mundo()
                if t is not None and t.is_alive():
                    # ESPERA a thread velha sair antes de trocar o modulo. Sem isto ela
                    # leria o contador de geracao ja zerado pela recarga e continuaria
                    # rodando com o codigo antigo, em paralelo com a nova.
                    t.join(timeout=5.0)
                    if t.is_alive():
                        print("[recarga] a thread do mundo nao saiu em 5 s -- abortando")
                        continue
                hp = dict(entity_sim.HP_EXTERNO)
                importlib.reload(data_files)
                data_files.carregar()
                # `db` TAMBEM. Ficou de fora ate 27/08 e custou uma sessao inteira: adicionei
                # `atq_fisico_base` e `protecao_base` ao db.py, recarreguei, e o processo
                # continuou com a versao antiga do modulo em memoria. O combate morria em
                # "module 'db' has no attribute 'atq_fisico_base'" no primeiro golpe, e para o
                # jogador isso aparecia como o jogo a desligar-se sempre que ele atacava.
                #
                # Recarregar `db` e seguro pelo mesmo motivo que recarregar `entity_sim`: ele
                # nao guarda estado do jogador, so a ligacao ao banco (que se reabre sozinha
                # na primeira consulta) e caches de tabelas, que e bom perder. Quem NAO pode
                # ser recarregado continua a ser `mundo`, que guarda quem esta ligado.
                importlib.reload(db)
                importlib.reload(entity_sim)
                entity_sim.HP_EXTERNO.update(hp)   # o dano ja levado nao se perde
                # O GANCHO DO CHAO tem de ser religado. O `entity_sim` novo traz um
                # ENTREGAR_ITEM = [None] em folha, e sem isto o saque parava de funcionar
                # depois da primeira recarga -- sem erro nenhum, porque o rodar_sacos trata
                # o gancho vazio como "deixa o saco no chao". E a mesma armadilha do espiao
                # do fio: estado que vive num modulo recarregavel e apontado de fora.
                entity_sim.ENTREGAR_ITEM[0] = entregar_no_inventario
                entity_sim.ENVIAR_FICHA[0] = _reenviar_ficha
                entity_sim.iniciar_mundo(print)
                print(f"[recarga] entity_sim e Data recarregados; "
                      f"{len(mundo.nomes())} jogador(es) seguem conectados")
        except Exception as e:                                    # noqa: BLE001
            print(f"[recarga] falhou: {type(e).__name__}: {e}")
        _time.sleep(1.0)


class ConexaoSequenciada:
    """Envolve o socket e reescreve o NUMERO DE SEQUENCIA de tudo o que sai.

    PORQUE EXISTE. O servidor tinha SEIS contadores de sequencia independentes, com pontos
    de partida cravados no codigo -- 90000 no `_seq_apanhar`, 20000 no `ataque_seq`, 800000
    nas ressurreicoes, 10 no keepalive, o do laco do mundo, e ainda o do proprio client nos
    ecos. Cada um andava por sua conta, e no fio isso via-se assim:

        oficial   2680 pacotes, sequencia de 1 a 2670, DOIS recuos (os dois de keepalive)
        nosso      523 pacotes, sequencia de 1 a 161335, NOVENTA E DOIS recuos

    Um `08 02` saia com 90073 e o seguinte com 22; um `05 09` com 161335 depois de um 93. O
    client recebia os pacotes -- estao todos gravados do lado dele -- e nao fazia nada com
    eles. Foi o que manteve a velocidade de movimento presa na base o dia inteiro: a ficha
    dizia 165,88, o buff chegava com o formato certo, e o personagem andava a 63.

    O OFICIAL REESCREVE ATE OS ECOS, e isso esta medido: o client pediu `06 00` com
    sequencia 0x3d e a resposta veio com 0x7a, a dele e nao a do pedido. Portanto reescrever
    tudo nao quebra o emparelhamento pedido/resposta -- e o que o jogo faz.

    O KEEPALIVE ENTRA NO MESMO CONTADOR, e isto quase ficou errado. Os dois recuos do
    oficial sao `0b 00` a voltar a 1, e eu li isso como "o keepalive tem contador proprio".
    Nao tem: sao DUAS SESSOES no mesmo ficheiro, cada uma a comecar do 1. Olhando o inicio
    de uma sessao ve-se que e uma serie so:

        0b 00 seq 1    02 00 seq 2    02 06 seq 3    0b 00 seq 4    0b 00 seq 5

    Envolve-se o socket em vez de corrigir os 48 sitios que chamam `sendall`: um ponto so,
    e nenhum caminho de envio pode escapar por esquecimento.
    """

    def __init__(self, sock):
        self._sock = sock
        self._seq = 0
        self._lock = threading.Lock()

    def __getattr__(self, nome):
        return getattr(self._sock, nome)

    def _proxima(self):
        with self._lock:
            self._seq += 1
            return self._seq

    def _renumerar(self, dados: bytes) -> bytes:
        """Reescreve [2:6] de cada frame. Devolve o original se nao souber separar."""
        saida = bytearray()
        i, n = 0, len(dados)
        while i + 4 <= n:
            tam = int.from_bytes(dados[i:i + 4], "big")
            if tam < 6 or i + 4 + tam > n:
                return bytes(dados)          # nao e um fluxo de frames nosso
            quadro = bytearray(dados[i:i + 4 + tam])
            quadro[6:10] = self._proxima().to_bytes(4, "big")
            saida += quadro
            i += 4 + tam
        if i != n:
            return bytes(dados)              # sobrou lixo: melhor nao mexer
        return bytes(saida)

    def sendall(self, dados, *a, **kw):
        return self._sock.sendall(self._renumerar(bytes(dados)), *a, **kw)

    def send(self, dados, *a, **kw):
        return self._sock.sendall(self._renumerar(bytes(dados)), *a, **kw)


def main():
    port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
    threading.Thread(target=vigiar_recarga, daemon=True).start()
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, port))
    srv.listen(5)
    print(f"world_server (hop3) escutando em {HOST}:{port}")
    while True:
        conn, addr = srv.accept()
        # TCP_NODELAY -- DESLIGA O ALGORITMO DE NAGLE.
        #
        # Sem isto o sistema JUNTA as nossas escritas pequenas: segura uma ate a anterior ser
        # confirmada, e como o outro lado usa delayed ACK isso soma ate 200 ms. Os pacotes
        # deixam de sair de 0,6 em 0,6 s e passam a sair em molhos, com o intervalo a variar
        # ao sabor da rede. O client desenha o que recebe quando recebe -- e o monstro treme.
        #
        # COMO SE CHEGOU AQUI, porque a historia importa. Passaram-se dois dias a medir o
        # movimento e a corrigir seis defeitos reais (o relogio de 7 s, o raio de visao, o Z
        # fixo, os teletransportes da trilha, o alcance de ataque, o campo do rumo). Todas as
        # metricas passaram a bater certo com o oficial e o jogador continuou a ver o bicho a
        # chacoalhar.
        #
        # O que separou as hipoteses foi por UMA entidade a repetir, byte a byte, uma hora de
        # gravacao do servidor oficial (ver tools/extrair_replay.py). Ela chacoalhou tambem.
        # Com os bytes do proprio jogo original a tremer no mesmo client, o defeito nao podia
        # estar no conteudo -- so na entrega.
        #
        # Nenhuma medicao do MOVIMENTO podia apanhar isto: o espiao grava o que o servidor
        # MANDA, e o que estava errado era quando o sistema operativo o deixava sair.
        try:
            conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
        except OSError as e:                                      # noqa: BLE001
            print(f"[hop3] TCP_NODELAY recusado em {addr}: {e}")
        # UM CONTADOR SO, e a partir daqui ninguem lhe escapa -- ver ConexaoSequenciada.
        conn = ConexaoSequenciada(conn)
        t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True)
        t.start()


if __name__ == "__main__":
    main()
