Post

Hot Reload em C++

O Hot-Reload é um recurso para atualizarmos nosso código on_the_fly.

Primeiramente precisaremos compilar nosso jogo em 2 translation unit:

  1. Platform Layer
  2. Game Layer

Sendo que o game layer será carregado dinamicamente através de uma biblioteca onde vamos construir e compilar separadamente (dll).

No arquivo de build, vamos definir dois pontos de compilação.

1
2
cl %COMPILER_FLAGS% ..\code\game.cpp -Fmgame.map /LD /link -incremental:no /EXPORT:game_get_sound_samples_impl /EXPORT:game_update_impl
cl %COMPILER_FLAGS% ..\code\win32_game.cpp -Fmwin32_game.map /link %LINKER_FLAGS%

No primeiro temos o game.cpp com os códigos da camada do jogo.

O ponto importante é que vamos ter um arquivo map específico para essa camada, bem como as definições de /LD para informar que será um arquivo .dll, o -incremental:no para o linker não incrementar nossa biblioteca (visto que iremos trocar dinamicamente - ver código) e as definições de /EXPORT.

Este último (export) é obrigatório. É nele que vamos informar o nome da função que será exportada para o nosso executável .exe. Ou seja, qual função DLL pode ser chamada pelo executável.

Arquivos .exe e .dll tem o mesmo princípio. A diferença é que o dll não tem um entry-point main como o .exe. Mas são considerados a mesma coisa.

A função precisa ser um nome correto em C para que possamos usar o GetProcAddress e acessar o endereço da função carregada a partir do DLL. No código ficará mais claro.

O nome da função precisa ser anotado com extern C. Essa keyword informa ao compilador que não usaremos o naming padrão do c++ (a linguagem C++ modifica o nome das funções) - ver arquivo .map

Como nosso jogo somente usará as procedures de game update e get sound, serão elas que estarão expostas as platform-layers.

Refatorando funções

Como mencionado, precisamos fazer algumas alterações para criar uma DLL do nosso jogo para servir como hot-reload.

  1. Exportar funções
  2. Carregando DLL

Exportar funções

As funções de serviços que o game layer fornece para a platform layer como update() ou get_sound() precisam ser pointeiros para que possamos carregá-los com o GetProcAddress através de um DLL.

O primeiro passo é redefinir nossas assinaturas de funções nos header files para um typedef de função.

1
2
3
// game.h
#define GAME_UPDATE(name) void name(Game_Memory *memory, Game_Back_Buffer *buffer, Game_Input *input)
typedef GAME_UPDATE(game_update);

Neste exemplo, definimos uma macro GAME_UPDATE que será a assinatura da procedure e em seguida definimos um tipo para essa função. (semelhante ao Adicionando Teclado e Gamepad ao Jogo).

Agora temos um ponteiro de função.

E no arquivo de implementação, vamos usar a macro para definir a assinatura e o corpo desse ponteiro de procedure.

1
2
3
4
5
// game.cpp
extern "C" GAME_UPDATE(game_update_impl)
{
  ...
}

Note que ela se chama game_update_impl. Esse nome será usado nas flags /EXPORT na hora de compilar, informando que será uma função exportada da DLL.

Também precisamos definir como extern "C" para que o compilador não modifique o nome da nossa procedure (ver Name Mangling)

As procedures não podem ser mais internal visto que ela precisa ser exportada para outro translation unit.

O próximo passo é definir uma procedure stub para caso a platform-layer não consiga carregar da DLL, ela tenha uma procedure “fake”.

1
2
3
4
// win32_game.cpp
GAME_UPDATE(game_update_stub)
{
}

Fazer o mesmo procedimento para outras procedures que a platform-layer precisar.

Carregando DLL

Vamos criar uma struct para armazenar os ponteiros das procedures exportadas pela DLL:

1
2
3
4
5
6
7
8
// win32_game.h
struct Win32_Game
{
    HMODULE dll; // dll itself
    game_update *update; // pointer to procedure from game.h
    game_get_sound_samples *get_sound_samples; // pointer to procedure from game.h
    bool loaded; // check if dll is loaded
};

Agora criaremos a função para carregar um DLL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
internal Win32_Game
win32_load_game()
{
    Win32_Game win32_game = {};

    // NOTE: Para carregar um novo dll ele não pode estar sendo usado em outro
    // processo. Por isso, usamos uma cópia, onde primeiro compilamos o
    // game.dll e depois, carregamos a cópia. Dessa maneira, podemos
    // fazer a re-compilação sem problemas.
    // (Usaremos um caminho absoluto depois)
    CopyFile("game.dll", "game_temp.dll", FALSE);

    // carrega a dll
    win32_game.dll = LoadLibraryA("game_temp.dll");

    if (win32_game.dll) {
        // usando o GetProcAddress para carregar o ponteiro de onde
        // as procedures estão a partir do DLL. 
        // O nome precisa ser a implementação correta.
        win32_game.update = (game_update *)
            GetProcAddress(win32_game.dll, "game_update_impl");

        win32_game.get_sound_samples = (game_get_sound_samples *)
            GetProcAddress(win32_game.dll, "game_get_sound_samples_impl");

        win32_game.loaded = win32_game.update && win32_game.get_sound_samples;
    }

    // se não conseguirmos carregar, vamos usar nesses 
    // ponteiros as procedures stubs.
    if (!win32_game.loaded) {
        win32_game.update = game_update_stub;
        win32_game.get_sound_samples = game_get_sound_samples_stub;
    }

    return win32_game;
}

Para descarregar a DLL:

1
2
3
4
5
6
7
8
9
10
11
internal void
win32_unload_game(Win32_Game *game)
{
    if (game->dll) {
        FreeLibrary(game->dll);
        game->dll = NULL;
        game->loaded = false;
    }
    game->update = game_update_stub;
    game->get_sound_samples = game_get_sound_samples_stub;
}

Agora dentro do main-loop do jogo, podemos descarregar e carregar a DLL baseado em algum critério, seja por um tempo ou quando um novo arquivo for compilado baseado no seu timestamp de data de criação no disco.

Esta postagem está licenciada sob CC BY 4.0 pelo autor.