-
Notifications
You must be signed in to change notification settings - Fork 18
Hooking Into Existing Code
Note: This feature is very WIP, and is subject to a lot of change in the future.
Sometimes, you might end up wanting to change some behavior of the engine, but find that there are no existing event hooks that allow you to add the changes you want. This page will describe an experimental approach to accomplishing this.
Let's take an example that's part of a legitimate goal of designing the engine: being able to port changes in game behavior from other variants. In omake overhaul, the behavior for experience gain was changed so that if a member of your party gains skill experience, other members in your party will also gain experience for that same skill based on their Learning stat.
The question is: How do you implement this feature on top of OpenNefia?
Looking at the HSP source code of oomSEST, a variant based off of omake overhaul, we can see that a new function has been added to implement this behavior, skillexp2
:
#deffunc skillexp2 int skill_id, int chara, int base_exp, int skill_exp_divisor, int chara_exp_divisor
skillexp skill_id, chara, base_exp, skill_exp_divisor, chara_exp_divisor
orig_stat = stat
if (base_exp > 0) {
if (skill_id >= 100) {
if (chara_exp_divisor != 1000) {
if (chara >= 1 & chara <= 15) {
repeat 15, 1
if (cState(cnt) != 1 & cState(cnt) != 7) {
continue
}
if (chara != cnt) {
learning_lvl = sdata(14, cnt)
extra_exp = base_exp / (20 - sqrt(limit(learning_lvl, 1, 225)))
if (extra_exp > 0) {
skillexp skill_id, cnt, extra_exp, skill_exp_divisor, chara_exp_divisor
}
}
loop
}
}
}
}
return orig_stat
You might not know any HSP, but basically this function loops through the party (characters 1-15) and adds skill experience to each character if someone in the party gains skill experience. Also, skillexp
is the actual function that makes a character gain skill experience. skillexp
is taken from the original source code of Elona, and its equivalent in OpenNefia is Skill.gain_skill_exp()
. Finally, stat
is a global variable modified by skillexp
to indicate how many levels were gain/lost from the experience gain.
So where does skillexp2
get called? Literally everywhere. In oomSEST, almost every single call to skillexp
from the original source code has been replaced by skillexp2
. This is a problem if our idea is to create a new function like enhanced_skill_exp_gain()
that wraps Skill.gain_skill_exp()
and somehow make it get called instead. It seems like we'd have to change every single call to Skill.gain_skill_exp()
to enhanced_skill_exp_gain()
somehow, which is only feasible by editing the source code manually. Of course, that won't work if our goal is to distribute our change as a mod that can be enabled and disabled whenever you please. And also, there isn't an event that we can use that ends up calling Skill.gain_skill_exp()
underneath like skillexp2
does.
So our problem is that we have a piece of code that we can't change easily, because it's part of the engine and gets called in places outside of our control. What if there was a way to change the behavior of this function without needing to touch the original code?
This is exactly the use case for a programming technique called aspect-oriented programming. The idea is that you create a new function that modifies the original function in the way you want, and then add the modified function's behavior to every place that calls the original function transparently. This new function you create to modify the original function is known as "advice", and adding our custom behavior to the original function is called "advising" the function.
For a quick example, here is how we would implement skillexp2
using advice in OpenNefia:
local Chara = require("api.Chara")
local Skill = require("mod.elona_sys.api.Skill")
local Advice = require("api.Advice")
local function gain_skill_exp_in_party(orig_fn, chara, skill, base_exp, exp_divisor_stat, exp_divisor_level)
local results = {orig_fn(chara, skill, base_exp, exp_divisor_stat, exp_divisor_level)}
local skill_data = data["base.skill"]:ensure(skill)
if base_exp > 0 and skill_data.type == "skill" and exp_divisor_level < 1000 and chara:is_ally() then
for _, ally in Chara.iter_allies(chara:current_map()) do
if Chara.is_alive(ally) and ally ~= chara then
local learning_lvl = ally:skill_level("elona.stat_learning")
local extra_exp = base_exp / (20 - math.sqrt(math.clamp(learning_lvl, 1, 225)))
if extra_exp > 0 then
orig_fn(ally, skill, extra_exp, exp_divisor_stat, exp_divisor_level)
end
end
end
end
return table.unpack(results, 1, table.maxn(results))
end
Advice.add("around", Skill.gain_skill_exp, "[oo] Party skill experience gain", gain_skill_exp_in_party)
To understand this code, we first have to understand a few other things, mostly centered around the api.Advice
module. This module is how you add your own code to the functions you want. It takes a location like "around"
that describes how the advice should be called, the function to be advised, an identifier for the advice as a string, and finally the actual advice function.
Note: To increase stability,
Advice.add()
will probably be changed in the future to take a module require path and function name ("mod.elona_sys.api.Skill"
,"gain_skill_exp"
) instead of a function object (Skill.gain_skill_exp
). This is because functions can be renamed from one release of a mod to the next, so the function object would end up beingnil
in some cases.
To understand how an advised function behaves, it's important to understand the location argument passed in to Advice.add()
. For example, using this parameter you can:
- call some code before or after a function is called
- control where the original function gets called after being surrounded by some new code
- modify the arguments that get passed to the original function
- modify the return values that get returned from the original function
See the source code of the Advice
module for more details on the possible values of the location parameter. A few brief descriptions of them:
-
before
/after
: Call your new code before/after the original function gets called. -
around
: Call the original function inline in your new code. -
override
: Completely ignore the original function and only call your new code. -
before_while
/after_while
: Call the original/advice function, and if it returns truthy, also call the other advice/original function. -
before_until
/after_until
: Call the original/advice function, and if it returns falsy, also call the other advice/original function. -
filter_args
: Modify the function arguments before the original function gets called. -
filter_return
: Modify the return values from the original function after it gets called.
Note that you can add more than one piece of advice to a function. That way you can have advice from multiple different mods all affecting the same function, which can either be a good or bad thing. In case things get out of hand, there will also be the ability to enable or disable specific pieces of advice, or remove all advice from a function.
In our example, we pass "around"
as the location. This means our advice function should have a signature like function(orig_fn, arg1, arg2, ...)
. The advice function will get passed the original function (Skill.gain_skill_exp
) as the first argument, orig_fn
. This way you can call the original function wherever you want in the code you add. The original function, Skill.gain_skill_exp
, has the arguments (chara, skill, base_exp, exp_divisor_stat, exp_divisor_level)
, so these will come after orig_fn
in our advised function's argument list when using the "around"
location.
The rest of the code is a direct port of skillexp2
from the original HSP source code. Hopefully the similarities are somewhat clear from comparing the two. The difference is that, since Lua allows for better extensibility than HSP, you would be able to easily distribute this change as a mod, and potentially even enable and disable the change as a config option.
One of the sticking points of many variants of Elona are the changes to game balance, like the rate at which you gain character or skill experience, or the cost of learning or training skills. The problem is that each variant has its own set of changes, each player has their own preferences, and due to the nature of the HSP codebase, all of the code changes for game balance adjustments are mutually exclusive to one another.
The hope is that with OpenNefia, you'll be able to pick and choose the game balance changes you want, write your own if you prefer, and even combine them together:
-- Returns the amount of platinum that training a skill costs.
function Calc.calc_skill_train_cost(skill_id, chara)
return math.floor(chara:base_skill_level(skill_id) / 5 + 2)
end
-- Set the train cost to a flat rate of 10 platinum.
local function flat_rate(skill_id, chara)
return 10
end
-- Or base it on the rate of story completion.
local function story_completion_rate(skill_id, chara)
return math.floor((chara:base_skill_level(skill_id) / 5 + 2) * Sidequest.progress("my_mod.main_quest"))
end
if config["my_mod.skill_train_cost_formula"] == "flat" then
Advice.add("override", Calc.calc_skill_train_cost, "Skill train cost: flat", flat_rate)
elseif config["my_mod.skill_train_cost_formula"] == "story" then
Advice.add("override", Calc.calc_skill_train_cost, "Skill train cost: story completion", story_completion_rate)
else
Advice.remove_all(Calc.calc_skill_train_cost)
end
-- And in addition to the above, cut the rate in half if we're playing on "Overdose" mode.
local function overdose_modifier(cost)
if save.my_mod.is_overdose_mode then
return cost / 2
end
return cost
end
Advice.add("filter_return", Calc.calc_skill_train_cost, "Skill train cost: overdose mode modifier", overdose_modifier, { priority = 150000 })
This setup relies on the formulas you want to modify being in a public module like Calc
. As such, when writing a mod for OpenNefia it's recommended to separate out common formulas like experience gain into their own functions in public modules. By doing this, you automatically add support for modifying the function using the advice system for free.
Note that there are several major caveats to using advice:
-
You can currently only create advice for functions that are in public API modules. If advice proves to be useful and stable enough, then some way of making it work for other kinds of functions, like callbacks on data entries, could be figured out.
-
Advice must follow the same signature as the original function, preserving the possible return values. For example, changing the return type of a function from a number to a string using advice will probably break a lot of things. This also means that any change to the signature of the function from a version update will likely require you to rewrite the advice. Of course, this kind of breaking change can still happen even without using advice at all, like if the arguments to a public function are changed.
Also, you have to be extra careful about functions that can return multiple values:
function MyApi.get_some_numbers(amount)
local result = {}
for n = 1, amount do
result[#result+1] = n
end
return table.unpack(result)
end
local function advice(orig_fn, amount)
local ret1, ret2 = orig_fn(amount) -- Misses the extra return values!
return ret1 + ret2, ret1 * ret2
end
For functions that return a variable number of arguments, you might have to wrap them in a new table, then unpack them later:
local function advice(orig_fn, amount)
local results = {orig_fn(amount)}
-- ...
return table.unpack(results, 1, table.maxn(results))
end
The reason we call table.unpack
with table.maxn
is because table.unpack
will stop if there is a nil
value anywhere in the table, but there can be other values after the nil
that will be missed, because by default Lua treats the first nil
value as the end of the list part of the table. table.maxn
will return the length of the list part, additionally counting nil
values in between.
-
There isn't a way to selectively apply advice to only some calls to the function and leave others unchanged. Advice will always be applied to everywhere the function gets called originally.
-
Also, advice won't solve every problem related to extensive game engine changes. There will still be times where a feature is desirable but there is neither an event nor a public function that can be hooked into to add the feature in an extensible manner. One example is how omake overhaul extensively redesigns the AI and various other systems to behave according to a "faction" system, where characters will act differently towards each other depending on their faction instead of if they're simply an enemy or not. This kind of change is cross-cutting and affects many unrelated areas of the codebase. As extensibility is a concern, it might not even be possible to implement the feature by directly porting the code from the variant, and instead a totally new design based around what features the engine provides may have to be attempted instead. How to design the engine around the addition of these features is one of the largest challenges faced in the development of OpenNefia.