Hooks are sometimes necessary, but they are also one of the easiest ways to break the client or another mod. A safe hook is narrow, observable, reversible where practical, and honest about version sensitivity.
Composition rules
- Patch the narrowest owner that actually produces the state you need.
- Install the patch only after the target symbol is known to exist in the current build.
- Store the callable you replace.
- Do not wrap a method twice.
- Call the original exactly as required and return its result.
- Keep pre-hook and post-hook logic small and exception-bounded.
- Do not use broad empty exception handlers.
- Make compatibility failures visible in logs.
Safe wrapper pattern
import logging
LOG = logging.getLogger('mod.example.hooks')
_originalMethod = None
_isPatched = False
def _patched_method(self, *args, **kwargs):
try:
_before_original(self, args, kwargs)
except Exception:
LOG.exception('Pre-hook failed')
result = _originalMethod(self, *args, **kwargs)
try:
_after_original(self, result, args, kwargs)
except Exception:
LOG.exception('Post-hook failed')
return result
def _install_patch(targetClass):
global _originalMethod, _isPatched
if _isPatched:
return
current = getattr(targetClass, 'methodName', None)
if current is None:
LOG.warning('methodName is missing in this client build')
return
_originalMethod = current
targetClass.methodName = _patched_method
_isPatched = True
Signature drift
Private methods can gain, lose, or reorder arguments between client builds. Use
*args and **kwargs only when the wrapper can preserve behavior without
guessing. If the hook needs a specific argument, verify the current call sites and log when the
expected shape is not present.
Unhooking and ownership
Unhooking is safest when the patch is installed by one owner and no other mod has wrapped the method afterward. If another wrapper was added later, blindly restoring the original callable can remove someone else’s patch. Prefer installing once for the process lifetime unless the target owner itself is short-lived and explicitly supports cleanup.
Captured callables can outlive the patch
Restoring the class attribute does not invalidate references already captured by later-loaded wrappers. A downstream wrapper may continue calling your wrapper after your module has finalized. Therefore, restore the class attribute only while it still points to your wrapper, but keep every original callable needed by that wrapper alive for as long as the process can still reach it.
- Do not clear saved originals unconditionally during teardown.
- Guard a wrapper against a genuinely unavailable original, but do not use that guard to hide incorrect ownership.
- Test both load orders: your wrapper before another mod and your wrapper after it.
- Exercise teardown while a downstream wrapper still holds the captured callable.
When not to hook
- A stable event already carries the needed state.
- A service or controller exposes a supported notification.
- The method is hot and the hook would run every frame.
- The desired data is owned by a deeper or higher layer than the method being patched.
- The patch would change server-authoritative behavior or hidden gameplay information.
Weak callbacks can bind by function or by name
Weak ownership does not establish how a callback chooses its method. BoundMethodWeakref saves a weak owner reference and the method name, then performs getattr(owner, name) on each call. Replacing that attribute on the still-live owner or its class can redirect an existing registration. WeakMethodProxy instead weakly retains the original function and owner and reconstructs that bound method; it does not look up a later method replacement by name.
The camera settings listener uses the name-lookup wrapper for keyed callbacks. Its registration rejects an already-occupied setting key rather than replacing that registration. Changing a method attribute and changing the registered handler owner are therefore separate operations. Inspect the wrapper used by the actual caller before deciding whether a method patch affects callbacks already registered.
Selected Python checks used inert owners and a keyed-listener substitute, with the original function deliberately retained so collection would not affect the comparison. They establish the inspected lookup distinction, not garbage-collection timing, Python 2 descriptor behavior, actual camera callbacks or native/client execution.
Client AOP cleanup is shared with the wrapper
The client aspect helper reuses functions already bearing its non-None aspect marker. Overlapping pointcuts can therefore share one wrapper and aspect list. Clearing one pointcut removes all aspects from that wrapper and restores its original function without checking whether the live namespace still points to that wrapper. A stale clear can overwrite a later replacement; separate Weaver objects do not establish independent cleanup ownership.
A cleared pointcut retains its wrapper records, and selective Weaver removal shifts later list indices. These are not stable per-owner restoration tokens. Keep cooperative hook teardown tied to the live attribute and the exact wrapper owned by the mod, as in the existing restoration pattern. These findings come from current Python source and synthetic namespace checks; no real client hooks, Python 2 descriptors or installed collisions were tested.
Weaver.findPointcut returns the first matching concrete class's index, so lookup identifies a
class match rather than a registration owner. Static inspection of original Python bytecode from WG
2.4.1.1 #989 confirms -1 on no match, consistent with the inspected settings caller's
absent-pointcut guard; the readable dump omits that final return. This resolves the inspected lookup branch,
not whole-module parity, successful hook installation or Python 2 descriptor behavior.