It is not hot it is atleast lukewarm
One of the most desirable features of a game development environment is the ability to change code and see the changes live. Vexingly many game developers use a statically typed compiled language making hot code reload more difficult. As someone that went though the work this documents the journey across the potato fields.
Dynamic libraries - I swear they just keep moving on me.
For those not in the know a dynamic library is a compiled blob of code that you can load at runtime. Enabling both the ability to add new code and to replace implementations. They have a symbol table which lets users search for and access procedures and variables once loaded. It is this which will allow us to write hot code reload. The game code can be compiled into a dynamic library and be loaded by a host program. Allowing the programmer to change code then recompile and continue where left off.
hcr1host.nim
import std/dynlib
type Entry = proc() {.cdecl.}
var lib = loadLib("./libhcr.so")
let entry = cast[Entry](lib.symAddr("entry"))
entry()
hcr1lib.nim
proc entry() {.exportc, dynlib.} = echo "Hello"
hcr1lib.nims
--app:lib
when defined(linux):
--o:"libhcr.so"
elif defined(windows):
--o:"libhcr.dll"
else:
--o:"libhcr.dylib"
This is a basic program with a pluggable function.
When hcr1host
runs it loads the library ./libhcr.so
then it calls the entry
procedure declared there.
Those smart enough to always plug in a USB type-A first try can see where this takes us.
The first step to that quest is to have a loop that watches the file.
Here cause I prefer brevity will resort to a simple watcher(A cross platform solution that will make anyone knowledgable cry and even whimper.).
hcr2host.nim
import std/[dynlib, times, os]
type Entry = proc() {.cdecl.}
const libPath =
when defined(linux):
"./libhcr.so"
elif defined(windows):
"./libhcr.dll"
else:
"./libhcr.dylib"
var lastLoad = default Time
while true:
let thisLoad =
try:
getLastModificationTime(libPath)
except CatchableError:
continue
if lastLoad < thisLoad:
lastLoad = thisLoad
var lib =
try:
loadLib(libPath)
except CatchableError as e:
echo "Failed to load dynamic library: ", e.msg
continue
if lib == nil:
echo "Failed to load lib: ", libPath
else:
let entry = cast[Entry](lib.symAddr("entry"))
if entry == nil:
echo "No function named 'entry'"
if lib != nil:
lib.unloadLib()
continue
entry()
lib.unloadLib()
With this one can modify the hcr1lib.nim
and recompile it and without closing hcr2host
will see a live change.
Tinkering with it you may notice a problem that sticks out like a toe in a ripped sock.
Nothing persists on reload.
hcr2lib.nim
var i = 0
proc entry() {.exportc, dynlib.} =
inc i
echo i
Using this as the library every reload prints 1
and it does not persist.
This means one needs to store global state somehow... the best way of doing that is you guessed it dynamic symbols!
Not only does a dynamic library add to a symbol table the host program also does.
To do this one needs to invent a saveInt
procedure which lets the library save and reload state.
Though a cookie jar is only good for storage cause you can get cookies out which means it also needs a getInt
procedure to fetch the value stored.
hcr3host.nim
import std/[dynlib, times, os, tables]
{.passc: "-rdynamic", passL: "-rdynamic".} ## Needed so we can access symbols from children
type Entry = proc() {.cdecl.}
const libPath =
when defined(linux):
"./libhcr.so"
elif defined(windows):
"./libhcr.dll"
else:
"./libhcr.dylib"
var ints: Table[string, int]
proc saveInt(name: string, i: int) {.exportc, dynlib, raises: [].} =
ints[name] = i
proc getInt(name: string, i: var int) : bool {.exportc, dynlib, raises: [].} =
if name in ints:
try:
i = ints[name]
true
except CatchableError:
false
else:
false
var lastLoad = default Time
while true:
let thisLoad =
try:
getLastModificationTime(libPath)
except CatchableError:
continue
if lastLoad < thisLoad:
lastLoad = thisLoad
var lib =
try:
loadLib(libPath)
except CatchableError as e:
echo "Failed to load dynamic library: ", e.msg
continue
if lib == nil:
echo "Failed to load lib: ", libPath
else:
let entry = cast[Entry](lib.symAddr("entry"))
if entry == nil:
echo "No function named 'entry'"
if lib != nil:
lib.unloadLib()
continue
entry()
lib.unloadLib()
Nim specific but exceptions do not raise across dynamic library barriers so it is best to return a bool as we do here.
This allows the reloading code to set a default value if we do not load.
With C -rdynamic
is required to be able to access the host procedures from the dynamic library
hcr3lib.nim
proc saveInt(name: string, val: int) {.importc, dynlib"".}
proc getInt(name: string, val: var int): bool {.importc, dynlib"".}
var i = 0
if not getInt("i", i):
i = 0 # Redundant but let's stay classy
proc entry() {.exportc, dynlib.} =
inc i
echo i
saveInt("i", i)
More Nim specific details dynlib
loads using dlopen
which means we can supply ""
and it will load from this program's symbol table.
In less nerdy POSIX talk it means it will load getInt
and setInt
from the host program.
With all this now one can easily replace their dynamic library and now they can reload integers! This can be expanded to use tagged unions instead to enable storing more complex state(infact in Potato deeply nested structures save just fine!)
Serialization
The heart of storing state across reloads is ensuring the old data can be migrated to the most recent binary with possible new fields added.
In this case it means one will use a tagged union across primitive types.
Meaning we need a single data type that can hold int
, float
, string
, a list, and a structure.
import std/tables
type
HcrKind = enum
Int
Float
String
Array
Struct
HcrObj = object
case kind: HcrKind
of Int:
i: int
of Float:
f: float
of String:
s: string
of Array:
arr: seq[HcrObj]
of Struct:
fields: Table[string, HcrObj]
This data type is sufficient to store every type under the sun (though in the case of Potato Nim's std/json.JsonNode
is just used).
To enable support of reference types all data of a structure type should be stored to a root level HcrObj
where the entry object is at a field named data
.
This allows storing references in the top level using their old pointer value as a field name.
With that the following is how mapping between types works:
- Any integer, enum, char, or bool maps to
Int
- 32bit and 64bit floats map to
Float
string
maps toString
ptr T
,pointer
, andproc
map toInt
ref T
stores as anInt
in place. Though adds to the root object's fields at its old pointer value. On load a table of the old pointer to new must be stored to migrate to new pointers.seq[T]
andarray[Idx, T]
map toArray
set[T]
maps toString
just allocate a string which issizeof(set[T]))
and copy the memory overobject
andtuple
map toStruct
, iterate the fields and store tofields
It is also very important to note that since pointers are assumed to be valid after reload the old library must not be unloaded. This will leak memory but it also ensures the pointers are still pointing to alive data. Doing this will also force all pointer procedures to be reloaded on program reload as migrating pointer procedures is not a fun problem. Pointer procedures to named procedures is relative easy to migrate, but any anonymous procedure is mangled in such a way you cannot be certain two procedures with the same name are the same. For intuitive hot code reload it is best to avoid pointer procedures and use some sort of global memory or vtable instead, that way it can be reinitialised and the procedures will be updated.
To enable saving before reload one should also create a list of serializers to store all global variables to the host program.
# Inside the library
var serializers: seq[proc()]
proc hcrSave() {.exportc, dynlib.} =
for serializer in serializers:
serializer()
var someInt = 0
serializers.add proc() =
saveInt("someInt", someInt)
hcrSave
can then be called before reload on the host to save the state.
Which means when the next library is loaded it will fetch the memory stored in the host and continue as if nothing happened.
Signals
If a loaded program crashes one should not have to relaunch the program.
It likely should continue where it left off rerunning the frame.
To achieve this one can use signal handlers to call a hcrError
procedure from the host program.
This hcrError
will use siglongjmp
to return the program back to before the loop was called and let the program continue again.
The rest of the Tyto
Finally with all that work the following is practically what one will see when they build the system following this writeup.
Though of course expansion for serializers using the HcrObj
should be done.
hcr4host.nim
import std/[dynlib, times, os, tables, tempfiles]
import system/ansi_c
{.passc: "-rdynamic", passL: "-rdynamic".} ## Needed so we can access symbols from children
type Entry = proc() {.cdecl.}
const libPath =
when defined(linux):
"./libhcr.so"
elif defined(windows):
"./libhcr.dll"
else:
"./libhcr.dylib"
var ints: Table[string, int]
proc saveInt(name: string, i: int) {.exportc, dynlib, raises: [].} =
ints[name] = i
proc getInt(name: string, i: var int) : bool {.exportc, dynlib, raises: [].} =
if name in ints:
try:
i = ints[name]
true
except CatchableError:
false
else:
false
const
ErrorJump = 1
QuitJump = 2
type sigjmp_buf {.bycopy, importc: "sigjmp_buf", header: "<setjmp.h>".} = object
proc sigsetjmp(jmpb: C_JmpBuf, savemask: cint): cint {.header: "<setjmp.h>", importc: "sigsetjmp".}
proc siglongjmp(jmpb: C_JmpBuf, retVal: cint) {.header: "<setjmp.h>", importc: "siglongjmp".}
var jmp: C_JmpBuf
proc hcrError() {.exportc, dynlib.} =
siglongjmp(jmp, ErrorJump)
proc hcrQuit() {.exportc, dynlib.} =
siglongjmp(jmp, QuitJump)
var
lastLoad = default Time
crashed = false
lib: LibHandle
entry: Entry
while true:
let thisLoad =
try:
getLastModificationTime(libPath)
except CatchableError:
continue
if lastLoad < thisLoad:
lastLoad = thisLoad
if lib != nil:
cast[proc(){.nimcall.}](lib.symAddr("hcrSave"))()
lib =
try:
let tempLibPath = genTempPath("someLib", ".so") # Move it so we can reload it Operating Systems can be funky
copyFile(libPath, tempLibPath)
loadLib(tempLibPath, false) # Do load symbols to global table
except CatchableError as e:
echo "Failed to load dynamic library: ", e.msg
continue
if lib == nil:
echo "Failed to load lib: ", libPath
continue
entry = cast[Entry](lib.symAddr("entry"))
if entry == nil:
echo "No function named 'entry'"
if lib != nil:
lib.unloadLib()
lib = nil
continue
echo "Loaded new lib"
if lib != nil and entry != nil:
case sigsetjmp(jmp, int32.high.cint)
of 0:
entry()
of ErrorJump:
crashed = true
echo "Crashed"
of QuitJump:
echo "Quit"
break
else:
echo "Incorrect jump"
sleep(16) # Pretend we're doing work like a game
hcr4lib.nim
import system/ansi_c
proc hcrError() {.importc, dynlib"".}
proc hcrQuit() {.importc, dynlib"".}
var serializers: seq[proc()]
proc hcrSave() {.exportc, dynlib.} =
for serializer in serializers:
serializer()
unhandledExceptionHook = proc(e: ref Exception) {.nimcall, gcsafe, raises: [], tags: [].}=
try:
{.cast(tags: []).}:
for i, x in e.getStackTraceEntries:
stdout.write x.fileName, "(", x.line, ") ", x.procName
stdout.write "\n"
stdout.write"Error: "
stdout.writeLine e.msg
stdout.flushFile()
hcrError()
except:
discard
{.push stackTrace:off.}
proc signalHandler(sign: cint) {.noconv.} =
if sign == SIGINT:
hcrQuit()
hcrError()
elif sign == SIGSEGV:
writeStackTrace()
echo "SIGSEGV: Illegal storage access. (Attempt to read from nil?)"
hcrError()
elif sign == SIGABRT:
writeStackTrace()
echo "SIGABRT: Abnormal termination."
hcrError()
elif sign == SIGFPE:
writeStackTrace()
echo "SIGFPE: Arithmetic error."
hcrError()
elif sign == SIGILL:
writeStackTrace()
echo "SIGILL: Illegal operation."
hcrError()
elif (when declared(SIGBUS): sign == SIGBUS else: false):
echo "SIGBUS: Illegal storage access. (Attempt to read from nil?)"
hcrError()
{.pop.}
#c_signal(SIGINT, signalHandler)
c_signal(SIGSEGV, signalHandler)
c_signal(SIGABRT, signalHandler)
c_signal(SIGFPE, signalHandler)
c_signal(SIGILL, signalHandler)
when declared(SIGBUS):
c_signal(SIGBUS, signalHandler)
proc saveInt(name: string, val: int) {.importc, dynlib"".}
proc getInt(name: string, val: var int): bool {.importc, dynlib"".}
var i = 0
if not getInt("i", i):
i = 0 # Redundant but let's stay classy
serializers.add proc() = saveInt("i", i)
proc entry() {.exportc, dynlib.} =
inc i
echo i
if i >= 10000:
hcrQuit() # Leave this place
hcr4lib.nims
--app:lib
--nimMainPrefix:"hcr"
when defined(linux):
--o:"libhcr.so"
elif defined(windows):
--o:"libhcr.dll"
else:
--o:"libhcr.dylib"
Closing
Thanks for reading. More information can be found by reading the source code of Potato. Including things not touched on here like how to use a single module for both the host and library.