Evite save corrompido com sistema de save no godot 4

Aprenda a construir um sistema de save no godot 4 com escrita atômica e criptografia contra corrupção de arquivos e trapaças no seu jogo.

Evite save corrompido com sistema de save no godot 4
Fonte (Acervo pessoal/maiastudios.com.br)

Salvar o progresso do jogador parece uma tarefa trivial até que o primeiro relatório de arquivo corrompido chega após o lançamento do jogo. Durante o desenvolvimento com a engine, muitos criadores utilizam chamadas simples de abertura e gravação de arquivos sem considerar interrupções repentinas de energia, quedas do sistema operacional ou fechamento forçado do processo. Implementar um sistema de save no godot 4 que seja à prova de falhas exige mais do que apenas despejar dicionários na pasta de usuário: demanda arquitetura atômica de escrita, validação de integridade e proteção contra adulteração externa.

A persistência de dados em jogos comerciais precisa lidar com cenários hostis. Se o computador desligar exatamente no milissegundo em que o ponteiro do disco sobrescreve o arquivo de salvamento, o resultado será um arquivo truncado de zero bytes e um jogador furioso que perdeu dezenas de horas de jogo. Neste artigo, você entenderá como superar essas limitações utilizando os recursos nativos do Godot 4.7.2, estruturando um pipeline resiliente e profissional para gravação em disco.

Por que a gravação direta de arquivos corrompe saves em produção?

Esquema técnico mostrando o fluxo de escrita atômica de um arquivo de save temporário sendo validado e substituindo o arquivo final sem corrupção.
Fonte (Acervo pessoal/maiastudios.com.br)

A abordagem ingênua para salvar jogos consiste em abrir um arquivo no modo de escrita, converter os nós ou variáveis para texto formatado e fechar o manipulador de arquivo imediatamente em seguida. O problema central dessa estratégia reside na forma como os sistemas operacionais gerenciam a memória secundária. Quando você chama métodos de gravação de arquivos, o sistema operacional não grava instantaneamente os dados nos setores físicos do SSD ou HD. Em vez disso, ele armazena o conteúdo em um buffer na RAM para otimizar as operações de entrada e saída.

Se o jogo for interrompido abruptamente por um travamento da aplicação, queda de energia ou fechamento pelo gerenciador de tarefas enquanto esse buffer ainda está sendo transferido, o arquivo existente em disco é parcialmente apagado antes que os novos dados sejam totalmente consolidados. Esse processo deixa o arquivo em um estado inconsistente, corrompendo a estrutura interna de dados.

Outro fator crítico é a sincronização entre threads. Em projetos de médio e grande porte, salvar o jogo de forma síncrona na thread principal causa travamentos perceptíveis de quadros na interface do usuário. No entanto, disparar gravações em threads secundárias sem controle rigoroso de concorrência pode levar a condições de corrida, onde duas operações tentam acessar e modificar o mesmo arquivo simultaneamente.

Por fim, há o desafio da evolução da estrutura do jogo. Conforme atualizações e novos patches são lançados, a estrutura do estado do jogador muda. Se o seu código tentar ler um save antigo sem tratar campos ausentes ou obsoletos, o parser lançará exceções no runtime e impedirá o carregamento. Para evitar todos esses cenários catastróficos, é necessário desenhar uma arquitetura focada na escrita atômica.

Como estruturar um sistema de save no godot 4 de forma segura?

Existem duas abordagens principais no Godot para armazenar dados de jogo: salvar arquivos de recursos customizados com ResourceSaver ou serializar estados em dicionários convertidos para formato JSON. Embora o uso de recursos nativos herdados da classe Resource seja extremamente prático no editor, ele traz sérios riscos de segurança e compatibilidade quando distribuído para o público final em projetos exportados.

Recursos do tipo .tres ou .res podem conter scripts executáveis embutidos. Se o seu jogo carregar arquivos de recursos modificados por terceiros através de ResourceLoader.load(), o motor tentará instanciar esses dados, abrindo espaço para injeção de código malicioso na máquina do jogador. Por esse motivo, a comunidade e a documentação técnica recomendam reservar Resource para dados estáticos de design e utilizar JSON aliado a dicionários fortemente tipados para os arquivos de salvamento do usuário.

A tabela a seguir compara os critérios fundamentais entre as duas abordagens para apoiar sua decisão técnica:

Critério de Avaliação Salvar com Resource (.tres) Salvar com JSON (.json)
Segurança contra código malicioso Baixa (pode carregar GDScript arbitrário) Alta (dados puros, sem execução)
Facilidade de depuração manual Média (sintaxe nativa do Godot) Alta (texto legível e editável)
Migração de versões de dados Complexa e propensa a quebras Simples (manipulação dinâmica de chaves)
Desempenho de leitura/escrita Muito alto (binário nativo) Alto (parser C++ otimizado)
Criptografia nativa direta Requer empacotamento customizado Suporte nativo com FileAccess

Para construir um fluxo seguro, definiremos uma classe de serviço isolada no GDScript que gerenciará todas as operações de I/O na pasta segura user://. Essa classe converterá o estado do jogo em uma estrutura hierárquica de dados primitivos, validará o conteúdo antes da gravação e aplicará a técnica de escrita temporária.

Como implementar gravação atômica com arquivos temporários no GDScript?

A escrita atômica garante que uma operação de salvamento ocorra por completo ou não produza nenhum efeito no arquivo original. Isso é alcançado gravando todos os dados em um arquivo temporário intermediário com a extensão .tmp. Apenas quando a escrita for concluída com sucesso e o buffer for limpo, o arquivo temporário substitui o arquivo de save oficial por meio de uma operação de renomeação de nível de sistema operacional.

Substituir o arquivo existente via renomeação é uma operação atômica em sistemas de arquivos modernos. Se a energia cair durante a escrita no arquivo .tmp, o arquivo de save original permanecerá perfeitamente intacto e funcional no disco. A seguir está a implementação completa e funcional dessa técnica para o Godot 4.7.2:

class_name SaveManager
extends Node

const SAVE_PATH: String = "user://savegame.json"
const TEMP_PATH: String = "user://savegame.tmp"

static func save_game_data(data: Dictionary) -> Error:
    var json_string: String = JSON.stringify(data, "\t")
    var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)

    if file == null:
        var err := FileAccess.get_open_error()
        printerr("Falha ao criar arquivo temporario de save: ", err)
        return err

    file.store_string(json_string)
    file.flush()
    file.close()

    if not FileAccess.file_exists(TEMP_PATH):
        printerr("Arquivo temporario nao foi encontrado no disco.")
        return ERR_FILE_NOT_FOUND

    var dir := DirAccess.open("user://")
    if dir == null:
        printerr("Falha ao acessar o diretorio de usuario.")
        return DirAccess.get_open_error()

    if FileAccess.file_exists(SAVE_PATH):
        var remove_err := dir.remove(SAVE_PATH)
        if remove_err != OK:
            printerr("Falha ao remover arquivo de save antigo: ", remove_err)
            return remove_err

    var rename_err := dir.rename(TEMP_PATH, SAVE_PATH)
    if rename_err != OK:
        printerr("Falha ao renomear arquivo temporario para save final: ", rename_err)
        return rename_err

    print("Jogo salvo com sucesso atomicamente em: ", SAVE_PATH)
    return OK

static func load_game_data() -> Dictionary:
    if not FileAccess.file_exists(SAVE_PATH):
        print("Nenhum arquivo de save encontrado. Retornando dados padrao.")
        return {}

    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    if file == null:
        printerr("Erro ao abrir arquivo de save para leitura: ", FileAccess.get_open_error())
        return {}

    var content := file.get_as_text()
    file.close()

    var json := JSON.new()
    var parse_result := json.parse(content)

    if parse_result != OK:
        printerr("Erro ao parsear JSON na linha ", json.get_error_line(), ": ", json.get_error_message())
        return {}

    var data: Variant = json.get_data()
    if typeof(data) != TYPE_DICTIONARY:
        printerr("Formato de save invalido. Esperado Dicionario.")
        return {}

    return data as Dictionary

No trecho de código acima, chamamos o método flush() logo após store_string(). Essa instrução obriga o sistema operacional a esvaziar o buffer de memória e gravar os bytes no disco imediatamente, garantindo que o arquivo .tmp esteja completo antes que a troca de nome ocorra via DirAccess.

Como criptografar os dados do jogador com FileAccess no Godot 4.7.2?

Ilustração técnica mostrando a passagem de dados através de um nó de criptografia por chave antes do armazenamento no disco.
Fonte (Acervo pessoal/maiastudios.com.br)

Em jogos offline com conquistas, economia interna ou placares competitivos, armazenar dados em texto puro dentro da pasta do usuário convida a modificações maliciosas. Jogadores podem abrir o arquivo .json no bloco de notas e alterar a quantidade de moedas, nível de personagem ou vida máxima sem qualquer esforço.

Para proteger o arquivo contra adulterações diretas, o Godot fornece suporte nativo a criptografia simétrica com a classe FileAccess. Podemos abrir manipuladores de arquivos criptografados utilizando os métodos open_encrypted_with_pass ou open_encrypted. A criptografia utiliza o algoritmo AES-256, exigindo uma chave de acesso para cifrar e decifrar o conteúdo durante as operações de leitura e escrita.

Abaixo está a implementação da camada de segurança adicionada ao nosso gerenciador de persistência:

class_name EncryptedSaveManager
extends Node

const ENCRYPTED_SAVE_PATH: String = "user://savegame.dat"
const ENCRYPTED_TEMP_PATH: String = "user://savegame.tmp"
const SECRET_KEY: String = "SuaChaveSecretaUnicaAqui_2026_GDScript"

static func save_encrypted_data(data: Dictionary) -> Error:
    var json_string: String = JSON.stringify(data)
    var file := FileAccess.open_encrypted_with_pass(ENCRYPTED_TEMP_PATH, FileAccess.WRITE, SECRET_KEY)

    if file == null:
        var err := FileAccess.get_open_error()
        printerr("Erro ao abrir arquivo temporario criptografado: ", err)
        return err

    file.store_string(json_string)
    file.flush()
    file.close()

    var dir := DirAccess.open("user://")
    if dir == null:
        return DirAccess.get_open_error()

    if FileAccess.file_exists(ENCRYPTED_SAVE_PATH):
        dir.remove(ENCRYPTED_SAVE_PATH)

    var rename_err := dir.rename(ENCRYPTED_TEMP_PATH, ENCRYPTED_SAVE_PATH)
    if rename_err != OK:
        printerr("Erro ao renomear save criptografado: ", rename_err)
        return rename_err

    return OK

static func load_encrypted_data() -> Dictionary:
    if not FileAccess.file_exists(ENCRYPTED_SAVE_PATH):
        return {}

    var file := FileAccess.open_encrypted_with_pass(ENCRYPTED_SAVE_PATH, FileAccess.READ, SECRET_KEY)
    if file == null:
        printerr("Falha ao decifrar save. Chave incorreta ou arquivo corrompido: ", FileAccess.get_open_error())
        return {}

    var content := file.get_as_text()
    file.close()

    var json := JSON.new()
    if json.parse(content) != OK:
        printerr("Falha na estrutura interna do JSON decifrado.")
        return {}

    return json.get_data() as Dictionary

Um ponto crucial sobre a criptografia no cliente é que a chave codificada no GDScript estará presente no binário exportado. Desenvolvedores avançados podem inspecionar o código compilado e extrair a chave estática. Para aumentar substancialmente a proteção, você pode gerar chaves dinâmicas combinando uma string secreta com o identificador único de hardware obtido via OS.get_unique_id(). Dessa forma, um arquivo de salvamento copiado da máquina de um jogador não funcionará no computador de outro.

Como gerenciar múltiplos slots e versionamento de esquema de dados?

Conforme o desenvolvimento do seu jogo avança e novas atualizações são publicadas no repositório, a estrutura interna das informações salvas inevitavelmente evolui. Adicionar novas armas, alterar atributos ou reorganizar árvores de habilidades pode quebrar a desserialização de arquivos gravados por versões antigas do cliente. Para solucionar isso, todo sistema profissional precisa incorporar um controle semântico de versão no cabeçalho do arquivo e implementar um padrão de migração sequencial.

Incluir a chave version no dicionário raiz do arquivo permite que o gerenciador identifique exatamente qual versão do jogo gerou aquele registro. Se a versão lida for inferior à versão atual da aplicação, um pipeline de migração é executado em cascata antes que os dados cheguem aos nós da cena principal.

Abaixo temos um exemplo prático de como aplicar migração sequencial de esquema em GDScript:

class_name SaveMigrator
extends Node

const CURRENT_SAVE_VERSION: int = 3

static func migrate_data(raw_data: Dictionary) -> Dictionary:
    var data_version: int = raw_data.get("version", 1)

    while data_version < CURRENT_SAVE_VERSION:
        match data_version:
            1:
                raw_data = _migrate_v1_to_v2(raw_data)
            2:
                raw_data = _migrate_v2_to_v3(raw_data)
            _:
                printerr("Versao desconhecida de esquema de save: ", data_version)
                break
        data_version = raw_data.get("version", data_version)

    return raw_data

static func _migrate_v1_to_v2(old_data: Dictionary) -> Dictionary:
    print("Migrando dados de save da v1 para v2...")
    var new_data := old_data.duplicate(true)
    new_data["inventory_size"] = 20
    new_data["version"] = 2
    return new_data

static func _migrate_v2_to_v3(old_data: Dictionary) -> Dictionary:
    print("Migrando dados de save da v2 para v3...")
    var new_data := old_data.duplicate(true)
    if new_data.has("player_gold"):
        new_data["currencies"] = {"gold": new_data["player_gold"], "gems": 0}
        new_data.erase("player_gold")
    new_data["version"] = 3
    return new_data

Além do versionamento, organizar o salvamento em múltiplos slots exige parametrizar o caminho dos arquivos em disco. Em vez de usar um nome estático como savegame.json, crie caminhos dinâmicos como user://saves/slot_1.json ou user://saves/slot_2.dat. Garanta que o diretório user://saves/ seja criado com o método DirAccess.make_dir_recursive_absolute() antes de tentar salvar em uma subpasta zerada.

Tratar salvamentos automáticos (autosave) exige a mesma disciplina. Separe o slot de autosave dos slots manuais para evitar que um salvamento automático disparado em uma área de perigo sobrescreva a última escolha deliberada feita pelo jogador no menu principal.

Implementar essa separação em módulos independentes mantém seu código limpo, testável e pronto para expansões futuras. Teste exaustivamente cenários de interrupção forçada abrindo o jogo pelo terminal e matando o processo no meio do salvamento; se os dados anteriores continuarem intactos, sua arquitetura passou no teste de produção.

Adoção de padrões de arquitetura defensiva na camada de persistência garante um sistema de save no godot 4 robusto e confiável que preserva a experiência e a confiança dos seus jogadores.

Gostou? Compartilhe

Mais em GameDev