Fixed callback bridge pointer buffers#1
Merged
Merged
Conversation
Havok's callback ABIs pass no context pointer, so each live callback needs a distinct compile-time thunk address; the pool size is fixed per build and cannot grow at run time without JIT trampolines. Worlds with many grids/blocks (e.g. 699 grids / ~13k thrusters) exhausted the previous limit of 4096 for the void_ptr_ptr family and aborted. - Port a self-contained thunk generator (tools/generate_havok_thunks.py) into this repo; it regenerates src/HavokThunk_*.cpp + HavokThunkRegistry.h from a hardcoded family table, no decompiled-source dependency. - Raise CALLBACK_SLOTS 4096 -> 16384 for all families. - Emit a clear multi-line diagnostic on exhaustion naming the family, the slot count, the root cause, and the exact fix before std::abort(). - Document the mechanism and limitation in README.
Replace the flat CALLBACK_SLOTS with a per-family map, sized to each family's worst case. Analysis of the game's Havok wrapper shows the slot demand depends on how each delegate is marshaled: - Listeners/handlers held in static readonly fields (entity, contact, sound, constraint, activation listeners; wheel modifier; break-off; task profiler; log) marshal to a single shared pointer and dispatch per-instance via the listener handle, so they need only a handful of slots (default 256). - HkPhantomCallbackShape is the only per-instance producer: each live phantom shape burns 2 void_ptr_ptr (enter/leave) + 1 void_ptr (delete). The game makes one per trigger/detector volume (connectors, ejectors, collectors, gravity generators, merge blocks, safe zones), so void_ptr/void_ptr_ptr scale with block count -> 16384. - HkShapeLoader cleanup and HkConstraint.FindConnectedConstraints marshal a fresh, never-released callback per call, so void_ptr_int / void_ptr_int_ptr get a 4096 margin. Total drops from 180224 slots (flat 16384) to 42752; libHavok.so ~87 MB -> ~22 MB, Havok build ~92s -> ~23s. The exhaustion diagnostic now names the per-family limit. Documented the sizing rationale in README.
Port the full Havok wrapper generator into the repo, consolidating the thunk-only generator into it. tools/generate_havok_wrapper.py now regenerates src/Havok.cpp (one extern "C" shim per export, parsed from the decompiled [DllImport]s in ../dotnet-game-local/HavokWrapper/Havok) together with the thunk sources and header. Verified it reproduces the committed output byte-for-byte before layering the change below. The decompiled sources are a regenerate-time dependency only; the generated files are committed. Fix the phantom-shape slot leak (the only per-instance callback producer): HkPhantomCallbackShape marshalled a distinct enter/leave/delete delegate per instance, and nothing ever released those bridge slots when the shape (block) was destroyed, so void_ptr/void_ptr_ptr grew with the cumulative number of phantom shapes ever created rather than the concurrent count. Havok only signals shape destruction via the delete callback, so hand every phantom one shared native dispatcher instead of a per-instance bridged delete. On destruction Havok calls it with the shape handle; it forwards to the managed delete handler, then releases the enter/leave bridge slots for reuse. Verified with a create/destroy stress test (20000 cycles stay bounded; the same test without reclaim aborts at 8192, i.e. 16384/2). Because the delete callback is no longer bridged, void_ptr loses its only per-instance producer and returns to the default pool (16384 -> 256). void_ptr_ptr stays at 16384 but now bounds concurrent live phantoms, not the cumulative total. libHavok.so ~22 MB -> ~14 MB.
bridge_/release_ previously did full linear scans of the per-family slot array:
bridge did an O(N) dedup pass (always a full scan for the per-instance phantom
delegates that never match) plus an O(N) allocation pass, and release did an O(N)
scan. At N=16384 for void_ptr_ptr this made phantom registration/teardown a
bottleneck -- O(phantoms * poolsize), quadratic in phantom count at load time,
made hotter by the new per-shape reclaim.
Replace the scans with O(1) management: an unordered_map<void*, {index,refs}> for
dedup and a free-list stack (plus a high-water mark) for allocation. The per-slot
atomic targets[] array and the lock-free thunk read path are unchanged; only
bridge_/release_ change, still under the family mutex. Drops the parallel
refcounts[] array (refcount now lives in the map).
Microbenchmark (200k phantom create/destroy cycles, N=16384): 10.5 us/cycle ->
0.037 us/cycle (~285x). Verified dedup, refcounting, slot free/reuse, and that
exhaustion still aborts at exactly N live targets with the diagnostic.
Doubles the concurrent live-phantom ceiling (~8k -> ~16k phantom shapes) for very large worlds. libHavok.so ~15 MB -> ~22 MB; register/lookup stay O(1) so runtime is unaffected (only a larger targets[]/thunks[] array).
Add docs/havok-callback-bridge.md: for every signature family, the Havok delegates that map to it, how the game's wrapper marshals each (shared-static / per-instance / per-call), the resulting peak distinct-pointer count, the slot limit and its margin, the phantom-shape reclaim, and the cost of changing a limit. Link it from the README.
Investigated whether the two "per-call" families could be made round-robin. Checking the C# wrapper showed neither is a clean fit: - void_ptr_int mixes a genuinely per-call callback (HkShapeLoader cleanup's returnByteArray, a capturing lambda) with a RETAINED handler (the voxel batch request handler installed via SetShapeRequestHandler, which Havok invokes later). Round-robin over the shared pool would eventually overwrite the retained slot and crash, so it is unsafe here. - void_ptr_int_ptr's only producer is HkConstraint.FindConnectedConstraints, whose reader is the static ConstraintReader method (state passed via userData/GCHandle, not captured). C# caches the method-group delegate, so it dedups to one slot -- it was never actually per-call and never leaked. Fix the one real leak precisely instead of round-robin: since Havok invokes the cleanup callback only synchronously, release its bridge slot immediately after the call (new SYNC_RELEASE_ARGS in the generator -> emits release_ after the call). This frees at the exact dead moment and never touches the retained handler. Both families drop from 4096 to the default 256. Total 43008 -> 35328 slots; libHavok.so ~22 MB -> ~19 MB. Verified: 10k synchronous calls stay bounded with the retained handler intact, and the same loop without the release aborts at 256. Docs and README updated.
Size each non-phantom family to its verified peak of distinct concurrently-live callback pointers instead of a flat 256: - Exactly one static pointer -> a collapsed single-slot bridge: one thunk backed by one target, no index map / free-list / thunk array. A second distinct *live* target aborts with a clear message (it would mean the family was mis-sized). Applies to bool_ptr_ptr, int_ptr_ptr_uint_ptr, void_charptr_int, void_i64, void_void, void_ptr_int_ptr. - A few static pointers -> smallest power of two >= 16x the peak: void_ptr (7 -> 128), void_charptr (2 -> 32), float_ptr (2 -> 32). - void_ptr_int keeps a multi-slot pool (128): it also carries the RETAINED voxel batch handler alongside the released-after-call cleanup callback, sized for loader concurrency. void_ptr_ptr unchanged (32768, per-instance phantom pool). Total 35328 -> 33094 slots; libHavok.so ~19 MB -> ~17 MB. Verified the single-slot bridge: dedup+refcount, sequential reuse after release, and abort on a second concurrent distinct target. Docs/README/memory updated.
viktor-ferenczi
marked this pull request as draft
July 13, 2026 06:12
viktor-ferenczi
marked this pull request as ready for review
July 13, 2026 06:22
HkConstraint_FindConnectedConstraints bridged its ConstraintReader delegate but never released the slot, and void_ptr_int_ptr was sized as a single collapsed slot on the assumption that the reader marshals to one process-wide native pointer. It does not: the C# method-group delegate is re-marshaled to a fresh native thunk per world load, so the first load's thunk stayed pinned live and loading a second world into the same headless session aborted the single-slot family. Release the reader right after the (synchronous, non-retained) call via SYNC_RELEASE_ARGS so nothing accumulates across loads, and give void_ptr_int_ptr a 128-slot pool sized for call concurrency rather than a fixed static count. Regenerated Havok.cpp and the thunk family.
…filer risk Record the results of the full re-audit that followed the ConstraintReader abort. Correct the bridge doc and README, which mis-classified void_ptr_int_ptr (HkConstraint.ReadConstraintsCallback) as a safe single-slot shared-static family; it is per-call (a bare method group marshalled to a fresh native thunk per world load) and is now pooled + sync-released. Add the field-vs-method-group distinction that drives correct classification, and document that the HkTaskProfiler single-slot families are a dormant copy of the same bug -- harmless only because HkTaskProfiler.Init is dead code. Note void_charptr's real production peak is 1 (Log). Confirm all other families remain safe.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Right-size the Havok callback-bridge pools per signature family. Replaced the single flat
CALLBACK_SLOTSlimit (which aborted on load-heavy worlds like "Many Lifters Slowness", 699 grids) with per-family sizing driven by how the game marshals each callback: families backed by a sharedstatic readonlydelegate collapse to a single slot (no map/free-list), the per-instancevoid_ptr_ptrfamily grows to 32768, and per-call loader families stay small. KeepslibHavok.soat ~17 MB instead of ~180 MB if every family used the max.Reclaim phantom-shape callback slots.
HkPhantomCallbackShapeis the only wrapper that hands Havok a fresh native delegate per instance (2void_ptr_ptrslots per live phantom). Since Havok passes no context and only signals destruction via the delete callback, every shape is now handed one sharedphantom_delete_dispatch; on destruction it forwards to the managed delete handler and frees the shape's enter/leave slots. This bounds usage to concurrent live phantoms rather than the cumulative total, preventing slow leaks over long build/destroy sessions.Release per-call synchronous slots.
HkShapeLoaderbuffer-cleanup callbacks are now released immediately after the call returns, so short-lived per-call delegates no longer accumulate.Make
bridge_*/release_*O(1). Replaced the per-family linear scans (O(pool size) dedup + allocation + release, quadratic in phantom count at load time) with an index map plus a free-list stack. The lock-free per-slot atomic read path on the invoke side is unchanged. ~285× faster in a 200k phantom create/destroy microbenchmark.Docs and generator. Added
docs/HavokCallbackBridge.mdand a README section documenting the bridge design, per-family mappings, sizing rationale, and the compile-time pool-size limitation; ported the full wrapper generator (tools/generate_havok_wrapper.py) that regeneratesHavok.cppand the thunk sources together.Publishing release build as primary and debug build as secondary.