ZOA Extensions
One contract and one browser for every third-party package the stack can talk to.
Extensions settles where the code that knows about someone else's package lives. Before this package it lived in two places, an IExtensionIntegrator in terrain shaped around the terrain pipeline and an IntegrationDefinition in nucleon shaped around the player-rig wizard. Both still exist and still work; both are now lifted onto one contract through adapters, and one browser lists everything.
IFoundryExtension is small: identity, category, availability, a diagnostic line, a refresh, and an optional setup hook, which covers everything the browser draws. Anything more specific lives in a capability sub-interface that a consumer casts to, so a terrain integrator and an FMOD define-symbol integration can sit in the same list without either contract growing to accommodate the other.
The other rule the package holds to is that a bridge never becomes a hard dependency. Every runtime bridge here reaches its target package through reflection and silently no-ops when the package is absent, so com.zoa.extensions compiles in a project that has none of the integrations installed.
Depended on by (2)
How it works
Concepts
One contract, three capability layers
IFoundryExtension is the umbrella. ExtensionId is a stable identifier used both to de-duplicate registrations across domain reloads and to correlate a runtime integrator with a curated catalogue row. DisplayName, Description and Category drive the browser row. IsAvailable says whether the underlying package is detected. DiagnosticSummary is the short status line, something like five tree prefabs and three grass billboards detected, or Blaze AI runtime detected with the perception bridge inactive. Refresh re-runs detection, and SupportsSetup plus RunSetup provide the one-click action.
IFoundryScriptingSymbolExtension adds the define-symbol layer for integrations gated behind a compile symbol. Detection runs in two stages: reflect across AppDomain for any of RequiredTypeNames to decide the package is present, then declare ScriptingDefineSymbol in Player Settings when the user runs Setup so the gated code starts compiling. IsScriptingSymbolActive backs the DEFINE ON and DEFINE OFF pill in the browser.
IFoundryExtensionWithDemo adds a runnable demo. An extension can return a DemoScenePath for a scene already on disk, or override GenerateAndOpenDemo to author one first. The browser only shows the button when the extension is also detected, because every demo expects its package's runtime types to resolve.
Registration is self-service and idempotent
FoundryExtensionRegistry is a static list. Extensions register themselves at editor load from an [InitializeOnLoadMethod]; the browser iterates All to render rows and pipeline-specific consumers cast entries to the capability they care about.
Register de-duplicates by ExtensionId and replaces the stored reference rather than appending, so a domain reload leaves the registry holding the fresh instance instead of a stale one plus a duplicate row. Changed fires on register and unregister, which is how the browser repaints as integrators stream in after a reload.
How the legacy contracts are lifted
TerrainIntegratorAdapter wraps a terrain IExtensionIntegrator, forwarding its properties verbatim and reporting the Terrain category by default, with a constructor override for integrators that belong elsewhere such as Cinemachine landing under PlayerRig. It is presentation only: the legacy ExtensionIntegratorRegistry still drives the demo scene generator's apply pass unchanged.
NucleonIntegrationAdapter wraps a player-rig IntegrationDefinition and implements IFoundryScriptingSymbolExtension, so the browser can show the gating symbol state and offer a one-click add. It discovers every concrete IntegrationDefinition subclass by reflection at editor load. The player wizard keeps its own discovery loop; this adapter is purely additive.
CuratedEntryAdapter wraps an ExtensionEntryDefinition, the curated catalogue asset that still lives in com.zoa.terrain. Detection rides the shared ExtensionDetectionService with its UPM probe, asset-path scan and folder fuzzy match. Setup handles all three install paths: FreeUnity adds by UPM id, FreeOSS adds by pinned git URL, Paid opens the Asset Store page.
When an integrator and a curated row describe the same thing
A package such as ProBuilder can appear twice: once as a live terrain integrator and once as a curated catalogue entry with a normalised id like curated.ProBuilder. The browser renders only one row.
The integrator wins, because its detection is canonical, resolved synchronously through the UPM package probe, and its setup hook does the real scene-side wiring. The curated entry is kept as an install fallback: when the integrator reports missing, the row's Install button delegates to the curated entry's setup hook so the user still gets a one-click install.
Demos come from the other direction
Extensions sits well below demo in the package graph, so it cannot reference com.zoa.demo to open a demo scene. FoundryExtensionDemoRegistry inverts that: com.zoa.demo registers a handler per extension id from an [InitializeOnLoadMethod], and the browser is a passive consumer that surfaces an Open Demo button when both a handler exists and the extension is detected.
A handler is an extension id, a callable, an optional button label and a description. The demo package's bindings map ProBuilder and Splines to the AI Encounters generator and Cinemachine to the Full Showcase generator, so opening a demo from the browser regenerates a scene that actually exercises the integration.
Adding a demo therefore never touches com.zoa.extensions; the registry is the only seam between the two packages.
Reflection is the dependency strategy
The runtime bridges compile without their targets. BlazeAiAgentDamageBridge, EmeraldAiAgentDamageBridge and BehaviorDesignerAgentDamageBridge each sit on the same GameObject as the third-party agent and implement Foundry's IAiDamageReceiver, forwarding a DamageContext into the vendor's damage method through cached reflection. When the player's hitscan walks up from a hitbox collider looking for IAiDamageReceiver, it resolves to the bridge.
FinalIkAgentRig adds aim and look-at solvers to an agent rig at runtime through reflection, no-opping entirely when Final IK is absent. PuppetMasterDeathBridge converts an agent's death into an active-ragdoll state so a body falls and keeps responding to hits instead of freezing on a T-pose. ZOAEnviroDayNightDriver either pushes the configured ZOA time of day into Enviro on Awake or pulls Enviro's current time every frame so the HUD clock, AI senses and vegetation read one clock.
Editor-side bridges follow the same rule. The AI bridges cover Blaze AI, Emerald AI 2025, Behavior Designer and Sensor Toolkit 2, plus a set of detect-only entries for RAIN AI, Behavior Bricks, Polarith AI Free, Apex Path and Utility AI, and Sensor Toolkit v1 that probe for types and report presence without wiring anything. The engine bridges cover DOTween, Easy Save 3, Enviro 3, Final IK and PuppetMaster.
In the editor
Screens
Screenshot pending
/screenshots/extensions-browser.png
The browser with several category foldouts open, each header reading something like Terrain 4/9, rows showing a mix of detected and missing states, one row with a DEFINE OFF pill and a Setup button, another with an Open Demo button.
Screenshot pending
/screenshots/extensions-detail.png
A single expanded row for a detected integration: diagnostic summary line, define-symbol state, and the Setup and demo buttons side by side.
Setup
Workflow
- 01
Open the browser
Tools > ZOA > Advanced > Maintain > Extensions > Foundry Extensions, or the Extensions page in the Workbench under System & Configuration, which embeds the same window. Categories are collapsible and each header shows how many of its entries are detected.
- 02
Install what you need
A row for a package you do not have offers Install. Free Unity packages add through UPM by id, curated open-source packages add through UPM by pinned git URL, and paid Asset Store items open their store page for you to buy and import.
- 03
Run setup
A detected extension that supports setup shows a Setup button. For a scripting-symbol integration that means declaring the define in Player Settings so the gated code compiles, which triggers a domain reload. For a scene-side integration it means wiring the integration into the anchors in the setup context.
- 04
Try the demo
When com.zoa.demo has registered a handler for a detected extension, the row offers a demo button. ProBuilder and Splines open AI Encounters, Cinemachine opens the Full Showcase; each regenerates the scene so the integration is actually exercised.
- 05
Write your own extension
Implement IFoundryExtension in an editor assembly, add IFoundryScriptingSymbolExtension if a define gates your code, and register from an [InitializeOnLoadMethod]. Keep detection cheap, since Refresh runs on every browser refresh, and keep the runtime side reflection-only so the assembly compiles without the target package.
Surface
Key types
IFoundryExtension
interface
The umbrella contract. It carries identity, availability and a setup hook, which covers everything the browser draws; anything domain-specific belongs in a capability sub-interface.
- string ExtensionId { get; }
- string DisplayName { get; }
- string Description { get; }
- FoundryExtensionCategory Category { get; }
- bool IsAvailable { get; }
- string DiagnosticSummary { get; }
- void Refresh()
- bool SupportsSetup { get; }
- void RunSetup(FoundryExtensionSetupContext context)
IFoundryScriptingSymbolExtension
interface
For integrations gated behind a compile symbol. Detect the package by probing types, then declare the symbol on Setup so the gated code compiles.
- string ScriptingDefineSymbol { get; }
- IReadOnlyList<string> RequiredTypeNames { get; }
- bool IsScriptingSymbolActive { get; }
IFoundryExtensionWithDemo
interface
For extensions that ship a runnable demo. Either name a scene asset on disk or override the generator hook to author one, and the browser adds a button when the extension is also detected.
- string DemoScenePath { get; }
- string DemoButtonLabel { get; }
- string DemoDescription { get; }
- bool GenerateAndOpenDemo()
FoundryExtensionRegistry
class
The static list every extension registers into. De-duplicates by id and replaces on re-register, so a domain reload refreshes rather than duplicates.
- static IReadOnlyList<IFoundryExtension> All { get; }
- static event Action Changed
- static void Register(IFoundryExtension extension)
- static void Unregister(string extensionId)
- static void RefreshAll()
- static bool ContainsId(string extensionId)
FoundryExtensionSetupContext
struct
Optional anchors handed to RunSetup. Every field may be null; an extension keys off whichever applies to its domain and ignores the rest.
- GameObject PlayerRoot
- GameObject EnvironmentRoot
- string Source
- static FoundryExtensionSetupContext Empty { get; }
FoundryExtensionCategory
enum
Browser grouping: Tooling, Terrain, PlayerRig, Ai, Sensors, AssetPack, Audio, Animation, Networking, Ui, and Other which renders last. Adding a category is forward-compatible; a legacy asset whose value fails to deserialise falls back to Tooling.
FoundryExtensionDemoRegistry
class
Maps an extension id to a demo invocation. com.zoa.demo registers the handlers; the browser consumes them, which keeps the dependency pointing the right way.
- static void Register(string extensionId, Action invoke, string buttonLabel = null, string description = null)
- static bool Has(string extensionId)
- static DemoHandler Get(string extensionId)
- class DemoHandler { string ExtensionId; string ButtonLabel; string Description; Action Invoke; }
TerrainIntegratorAdapter
class
Presentation-only lift of a terrain IExtensionIntegrator onto the unified contract. Reports SupportsSetup false, since the legacy registry still owns the apply pass.
NucleonIntegrationAdapter
class
Lifts a player-rig IntegrationDefinition onto the unified contract as a scripting-symbol extension. Discovers every concrete subclass by reflection at editor load.
CuratedEntryAdapter
class
Lifts a curated ExtensionEntryDefinition into the browser. Detection through the shared ExtensionDetectionService, install through the shared ExtensionInstallService.
- static ExtensionDetectionService Detection { get; }
- static ExtensionInstallService Install { get; }
FoundryExtensionBrowserWindow
class
The unified browser. Collapsible category sections with a detected-over-total header per category, row de-duplication, and a repaint when the UPM package list arrives.
- const string CapabilityId = "extension-browser"
- const string MenuPath = "Tools/ZOA/Advanced/Maintain/Extensions/Foundry Extensions"
- static void Open()
- static void OpenStandalone()
Usage
Examples
using System.Collections.Generic;
using UnityEditor;
using ZOA.Extensions.Unity;
using ZOA.Extensions.Unity.Editor;
[InitializeOnLoad]
internal static class WwiseBridgeRegistration
{
static WwiseBridgeRegistration() =>
FoundryExtensionRegistry.Register(new WwiseBridge());
}
internal sealed class WwiseBridge : IFoundryScriptingSymbolExtension
{
private bool _detected;
public string ExtensionId => "audio.wwise";
public string DisplayName => "Wwise (Audiokinetic)";
public string Description => "Routes ZOA audio buses to Wwise events.";
public FoundryExtensionCategory Category => FoundryExtensionCategory.Audio;
public bool IsAvailable => _detected;
public string DiagnosticSummary => _detected
? (IsScriptingSymbolActive
? "Wwise detected, define active."
: "Wwise detected, define not set.")
: "Wwise not found in this project.";
// Refresh runs on every browser refresh, so keep detection cheap:
// a type probe, not an asset database sweep.
public void Refresh() => _detected = ProbeTypes();
public string ScriptingDefineSymbol => "ZOA_AUDIO_WWISE";
public IReadOnlyList<string> RequiredTypeNames => new[] { "AkSoundEngine" };
public bool IsScriptingSymbolActive =>
PlayerSettings.GetScriptingDefineSymbols(
UnityEditor.Build.NamedBuildTarget.Standalone)
.Contains(ScriptingDefineSymbol);
public bool SupportsSetup => _detected && !IsScriptingSymbolActive;
public void RunSetup(FoundryExtensionSetupContext context)
{
// Declaring the define triggers a domain reload, so nothing
// after this call in the same frame is guaranteed to run.
AddDefineSymbol(ScriptingDefineSymbol);
}
private static bool ProbeTypes() => false;
private static void AddDefineSymbol(string symbol) { }
}#if UNITY_EDITOR
using UnityEditor;
using ZOA.Extensions.Unity.Editor;
internal static class WwiseDemoBinding
{
// Registered from the package that OWNS the demo, never from
// com.zoa.extensions: the dependency only points one way.
[InitializeOnLoadMethod]
private static void Register()
{
FoundryExtensionDemoRegistry.Register(
extensionId: "audio.wwise",
invoke: () => new AudioShowcaseGenerator().Generate(openAfter: true),
buttonLabel: "Open Audio Showcase",
description:
"Opens the Audio Showcase. Per-bus mixer routing plays through " +
"Wwise events when the integration is active.");
}
}
#endifusing System.Linq;
using UnityEngine;
using ZOA.Extensions.Unity;
using ZOA.Extensions.Unity.Editor;
FoundryExtensionRegistry.RefreshAll();
foreach (var ext in FoundryExtensionRegistry.All
.Where(e => e.Category == FoundryExtensionCategory.Ai)
.OrderBy(e => e.DisplayName))
{
Debug.Log($"{ext.DisplayName}: {(ext.IsAvailable ? "detected" : "missing")} — {ext.DiagnosticSummary}");
// Capability layers are discovered by cast, which is why the
// umbrella contract never had to grow to accommodate them.
if (ext is IFoundryScriptingSymbolExtension symbol && !symbol.IsScriptingSymbolActive)
Debug.Log($" define {symbol.ScriptingDefineSymbol} is not set.");
}Tooling
Editor tools
Foundry Extensions
Tools > ZOA > Advanced > Maintain > Extensions > Foundry Extensions
The unified browser: every registered extension grouped into collapsible category sections, each header showing detected over total, with Install, Setup and demo actions per row.
Extensions (Workbench)
Workbench > System & Configuration > Extensions
The same browser embedded as a Workbench capability. The window stays the owner of detection and setup; the module only hosts it.
Read this
Notes and caveats
See also