I was thinking about writing some blog-like posts to explain how JHCR actually works.
This first one will give a high-level overview. If you like this and want me to write
more please tell me.
The Problem
Modifying WarCraft 3 maps has a long iteration period due to closing WC3,
changing your code and restarting WC3. JHCR (Jass hot code reload) tries to
improve this cycle by replacing the slow actions of closing and starting WC3
with a hopefully faster compiler which pushes your code changes directly into
an already running WC3.
But how could something like that even work? The answer is multi faceted.
So let's start with some very basic mockups.
The most simple Idea
Let's say we want to reload a single function, how would that look like in code?
JASS:
function foo takes nothing returns nothing
if function_was_reloaded("foo") then
// somehow execute the new body
// call BJDebugMsg("New Foo")
else
// old body of foo
call BJDebugMsg("Foo")
endif
endfunction
While this has many holes it's conceptually correct already. But for the sake
of easy implementation we split up the function into two functions, the original
function but with a new name and a new function but with the old name:
JASS:
function JHCR_foo takes nothing returns nothing
call BJDebugMsg("Foo")
endfunction
function foo takes nothing returns nothing
if function_was_reloaded("foo") then
// somehow execute the new body
// call BJDebugMsg("New Foo")
else
call JHCR_foo()
endif
endfunction
This way we don't have to change other functions calling
foo
when we update it.
Everything from a simple call-statement over trigger-actions to such things as
ExecuteFunc
still work just fine.
The harder part is now how to actually execute the new body of
foo
.
To achieve this JHCR provides both a compiler to- and an interpreter for a
custom bytecode language which will be superficially introduced in the next
section.
The Bytecode
The bytecode was carefully designed to strike a balance between size, parsing
speed and execution speed as all of these factors play a big role in our limited
WC3 environment.
For example the original
foo
would be translated to this bytecode:
Code:
fun 3 foo
lit string -2 Foo
bind string 1 -2
call -1 2 BJDebugMsg
ret nothing
But this is of course way too verbose and also not very fast to parse.
So this format is only used for human consumption; to communicate with WC3
JHCR provides another format for this exact bytecode which looks like this:
Code:
| fun | | bind | |ret|
263.....21136-2.......3.....Foo201361........-2.......22-1.......2.....29139
| lit | | call |
I've annotated the different parts to show the opcode they represent.
This is way easier to parse programatically and takes up less space.
Every bytecode uses a fixed number and we encode numbers in a fixed width
with added padding to achieve a good tradeoff between size and parsing speed.
For fast parsing we use the fact that
S2I("123asdf") == S2I(123)
so we can move
in well defined chunks over the input string.
There are plenty more opcodes ofcourse but we'll save them for a future post.
Integrating
As we've seen previously we took special care to preserve the original name
of our function but this was mostly to integrate our system into the maps script.
Now we will talk about integrating the maps script into JHCR.
Functions
Take the aboves bytecode as an example: we call the BJ-function
BJDebugMsg
in
there. How do we actually achieve this?
As JHCR takes both
common.j
and
Blizzard.j
at compile time we assign each
function an unique id (
BJDebugMsg
has id 2 in our example). Now, to be able to
call this function we generate a huge if-then-else block for each id at compile
time to call out to different functions. If we limit ourself to only three functions,
that is
DisplayTimedTextToPlayer
,
BJDebugMsg
and
foo
we can look at the code
JHCR generates below.
JASS:
function JHCR_Auto_call_predefined takes integer JHCR_reg,integer JHCR_i,integer JHCR_ctx returns nothing
if (JHCR_i < 2) then
call DisplayTimedTextToPlayer (JHCR_Table_get_player (JHCR_Context_bindings[JHCR_ctx],1),JHCR_Table_get_real (JHCR_Context_bindings[JHCR_ctx],2),JHCR_Table_get_real (JHCR_Context_bindings[JHCR_ctx],3),JHCR_Table_get_real (JHCR_Context_bindings[JHCR_ctx],4),JHCR_Table_get_string (JHCR_Context_bindings[JHCR_ctx],5))
else
if (JHCR_i < 3) then
call BJDebugMsg (JHCR_Table_get_string (JHCR_Context_bindings[JHCR_ctx],1))
else
call foo ()
endif
endif
endfunction
This is ofcourse quite a mouthfull but it all follows the same pattern.
The first parameter is the register the return-value of the called function is
stored in. but since all our functions return
nothing
it wont be used here.
The next parameter is the id of the function we want to call. As you can see
we do a bunch of comparisons to get to the correct function.
The next parameter is a struct called
context
in which we store a bunch of
information for the interpreter, most importantly here the parameters we want
passed to the function we want to call.
Those parameters are passed via the
bind
opcode which you can see just before
the
call
opcode in our small example above.
Globals
We also need to be able to read and write globals from our interpreter and we
deploy a similar technique as we did for functions.
For each available type in JASS (
handle
,
integer
,
unit
, etc.) we generate
two functions to read and write a specific global variable of that type.
As with functions we assign each global variable a per-type unique id on which
we do a bunch of comparisons to set or read the correct value.
Let's again only look at a very small input script with only two global variables
bj_MAX_PLAYERS
and
bj_forLoopAIndex
where
bj_MAX_PLAYERS
is declared constant.
JHCR will generate the following functions:
JASS:
function JHCR_Auto_get_global_integer takes integer JHCR_i returns integer
if (JHCR_i < 2) then
return bj_MAX_PLAYERS
else
return bj_forLoopAIndex
endif
endfunction
function JHCR_Auto_set_global_integer takes integer JHCR_i,integer JHCR_v returns nothing
if (JHCR_i < 2) then
return
else
set bj_forLoopAIndex = JHCR_v
endif
endfunction
Note the then-case in
JHCR_Auto_set_global_integer
: since
bj_MAX_PLAYERS
is
constant we actually can't set it. But both can of course be read.
A similar approach is used for global arrays.
Update
To actually reload changed functions JHCR read the new script file and checks
for each function if the hash of the function has changed. If that happens
the bytecode like above is created and written to a file to be loaded by the
Preload
-native. For example the changed
foo
function would generate the
following preload-file:
JASS:
function foo takes nothing returns nothing
call BJDebugMsg("New foo")
endfunction
function PreloadFiles takes nothing returns nothing
call SetPlayerTechMaxAllowed (Player (0),1,1)
call BlzSetAbilityTooltip ('Agyv',"263.....21136-2.......7.....New foo201361........-2.......22-1.......2.....29139",0)
call SetPlayerTechMaxAllowed (Player (0),2,0)
endfunction
The generated bytecode is of course very similar to the previously shown since
we only changed one string literal after all.
Now what happens with this next is topic of a future post.
We will end this little post here even though it only scratches the surface of
what JHCR has to do.
I hope you enjoyed this quick overview of JHCR.