Skip to content

Fixed callback bridge pointer buffers#1

Merged
viktor-ferenczi merged 15 commits into
mainfrom
fixes
Jul 14, 2026
Merged

Fixed callback bridge pointer buffers#1
viktor-ferenczi merged 15 commits into
mainfrom
fixes

Conversation

@viktor-ferenczi

@viktor-ferenczi viktor-ferenczi commented Jul 13, 2026

Copy link
Copy Markdown
Contributor
  • Right-size the Havok callback-bridge pools per signature family. Replaced the single flat CALLBACK_SLOTS limit (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 shared static readonly delegate collapse to a single slot (no map/free-list), the per-instance void_ptr_ptr family grows to 32768, and per-call loader families stay small. Keeps libHavok.so at ~17 MB instead of ~180 MB if every family used the max.

  • Reclaim phantom-shape callback slots. HkPhantomCallbackShape is the only wrapper that hands Havok a fresh native delegate per instance (2 void_ptr_ptr slots per live phantom). Since Havok passes no context and only signals destruction via the delete callback, every shape is now handed one shared phantom_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. HkShapeLoader buffer-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.md and 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 regenerates Havok.cpp and the thunk sources together.

  • Publishing release build as primary and debug build as secondary.

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
viktor-ferenczi marked this pull request as draft July 13, 2026 06:12
@viktor-ferenczi
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.
@viktor-ferenczi
viktor-ferenczi merged commit e29a27a into main Jul 14, 2026
1 check passed
@viktor-ferenczi
viktor-ferenczi deleted the fixes branch July 14, 2026 00:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant