Helper Utilities Guide¶
TL;DR: Why Use These¶
Static helper classes and utilities that solve common programming problems without needing components on GameObjects. Use these for predictive aiming, path utilities, threading, hashing, formatting, and more.
Contents¶
- Gameplay Helpers: Predictive aiming, spatial sampling, rotation
- GameObject & Component Helpers: Component discovery, hierarchy manipulation
- Transform Helpers: Hierarchy traversal
- Coroutine Wait Pools: Configure
Buffers.GetWaitForSeconds*caching - Pooling Unity Objects That Outlive Their Scope:
TrackedObjectPool<T> - Threading: Main thread dispatcher, single-threaded pool teardown
- Path & File Helpers: Path resolution, file operations
- Scene Helpers: Scene queries and loading
- Advanced Utilities:
RestorableGlobal<T>,BitOps, statistics, null checks, hashing, SHA-256, formatting - Texture and Sprite Pixel Helpers: Rotation and sprite-region extraction
- Environment Detection: CI, batch mode, and runtime environment
Coroutine Wait Pools¶
Unity allocates a new WaitForSeconds/WaitForSecondsRealtime every time you yield with a literal. Buffers.GetWaitForSeconds(...) and Buffers.GetWaitForSecondsRealTime(...) pool those instructions to reduce coroutine allocations, but each distinct duration used to stick around forever. Large ranges (randomized cooldowns, tweens, etc.) could leak thousands of instances.
Constant, computed, and random finite durations are supported. With the default settings, the cache uses the exact float value and retains at most 512 distinct durations; further values allocate a valid instruction without adding an entry. Optional quantization rounds both cached instructions and uncached fallbacks to the same duration. If quantization arithmetic would overflow, the original finite duration is preserved. A zero cache limit explicitly enables unbounded caching.
| C# | |
|---|---|
New pooling policy knobs (Runtime 2.2.1+):
| Setting | Default | Purpose |
|---|---|---|
Buffers.WaitInstructionMaxDistinctEntries | 512 | Upper bound on distinct cached durations. Set to 0 to disable the cap, or tighten it for editor/dev builds. When the limit is reached the cache stops growing (or evicts, if LRU is enabled). |
Buffers.WaitInstructionQuantizationStepSeconds | 0 (off) | Rounds requested durations to the nearest step before caching. Useful when you can tolerate millisecond snapping (e.g., .005f → .01f). |
Buffers.WaitInstructionUseLruEviction | false | When true, the cache becomes an LRU: it evicts the least recently used duration whenever it hits the max entry count instead of rejecting new ones. Diagnostics expose the eviction count. |
Buffers.TryGetWaitForSecondsPooled(float seconds) / TryGetWaitForSecondsRealtimePooled | n/a | Returns the cached instruction or null if the request would exceed the cap. Use this when you want to detect “unsafe” usages and allocate manually instead. |
Buffers.WaitForSecondsCacheDiagnostics / .WaitForSecondsRealtimeCacheDiagnostics | snapshot | Exposes DistinctEntries, MaxDistinctEntries, LimitRefusals, and whether quantization is active so you can surface metrics in your own tooling. |
⚙️ Project-wide defaults: Open the Coroutine Wait Instruction Buffers foldout under Project Settings ▸ Wallstop Studios ▸ Unity Helpers to edit these knobs. The settings asset lives at
Resources/Wallstop Studios/Unity Helpers/UnityHelpersBufferSettings.asset, ships with your build, and automatically applies on script/domain reload or when a player starts (unless your code overrides the values at runtime). Use Apply Defaults Now to push the current sliders into the active domain or Capture Current Values to snapshot whateverBuffersis using in play mode.🔒 Persistence Behavior: When you click Apply Defaults Now, the settings are immediately:
- Saved to disk: The asset is marked dirty and saved via
AssetDatabase.SaveAssets()- Applied to the runtime:
Buffers.WaitInstruction*properties are updated immediatelyThis ensures settings persist across:
- Domain reloads (script recompilation, entering/exiting play mode): Via
[InitializeOnLoadMethod]- Editor restarts: The asset is saved to disk and reloads automatically
- Standalone builds: The asset ships under
Resources/and auto-applies via[RuntimeInitializeOnLoadMethod]Toggle Apply On Load to control whether the saved defaults auto-apply when the domain loads. If disabled, the asset serves as a reference and you must call
asset.ApplyToBuffers()manually.
⚠️ Limit warnings: In Editor and Development builds the first limit hit (and every 25th after) emits a warning so you can spot misuses quickly. Production builds skip the log to avoid noise.
✅ Deterministic fallback: When the cache refuses a duration,
Buffers.GetWaitForSeconds*still returns a valid instruction; it just isn’t cached, so highly variable waits no longer lead to unbounded memory growth.
Pooling Unity Objects That Outlive Their Scope¶
TrackedObjectPool<T> pools UnityEngine.Object instances whose lifetime ends in a callback rather than at the end of a scope (a tween's OnComplete, an animation event, a coroutine).
That is the one shape WallstopGenericPool<T> cannot serve. It hands out a PooledResource<T> whose disposal returns the item, which is a lexical scope and therefore cannot strand anything; it also cannot refuse a return, and refusing one is the point here. Use it for scratch buffers, and this for pooled effects.
| Member | What it answers |
|---|---|
TryTake(out T) | false when the pool is disposed or nothing could be produced. An item destroyed while pooled is discarded, never handed out. |
Release(T) | false for a double release, for something this pool never handed out, and for a release arriving after Dispose. It never throws: the caller is usually a completion callback, where a throw surfaces nowhere. |
InFlightCount | How many are checked out, and would be destroyed by a teardown right now. |
Dispose() | Applies onDestroy to everything, in flight included, draining the tracking list first so a Release from a destroyed item's own ending is refused rather than counted twice. |
An item destroyed while checked out is still removed from tracking when released: ReferenceEquals(item, null) asks whether anything was handed in, item == null asks whether it is gone, and skipping the removal on the second question leaks one dead reference per use. The pool never calls Object.Destroy on its own initiative: onDestroy is where destruction lives, and a null one means something else owns it.
Callbacks can dispose the pool or destroy an item. TryTake returns false with a null output when its callback disposes the pool, destroys the item, or releases it. A nested take after that release keeps its own ownership. A release callback that disposes the pool retires the returned item through onDestroy instead of retaining it.
Callback exceptions are logged. A failed producer returns no item; it remains responsible for resources it never returns. A failed take retires its live item through onDestroy while that take still owns it. A failed release callback retires its live item. Release still returns true because it removed an item owned by this pool. If onDestroy throws, cleanup continues with the remaining items and clears all tracking. The caller must clean up any native object its destruction callback failed to destroy.
Gameplay Helpers¶
Predictive Aiming¶
What it does: Calculates where to aim when shooting at a moving target, accounting for projectile travel time.
Problem it solves: Shooting a bullet at where an enemy is misses if they're moving. You need to aim at where they will be.
When to use:
- Turrets shooting at moving enemies
- AI aiming at moving players
- Predictive targeting systems
- Guided missiles
When NOT to use:
- Homing projectiles (use steering behaviors)
- Instant-hit weapons (use raycasts)
- Slow-moving or stationary targets (just aim directly)
Spatial Sampling¶
Get random points in circles/spheres:
| C# | |
|---|---|
Use for:
- Spawn points (enemies, pickups, particles)
- Explosion damage distribution
- Random movement destinations
- Scatter patterns
Circular Curves and Arcs¶
ShapeHelper generates evenly spaced Vector2 points from angles expressed in degrees. Curve generation includes both requested endpoints; a one-point curve samples the angular midpoint.
| C# | |
|---|---|
GenerateTopCircularFraction takes a fraction of the upper 180° semicircle and centers it on 90°. GenerateCircularFraction takes a fraction of a full 360° circle and centers it on a caller-supplied angle. A full-circle fraction includes geometrically identical first and last points, which is useful for an open polyline; omit the last point when feeding a renderer that already closes its loop.
Supplying a buffer clears and reuses that list. Invalid counts, radii, fractions, centers, or angles fail softly by returning an empty destination instead of throwing.
Smooth Rotation Helpers¶
Get rotation speed for smooth turning:
Handles:
- Frame-rate independence
- Shortest rotation path (doesn't spin 270° when 90° is shorter)
- Angle wrapping (0-360°)
Delayed Execution¶
Execute code after delay or next frame:
| C# | |
|---|---|
Uses coroutines under the hood.
Repeating Execution with Jitter¶
Run a function repeatedly with an optional randomized initial delay:
Pass context when the object that owns the work is not the MonoBehaviour that hosts the coroutine. The first callback failure is filed against that object in the Console. A null context uses the coroutine host.
Pass an exceptionHandler to program against failures instead of logging them. It receives every failed invocation's zero-based index, resolved context, and exception. The coroutine continues when the action or handler throws; a handler failure is logged once.
| C# | |
|---|---|
Use for:
- Enemy spawning with variability
- Random event triggers
- Staggered updates to spread CPU load
- Natural-feeling timing
updateRate values that are nonpositive, NaN or infinite use the once-per-frame behavior, including when initial jitter or waitBefore is enabled. Jitter is applied only before the first invocation.
For cached computations, TimedCache<T> requires a finite nonnegative lifetime and throws ArgumentException for a negative or nonfinite lifetime. Negative or nonfinite jitter overrides act as zero jitter. Zero lifetime supports jitter without requesting an empty random range; an explicit finite positive jitter override still delays the initial expiry. Expiry arithmetic preserves finite lifetimes and jitter when their sum would overflow a float. The supplied float clock expires the cache only when elapsed time exceeds the lifetime plus initial jitter. Small clock offsets and jitter remain significant beside very large lifetimes. A factory or time provider exception leaves the cached value, refresh timer, and initial jitter unchanged; retrying uses the original expiry schedule. A successful refresh consumes the initial jitter.
Layer & Label Queries¶
| C# | |
|---|---|
Use for:
- Populating dropdowns in editor tools
- Runtime layer/label validation
- Configuration systems
Collider Syncing¶
Update PolygonCollider2D to match sprite:
| C# | |
|---|---|
GameObject & Component Helpers¶
Cached Component Lookup¶
Tag-based component finding with caching:
Performance: First call searches the scene using GameObject.FindWithTag; subsequent calls use a cached O(1) dictionary lookup.
Lifetime: An entry holds a strong reference to the component it cached, so while the game is running, unloading a scene drops every entry whose object went with it. Anything that survives the unload stays cached. In the editor outside play mode nothing sweeps the cache; ClearTagCache() drops the lot either way.
Component Existence Checks¶
| C# | |
|---|---|
Get-or-Add Pattern¶
| C# | |
|---|---|
Hierarchical Enable/Disable¶
Recursively enable/disable components:
| C# | |
|---|---|
Use for:
- Toggling collision for entire character rigs
- Hiding/showing complex prefabs
- Debug visualization toggles
Bulk Child Destruction¶
| C# | |
|---|---|
Use for:
- Clearing inventory UI
- Resetting spawn containers
- Cleanup before repopulating
Smart Destruction¶
Editor/runtime aware destruction:
| C# | |
|---|---|
Use in editor tools to avoid "Destroying assets is not permitted" errors.
Prefab Utilities¶
Transform Helpers¶
Hierarchy Traversal (Depth-First)¶
Visit all children recursively:
Hierarchy Traversal (Breadth-First)¶
Visit by depth level:
| C# | |
|---|---|
Use for:
- Finding immediate area (not entire tree)
- Level-based operations
- Performance-sensitive searches
Parent Traversal¶
Walk up the hierarchy:
Use for:
- Finding UI Canvas parents
- Inheritance checking (is this under X?)
- Walking to root of hierarchy
Direct Children/Parents¶
| C# | |
|---|---|
Threading¶
UnityMainThreadDispatcher¶
Execute code on Unity's main thread from background threads:
Problem it solves: Unity APIs can only be called from the main thread. Background Tasks/threads can't directly manipulate GameObjects. This marshals callbacks back to the main thread.
See the dedicated Unity Main Thread Dispatcher guide for details about auto-creation, queue limits, the AutoCreationScope helper, and instance cleanup through the public AutoCreationScope.Enabled(...) and Disabled(...) operations.
Async version with result:
| C# | |
|---|---|
SingleThreadedThreadPool¶
Run background work one item at a time, in enqueue order:
The optional noWorkWaitTime controls how long an idle worker waits for a signal. Invalid delays use the default one-second interval, so a mistaken negative or oversized timeout cannot stop the worker and strand later save jobs. Valid delays retain SemaphoreSlim's integer-millisecond truncation, including fractional values, zero, and Timeout.InfiniteTimeSpan. Enqueueing still signals the worker immediately, even with an infinite idle delay.
Use TryEnqueue when the caller must know whether submission succeeded. Its Action, Func<Task>, and Func<ValueTask> overloads return false for a null delegate or a pool already closed by draining or disposal. Admission is checked atomically with queue insertion; checking IsAcceptingWork before Enqueue cannot provide that guarantee. Existing Enqueue overloads ignore null or rejected work.
A true result means the work entered the queue. It does not promise successful execution or protect it from later disposal. Stop producers and drain the pool when accepted save work must run.
Disposal discards queued work. Dispose() and DisposeAsync() cancel the worker rather than draining it, so anything enqueued but not yet started is dropped; the await inside DisposeAsync() waits only for the item already in flight. Disposal immediately removes pending work and releases the queue's references to its delegates; concurrent submissions cannot leave new work in a shut-down queue. Concurrent disposal calls all wait for the same in-flight item and handle cleanup, so none returns while shutdown is still running. Count reaches zero once the in-flight item finishes. That is what you want for work that can simply be redone, such as a generation pass, and not what you want for durable work such as a persistence write.
The window is narrow enough to hide in testing: work enqueued a millisecond or more before disposal almost always completes, work enqueued immediately before it almost never does.
Call DrainAsync() when queued items must run. It closes the pool to new work permanently, then returns once the queue is empty and nothing is executing. If disposal interrupts the drain, DrainAsync() returns false even when disposal emptied the pending queue:
DrainAsync() returns false when the wait was abandoned via its CancellationToken, the pool was already disposed, or the worker had already stopped with items still queued, so a caller can fall back to writing the final state itself. IsAcceptingWork reports whether Enqueue still does anything.
The guarantee covers every item the calling thread enqueued before the call. A producer racing the drain from another thread is not covered, so stop those producers first.
One caveat on the synchronous Dispose(): it blocks the calling thread until the in-flight item finishes. The pool posts nothing back to Unity's main thread, so this is safe for ordinary work items. A work item whose own continuations capture the main thread's synchronization context would deadlock, because OnDestroy runs on that thread; prefer DisposeAsync() there. A work item can initiate DisposeAsync() and then return, but it must not await its own shutdown or call Dispose() because shutdown waits for that item to finish.
Semaphore Leases¶
SemaphoreSlim makes you pair every wait with a finally. Acquire() returns a SemaphoreLease instead, so the critical section is a using block:
When you would rather not block, TryAcquire reports failure instead:
| C# | |
|---|---|
SemaphoreLease is a struct, so an uncontended acquire allocates nothing. Its shared disposal slots are recycled when a lease is disposed on another thread, including after an await that resumes on a worker thread.
Disposal is tracked and idempotent. Disposing a lease twice returns one permit, not two. Without an explicit maximum, a stray Release() can raise the permit count above the intended limit and let two callers into a section built for one. IsHeld reports whether a lease still owns a permit, and a lease from a failed TryAcquire is not held, so disposing it is a no-op.
Copying a lease is safe. Assigning it to another variable, capturing it, or passing it by value produces copies that all point at the same permit, and exactly one of them releases it — whichever is disposed first. The claim is held outside the struct, where every copy reads the same state, so IsHeld reports false on every copy once any of them has released.
Construct semaphores with an explicit maximum. Copying is safe through the lease's shared claim. The maximum determines what happens when another caller releases a permit while a lease is held:
| Constructor | Another caller releases while a lease is held |
|---|---|
new SemaphoreSlim(1, 1) | A stray release raises SemaphoreFullException when the lease is disposed. Count survives. |
new SemaphoreSlim(1) | Maximum defaults to int.MaxValue, so the lease's release succeeds; the count rises to 2 and a second caller can enter the section. |
Disposing a lease after its semaphore has been disposed is safe. A full semaphore during lease disposal indicates another caller released a permit it did not own; that error is allowed to throw.
Acquire directly into a using and let the lease dispose there. Do not call Release() separately for a permit held by a lease.
Acquire() and AcquireAsync() throw ArgumentNullException on a null semaphore rather than handing back a lease that is not held: a silently unlocked critical section surfaces far from its cause. TryAcquire reports false instead.
Logging¶
Use the Logging Extensions guide for:
- Rich text tags applied directly inside interpolated strings (
$"{value:b,color=red}") - Thread-aware logging helpers (
this.Log,this.LogWarn,this.LogError,this.LogDebug) - Tips for registering custom decorations and gating logs per-object or globally
These helpers rely on the same dispatcher utilities above, so logging from jobs/background threads stays safe.
Fire-and-forget on main thread:
| C# | |
|---|---|
When to use:
- Async file loading callbacks
- Network request callbacks
- Database query results
- Background computation results that update UI
Important:
- Works in both edit mode and play mode
- Actions queued during edit mode execute in next editor update
- Don't block the main thread with long operations
Path & File Helpers¶
Path Sanitization¶
Normalize path separators:
| C# | |
|---|---|
Unity prefers forward slashes. Use this for cross-platform paths.
Directory Utilities¶
DirectoryHelper.ResolvePackageAssetPath returns an AssetDatabase path for package-relative content, including local packages referenced from an external checkout. Assets installations retain their Assets/ path; embedded, cached and external packages use Packages/<package-id>/. An external checkout with a missing or whitespace-only package identifier returns an empty path. FindAbsolutePathToDirectory uses the same resolver for directories in this package.
Create directories safely:
| C# | |
|---|---|
In the Editor, directory creation canonicalizes paths before touching disk or the AssetDatabase. Assets/Generated/Discarded/../Data creates Assets/Generated/Data; discarded folders are not created. Mixed separators and any casing of the initial Assets segment are supported. Invalid paths and paths resolving outside Assets, such as Assets/../Temp/Data, raise ArgumentException without writing outside Assets. Blank inputs remain a no-op. Existing disk-only directories are imported without creating numbered duplicates.
AssetDatabaseBatchHelper.EnsureAssetFolder and EnsureAssetParentFolder apply the same normalization and return false for invalid or escaping paths. Singleton failure cleanup also refuses escaping paths before deleting assets or disk files. These checks provide lexical containment; symbolic links within Assets are not resolved, so physical containment is not guaranteed. Player builds retain the ordinary Directory.CreateDirectory behavior.
Find package root:
| C# | |
|---|---|
Use for:
- Editor tools generating assets
- Finding package-relative paths
- Build scripts creating folders
Path Conversion¶
Convert between absolute and Unity-relative paths:
| C# | |
|---|---|
For paths in Library/PackageCache, DirectoryHelper.AbsoluteToUnityLoadablePath needs the package ID to build a Packages/<package-id>/ path. It returns an empty string when the package ID is blank, so callers can skip a path that Unity cannot load.
Get calling script's directory:
| C# | |
|---|---|
File Operations¶
Initialize file if missing:
| C# | |
|---|---|
InitializePath stages and flushes initial contents before publishing them at a free path. A failed write leaves the destination absent, so you can retry without deleting a partial save. It returns false for an existing file or directory, a blank path, an I/O failure, or a platform or filesystem that cannot publish a complete file atomically while preserving a competing creator. Null contents create an empty file. Missing parent directories are created. Paths containing spaces and legal long filenames remain supported.
Operations through DurableFile and FileHelper serialize on the same path within this process. Publication preserves a file created by another process before publication; it does not prevent another process from replacing the file afterward. Temporary files use short random names in the same directory and are removed on a best-effort basis. An interrupted process can leave one behind.
Async file copy:
A zero or negative copy buffer returns false before opening files. The destination keeps its previous contents if reading or staging fails, or if the copy is cancelled. A successful copy replaces it after the full source has been staged. On platforms that do not support file replacement, the final swap deletes the old destination before moving the staged file; a failed move can leave the destination absent. A temporary sibling file may remain after an interrupted process.
| C# | |
|---|---|
Use for:
- Large file operations without blocking
- Cancellable copy operations
- Streaming file operations
Durable Writes for Player Data¶
File.WriteAllText empties the destination before it writes a single byte. If the game is killed, the device loses power, or the disk fills in between, the player's save is gone and a truncated one is in its place. DurableFile writes the new contents to a sibling file, forces them to disk, and only then swaps them over the destination.
| C# | |
|---|---|
The text overload writes UTF-8 without a byte order mark by default. Pass an Encoding when the file format requires its preamble; TryWriteAllText(path, text, Encoding.UTF8, out error) writes the UTF-8 byte order mark before the text.
For binary saves, TryWriteAllBytes and WriteAllBytesAsync accept the serialized byte[] directly. They use the same staging and flush guarantees as text writes, without encoding or copying the payload. Missing directories are created; null or empty bytes replace the destination with an empty file. Keep the array unchanged until the call completes.
| C# | |
|---|---|
Every method reports failure instead of throwing, and the async ones return the exception (null on success):
Serializer.WriteToJsonFile and WriteToJsonFileAsync already write through this, so JSON saves get the guarantee without changing any code.
Async whole-file writes and copies check cancellation again after staging and immediately before publication. Cancellation observed there returns a failure, preserves the previous destination (or its absence), attempts to remove the staged file, and releases ownership for a retry. Staged-file cleanup is best effort: a deletion failure can leave that temporary file behind. Cancellation arriving after that final check can still publish the complete new file. Appending records has a separate contract: a cancelled append may have written part of its new record.
What it promises:
- On platforms with
File.Replace, a reader sees either complete old or complete new contents. - Unsupported
File.Replacereports failure and preserves the existing destination. Creating an absent destination still uses a move; subsequent replacements require platform support. - The data is forced out of the page cache before the swap makes it live.
- Concurrent writes to the same path from your game are serialized.
- A second process using the public
DurableFilewrite, append, or delete APIs cannot change the same destination while the first operation owns it. Its competing operation reports failure.
What it does not promise:
- It is not full crash safety. .NET cannot flush a directory, so a filesystem may still reorder the rename behind the data write.
- The ownership applies to one destination at a time. It is not a transaction across several files.
- Tools that do not use
DurableFiledo not share this ownership and can still edit a destination. - An internal compare-then-replace operation checks a file before staging its replacement, but unrelated writers can edit the destination between that check and the swap. It does not provide atomic content compare-and-swap against external tools. See #863 for the stronger contract under investigation. Tests reproduce a direct edit after comparison for both text and byte replacement: the complete staged replacement wins, so the external edit is lost. Use a shared writer protocol or a transactional store when that loss is unacceptable.
A leftover .tmp sibling (DurableFile.TemporarySuffix) is what an interrupted write leaves behind; it is safe to ignore or delete. DurableFile.TryDelete succeeds when a file is absent and reports failure for a directory or a file it cannot delete.
Scene Helpers¶
Scene Queries¶
SceneHelper.IsSceneLoaded returns false for null, empty, or whitespace-only names and paths, including when an unsaved scene has an empty path. Valid names and paths are matched exactly.
Check if scene is loaded:
| C# | |
|---|---|
Get all scene paths (editor):
| C# | |
|---|---|
Temporary Scene Loading¶
Constructing SceneHelper.SceneLoadScope with a null, empty, or whitespace-only path does nothing: it loads no scene, invokes no callback, and completes disposal immediately.
Call retrieval and SceneLoadScope construction on Unity's main thread. Retrieval accepts loaded scene names or paths, including nonactive scenes, and returns inactive objects too. Already loaded scenes are borrowed: disposing a result preserves their objects, active scene, and loaded scene count. Name matches are checked before resolving Build Settings aliases.
In players, new loads use scenes available to Unity, including packed Build Settings scenes; they do not require .unity source files on disk. Editor tools may also load scene assets outside Build Settings. Blank or unavailable inputs return empty/default results.
Load scene, extract data, unload owned scenes:
Retrieval disposal runs immediately on the calling main thread or posts to the Unity synchronization context captured during retrieval. It does not depend on dispatcher delivery. Await disposal before considering cleanup complete; a missing context or an unload failure faults the returned operation.
A direct SceneLoadScope exposes LoadTask for the loaded scene or load/callback failure. Its disposal must run on Unity's main thread. Disposal during loading suppresses the user callback, waits for loading, then unloads the exact scene owned by that scope. Multiple loads of the same path retain distinct scene handles. Repeated disposal awaits the same cleanup, and callback failures remain observable while cleanup still unloads the owned scene. Scene-loaded subscriptions and callback captures are released after completion or failure. These helpers retain Unity's synchronous scene-loading API, which can stall a frame; they do not provide a streaming-performance guarantee.
Use for:
- Extracting data from data-only scenes
- Editor tools reading scene contents
- Validation scripts
- Testing scene contents
Advanced Utilities¶
DisposableScope¶
Use DisposableScope.Create for a short cleanup that belongs to a using block:
| C# | |
|---|---|
Add actions with WithCleanup and dispose the final returned scope. Added actions run in reverse order, including when one throws:
| C# | |
|---|---|
Each layer is a value type. Copies share a disposal lease, so each action runs at most once even if several copies are disposed. Disposing a default scope or passing a null action is harmless, and Dispose never throws. For reverse ordering, keep the final scope and let its using block dispose it; disposing an earlier copy before the final scope can run that earlier action sooner. Concurrent disposal of copies still runs each action once, but ordering across threads is undefined.
The scope itself does not allocate after its lease slots have warmed up. Reuse noncapturing delegates when an allocation-free call site matters; a capturing lambda can allocate when it creates its delegate.
RestorableGlobal<T>¶
The problem: the obvious way to borrow a global for the length of a block captures the previous value in the scope's own field and restores from that field. Making the scope readonly fixes the per-copy "have I been disposed?" flag and fixes nothing here: every copy agrees about what to put back and none of them about whether it already has, so a second Dispose re-imposes a value the world has moved past. WUH014 reports the shape; this type is the answer.
The scope holds an identifier rather than a value, and gives it back with a call to the owner. Identifiers are never reused, so a stale copy's release is a no-op instead of a re-imposition.
Nesting and out-of-order disposal. Nesting one borrow inside another is the ordinary case, so the rule is stated for any depth and any order:
- The global always holds the value the newest live borrow asked for. Releasing an older borrow writes nothing — the newer borrow is still running and is still entitled to what it asked for.
- The released borrow's restore value is inherited by the borrow above it, so the last release returns the global to the value it held before the outermost borrow, whatever order the releases happened in.
- That inheritance is conditional: the borrow above only takes it over when what it captured is still the value the released borrow applied. Where something else wrote to the global in between, that write is what comes back.
Unwinding every newer borrow along with an out-of-order older one was rejected: it takes a value away from a scope that is still running.
Nothing throws. Disposing a default scope, disposing twice, disposing after ReleaseAll(), and a getter or setter that throws are all handled — the failure is logged and the bookkeeping stays consistent. TryBorrow reports whether the value actually took; IsHeld answers for every copy at once; Depth is how many borrows are live.
Cost. A borrow allocates nothing once the slot table has grown to the deepest nesting reached: the scope is a readonly struct, so using calls Dispose directly rather than through a boxed interface. Construction allocates the owner, its table and the two delegates, once.
Threading. A per-instance monitor guards the table, so concurrent borrows cannot corrupt it — and the getter and setter run under that monitor, which makes a borrow atomic against another thread's. A process-wide cell still has one value, so two threads borrowing at once last-writer-wins on the thing itself, and most globals worth borrowing (Unity's among them) are main-thread only regardless. Under SINGLE_THREADED the monitor is compiled out.
Unity-Aware Null Checks¶
The problem: Unity's == operator overload can be slow, and destroyed UnityEngine.Objects return true for == null but false for is null.
| C# | |
|---|---|
Handles:
- Destroyed UnityEngine.Objects
- Actual null references
- Optimized checks for non-Unity types
Cached Generic Type Classification¶
ObjectsTypeTraits<T> in WallstopStudios.UnityHelpers.Core.Helper exposes three read-only classifications cached once for each closed generic type. They describe the declared T:
| Field | Meaning |
|---|---|
IsValueType | T is a value type, including enums and nullable value types. |
IsObjectType | T is exactly System.Object. Other reference types return false. |
IsUnityObject | T is UnityEngine.Object or derives from it. Arrays and collections of Unity objects return false. |
An object variable holding a boxed struct or a GameObject retains the object classifications. The flags do not inspect an instance or change when a Unity object is destroyed. Use Objects.Null(value) for Unity-aware instance null checks. A nullable value type still has IsValueType == true when its value is null, so this flag does not establish non-nullness.
Hash Code Composition¶
Combine hash codes correctly:
| C# | |
|---|---|
Supports up to 20 parameters. The mixing step is FNV-1a, for good distribution.
Hash entire collections:
| C# | |
|---|---|
Use for:
- Custom GetHashCode implementations
- Dictionary keys with multiple fields
Not for anything that outlives the process. The mixing is fixed, but each argument contributes its ordinary GetHashCode() value, and those are not portable: .NET randomizes string hash codes per process, a UnityEngine.Object hashes to a session-local instance id, and any other type answers with whatever its author wrote. Two runs of the same build on the same machine can disagree. Persisting one of these values, sending it over a network, or comparing it against a stored copy will appear to work and then fail.
Stable Hashing for Saves and Networking¶
Unity object hashes remain stable after Destroy or DestroyImmediate, including values passed as object, spans, and sequences. Only a managed null reference hashes as null. This keeps dictionary and set keys reachable after their Unity objects are destroyed. Objects.Null and Objects.NotNull still use Unity's destroyed-object checks.
Objects.StableHash32V1 hashes bytes and nothing else, so its answer depends only on its arguments: the same bytes and the same seed produce the same value in every process, on every platform, and in every later version of this package. The algorithm is frozen -- that is what the V1 names -- so a future change arrives under a different name rather than as a new answer here.
| C# | |
|---|---|
An empty span returns the seed unchanged, so chunks can be folded together by passing the previous result as the next seed. Encode text yourself, so the encoding is part of your format rather than an assumption of this one. Being a 32-bit non-cryptographic hash, it is for identity and change detection, never for security.
SHA-256 Digests (Objects)¶
When identity needs to survive a hostile reader: Objects.Sha256Hex hashes text (as UTF-8) or bytes into the standard 64-character lowercase hex digest, and Objects.TrySha256HexOfFile hashes a file, reporting a missing or unreadable file with false instead of throwing.
| C# | |
|---|---|
Use it where a 32-bit stable hash is not enough: content-addressed cache keys, detecting whether a download or import actually changed, and tamper checks on player-supplied data. Unlike StableHash32V1, no adversary can craft a second input with the same digest, and unlike the HashCode family, the value is stable across processes and platforms.
Texture and Sprite Pixel Helpers¶
SpriteHelpers.RotateTexture90, RotateTexture180 and ExtractSpriteRect produce a new texture and leave the source untouched. They preserve writable source formats and use RGBA32 for readable compressed sources. A quarter turn swaps dimensions. Sprite extraction reads a small sprite's rectangle directly when supported, and uses the lower-memory full-texture path for large regions or formats such as Crunch that reject region reads.
Both return null, with a logged reason, when the texture is not readable or its format refuses pixel writes, so a compressed atlas never throws mid-load. Each returned texture is a new allocation: destroy it when finished with it.
| C# | |
|---|---|
ExtractSpriteRect copies the sprite's textureRect region from its source sheet, so it reads a sprite out of a larger sheet without touching the sheet itself. The sheet must be readable, and a rect that leaves the sheet is reported as a logged null rather than an out-of-range read.
Formatting¶
Human-readable byte counts:
| C# | |
|---|---|
Auto-scales to B, KB, MB, GB, TB.
Use for:
- File size displays
- Memory usage UI
- Profiling output
- Download progress
Multi-Dimensional Array Iteration¶
Enumerate 2D/3D array indices:
Also supports 3D arrays with (int, int, int) tuples.
Binary Array Conversion¶
Marshalling between int[] and byte[]:
| C# | |
|---|---|
Use for:
- Network serialization
- Binary file formats
- Save game data
- High-performance data conversion
Performance: Uses native memory copy (Buffer.BlockCopy) which is faster than element-by-element loops due to optimized native implementation, though both are O(n).
Bit Manipulation (BitOps)¶
One home for the bit math every system re-derives: BitOps centralizes the SWAR popcount, trailing zero count, floor log2, highest-bit isolation, power-of-two detection and power-of-two ceiling that data structures, sorters and capacity sizing keep re-implementing. Every method is a pure, allocation-free function of its inputs; signed overloads interpret their argument as the two's-complement bit pattern.
NextPowerOfTwo throws ArgumentOutOfRangeException when no exact power of two is representable (negative values, or values beyond 2^30 / 2^31 / 2^62 / 2^63 for int / uint / long / ulong) instead of overflowing silently.
Descriptive Statistics (WallMath)¶
One home for the numbers gameplay code keeps re-deriving: WallMath.Median, Percentile, Mean and StandardDeviation read an IReadOnlyList directly, so a List<T>, a T[] or a pooled buffer all work with no intermediate copy on your side. TryClopperPearsonInterval computes an exact confidence interval for a measured binomial success rate. TryExactSignTest compares paired measurements without a large-sample approximation. TryFisherExactTest compares two binary groups with fixed margins. Sorting for median and percentile happens on a pooled internal copy, so your list is never reordered.
Mean and StandardDeviation snapshot numeric List<T> inputs into privately owned pooled arrays and return them before the call exits. No disposable handle escapes. Array inputs retain their direct path, and other IReadOnlyList<T> implementations retain their indexed reads. Population/sample behavior, accumulation order and caller contents remain unchanged.
Conventions, chosen once so callers do not have to guess:
Medianof an even count averages the two middle elements; the halving is done indouble, so extreme magnitudes cannot overflow.Percentileinterpolates linearly between closest ranks (0is the minimum,1the maximum). Finite opposite extremes stay finite, and primitive conversions do not box the elements. Exact ranks retain their stored value. Interior interpolation preserves equal infinities, returns the infinite endpoint when only one endpoint is infinite, and returns NaN between opposite infinities. Results are undefined for data containing NaN; a NaN or out-of-range percentile throws.Meanaccumulates indouble, so a float sum cannot lose magnitude and an int sum cannot overflow. Integral data returnsdouble, matchingEnumerable.Average.StandardDeviationis the population standard deviation by default; passsample: truefor Bessel's correction when the data is a sample of a larger population.
For a binary outcome, use an exact Clopper-Pearson interval when the sample is small or the observed rate is close to zero or one:
| C# | |
|---|---|
The interval is equal-tailed and includes both endpoints. Zero successes produces a lower bound of zero; success on every trial produces an upper bound of one. Invalid counts, a non-positive trial count, or a non-finite/confidence level outside the open interval (0, 1) return false and set both outputs to zero.
For paired measurements, count how many non-tied pairs improved and regressed. The exact sign test returns the probability of an outcome at least as imbalanced under an equal-chance null hypothesis:
| C# | |
|---|---|
Exclude tied pairs before calling the method. Negative counts, no non-tied pairs, or a combined count beyond int.MaxValue return false and clear the output. The calculation uses the exact binomial tail and remains bounded for large counts; extremely small probabilities can round to zero in double.
For two independent binary groups, Fisher's exact test sums every fixed-margin table no more likely than the observed table:
| C# | |
|---|---|
Negative counts, an empty table, totals beyond int.MaxValue, or more than one million possible fixed-margin tables return false and clear the output. The table-count limit bounds work for data that needs a large-sample method instead.
For stratified binary outcomes, estimate the common odds ratio with Mantel-Haenszel pooling:
| C# | |
|---|---|
Rows and columns must have consistent meanings across strata. With cells (a, b, c, d) and total n, pooling computes sum(a*d/n) / sum(b*c/n), as in statsmodels' pooled odds ratio. Empty strata contribute nothing. Null or empty input, any negative cell, and an undefined zero-over-zero ratio return false and clear the output. A zero numerator produces zero; a positive numerator with a zero denominator produces positive infinity. All non-negative int cell counts are supported, including tables whose total exceeds int.MaxValue. The method reads the existing buffer without allocating or changing it. No continuity correction is added. This is a common-effect estimate, with independent observations within and between strata; it does not supply a confidence interval, an exact significance test, or a test of homogeneity.
For an already computed chi-square statistic, get its upper-tail probability directly:
| C# | |
|---|---|
The probability is Q(degreesOfFreedom / 2, statistic / 2), the regularized upper incomplete gamma function. Its evaluation uses the gamma series and continued fraction, with a stable logarithmic prefactor for large degrees of freedom. This evaluates the chi-square distribution; whether that distribution is appropriate for a particular test remains the caller's statistical assumption. It does not turn a small-sample chi-square test into an exact test.
Degrees of freedom must be a positive int. A zero statistic returns one, positive infinity returns zero, and negative or NaN statistics return false. Both numerical paths have a one-million-iteration convergence limit and fail with false and zero output if convergence cannot be established. Extremely small probabilities can underflow to zero; ordinary small tails are evaluated directly rather than by subtracting a cumulative probability from one. The method allocates no scratch buffer. Reference tests cover finite exponential sums for even degrees, independently evaluated small tails, and high-precision density quadrature at int.MaxValue degrees of freedom.
| C# | |
|---|---|
The descriptive list methods throw on an empty list and a null receiver: a statistic of nothing is undefined, and the package fails closed rather than inventing a zero.
Custom Comparers¶
Create IComparer from lambda:
| C# | |
|---|---|
Reverse any comparer:
| C# | |
|---|---|
Browser String Storage¶
BrowserStorage provides scoped string storage. WebGL players use the browser's localStorage. Editor and other players use PlayerPrefs. Backend selection happens at compile time. A blocked or full browser store returns failure rather than switching to another store and hiding existing data.
| C# | |
|---|---|
Call these methods on Unity's main thread. A scope must be nonblank; invalid scopes produce a store with IsConfigured == false whose operations return false. Keys are literal, case-sensitive strings; whitespace keys are valid, null and empty keys are rejected. Different scopes cannot alias each other's keys, even when scopes or keys contain colons. The store never clears all browser or PlayerPrefs data. Null values, null characters and unpaired UTF-16 surrogates in keys, scopes or values are rejected; invalid writes leave existing values unchanged. Reads return false with a null result when a key is missing or the backend fails. Present empty strings return true. Browser values containing invalid text written by another application are rejected rather than silently truncated by the native string bridge.
Writes and deletes are synchronous browser operations. The PlayerPrefs backend calls Save after each mutation. Success means the backend accepted the operation; it does not guarantee disk flush, future availability, cross-tab transactions or protection from browser eviction. If PlayerPrefs saving fails, its in-memory mutation may already have happened. Use an application-specific scope on shared origins. Stored strings are not encrypted. Private browsing, denied storage access and quotas can cause failure. Each operation constructs a scoped key, and reads allocate their returned string.
See Unity's browser scripting guide and PlayerPrefs documentation for native string marshalling and platform storage limits.
WebGL startup class 115 diagnostics¶
If a Unity 6000.5.2f1 WebGL build logs Could not produce class with ID 115, preserve its output and use Clean Build in Build Profiles. A build script can pass BuildOptions.CleanBuildCache to BuildPipeline.BuildPlayer, as described in Unity's clean-build guide. Keep the browser console visible when checking the rebuilt player.
If a clean build does not help, close Unity and move Library aside before rebuilding imported state. Keep a copy of the failing output and compare the same source and build settings after recovery. This rebuild leaves browser storage data intact and does not require weaker stripping settings. A successful storage read does not by itself show that the startup registration error is resolved; check the browser console as well.
Environment Detection¶
CI/CD Detection¶
Detect if running in a CI environment:
| C# | |
|---|---|
Read a named value from the current process, or use the deterministic overload when parsing a stored command line. Names are matched exactly and a trailing flag has no value:
| C# | |
|---|---|
GetCommandLineArguments preserves repeated values in order. Both helpers fail soft for null input or a blank name, and the current-process overload returns null instead of throwing when process arguments are unavailable. Blank names are rejected before reading process arguments. Names with surrounding spaces match exactly, and values retain literal whitespace.
Supported CI systems (checked via environment variables):
| CI System | Environment Variable |
|---|---|
| Generic CI | CI |
| GitHub Actions | GITHUB_ACTIONS |
| GitLab CI | GITLAB_CI |
| Jenkins | JENKINS_URL |
| Travis CI | TRAVIS |
| CircleCI | CIRCLECI |
| Azure Pipelines | TF_BUILD |
| TeamCity | TEAMCITY_VERSION |
| Buildkite | BUILDKITE |
| AWS CodeBuild | CODEBUILD_BUILD_ID |
| Bitbucket Pipelines | BITBUCKET_BUILD_NUMBER |
| AppVeyor | APPVEYOR |
| Drone CI | DRONE |
| Unity CI | UNITY_CI |
| Unity Tests | UNITY_TESTS |
Check specific environment variables:
Use for:
- Skipping interactive dialogs in CI
- Disabling expensive editor visualizations
- Conditional test behavior
- Build automation scripts
- Asset processors that shouldn't run headless
Best Practices¶
Performance¶
- Cache lookups:
Helpers.Find<T>()caches, but don't call every frame anyway - Use buffered variants:
IterateOverAllChildrenRecursivelywith buffers for hot paths - Main thread dispatch: Don't send hundreds of tiny tasks, batch work
- Hierarchy traversal: Use breadth-first with depth limits for large hierarchies
Threading¶
- Main thread rule: Only Unity APIs need main thread, pure C# can stay on background threads
- Avoid blocking: Don't wait for main thread results in tight loops
- CancellationToken: Support cancellation for long operations
Architecture¶
- Component vs Helper: Components (MonoBehaviours) for per-object state, Helpers for stateless operations
- Static method smell: If you need instance state, use a component instead
- Editor/Runtime split: Use
#if UNITY_EDITORguards for editor-only helpers
Code Organization¶
- Namespace imports: Use
using WallstopStudios.UnityHelpers.Core.Helper;at top of file - Don't extend helpers: These are sealed utility classes, not inheritance hierarchies
- Prefer composition: Use helpers from components, don't try to combine them
Related Documentation¶
- Intelligent Pooling System - Advanced object pooling with auto-purging
- Math & Extensions - Extension methods on built-in types
- Utility Components - MonoBehaviour-based utilities
- Reflection Helpers - High-performance reflection utilities
- Singletons - RuntimeSingleton and ScriptableObjectSingleton
- Data Structures - Cache, spatial trees, and other collections