v3.2.0
Added
- Three per-kind marginal registration benchmark scenarios
(UntargetedRegistration_Marginal,TargetedRegistration_Marginal,
BroadcastRegistration_Marginal) that measure the GC-allocation cost of an additional
same-type registration on an already-warm bus -- the surface the registration
allocation reductions target -- so the published performance tables now show per-kind
registration cost (allocation count + bytes), not just dispatch throughput. Each
registers 1000 more handlers of one warmed message type using distinct, pre-built
handler delegates, so the measured window captures only the registration machinery
(never the handler delegate, and never a same-handler refcount bump). They are
wall-clock (zero-throughput) rows whose allocation columns populate on the
profiler-bearing in-editor PlayMode/Mono leg and readn/aon the published Standalone
IL2CPP Release leg (which strips the profiler), exactly like the existing
registration-flood rows. - Benchmarks now report the total allocated BYTES per measurement batch (
gcAllocatedBytes)
alongside the existing managed-allocation CALL count (gcAllocations). Bytes are
measured from a before/after delta of the live Unity
ProfilerRecorder(ProfilerCategory.Memory, "GC Allocated In Frame").CurrentValue
-- a within-frameGC.Alloc-hook byte accumulator that SUMS allocation-hook bytes
rather than measuring a heap-size difference, so it is exact (verified: 100 x
byte[10000]reads 1,003,200 bytes, run-to-run identical) and immune to mid-window
collections (a heavy-churn region that swung aGC.GetTotalMemorydelta to -133 MB
read a stable 8,000,000 bytes here). The byte counter is profiler-dependent
like the count probe: in the editor and development players it is functional, and on
the published non-development Standalone IL2CPP Release leg -- where the profiler is
stripped -- BOTH metrics report theUnmeasuredsentinel (renderedn/a), never a
fabricated0. Bytes are INFORMATIONAL -- the perf-delta PR comment renders byte
deltas goodness-signed (N fewer bytes/N more bytes) -- while the regression gate
stays on the allocation COUNT, which remains the canonical signal (Unity's own
Performance Testing package likewise reads the alloc-CALL count, not bytes). The
cross-library comparison tables now also surface this byte metric as a dedicated
GC-allocated-bytes matrix, and the UnitySendMessagecomparison boxes its value
payload on every dispatch (as any realSendMessage(value)call must) so its
allocation columns show that real per-call cost instead of a misleading zero. New API:
AllocationProbe.MeasureWithBytesreturningAllocationProbe.AllocationSample,
AllocationProbe.Window.SampleBytes()/SampleBoth(),
AllocationProbe.BytesFunctional,BenchmarkMeasurement.GcAllocatedBytes,
ColdLatencyMeasurement.MedianGcAllocatedBytes, and
MinimumMeasurement<T>.GcAllocatedBytes. - New "What's New in 3.x" documentation page (Getting Started) that summarizes
the user-visible improvements across the 3.x line -- faster zero-allocation
dispatch, the base-call analyzer and inspector overlay, project-wide settings,
memory-reclamation controls,ReregisterOnEnableAfterRelease, the clarified
ToggleMessageHandlersemantics, Unity 6.4+/6.5 compatibility, and the
dependency-injection helpers -- with links to the authoritative changelog and
the migration guide.
Fixed
- The token-creation allocation guard no longer flakes in a warm editor. The
diagnostics-lazy win (tokenCreateallocating 7 managed objects instead of 11) was
guarded by an absoluteGC.Allocrecorder COUNT budget, but a warm, long-lived editor
domain attributes background allocations to whatever measurement window is open --
measured on the host editor, the minimum over 64 windows was ~19 allocations for that
7-allocation operation (median ~51), well above any budget tight enough to catch the
revert -- so the guard false-failed run-to-run. A measure-first probe also disproved the
assumption that the swing came from theDxPoolscollection pools: the steady refcount
registration path does not rent from them at all (hits = misses = 0), so the noise is
pure background-editorGC.Allocpollution that no pool pre-warm can remove. The guard
is now a DETERMINISTIC state assertion -- afterCreatewith diagnostics off, the lazy
_callCounts/_emissionBufferbacking fields must still benull-- which uses no
allocation probe, never flakes, and runs in the per-PR EditMode correctness leg rather
than the weekly perf-gated Allocation scope. - Two more memory-reclamation/registration allocation guards no longer flake in a warm
editor. The forced-trim guard measured aGC.AllocCOUNT budget over a 32-trim window
and the dirty-target reuse guard a per-batch count budget; both false-failed run-to-run
on the same warm-editor ambient-noise floor that the token-creation guard hit. The
forced-trim guard is now a DETERMINISTIC assertion on the exactIMessageBus.TrimResult
eviction counts (the first force-trim reclaims; every subsequent one is an idempotent
no-op that evicts nothing and leaves the live slot count stable) plus aDxPools
Misses-flat check (repeated no-op trims rent no fresh pooled collection); the
dirty-target guard now relies solely on its existing exact pool Hits/Misses assertions
(renting the warmed collection, never allocating a fresh one). Neither uses an allocation
probe, so neither flakes. The two deterministic registration STORAGE structural guards
(the by-value metadata parameter and the staging-function map value type) were also moved
from the weekly perf-gated Allocation suite into the per-PR EditMode correctness leg, so
they protect every PR. Internal test/quality change only; no public API or runtime
behavior change. - Benchmark and library-comparison allocation reporting is now honest. The harness
measured allocations withGC.GetAllocatedBytesForCurrentThread(), which returns
0for every allocation under Unity's Boehm GC (verified: a forced 1 MB array
allocation read back a0-byte delta), so the published "allocated bytes" column
was a vacuous0for every technology -- hiding real per-operation allocations
(UnitySendMessage, for example, boxes its value-type payload once per call).
The metric is now a COUNT of managed allocations from the reliableGC.Alloc
profiler recorder (newAllocationProbe), renamedgcAllocations, and reports an
Unmeasuredsentinel (renderedn/a) -- never a fabricated0-- when no probe
is available on a backend. The perf-doc/PR-comment renderers, the regression gate,
and the methodology docs were updated accordingly; the doc's allocation column
readsn/auntil the next CI run repopulates real counts. - Source generators (
[DxUntargetedMessage],[DxTargetedMessage],
[DxBroadcastMessage],[DxAutoConstructor]) now work in projects that do not use
assembly definitions. The generator and analyzer DLLs previously shipped under the
editor-onlyEditor/Analyzers/folder, which Unity scopes to the package's editor
assembly and assemblies that reference it -- so a consumer's runtime code in
Assembly-CSharp(or a runtime asmdef referencing only the runtime assembly) never
received the generator, and[Dx*Message]types failed to implement their generated
interface with crypticCS0315/CS0452errors. The labeled DLLs now ship under
Runtime/Analyzers/(governed by the all-platforms runtime assembly), so Unity
applies the generator to the DxMessaging runtime assembly and every assembly that
references it, including the predefinedAssembly-CSharp. No assembly definition is
required. Closes GitHub issue #229. - Documentation no longer teaches the obsolete
IMessageBus.GlobalDiagnosticsMode
API. The reference, patterns, glossary, and migration guides now use
IMessageBus.GlobalDiagnosticsTargetswith theDiagnosticsTargetflags enum
(matching the canonical diagnostics guide), and the API reference no longer shows a
write to the read-onlyIMessageBus.DiagnosticsModeproperty. A source-derived
drift-guard (DocsObsoleteApiReferenceTests) now fails the build if any published
doc references a member marked[Obsolete]in the runtime, so this cannot regress.
Changed
- Registration now allocates one fewer managed object per handler in the common case. The
registration token stored every handle's de-registration in a per-handle holder object,
but the common case is EXACTLY ONE de-registration per handle, so the single
de-registration delegate is now stored INLINE in the token's de-registration map (with no
holder object); a holder is allocated only when a rare second de-registration accumulates
on the same handle (the re-entrant retarget-recovery replay). The ordering,
partial-failure, and rollback-baseline semantics are unchanged (pinned by the existing
PendingDeregistrationStorageTestsand the full re-entrancy suite), and a new
RegistrationStorageStructuralGuardTests.DeregistrationsStoreInlineActionNotPerHandleHolder
deterministically guards the inline storage against regression. Internal change only; no
public API or behavior change. - BREAKING (v4): the
IMessageBusregistration contract now returns an opaque,
zero-allocation handle instead of a deregistration delegate. The 14
IMessageBus.Register*<T>methods return a newreadonly struct
DxMessaging.Core.MessageBus.MessageBusRegistration(previouslySystem.Action), and a
newvoid IMessageBus.Deregister<T>(in MessageBusRegistration registration) where T : IMessage
undoes a registration (call it with the sameTyou registered with; for
RegisterGlobalAcceptAll, anyT : IMessageworks since the global slot is not
type-keyed). This removes the per-registration bus-side deregistration closure (the
priorActionplus its display class) from the handler registration path -- roughly
two fewer managed allocations per registration -- by packing the deregistration
snapshot into the returned value handle and re-expressing the bus's deregistration logic
against it (behaviour and the four reentrancy invariants -- generation guard, identity
liveness / over-deregistration, token idempotency, no counter underflow -- are unchanged).
CustomIMessageBusimplementers that wrapMessageBus(the
DelegatingMessageBus-style extension point) just forward the new members; from-scratch
implementers mint handles via the publicMessageBusRegistration(long, object)
constructor and read them back in their ownDeregister<T>.
The Unity-facing surface is unaffected:MessageRegistrationTokenand
MessageAwareComponentkeep their existing public shapes. TheMessageHandler.Register*
facades were narrowed frompublictointernal(they now return the internal
HandlerDeregistration, still implicitly anAction) as part of this bus<->handler
boundary rework -- see the dedicated entry below; only code that called those facades
directly is affected. The larger per-registration allocation reduction (collapsing the
token/handler closures onto a single per-handleRegistrationobject) landed on top of this
contract change -- see the entries below. - The published performance report no longer prints columns of
n/a. The
Standalone IL2CPP leg runs in a Release player whose stripped profiler cannot
measure GC allocations or bytes, so the renderer now OMITS a memory column from a
per-scope dispatch table when every row is unmeasured (the Standalone table is
throughput-only), omits a whole cross-library memory matrix when no leg measured
that metric, and drops the unmeasured allocation/byte segment from each per-PR
delta cell -- instead of filling them with a wall ofn/a. The real allocation
and byte numbers still publish from the in-editor PlayMode (Mono) leg, andn/a
now appears only as a genuine per-row or per-library cell (a metric measured for
the scope in general but missing for that one entry). - Registration allocates less. Each registration token's diagnostics-only
call-count and emission-history collections are now created lazily instead of
eagerly, so a token whose owner never enables diagnostics (the default) no longer
pays for them -- token creation drops from 11 to 7 managed allocations. The
per-registration metadata is now passed by value instead of through a closure
factory that was invoked immediately, removing one delegate allocation per handler
registration. Steady-state dispatch stays allocation-free, and diagnostics, the
inspector overlay, and all lifecycle behavior are unchanged. - Registering a handler OR a post-processor with the convenience
Action<T>/
Action<InstanceId, T>overloads now allocates one fewer closure per registration.
This covers the handler registers (RegisterUntargeted,RegisterTargeted,
RegisterTargetedWithoutTargeting, the sourced-broadcast register, and
RegisterBroadcastWithoutSource) and the matching post-processor registers
(RegisterTargetedPostProcessor,RegisterTargetedWithoutTargetingPostProcessor, the
GameObject/Component/source broadcast post-processors, and
RegisterBroadcastWithoutSourcePostProcessor). Each such registration previously built
two delegates -- a diagnostics-augmentedActionwrapper plus a separate by-ref
FastHandleradapter for the flat-dispatch path -- and now folds diagnostics into a
single by-refFastHandlerclosure that is the dispatch target directly (also removing a
per-dispatch indirection). The public API is unchanged; the optimization is internal and
verified by differential allocation guards that pin theActionregistration cost to the
already-optimalFastHandlerregistration cost. Diagnostics, deduplication, and dispatch
ordering are preserved. - Every registration kind now allocates about two fewer managed objects. The token used to
wrap each staged registration in a per-registration parameterlessAction(a delegate plus
its display class) whose only job was to re-bundle the handle, the staging function, and the
de-registration bookkeeping; the token now stores each staging function directly and pairs it
with its handle in the replay queue, so that wrapper -- and the closureInternalRegister
needed to build it -- is gone. Measured cold-registration floor (FastHandler, diagnostics off):
untargeted drops from 14.69 to 12.69 managed allocations per registration (a clean -2.00), with
the same ~2-allocation reduction across targeted, broadcast, without-targeting/source, and
post-processor registrations (~12% fewer registration allocations overall). The de-registration
replay, rollback-on-failure, re-entrancy, and equal-priority registration-order semantics are
unchanged; the public API is unchanged. Pinned structurally by a deterministic guard that the
token stores the staging function (not anActionwrapper). - Every active registration now allocates about two fewer managed objects again. The token
tracked each handle's live de-registration in a per-handle holder that eagerly allocated a
List(plus its backing array) to hold what is almost always a single de-registration; the
holder now keeps that one de-registration inline and only allocates an overflow list on the
rare second de-registration for the same handle. Measured cold-registration floor (FastHandler,
diagnostics off): untargeted drops from 13.29 to 11.29 managed allocations per registration (a
clean -2.00), with the same ~2-allocation reduction across targeted and broadcast. The
insertion-order, partial-failure-retryable, and rollback-baseline de-registration semantics are
unchanged (verified by a differential simulation across every count/failure/start-index
combination); the public API is unchanged. - Each handler/post-processor registration now allocates one fewer managed object by replacing
its per-registration de-registration closure with a per-handle object. The typed
handler used to hand back a captured parameterlessAction(a delegate plus the display class
holding the slot, cache key, priority, and generation it closed over) as the thing the token
would later invoke to tear the registration down; that closure is now a single per-handle
HandlerDeregistrationobject whoseDeregister()instance method carries the same captured
state as plain fields (and converts implicitly toAction, so the token's replay/rollback
machinery is untouched). To let one non-generic object tear down a generic
HandlerActionCache<TU>without re-introducing a per-handler-delegate-type closure, the erased
IHandlerActionCachegained three non-generic operations (ContainsEntry,BumpVersion,
DeregisterEntry). The de-registration order (generation guard, slot-version liveness,
keyed-vs-scalar split, identity/over-de-registration check, version bump, bus de-register,
refcount decrement) is a faithful re-expression of the prior closure body, pinned by the 19
arbitrary-orderMixedOrderDeregistrationTestsand the full re-entrancy suite. Because these
MessageHandler.Register*facades now return the internalHandlerDeregistration(still
implicitly anAction), they were narrowed frompublictointernal-- this is part of the
v4 bus<->handler boundary rework and does NOT touch the Unity-facing surface
(MessageRegistrationToken,MessageAwareComponent); only code calling
MessageHandler.Register*directly is affected. - Every registration kind now allocates roughly half as many managed objects again by
collapsing the token's per-handle staging closures into one unified per-handle object.
Each registration previously staged aFunc<handle, HandlerDeregistration>(a delegate plus
its display class capturing the target/source, user handler, and priority) whose nested
AugmentedHandlerlocal function became a second delegate (the diagnostics-augmented flat
invoker). Both are now a single per-handleRegistrationobject stored in_registrations:
its fields hold the captured staging state, and its diagnostics-augmented invoker is an
instance method bound to the object -- soMessageHandlerstill receives aFastHandler<T>
delegate and the hot dispatch path stays delegate-based (no virtual/interface call per
dispatch). ARegister()method runs a kind-switch to call the matching
MessageHandler.Register*and reproduce the exact prior staging body per kind. Because the
constrainedMessageHandler.Register*<T>calls requireT : ITargetedMessage/
IUntargetedMessage/IBroadcastMessage(which a singleRegistration<T> where T : IMessage
cannot satisfy), the object is realized as three constraint-family generic subclasses plus one
non-generic global-accept-all subclass over a non-genericRegistrationbase -- still a
unified per-handle object with a kind-switch, not a per-method subclass explosion. Measured
marginal cost (cold, FastHandler, diagnostics off) drops from ~9.3 to ~4.6 managed allocations
per registration (about half). The equal-priority registration-order dispatch, idempotent
double-deregister, partial-failure rollback, generation/slot-version guards, refcount handlers,
and diagnostics call-counts/emission semantics are all unchanged (pinned by the 19
MixedOrderDeregistrationTestsand the full re-entrancy suite); the public API is unchanged.
Pinned structurally by a deterministic guard that_registrationsstores the unified
Registrationobject, not aFunc/Actionwrapper. - Deregistration is now O(1) on the steady path. The v4 bus
Deregister<T>had been
re-resolving the sink from the registration method (aswitchplusScalarSinkForMethod/
ContextSinkForMethod) and re-walking the whole sink -> type -> [context ->] priority ->
handler chain on every call -- even though the leaf handler-cache is already captured on the
registration handle. It now operates on that captured cache DIRECTLY, deferring the
re-resolution to a cold fallback taken only when the handler is not found there (the rare
post-sweep or over-deregistration case, which the fallback classifies as a silent no-op vs an
error). This removes the per-deregistration re-resolution that had regressed the
deregistration-heavy teardown path, with no change to dispatch, registration allocations, the
generation/sweep-staleness/over-deregistration guards, or the public API; the throw-safe
ordering (the handler-cache mutation stays after the throwingIMessageBus.Deregister) is
unchanged. A stale over-deregistration whose(type, priority)slot was reused by a different
handler now leaves the live registration completely untouched (new
StaleOverDeregistrationTestsregression coverage). The token's de-registration store also
records the common first-registration in a single dictionary operation (TryAdd) instead of a
probe-then-insert pair. - The bug-report issue template now offers the package version as a dropdown of
released versions (with anOtherfallback) instead of a free-text field, so
reports carry an exact, valid version. The list is generated from
package.json,CHANGELOG.md, and git tags, kept in sync by
npm run check:issue-template-versions(gated invalidate:all), and
self-heals on the default branch via theUpdate Issue Template Versions
workflow. Closes GitHub issue #230. - The Roslyn source generator no longer copies its DLLs into the consumer's
Assets/Plugins/Editor/folder on editor load; it (with its pinned Roslyn dependency
DLLs) ships ready-to-use under the package'sRuntime/Analyzers/folder
(RoslynAnalyzer-labeled, excluded from player builds). Projects upgrading from an
earlier version have the redundant in-project copy removed automatically during asset
import, before script compilation, so the package's copy and the old in-project copy
never both run the generator (which would otherwise duplicate generated members). No
manual action is required: the cleanup only removes the
Assets/Plugins/Editor/WallstopStudios.DxMessagingfolder when it contains the
first-party source-generator DLL plus exact known legacy analyzer/dependency DLL names
the package created, and leaves any foreign DLL, foreign.meta, subfolder, or
other content untouched. If a mixed or incomplete legacy payload is detected, the
editor logs one warning with manual cleanup guidance instead of deleting the folder
silently.
Install
- Import the attached
.unitypackagedirectly into your Unity project, or - add
com.wallstop-studios.dxmessagingthrough the Unity Package Manager, pinning this
release ascom.wallstop-studios.dxmessaging@3.2.0(scoped npm registry) or the
matching Git tag.
Each release attaches the npm tarball and the .unitypackage, each with a
.sha256 checksum; verify a download with sha256sum -c <file>.sha256.