bl-sdk / pyunrealsdk

Python bindings for unrealsdk.
GNU Lesser General Public License v3.0
2 stars 4 forks source link

Add helper for hooks bound to a specific object #41

Closed apple1417 closed 1 week ago

apple1417 commented 2 months ago

A few people have requested the ability to add a hook which only fires on a particular object.

This is already relatively simple in Python by doing something like the following:

SOME_OBJ = unrealsdk.find_object("Object", "A.B.C")

def my_hook(obj, UObject, *_: Any) -> None:
  if obj != SOME_OBJ:
    return
  ...

However, this has some problems:

I think this layer is the right place to add support for it, handling it outside of Python has a several advantages, and I don't think we need to add complexity to unrealsdk for a rather niche use case, especially when other C plugins can implement the same handling relatively easily.


After discussing in the discord, the basic pattern we want is something like the following.

@special_hook(...)
def my_hook(*_: Any) -> None:  # standard hook args/ret
  pass

my_hook.add(obj.Func, ...)
my_hook.remove()

special_hook (naming tbd) is some helper defined in a library, which calls through to a unrealsdk.hooks.add_special_hook we provide. The main concept we want to keep is you add the hook using a BoundFunction instance, rather than specifying a function name and object separately.

add_special_hook would then be implemented something like:

[](const BoundFunction& func,
   Type type,
   const std::wstring& identifier,
   const py::object& callback) {
    // get object info

    add_hook(func.func->get_path_name(), type, identifier,
             [callback /*, object info */](Details& hook) {
                 // check hook.obj matches info, if not early exit

                 return handle_py_hook(hook, callback);
             });
}

i.e. it's just a wrapper around add hook, which does some extra checks before calling into Python. It's possible we could add some extra optimizations, like storing a set of allowed objects and comparing all at once.


At a high level this concept seems sound enough, but there are some questions when we get into the details, particularly when it comes to transient objects. To answer these we really need to see actual use cases, speculation just brings out more questions. And we need to answer these first since they may affect the interface we expose.

The basic idea for transient objects would be to have regular hooks when it's created and destroyed, and when it's created, you add a new object specific hook, when it's destroyed you remove it.

An immediate problem with this is you might not be able to get a hook before the object's destroyed - maybe best you can do is just on map load. This means we cannot rely on an object reference to tell which hook to remove - which is a problem if you want to add the same hook to multiple objects.

@special_hook
def on_fire_hook(*_: Any) -> None:
  print("player fired their gun")

on_fire_hook.add(PC.Pawn.Weapon.InstantFire)
if PC.Pawn.OffhandWeapon:
  on_fire_hook.add(PC.Pawn.OffhandWeapon.InstantFire)
...
on_fire_hook.remove(...) # what args?
on_fire_hook.remove_all() # fine

We can trivially do a remove all, but removing just a specific object is more tricky. We cannot rely on an object reference, so our only options are: come up with a unique hook identifier; or return a handle. Handles are kind of unergonomic, and easy to lose, so they're out. If we create a unique hook identifier, either we return it (which is just a handle again), or it will need to somehow be based on the object reference to be able to recreate it later (which we can't expect to exist when we remove it).

So we can't easily have a remove single hook function. Does this matter? Are people fine with just a remove all? Will people even want to add multiple object specific hooks to the same Python function, you can also handle that relatively simply in Python (with the same caveats as the n=1 case)? We don't know, this is all speculation, we need to see actual use cases.

Another question is how to deal with hook type. This isn't quite as hairy to solve, the helper can simply store what type it was registered with, it's more a question of ergonomics. Is it better to define a constant hook type when creating the function, or to assign it when actually adding the hook? Again we'll need to see use cases to decide.

@special_hook(Type.POST)
def my_hook(*_: Any) -> None:
  pass
# or 
my_hook.add(obj.Func, Type.POST)

This also has type checking implications - in add_hook we're able to hint that post hooks may not try block execution or override the return value. While I think it should be possible to enforce either way, we'll need different approaches based on if you define type early or late.


So basically, this issue is a call to users, if this feature existed, how would you use it.

apple1417 commented 2 months ago

Garbage collection is now less of a concern with the WeakPointer type added in 18294df4, which reduces the value of this feature down to just it'd be faster. Going to leave this up for a while to see what people think of it, but imo there's little point in adding this.