Como eu remendei o Universo

imagem

Existem muitos artigos sobre o desenvolvimento de jogos no Habré, mas entre eles existem muito poucos artigos relacionados a tópicos dos bastidores. Um desses tópicos é a organização da entrega, de fato, do jogo para um grande número de usuários por um longo tempo (um, dois, três). Apesar do fato de que, para alguns, a tarefa pode parecer trivial, decidi compartilhar minha experiência de fazer o trabalho de rake nesse assunto para um projeto específico. Alguém interessado - por favor.

Uma pequena digressão sobre a divulgação de informações. A maioria das empresas sente muita inveja de que a “cozinha interna” não se torne acessível ao público em geral. Por que - eu não sei, mas o que é - é isso. Neste projeto em particular - The Universim - tive sorte e o CEO da Crytivo Inc. (anteriormente Crytivo Games Inc.) Alex Koshelkov mostrou-se absolutamente sensato nesse assunto, por isso tenho a oportunidade de compartilhar minha experiência com outras pessoas.

Um pouco sobre patcher em si


Estou envolvido no desenvolvimento de jogos há muito tempo. Em alguns - como designer e programador de jogos, em alguns - como uma fusão do administrador e programador do sistema (não gosto do termo "devops", pois ele não reflete com precisão a essência das tarefas que realizo em tais projetos).

No final de 2013 (o horror de como o tempo passa), pensei em entregar novas versões (compilações) aos usuários. Obviamente, naquela época havia muitas soluções para essa tarefa, mas o desejo de fabricar um produto e a sede de "construção de bicicletas" venceram. Além disso, eu queria aprender C # mais profundamente - então decidi criar meu próprio patcher. Olhando para o futuro, direi que o projeto foi um sucesso, mais de uma dúzia de empresas o utilizaram e o estão usando em seus projetos, algumas pediram para fazer uma versão levando em consideração exatamente seus desejos.

A solução clássica envolve a criação de pacotes delta (ou diffs) de versão para versão. No entanto, essa abordagem é inconveniente para jogadores testadores e desenvolvedores - em um caso, para obter a versão mais recente do jogo, você precisa passar por toda a cadeia de atualizações. I.e. o jogador precisa reunir sequencialmente uma certa quantidade de dados que ele (a) nunca usará, e o desenvolvedor para armazenar em seu servidor (ou servidores) um monte de dados desatualizados que alguns dos jogadores podem precisar uma vez.

Em outro caso - você precisa baixar o patch da sua versão para a versão mais recente, mas o desenvolvedor precisa manter todo esse zoológico de patches em casa. Algumas implementações de sistemas de patches exigem determinado software e alguma lógica nos servidores - o que também cria uma dor de cabeça adicional para os desenvolvedores. Além disso, muitas vezes os desenvolvedores de jogos não querem fazer nada que não esteja diretamente relacionado ao desenvolvimento do próprio jogo. Vou dizer ainda mais - a maioria não é especialista em configurar servidores para distribuição de conteúdo - essa simplesmente não é sua área de atividade.

Com tudo isso em mente, eu queria criar uma solução o mais simples possível para usuários (que querem jogar mais rápido e não dançar com patches de versões diferentes), bem como para desenvolvedores que precisam escrever um jogo e não descobrir o que e por quê não atualizado pelo próximo usuário.

Sabendo como alguns protocolos de sincronização de dados funcionam - quando os dados são analisados ​​no cliente e apenas as mudanças do servidor são transmitidas -, decidi usar a mesma abordagem.
Além disso, na prática, de versão para versão durante todo o período de desenvolvimento, muitos arquivos de jogos mudam um pouco - a textura existe, o modelo em si, alguns sons.

Como resultado, parecia lógico considerar cada arquivo no diretório do jogo como um conjunto de blocos de dados. Quando a próxima versão é lançada, a compilação do jogo é analisada, um mapa de blocos é construído e os arquivos do jogo são compactados bloco por bloco. O cliente analisa os blocos existentes e apenas a diferença é baixada.

Inicialmente, o patcher foi planejado como um módulo no Unity3D, no entanto, um detalhe desagradável surgiu que me fez reconsiderar isso. O fato é que o Unity3D é um aplicativo (mecanismo) completamente independente do seu código. E enquanto o mecanismo está em execução, vários arquivos estão abertos, o que cria problemas quando você deseja atualizá-los.

Nos sistemas do tipo Unix, a substituição de um arquivo aberto (a menos que esteja especificamente bloqueado) não apresenta problemas, mas no Windows, sem dançar com um pandeiro, esse "fingimento com orelhas" não funciona. Foi por isso que criei o patcher como um aplicativo separado que não carrega nada além de bibliotecas do sistema. De fato, o próprio patcher acabou sendo um utilitário completamente independente do mecanismo Unity3D, o que não impediu, no entanto, adicioná-lo à loja Unity3D.

Algoritmo de Patcher


Portanto, os desenvolvedores lançam novas versões com uma certa frequência. Os jogadores querem que essas versões cheguem. O objetivo do desenvolvedor é fornecer esse processo com custos mínimos e com dor de cabeça mínima para os jogadores.

Do desenvolvedor


Ao preparar um patch, o algoritmo para as ações do patcher fica assim:

○ Crie uma árvore de arquivos do jogo com seus atributos e somas de verificação SHA512
○ Para cada arquivo:
► Divida o conteúdo em blocos.
► Salve a soma de verificação SHA256.
► Compactar um bloco e adicioná-lo ao mapa de blocos de arquivos.
► Salve o endereço do bloco no índice.
○ Salve a árvore de arquivos com suas somas de verificação.
○ Salve o arquivo de dados da versão.

O desenvolvedor precisa fazer o upload dos arquivos recebidos no servidor.

Lado do jogador


No cliente, o patcher faz o seguinte:
○ Copia-se para um arquivo com um nome diferente. Isso atualizará o arquivo executável do patcher, se necessário. Em seguida, o controle é transferido para a cópia e o original é concluído.
○ Baixe o arquivo de versão e compare com o arquivo de versão local.
○ Se a comparação não revelou nenhuma diferença - você pode jogar, temos a versão mais recente. Se houver alguma diferença, vá para o próximo item.
○ Baixe uma árvore de arquivos com suas somas de verificação.
○ Para cada arquivo na árvore do servidor:
► Se houver um arquivo, ele considera sua soma de verificação (SHA512). Caso contrário, ele considera que é, mas está vazio (ou seja, consiste em zeros sólidos) e também considera sua soma de verificação.
► Se a soma do arquivo local não corresponder à soma de verificação do arquivo da versão mais recente:
► Cria um mapa de blocos local e o compara com o mapa de blocos do servidor.
► Para cada bloco local diferente do remoto, ele baixa um bloco compactado do servidor e o substitui localmente.
○ Se não houver erros, atualize o arquivo de versão.

Fiz o tamanho do bloco de dados múltiplo de 1024 bytes. Após um certo número de testes, decidi que era mais fácil operar com blocos de 64 KB. Embora a universalidade no código permaneça:

#region DQPatcher class public class DQPatcher { // some internal constants // 1 minute timeout by default private const int DEFAULT_NETWORK_TIMEOUT = 60000; // maximum number of compressed blocks, which we will download at once private const UInt16 MAX_COMPRESSED_BLOCKS = 1000; // default block size, you can use range from 4k to 64k, //depending on average size of your files in the project tree private const uint DEFAULT_BLOCK_SIZE = 64 * 1024; ... #region public constants and vars section // X * 1024 bytes by default for patch creation public static uint blockSize = DEFAULT_BLOCK_SIZE; ... #endregion .... 

Se você reduzir os blocos, o cliente precisará de menos alterações quando as alterações forem poucas. No entanto, surge outro problema - o tamanho do arquivo de índice aumenta inversamente com a diminuição no tamanho do bloco - ou seja, se operarmos com blocos de 8 KB, o arquivo de índice será 8 vezes maior que com blocos de 64 KB.

Eu escolhi o SHA256 / 512 para arquivos e blocos das seguintes considerações: a velocidade difere um pouco em comparação com o (obsoleto) MD5 / SHA128, mas você ainda precisa ler blocos e arquivos. E a probabilidade de colisões com o SHA256 / 512 é significativamente menor do que com o MD5 / SHA128. Para ser completamente chato - é neste caso, mas é tão pequeno que essa probabilidade pode ser negligenciada.

Além disso, o cliente leva em consideração os seguintes pontos:
► Os blocos de dados podem ser deslocados em diferentes versões, ou seja, localmente, temos o número 10 e, no servidor, o número 12 ou vice-versa. Isso é levado em consideração para não baixar dados extras.
► Os blocos são solicitados não um de cada vez, mas em grupos - o cliente tenta combinar os intervalos dos blocos necessários e solicita-os ao servidor usando o cabeçalho Range. Isso também minimiza a carga do servidor:

 // get compressed remote blocks of data and return it to the caller // Note: we always operating with compressed data, so all offsets are in the _compressed_ data file!! // Throw an exception, if fetching compressed blocks failed public byte[] GetRemoteBlocks(string remoteName, UInt64 startByteRange, UInt64 endByteRange) { if (verboseOutput) Console.Error.WriteLine("Getting partial content for [" + remoteName + "]"); if (verboseOutput) Console.Error.WriteLine("Range is [" + startByteRange + "-" + endByteRange + "]"); int bufferSize = 1024; byte[] remoteData; byte[] buffer = new byte[bufferSize]; HttpWebRequest httpRequest = (HttpWebRequest)WebRequest.Create(remoteName); httpRequest.KeepAlive = true; httpRequest.AddRange((int)startByteRange, (int)endByteRange); httpRequest.Method = WebRequestMethods.Http.Get; httpRequest.ReadWriteTimeout = this.networkTimeout; try { // Get back the HTTP response for web server HttpWebResponse httpResponse = (HttpWebResponse)httpRequest.GetResponse(); if (verboseOutput) Console.Error.WriteLine("Got partial content length: " + httpResponse.ContentLength); remoteData = new byte[httpResponse.ContentLength]; Stream httpResponseStream = httpResponse.GetResponseStream(); if (!((httpResponse.StatusCode == HttpStatusCode.OK) || (httpResponse.StatusCode == HttpStatusCode.PartialContent))) // rise an exception, we expect partial content here { RemoteDataDownloadingException pe = new RemoteDataDownloadingException("While getting remote blocks:\n" + httpResponse.StatusDescription); throw pe; } int bytesRead = 0; int rOffset = 0; while ((bytesRead = httpResponseStream.Read(buffer, 0, bufferSize)) > 0) { // if(verboseOutput) Console.Error.WriteLine("Got ["+bytesRead+"] bytes of remote data block."); Array.Copy(buffer, 0, remoteData, rOffset, bytesRead); rOffset += bytesRead; } if (verboseOutput) Console.Error.WriteLine("Total got: [" + rOffset + "] bytes"); httpResponse.Close(); } catch (Exception ex) { if (verboseOutput) Console.Error.WriteLine(ex.ToString()); PatchException pe = new PatchException("Unable to fetch URI " + remoteName, ex); throw pe; } return remoteData; } 

Obviamente, o cliente pode ser interrompido a qualquer momento e, após o lançamento subsequente, continuará de fato seu trabalho e não fará o download de tudo do zero.

Aqui você pode assistir a um vídeo que ilustra o trabalho do patcher no projeto de exemplo Angry Bots:


Sobre como o patch do universo do jogo foi organizado


Em setembro de 2015, Alex Koshelkov entrou em contato comigo e se ofereceu para ingressar no projeto - eles precisavam de uma solução que proporcionasse atualizações mensais a 30 mil jogadores (com uma cauda). O tamanho inicial do jogo no arquivo morto é de 600 megabytes. Antes de entrar em contato comigo, houve tentativas de criar sua própria versão usando o Electron, mas tudo se deparou com o mesmo problema de arquivos abertos (a propósito, a versão atual do Electron pode fazer isso) e alguns outros. Além disso, nenhum dos desenvolvedores entendeu como tudo isso funcionaria - eles me forneceram vários designs de bicicletas, a parte do servidor estava ausente - eles queriam fazer isso depois que todas as outras tarefas fossem resolvidas.

Além disso, foi necessário resolver o problema de como evitar o vazamento das chaves dos jogadores - o fato é que as chaves eram para a plataforma Steam, embora o próprio jogo no Steam ainda não estivesse disponível ao público. A distribuição do jogo era exigida estritamente pela chave - embora houvesse uma chance de os jogadores compartilharem a chave do jogo com os amigos, isso poderia ser negligenciado, pois se o jogo aparecesse no Steam, a chave poderia ser ativada apenas uma vez.

Na versão normal do patcher, a árvore de dados do patch se parece com:
 ./
 | - linux
 |  | - 1.0.0
 |  `- version.txt
 | - macosx
 |  | - 1.0.0
 |  `- version.txt
 `- janelas
     | - 1.0.0
     `- version.txt


Eu precisava ter certeza de que apenas aqueles com a chave correta tivessem acesso.

Eu vim com a seguinte solução - para cada chave obtemos seu hash (SHA1), então a usamos como um caminho para acessar os dados do patch no servidor. No servidor, transferimos os dados do patch para um nível superior ao docroot e adicionamos links simbólicos ao diretório com os dados do patch no próprio docroot. Os links simbólicos têm os mesmos nomes que os hashes de chave, divididos apenas em vários níveis (para facilitar a operação do sistema de arquivos), ou seja, o hash 0f99e50314d63c30271 ... ... ade71963e7ff será representado como
 ./0f/99/e5/0314d63c30271.....ade71963e7ff -----> / full / path / to / patch-data /

Portanto, não é necessário distribuir as chaves para alguém que oferecerá suporte aos servidores de atualização - basta transferir seus hashes, que são absolutamente inúteis para os próprios jogadores.

Para adicionar novas chaves (ou excluir antigas) - basta adicionar / remover o link simbólico correspondente.

Com essa implementação, a verificação da chave em si obviamente não é executada em nenhum lugar; o recebimento de erros 404 no cliente indica que a chave está incorreta (ou foi desativada).

Deve-se observar que o acesso à chave não é uma proteção completa de DRM - essas são apenas restrições no estágio dos testes alfa e beta (fechados). E a pesquisa é facilmente cortada pelos próprios servidores da Web (pelo menos no Nginx, que eu uso).

No mês de lançamento, apenas 2,5 TB de tráfego foram entregues apenas no primeiro dia, nos dias seguintes, aproximadamente a mesma quantidade é distribuída em média por mês:

imagem

Portanto, se você planeja distribuir muito conteúdo, é melhor calcular com antecedência quanto vai custar. De acordo com observações pessoais - o tráfego mais barato dos hosters europeus, o mais caro (eu diria "ouro") da Amazon e do Google.

Na prática, as economias de tráfego por ano, em média, na The Universim são enormes - compare os números acima. Obviamente, se o usuário não tiver um jogo ou estiver muito desatualizado, um milagre não acontecerá e ele terá que baixar muitos dados do servidor - se for do zero, um pouco mais do que o jogo leva no arquivo morto. No entanto, com atualizações mensais, as coisas ficam realmente boas. Em menos de 6 meses, o espelho americano forneceu pouco mais de 10 TB de tráfego, sem o uso de um remendador esse valor teria crescido significativamente.

É assim que o tráfego anual do projeto se parece:

imagem

Algumas palavras sobre o "rake" mais memorável que tivemos que trabalhar no processo de criação de um patch personalizado para o jogo "The Universim":

● O maior problema estava me esperando dos antivírus. Bem, eles não gostam de aplicativos que baixam algo da Internet, modificam arquivos (incluindo executáveis) e também tentam executar o download. Alguns antivírus não apenas bloquearam o acesso a arquivos locais - eles também bloquearam as chamadas para o servidor de atualização, obtendo diretamente os dados que o cliente baixou. A solução foi usar uma assinatura digital válida para o patcher - isso reduz drasticamente a paranóia dos antivírus e o uso do protocolo HTTPS em vez do HTTP - elimina rapidamente alguns dos erros associados à curiosidade dos antivírus.

Atualização de progresso. Muitos usuários (e clientes) desejam ver o progresso da atualização. É preciso improvisar, pois nem sempre é possível mostrar de maneira confiável o progresso sem a necessidade de um trabalho extra. Sim, e a hora exata do final do processo do patch também não pode ser exibida, pois o próprio patcher não possui dados nos quais os arquivos precisam ser atualizados com antecedência.

● Um grande número de usuários dos EUA tem velocidades de conexão com servidores da Europa não muito altas. A migração do servidor de atualização para os EUA resolveu esse problema. Para usuários de outros continentes, deixamos o servidor na Alemanha. A propósito, o tráfego nos EUA é muito mais caro que o europeu, em alguns casos - várias dezenas de vezes.

● A Apple não está muito confortável com esse método de instalação de aplicativos. Política oficial - os aplicativos devem ser instalados apenas em sua loja. Mas o problema é que os aplicativos nos estágios alfa e beta não são permitidos na loja. E ainda mais, não há nada a falar sobre a venda de aplicativos brutos do acesso antecipado. Portanto, você deve escrever instruções sobre como dançar em papoulas. A opção com AppAnnie (então eles ainda eram independentes) não foi considerada devido ao limite no número de testadores.

● A rede é bastante imprevisível. Para que o aplicativo não desista imediatamente, tive que inserir um contador de erros. As nove exceções capturadas permitem que você informe firmemente ao usuário que ele tem problemas com a rede.

● Os sistemas operacionais de 32 bits têm restrições no tamanho dos arquivos que são exibidos na memória (MMF) para cada segmento de execução e para o processo como um todo. As primeiras versões do patcher usavam o MMF para acelerar o trabalho, mas como os arquivos de recursos do jogo podem ser enormes, tive que abandonar essa abordagem e usar fluxos de arquivos comuns. A propósito, uma perda de desempenho especial não foi observada - provavelmente devido à leitura proativa do sistema operacional.

● Você precisa estar preparado para os usuários reclamarem. Não importa o quão bom seja o seu produto, sempre haverá quem não estiver satisfeito. E quanto mais usuários do seu produto (no caso da Universim houver mais de 50 mil no momento) - mais quantitativamente haverá reclamações a você. Em termos percentuais, este é um número muito pequeno, mas em termos quantitativos ...

Apesar do sucesso do projeto como um todo, ele tem algumas desvantagens:

● Apesar de inicialmente ter retirado toda a lógica principal separadamente, a parte da GUI é diferente na implementação para MAC e Windows. A versão Linux não causou problemas - todos os problemas ocorreram principalmente apenas ao usar uma construção monolítica que não exigia o Mono Runtime Environment - MRE. Mas como você precisa de uma licença adicional para distribuir esses arquivos executáveis, foi decidido abandonar as construções monolíticas e simplesmente exigir o MRE. A versão Linux difere da versão Windows apenas no suporte a atributos de arquivo específicos para sistemas * nix. Para o meu segundo projeto, que será mais do que apenas um patcher, planejo usar uma abordagem modular na forma de um processo do kernel que é executado em segundo plano e permite gerenciar tudo na interface local. E o controle em si pode ser realizado a partir de um aplicativo baseado no Electron e similares (ou simplesmente a partir de um navegador). Com qualquer coisinha. Antes de falar sobre o tamanho da distribuição de tais aplicativos - veja o tamanho dos jogos. As versões demo (!!!) de algumas ocupam 5 ou mais gigabytes no arquivo (!!!).

● As estruturas usadas agora não economizam espaço quando o jogo é lançado para 3 plataformas - de fato, você precisa manter 3 cópias de dados quase idênticos, embora compactados.

● A versão atual do patcher não armazena em cache seu trabalho - sempre que todas as somas de verificação de todos os arquivos são recalculadas. Seria possível reduzir significativamente o tempo se o patcher colocasse em cache os resultados para os arquivos que já estão no cliente. Mas há um dilema - se o arquivo estiver danificado (ou ausente), mas a entrada de cache desse arquivo for salva, o patcher o ignorará, o que causará problemas.

● A versão atual não sabe trabalhar simultaneamente com vários servidores (a menos que você faça round-robin usando DNS) - eu gostaria de mudar para uma tecnologia “semelhante a torrent” para poder usar vários servidores ao mesmo tempo. Não há dúvida de usar clientes como fonte de dados, pois isso levanta muitos problemas legais e é mais fácil recusar desde o início.

● Se você deseja restringir o acesso a atualizações, essa lógica precisará ser implementada independentemente. Na verdade, isso dificilmente pode ser chamado de desvantagem, pois todos podem ter seus próprios desejos em relação às restrições. A restrição de chave mais simples - sem nenhuma parte do servidor - é bastante simples, como mostrei acima.

● Um patcher é criado para apenas um projeto por vez. Se você deseja criar algo semelhante ao Steam, já é necessário um sistema completo de entrega de conteúdo. E este é um projeto de um nível completamente diferente.

Eu pretendo colocar o patcher em domínio público depois que a "segunda geração" for implementada - um sistema de entrega de conteúdo de jogos que incluirá não apenas o patcher desenvolvido, mas também um módulo de telemetria (já que os desenvolvedores precisam saber o que os jogadores estão fazendo), Módulo Cloud Savings e alguns outros módulos.

Se você tem um projeto sem fins lucrativos e precisa de um remendador, escreva-me os detalhes do seu projeto e eu darei uma cópia gratuitamente. Não haverá links aqui, pois esse não é o hub "PR".

Ficarei feliz em responder suas perguntas.

Source: https://habr.com/ru/post/pt440870/


All Articles