Compilation
GameLisp code is quite fast to load. The Castle on Fire's codebase is currently around
800 kilobytes of idiomatic GameLisp code (15 KLoC plus comments), which is completely loaded in 550
milliseconds. For most games, it should be fine to simply call glsp::load
at startup.
However, if you're keen to improve startup performance, GameLisp supports pre-compilation of
its source code into a binary format, similar to Lua's binary chunks or Python's py_compile
module. Because this skips macro-expansion, it's much faster than glsp::load
: The Castle
on Fire's source, when pre-compiled, loads in less than 100 milliseconds.
Compiling your source code can also help to obfuscate it. When you use compiled code, there's no need to distribute the original source files alongside your executable. Reverse-engineering the GameLisp binary format into readable GameLisp code would be a challenge, to say the least.
Note that compiled GameLisp code does not run faster than uncompiled code. The final result is the same - the only difference is how the code is loaded into memory.
Because compilation relies on the serde
and
bincode
crates, it's hidden behind the
feature flag "compiler"
, which is disabled by default.
The compile!
macro
The easiest way to precompile GameLisp code is using the compile!
macro.
This is a procedural macro, so it runs while rustc
is executing. It starts up a generic, empty
GameLisp runtime, uses it to compile all of the GameLisp source files which you specify, and
embeds the result directly into the Rust executable (like include_bytes!
), returning it as a
&'static [u8]
.
That byte slice can then be passed to glsp::load_compiled
to run it.
The main downside of using compile!
is that, because your scripts are no longer being loaded
from the filesystem, the only way to change them is to run rustc
again, which is slow and
inconvenient. Therefore, you should generally only use compile!
when producing your final
binary for distribution:
The glsp::load_and_compile
function
compile!
is very convenient, but it always compiles your source files using an empty,
generic GameLisp runtime. This runtime will have access to the GameLisp standard library and any
macros you define using bind-macro!
or defmacro
,
but it won't run any of your Rust initialization code, so it won't have access to any rglobals,
any Rust functions, any assignments you've made to global variables from within Rust code, and
so on.
This will only matter if you access one of your rglobals, or call one of your Rust functions, in either of the following circumstances:
- When evaluating a toplevel form (see Evaluation)
- When running a macro expander
In the unlikely event that this is the case, your Rust program can perform its usual setup,
then compile GameLisp code manually by calling glsp::load_and_compile
. This
will return a GResult<(Val, Vec<u8>)>
, where the Val
is the result of loading and running
the file, and the Vec<u8>
is the result of compiling it.
It's straightforward to serialize a Vec<u8>
to a file. The next time your program runs, you
can read that Vec<u8>
back in, and pass it to glsp::load_compiled
as a byte slice.
The whole process is quite similar to OpenGL shader compilation.
The GameLisp binary format has absolutely no stability guarantees. If you recompile your executable, then you must also recompile any GameLisp binaries which that executable has produced in the past. Consider writing a build script which deletes any saved binaries.
Corner Cases
GameLisp code is different from Lua or Python code, because it has a macro-expansion pass. It's also different from Scheme and Rust, because those macros are defined dynamically - the set of bound macros can change between one toplevel form and the next.
Macro-expansion is expensive, so the only way to efficiently compile GameLisp code is to expand
it before compiling it. This means that when you call glsp::load_compiled
, any macros
in the current GameLisp runtime will be ignored. All that matters is which macros were present in
the runtime which compiled the code.
Unfortunately, because of the dynamic binding mentioned above, the only way to macro-expand
GameLisp code is to run it. This is why the function is glsp::load_and_compile
rather than
just glsp::compile
; one way of thinking about it is that we're loading the file (and all of the
files which it loads in turn), running it, and "recording" the execution in a format which can
be "played back" in the future.
99% of the time, you won't have to think about this. It's only relevant if the GameLisp environment
which calls glsp::load_and_compile
somehow differs from the GameLisp environment which calls
glsp::load_compiled
- because then the expected "playback" will differ from the actual
"recording", and panics or logic bugs may occur.
; because you're loading different files on different run-throughs, an
; error will occur if you compile this form on a linux machine and then
; run it on a non-linux machine.
(cond
(on-linux?
(load "linux.glsp"))
(else
(load "non-linux.glsp")))
; this macro expands to a constant value representing the screen width
; *at the time of macro expansion*. if this happens to be different between
; your own machine and the user's machine, the value will be incorrect.
(defmacro screen-w ()
(my-window-library:screen-width))
; because you're calling an rfn, this form will trigger an error if you pass
; it to the compile![] macro, rather than glsp::load_and_compile.
(my-sound-library:init)
; this macro stores data in a global variable during expansion, but global
; variables are not serialized by compile![] or glsp::load_and_compile, so
; the global will be empty when the expanded code is actually executed.
(defmacro logged (form)
(push! :log form)
form)
The simplest way to protect yourself against this is to use compile!
rather than
glsp::load_and_compile
, and perform all of your loading immediately after calling
Runtime::new
, before binding libraries or doing anything else which might modify the
Runtime
. This means that you won't be able to access libraries or Rust functions from the
toplevel or from macro expanders - but you will still be able to access them from within normal
GameLisp functions.
; this is fine, because it's a fn rather than a macro.
(defn screen-w ()
(my-window-library:screen-width))
; this is fine, as long as (init-sound) isn't called from the toplevel or
; from a macro expander.
(defn init-sound ()
(my-sound-library:init))
; you can take an expansion-time variable and make it available at run-time
; by converting it into a literal.
(do
(let-macro global->literal (name) `(quote ~(global name)))
(= :log (deep-clone (global->literal :log))))