Toolingcom.zoa.diagnostics · v0.1.0

ZOA Diagnostics

Static scanners that police the package graph, runtime probes that watch the frame, and one switch that strips both from a release build.

Diagnostics answers two different questions with one vocabulary. At edit time it asks whether the project's structure is sound: do the manifests parse, do the assembly references resolve, are there cycles, do namespaces follow convention, is anything still sitting in a legacy asset root, and which capabilities are still backed by placeholder logic. At runtime it asks whether the game is behaving: what did the last frame cost, what errors are repeating, what modifiers are on this entity's stats, where did that damage actually go.

Both sides speak in DiagnosticEntry values with a severity and a source. One wizard, one filter by package and severity, and one dashboard therefore serve a manifest error and a frame-budget violation alike.

The third thing the package owns is the off switch. A production profile plus a runtime manager gates every debug component in the project, and a build tool flips the profile to Production and physically removes the manager from the main menu scene, so a shipped build carries no diagnostics payload at all rather than a disabled one.

Depended on by (0)

Nothing yet. This is a leaf.

How it works

Concepts

Providers report, the service aggregates

IDiagnosticProvider is one source of findings: a ProviderId and a CollectDiagnostics that returns a snapshot of the current state. Providers do not filter, sort or present; they report.

IDiagnosticService owns the set. RegisterProvider and UnregisterProvider manage membership, CollectAll runs every provider and caches the result before firing DiagnosticsCollected, and GetByPackage and GetBySeverity filter that cache. GetBySeverity takes a minimum, so asking for Warning returns warnings, errors and criticals.

DiagnosticSeverity runs Info, Warning, Error, Critical with explicit numeric values, so a threshold comparison is meaningful. A DiagnosticEntry carries an id, severity, title, message, source, timestamp, tags, and a QuickFixAvailable flag the UI uses to decide whether to offer an action.

The static scanners

FoundryDiagnosticScanner builds a package inventory once and then runs the providers a request implies. ScanScope picks the shape: FullSystem runs every provider over every ZOA package, PerPackage runs every provider filtered to one, DependenciesOnly runs just the assembly integrity and cycle checks. The request also toggles the heavier optional passes.

PackageManifestProvider validates every package.json: it exists and parses, name is present and matches the folder, name carries the com.zoa. prefix, version is semver-shaped, and displayName is set, which is a warning rather than an error. AsmdefIntegrityProvider validates every assembly definition: it parses, its name is set and unique across the project, every reference resolves to a known asmdef name, and there are no empty or whitespace references, which is the signature of a botched edit.

DependencyCycleProvider builds the reference graph across every ZOA assembly and reports every cycle. Only edges between ZOA assemblies are walked, since a cycle through a Unity built-in is not possible given the directionality of Unity's own package dependencies. Cycles block compilation and violate the architectural contract outright, so they get their own provider instead of sitting inside the integrity check.

ArchitecturePolicyProvider catches higher-level policy violations that are not simple malformed-file errors. CanonicalLayoutProvider flags assets left in legacy roots superseded by Assets/Resources/ZOA and Assets/ZOA/Generated, and each finding names the migration menu item that fixes it. NamespaceConventionProvider reads every compiled file under Packages/com.zoa.*, strips comments, and checks the first namespace declaration starts with ZOA. It skips folders ending in a tilde since Unity excludes them from compilation, and it is optional because it is the heaviest check at O(files).

Capability truth

CapabilityTruthProvider is the unusual one. It reports capabilities that are present in the UI or API surface but still backed by stub, static, placeholder or fallback logic, so a button that exists but does not yet do the full job shows up as a finding.

Each rule is guarded by source evidence, pointing at the file and the specific gap. A completed fix therefore drops the finding on the next scan, with no checklist entry left to remember.

Runtime probes

FrameBudget tracks one system against a per-frame time and allocation budget. Construct it with a system name and limits, bracket the system's tick with BeginFrame and EndFrame, and read IsOverBudget, ViolationRate, AverageFrameMs and PeakFrameMs. GetViolationSummary formats the report.

PerformanceBenchmark runs a hot path a fixed number of iterations and returns min, max, median, mean, p95 and p99 in milliseconds plus an estimated allocation per iteration. It has no Unity engine dependency, so a test can assert a median directly.

ErrorTelemetry captures, categorises and throttles error reports so a single repeating failure does not flood the log. It keeps a bounded recent list, buckets by category with a per-bucket per-minute cap, exposes TotalErrorCount and ThrottledCount, and fires ErrorRecorded. It is engine-agnostic; wire it to Application.logMessageReceived or any other logging source by calling RecordError.

StatModifierDiagnosticsRegistry lets runtime systems that do not live on a Unity object publish a snapshot of an entity's stat modifiers, so editor and HUD diagnostics inspect the same data path as scene components. StatModifierSnapshot is decoupled from the runtime StatModifier struct rather than sharing it.

The dependency analyzer

IDependencyAnalyzer works over DependencyEdge values rather than over Unity's package data, which keeps it testable with synthetic graphs. AnalyzeDependencies builds the directed graph, FindCycles returns each cycle as the list of package ids forming the loop, and ValidateForbiddenEdges takes explicit (from, to) pairs and reports which of them exist.

The forbidden-edge check encodes project rules such as inventory must never depend on armory, and a scan enforces them instead of a reviewer.

Health signals

IHealthDashboard tracks a PackageHealthSignal per package: a status of Healthy, Degraded, Failing or Unknown, counts of passing and failing tests, a validation error count, and when it was last checked. RefreshAll re-evaluates every package and fires HealthUpdated per package.

The signal is a summary rather than a log. The Workbench health dashboard renders one row per package from it, and the row tells you which package to point the scanner at.

Stripping diagnostics from a release build

ZOAProductionProfile is a single ScriptableObject at Resources/ZOA/ZOAProductionProfile so the manager can load it without a scene reference. It holds a ZOAProductionMode, the enabled ZOADebugCategory flags, and a ForceDevelopmentInEditor toggle. ResolveEffectiveMode applies that editor override; ResolveEffectiveCategories returns None outright when the effective mode is Production, so a stale category flag cannot leak diagnostics into a shipped build.

ZOAProductionManager is a DontDestroyOnLoad singleton placed on its own root object in the main menu scene. On Awake it reads the profile, drives the canonical ZOADiagnosticsGate in com.zoa.foundation, and toggles every IZOADebugComponent it finds. Because it survives scene loads, every subsequent scene inherits the same policy without carrying its own copy.

The build tool does the stripping. Prepare Build for Release sets the profile to Production and removes the manager object from the main menu scene, so the shipped build has no diagnostics payload rather than a payload that is switched off. Restore Diagnostics for Development flips the profile back and re-installs the manager.

In the editor

Screens

Screenshot pending

/screenshots/diagnostics-health-dashboard.png

The Diagnostics module's Health Dashboard capability with a row per package showing Healthy, Degraded and Failing statuses, test pass and fail counts, and validation error counts.

Health dashboard

Screenshot pending

/screenshots/diagnostics-scan-results.png

The diagnostics wizard's review step after a full-system scan: severity counts across the top, scan duration and packages-inspected figures, and a list of findings with source and message.

Scan results

Screenshot pending

/screenshots/diagnostics-dependency-viewer.png

The Dependency Viewer capability showing the package and assembly graph, with at least one cycle highlighted and the packages forming the loop named.

Dependency viewer

Setup

Workflow

  1. 01

    Scan the project

    Tools > ZOA > Advanced > Validate > Diagnostics > Run Diagnostics routes into the Workbench Diagnostics module. Pick a scope, run, and review. Full System is the pre-commit sweep; Dependencies Only is the fast check when you have just moved an assembly reference.

  2. 02

    Read the health dashboard first

    The Health Dashboard capability shows one row per package with status, test counts and validation errors. It tells you where to point the scanner rather than making you read every finding.

  3. 03

    Fix cycles before anything else

    A dependency cycle blocks compilation and is an outright violation of the architectural contract. The Dependency Viewer shows the graph and DependencyCycleProvider names every loop by the package ids involved.

  4. 04

    Budget a hot system

    Wrap the system's tick in a FrameBudget and watch ViolationRate rather than a single frame. For a specific method, PerformanceBenchmark.Run gives a distribution you can assert against in a test.

  5. 05

    Install the production manager

    Tools > ZOA > Advanced > Build > Install Production Manager in Main Menu Scene creates the profile if needed, drops the manager on its own root object, binds the profile and saves the scene. Do this once per project.

  6. 06

    Strip diagnostics for release

    Tools > ZOA > Advanced > Build > Prepare Build for Release sets the profile to Production and removes the manager from the main menu scene, so the build ships with no diagnostics payload. Restore Diagnostics for Development undoes both.

Surface

Key types

IDiagnosticProvider

interface

One source of findings. Report a snapshot of current state; leave filtering and presentation to the service.

  • string ProviderId { get; }
  • IReadOnlyList<DiagnosticEntry> CollectDiagnostics()

IDiagnosticService

service

Owns the provider set, aggregates their output, and filters it. GetBySeverity takes a minimum, so Warning includes errors and criticals.

  • event Action DiagnosticsCollected
  • void RegisterProvider(IDiagnosticProvider provider)
  • void UnregisterProvider(string providerId)
  • IReadOnlyList<DiagnosticEntry> CollectAll()
  • IReadOnlyList<DiagnosticEntry> GetByPackage(string packageId)
  • IReadOnlyList<DiagnosticEntry> GetBySeverity(DiagnosticSeverity severity)

DiagnosticEntry

class

One finding. Immutable, timestamped, tagged, and flagged with whether a quick fix exists.

  • DiagnosticId Id { get; }
  • DiagnosticSeverity Severity { get; }
  • string Title { get; }
  • string Message { get; }
  • string Source { get; }
  • DateTime Timestamp { get; }
  • IReadOnlyList<string> Tags { get; }
  • bool QuickFixAvailable { get; }

DiagnosticId

struct

Readonly string-backed id with implicit conversions in both directions, so a literal works at any call site.

  • string Value { get; }
  • bool IsEmpty { get; }

DiagnosticSeverity

enum

Info 0, Warning 1, Error 2, Critical 3. The explicit values make a minimum-severity filter meaningful.

IDependencyAnalyzer

interface

Cycle detection and forbidden-edge validation over explicit edges, so it can be tested with a synthetic graph rather than a real project.

  • IReadOnlyList<DependencyEdge> AnalyzeDependencies(IReadOnlyList<DependencyEdge> edges)
  • IReadOnlyList<IReadOnlyList<string>> FindCycles()
  • IReadOnlyList<DependencyEdge> ValidateForbiddenEdges(IReadOnlyList<(string from, string to)> forbiddenPairs)

DependencyEdge

class

One directed edge, with flags for whether it participates in a cycle and whether it is optional.

  • string FromPackage { get; }
  • string ToPackage { get; }
  • bool IsCyclic { get; }
  • bool IsOptional { get; }

DependencyAnalyzer

class

The shipped analyzer. Depth-first search for cycles over an adjacency list built from the supplied edges.

IHealthDashboard

service

Per-package health signals with a refresh that re-evaluates every package and fires an event per package.

  • event Action<string> HealthUpdated
  • PackageHealthSignal GetPackageHealth(string packageId)
  • IReadOnlyList<PackageHealthSignal> GetAllPackageHealth()
  • void RefreshAll()

PackageHealthSignal

class

A package's summary: status, test pass and fail counts, validation error count, and when it was last checked.

  • string PackageId { get; }
  • PackageHealthStatus Status { get; }
  • int TestsPassing { get; }
  • int TestsFailing { get; }
  • int ValidationErrors { get; }
  • DateTime LastChecked { get; }

PackageHealthStatus

enum

Healthy, Degraded, Failing, Unknown.

FoundryDiagnosticScanner

class

The static scan entry point. Builds the package inventory once, runs the providers the request implies, and returns a report with severity counts, timing and inspection counts. Synchronous, so a CLI or build step can call it directly.

  • static ScanReport Run(ScanRequest request)
  • enum ScanScope { FullSystem, PerPackage, DependenciesOnly }
  • struct ScanRequest { ScanScope Scope; string TargetPackage; bool IncludePackageAndAsmdef; bool IncludeCanonicalLayout; bool IncludeNamespaceConventions; }
  • static ScanRequest ScanRequest.Default()
  • class ScanReport { IReadOnlyList<DiagnosticEntry> Entries; IReadOnlyList<string> ProvidersRun; int InfoCount; int WarningCount; int ErrorCount; int CriticalCount; TimeSpan Duration; int PackagesInspected; int AsmdefsInspected; }

FrameBudget

class

Per-system frame-time and allocation budget with violation tracking. Bracket the system's tick and read the rate rather than a single frame.

  • FrameBudget(string systemName, double maxFrameMs = 2.0, long maxAllocBytes = 0, ...)
  • void BeginFrame()
  • void EndFrame(long allocBytes = 0)
  • bool IsOverBudget { get; }
  • int TotalFrames { get; }
  • int ViolationCount { get; }
  • double ViolationRate { get; }
  • double AverageFrameMs { get; }
  • double PeakFrameMs { get; }
  • string GetViolationSummary()

PerformanceBenchmark

class

Engine-free hot-path benchmarking. Returns a full distribution rather than one number, so a test can assert on the median or p99.

  • static BenchmarkResult Run(string name, Action action, int iterations)
  • static BenchmarkResult RunWithAllocations(string name, Action action, int iterations)
  • class BenchmarkResult { double MinMs; MaxMs; MedianMs; MeanMs; P95Ms; P99Ms; TotalMs; EstimatedAllocBytesPerIteration; }

ErrorTelemetry

class

Captures, categorises and throttles errors so a repeating failure does not flood the log. Engine-agnostic; feed it from any logging source.

  • ErrorTelemetry(int maxRecentErrors = 100, int throttlePerBucketPerMinute = 10)
  • bool RecordError(string category, string message, ...)
  • IReadOnlyList<ErrorRecord> RecentErrors { get; }
  • int TotalErrorCount { get; }
  • int ThrottledCount { get; }
  • event Action<ErrorRecord> ErrorRecorded
  • IReadOnlyDictionary<string, int> GetErrorCountsByCategory()
  • IReadOnlyDictionary<ErrorSeverity, int> GetErrorCountsBySeverity()
  • string GetSummary()
  • void Reset()

StatModifierDiagnosticsRegistry

class

Shared publication point for stat modifier snapshots, so systems that are not Unity objects surface on the same diagnostics path as scene components.

  • static IReadOnlyList<StatModifierSnapshot> Snapshots { get; }
  • static event Action SnapshotsChanged
  • static void Publish(string entityId, IEnumerable<StatModifier> modifiers)
  • static void Publish(StatModifierSnapshot snapshot)
  • static bool Remove(string entityId)
  • static void Clear()

StatModifierSnapshot

class

An entity's modifiers at a moment: stat id, additive and multiplicative contributions, order, and the source that applied each one.

  • string EntityId { get; }
  • IReadOnlyList<StatModifierEntry> Modifiers { get; }
  • class StatModifierEntry { string StatId; float Additive; float Multiplier; int Order; string Source; }

ZOAProductionManager

component

DontDestroyOnLoad singleton that applies the profile to ZOADiagnosticsGate and toggles every IZOADebugComponent. One per project, in the main menu scene.

  • static ZOAProductionManager Instance { get; }
  • ZOAProductionProfile Profile { get; }
  • void ApplyProfile()
  • void SetMode(ZOAProductionMode mode)
  • void SetCategory(ZOADebugCategory category, bool enabled)
  • void RefreshDebugComponents()

DamageFlowDiagnosticHud

component

Renders recent shots as flow diagrams from DamageFlowBus: muzzle node, pellet branches, hit nodes with surface, region and multiplier badges, and penetration chains. An IZOADebugComponent in the Weapon category, so the production gate governs it.

DemoControlsOverlay

component

Controls help overlay listing every keyboard-mapped action grouped by action map. An IZOADebugComponent in the Overlay category.

  • bool ShowOnStart { get; set; }
  • void SetVisible(bool visible)
  • bool IsVisible { get; }

Surface

Authoring assets

ZOAProductionProfile

asset

The one asset that decides whether the running game is Development or Production and which debug categories are live. Assets > Create > ZOA > Diagnostics > Production Profile; canonical path Assets/Resources/ZOA/ZOAProductionProfile.asset.

  • const string ResourcesPath = "ZOA/ZOAProductionProfile"
  • ZOAProductionMode Mode { get; }
  • ZOADebugCategory EnabledCategories { get; }
  • bool ForceDevelopmentInEditor { get; }
  • ZOAProductionMode ResolveEffectiveMode()
  • ZOADebugCategory ResolveEffectiveCategories()

Usage

Examples

Running a scan and reading the reportcsharp
using ZOA.Diagnostics.Core.Models;
using ZOA.Diagnostics.Unity.Editor.Scanning;

var request = new FoundryDiagnosticScanner.ScanRequest(
    scope: FoundryDiagnosticScanner.ScanScope.FullSystem,
    targetPackage: null,
    includePackageAndAsmdef: true,
    includeCanonicalLayout: true,
    // The namespace pass reads every .cs file under Packages/com.zoa.*,
    // so it is opt-in rather than part of the default sweep.
    includeNamespaceConventions: false);

var report = FoundryDiagnosticScanner.Run(request);

UnityEngine.Debug.Log(
    $"{report.PackagesInspected} packages, {report.AsmdefsInspected} asmdefs in {report.Duration.TotalSeconds:F1}s: " +
    $"{report.CriticalCount} critical, {report.ErrorCount} error, {report.WarningCount} warning.");

foreach (var entry in report.Entries)
{
    if (entry.Severity >= DiagnosticSeverity.Error)
        UnityEngine.Debug.LogError($"[{entry.Source}] {entry.Title}: {entry.Message}");
}
Enforcing forbidden edgescsharp
using System.Collections.Generic;
using ZOA.Diagnostics.Core.Contracts;
using ZOA.Diagnostics.Core.Models;
using ZOA.Diagnostics.Core.Runtime;

IDependencyAnalyzer analyzer = new DependencyAnalyzer();

analyzer.AnalyzeDependencies(new List<DependencyEdge>
{
    new DependencyEdge("com.zoa.armory", "com.zoa.equipment"),
    new DependencyEdge("com.zoa.equipment", "com.zoa.inventory"),
    new DependencyEdge("com.zoa.inventory", "com.zoa.armory"),
});

// Cycles come back as the package ids forming each loop.
foreach (var cycle in analyzer.FindCycles())
    UnityEngine.Debug.LogError($"Cycle: {string.Join(" -> ", cycle)}");

// Rules a review would otherwise have to catch by eye.
var violations = analyzer.ValidateForbiddenEdges(new List<(string from, string to)>
{
    ("com.zoa.inventory", "com.zoa.armory"),
    ("com.zoa.messaging", "com.zoa.nucleon"),
});

foreach (var edge in violations)
    UnityEngine.Debug.LogError($"Forbidden edge: {edge.FromPackage} -> {edge.ToPackage}");
Budgeting a system's tickcsharp
using UnityEngine;
using ZOA.Diagnostics.Core.Runtime;

public sealed class ArmoryTickBudget : MonoBehaviour
{
    private readonly FrameBudget _budget = new FrameBudget("Armory", maxFrameMs: 2.0);

    private void Update()
    {
        _budget.BeginFrame();
        TickArmory();
        _budget.EndFrame();

        // One slow frame is noise. A violation rate is a finding, so report
        // on the rate rather than logging every frame that overruns.
        if (_budget.TotalFrames % 600 == 0 && _budget.ViolationRate > 0.05)
            Debug.LogWarning(_budget.GetViolationSummary());
    }

    private void TickArmory() { }
}
Publishing a custom providercsharp
using System;
using System.Collections.Generic;
using ZOA.Diagnostics.Core.Contracts;
using ZOA.Diagnostics.Core.Models;
using ZOA.Diagnostics.Core.Runtime;

public sealed class LoadoutIntegrityProvider : IDiagnosticProvider
{
    public string ProviderId => "mystudio.diagnostics.loadout-integrity";

    public IReadOnlyList<DiagnosticEntry> CollectDiagnostics()
    {
        var entries = new List<DiagnosticEntry>();

        foreach (var loadout in FindLoadouts())
        {
            if (loadout.SlotCount != 0) continue;

            entries.Add(new DiagnosticEntry(
                id: "loadout.empty",
                severity: DiagnosticSeverity.Error,
                title: "Loadout has no usable slots",
                message: $"'{loadout.Name}' resolves zero equipment slots.",
                source: "com.mystudio.loadouts",
                timestamp: DateTime.UtcNow,
                tags: new[] { "equipment", "authoring" },
                quickFixAvailable: false));
        }

        return entries;
    }

    private static IEnumerable<LoadoutRef> FindLoadouts() => Array.Empty<LoadoutRef>();

    private readonly struct LoadoutRef
    {
        public string Name => string.Empty;
        public int SlotCount => 0;
    }
}

// Register once during bootstrap; CollectAll picks it up from then on.
IDiagnosticService service = new DiagnosticService();
service.RegisterProvider(new LoadoutIntegrityProvider());
service.DiagnosticsCollected += () => { /* repaint */ };

Tooling

Editor tools

Diagnostics (Workbench)

Workbench > System & Configuration > Diagnostics

Five capabilities: Health Dashboard for per-package signals, Capability Truth for findings still backed by placeholder logic, Dependency Viewer for the package and assembly graph, Stat Viewer for live modifier snapshots by entity and source, and Interaction Trace for the runtime trace log.

Run Diagnostics

Tools > ZOA > Advanced > Validate > Diagnostics > Run Diagnostics

Routes into the Workbench Diagnostics module's health dashboard. The wizard itself walks Select Scope, Run, Review Results across Full System, Per Package and Dependencies Only.

Install Production Manager

Tools > ZOA > Advanced > Build > Install Production Manager in Main Menu Scene

Creates the production profile if it is missing, adds the manager to its own root object in the main menu scene, binds the profile and saves.

Prepare Build for Release

Tools > ZOA > Advanced > Build > Prepare Build for Release (strip diagnostics)

Confirms, sets the profile to Production, and removes every production manager from the main menu scene so the build ships silent with no diagnostics payload.

Restore Diagnostics for Development

Tools > ZOA > Advanced > Build > Restore Diagnostics for Development

Sets the profile back to Development and re-installs the manager.

Read this

Notes and caveats

See also