Skip to content

Wrapper Types ArrayW and ByRef

Adam ? edited this page Dec 27, 2021 · 1 revision

Wrapper Types

Background

In beatsaber-hook, there are several cases where wrapper types might be useful. A wrapper type is one which exists to completely 'wrap' a C# implementation and provide high level functionality to C++ mods while still being a valid C# type.

In the latest versions of beatsaber-hook, wrapper types are a concept (see: il2cpp_utils::has_il2cpp_conversion) which essentially boils down to two characteristics:

  • Are they constructible from void*
  • Do they have a conversion function to void*

Note that this also constrains based off of const-ness, so if you have a (valid) wrapper type that is used in a const way, it will not be a valid wrapper type unless the conversion function is also applicable in a const sense.

It may seem as if these constraints aren't particularly relevant, nor are the wrapper types themselves. To understand why they exist, let us dive a little bit into how beatsaber-hook performs type resolution.

Type Resolution

When you tell beatsaber-hook to call a C# method, say, via RunMethod, RunMethodThrow, RunMethodRethrow, etc. it performs type resolution. Essentially, it needs to (optionally, at first) perform type checking, where it checks to make sure the parameters you have provided to the method in question are actually valid. This is opt-out-able in most, if not all, standard RunMethod... calls as a non-type template parameter. After this, we either provide our parameters to a raw method pointer invoke (see RunMethodThrow) or will be forwarding the parameters to il2cpp_functions::runtime_invoke.

If we are forwarding our parameters to a method pointer, there is no conversion necessary, the only thing that matters is that the size of the arguments match up. Note that RunMethodThrow does not perform any special conversions for these wrapper types, so in essence, their size is forcibly constrained IF used in RunMethodThrow.

However, for runtime_invoke forwarding, the parameters must be converted to an array of void* in order to be passed into the runtime_invoke function. This process is not too complicated, see il2cpp_utils::ExtractValue for how it behaves on a value-per-value basis. Note that value types are converted to referenced locals and pointer types are potentially boxed or simply passed in explicitly. However, there exists a special case, specifically when wrapper types are passed in, and they are handled differently.

Wrapper types are handled differently so that we can convert them in place here and perform customizable logic instead of instantly converting to a stack local for an otherwise provided value type. This lets us be quite clever, since we can now do whatever we like in a wrapper type, so long as we can convert to a void* to be a valid parameter.

However, there is one more piece to this puzzle: construction. If we implicitly match a wrapper type to, say, a hook parameter, then we lose out on our customizable construction logic-- AND we require that the size of our wrapper type is the same as the pointer we are trying to wrap. Thus, wrapper types are not necessarily safe for use as hook parameters or returns (since the conversion function is also not called on return) unless they:

  • Have no custom constructor logic (are trivially constructible)
  • Are the same size as void*

IF AND ONLY IF this is true can they safely be used as hook parameters. Fortunately, the two current wrapper types in beatsaber-hook, ArrayW and ByRef satisfy this constraint as well.

I also conveniently skipped over one more detail related to construction: returns from invoked C# functions. In RunMethodThrow, returning a wrapper type is not valid unless it satisfies the same constraints as highlighted above. However, for the various other RunMethod... calls, you will see that a conversion step applies. Specifically, il2cpp_utils::FromIl2CppObject, which converts the return from a runtime_invoke call into the specified type. If we provide a valid wrapper type, it will call the void* constructor for us and thus give us custom functionality in a constructor of a wrapper type.

So, as a quick TL;DR:

  • Wrapper types follow different type conversion rules than other types
  • Wrapper types will NOT be constructed or converted if used in hooks or RunMethodThrow at all, they are ONLY safe to use if they:
    • Match the same size as a void*
    • Are trivially constructible and convertible to a void*
  • Wrapper types will be constructed if returned from runtime_invoke (and RunMethod... that are not RunMethodThrow)
  • Wrapper types will be converted if passed into runtime_invoke (and RunMethod... that are not RunMethodThrow)

Motivation

So. We covered HOW wrapper types work, but why do we even want them in the first place? There are two motivating examples here. The first (and perhaps more intuitive from a mod making perspective) is ArrayW.

ArrayW<T> is a wrapper type that wraps a held Array<T>* and provides more intuitive C++ syntax options. For example, instead of the verbose:

Array<int>* myArray = Array<int>::NewLength(1);
myArray->values[0]; // Get first element

you can now use:

ArrayW<int> myArray(1);
myArray[0]; // Get first element

In addition, iteration is much easier. Instead of the verbose (and slow):

for (il2cpp_array_size_t i = 0; i < myArray->Length(); i++) {}

you can now use:

for (auto item : myArray) {}

This additional level of syntax support is certainly a huge boon for those who often use arrays.

As for a more contrived example that requires a deeper understanding of the type checker in beatsaber-hook, ByRef<T> is actually a necessity in order to describe byref parameters. This is because that we can no longer count on our parameters being values if they are value types or pointers, and references if they are byref, due to the nature of how ExtractValue now behaves and how we extract parameters.

Namely, we require additional semantic information to deduce if a provided parameter value is should be considered to be a byref, a boxed pointer, an unboxed pointer, or simply a reference to a value type. As a result, ByRef<T> now exists to satisfy this. A ByRef<T> is the same size as a pointer and is trivially constructible and convertible from/to one. As such, we can use this type as a parameter in our hooks and also have added semantic information for the fact that it is a parameter that should be treated as a reference.

In the longer term future, it may also be useful to provide better semantic differences between out/in/ref parameters, which would be fairly easy to implement given this wrapper type system.

Conclusion

Overall, wrapper types are certainly useful. Not only because they allow custom functionality for conversions to and from the il2cpp domain, but also because they allow us to express additional semantic information to the mod developer, as well as provide additional, more intuitive syntax for them to use.

At the end of the day, I hope you learned something once again from this short little post on beatsaber-hook internals. As always, if you have questions about this post or others like it (or the library in general) feel free to reach out to Sc2ad#8836 on Discord.

Clone this wiki locally