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:
- Platform Layer
- 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.
- Exportar funções
- 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.