From a8ea18c404a0c2e30d970a30de9d8c5f26064ba4 Mon Sep 17 00:00:00 2001 From: Martin Vagstad Date: Thu, 30 Apr 2026 19:48:56 +0200 Subject: [PATCH 1/2] Add profile module: stats sampling, vitals, assert, save, snapshot, remote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `unityctl profile` family for capturing Unity Editor and remote-player performance data from the CLI: profile list-stats Enumerate built-in counters (~3,790 in test project) profile start/stop/status Session lifecycle with --max-duration auto-stop cap profile capture One-shot start+wait+stop, with optional --save .data profile vitals Curated 5-number report (avg/p99 frame, GC, draws, GPU) profile assert CI/regression gate: exit 1 if thresholds breached profile snapshot Memory snapshot via com.unity.memoryprofiler profile targets List editor + connected players profile connect Direct-URL connect (e.g. adb-forwarded Android) Local mode samples Unity.Profiling.ProfilerRecorder per editor tick. Remote mode (autoconnect-profiler builds, live device) drives the editor's profiler buffer and walks frames with RawFrameDataView.GetCounterValueAsLong on stop — same JSON shape, real device numbers. Verified end-to-end on a connected Samsung SM-A266B running an IL2CPP+ARM64 dev build. Stat name aliases (main, render, gpu, drawcalls, gc-alloc, ...) give agents stable handles independent of Unity's slightly inconsistent counter naming. Side effects: - Switching unity-project to Android target updates URP/GraphicsSettings/ ProjectSettings (productName, applicationIdentifier, etc.) — kept. - Set System.Globalization invariant culture in CLI Program.cs so threshold flags like --p99-frame-ms 16.7 parse correctly on non-en locales. - unity-project/Assets/Editor/AndroidProfileBuild.cs is a reusable helper that produces a Dev + AutoConnect Profiler APK from BuildPipeline. Known limitation: Memory counters (System Used Memory, GC Allocated In Frame) return 0 from Android — Unity's player doesn't stream the Memory profiler module by default. Markers exist; values aren't populated. CPU/GPU/draw counters work end-to-end. --- .claude/skills/unity-editor/SKILL.md | 16 + UnityCtl.Cli/ProfileCommands.cs | 692 ++++++++++++++++ UnityCtl.Cli/Program.cs | 10 + UnityCtl.Cli/Resources/SKILL.md | 16 + UnityCtl.Protocol/Constants.cs | 9 + UnityCtl.Protocol/DTOs.cs | 180 +++++ .../Editor/ProfilingManager.cs | 750 ++++++++++++++++++ .../Editor/ProfilingManager.cs.meta | 2 + .../Editor/UnityCtlClient.cs | 119 +++ .../Plugins/UnityCtl.Protocol.dll | Bin 56832 -> 68608 bytes unity-project/.gitignore | 4 + .../Assets/Editor/AndroidProfileBuild.cs | 88 ++ .../Assets/Editor/AndroidProfileBuild.cs.meta | 2 + .../Assets/Settings/UniversalRP.asset | 56 +- ...niversalRenderPipelineGlobalSettings.asset | 12 +- .../ProjectSettings/ProjectSettings.asset | 7 +- 16 files changed, 1934 insertions(+), 29 deletions(-) create mode 100644 UnityCtl.Cli/ProfileCommands.cs create mode 100644 UnityCtl.UnityPackage/Editor/ProfilingManager.cs create mode 100644 UnityCtl.UnityPackage/Editor/ProfilingManager.cs.meta create mode 100644 unity-project/Assets/Editor/AndroidProfileBuild.cs create mode 100644 unity-project/Assets/Editor/AndroidProfileBuild.cs.meta diff --git a/.claude/skills/unity-editor/SKILL.md b/.claude/skills/unity-editor/SKILL.md index 6baaf6b..5ba1f37 100644 --- a/.claude/skills/unity-editor/SKILL.md +++ b/.claude/skills/unity-editor/SKILL.md @@ -57,6 +57,22 @@ unityctl screenshot list-windows # List open editor windows (type, title unityctl screenshot window # Capture specific editor window by type or title unityctl screenshot window SceneView out.png # e.g. capture Scene view to out.png +# Profiling (frame stats, hitches, regression gates) +# Sessions: start → run scenario via other commands → stop returns summary JSON +unityctl profile vitals --duration 3 # Curated 5-number report: avg/p99 frame, GC alloc, draw calls, GPU +unityctl profile capture --duration 5 # One-shot start+wait+stop, prints summary +unityctl profile capture --duration 5 --save run.data # Also save .data for the Profiler window +unityctl profile start --stats main,gpu,drawcalls,gc-alloc --max-duration 30 # Returns sessionId +unityctl profile stop # Returns summary JSON: avg/p50/p95/p99/max + hitches +unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 1024 # CI gate, exit 1 on fail +unityctl profile list-stats --category Render # Enumerate built-in counters +unityctl profile snapshot --output mem.snap # Memory snapshot (requires com.unity.memoryprofiler) +unityctl profile targets # List editor + connected players +unityctl profile connect 127.0.0.1:54999 # Direct-URL connect (Android via adb forward) +# Stat aliases: main, render, gpu, drawcalls, gc-alloc, system-memory, total-frame, batches, triangles +# Default vitals stats: CPU Main/Render Thread Frame Time, GPU Frame Time, Draw Calls, GC Alloc, System Memory +# Profile in play mode for meaningful frame data; edit-mode samples editor update ticks (very fast). + # Video Recording (requires com.unity.recorder package) # Note: record start auto-enters play mode if not already playing unityctl record start # Start recording (manual stop) diff --git a/UnityCtl.Cli/ProfileCommands.cs b/UnityCtl.Cli/ProfileCommands.cs new file mode 100644 index 0000000..4426027 --- /dev/null +++ b/UnityCtl.Cli/ProfileCommands.cs @@ -0,0 +1,692 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using UnityCtl.Protocol; + +namespace UnityCtl.Cli; + +public static class ProfileCommands +{ + public static Command CreateCommand() + { + var profileCommand = new Command("profile", "Profiler operations: capture frame stats, detect hitches, save full profiler data"); + + profileCommand.AddCommand(BuildListStatsCommand()); + profileCommand.AddCommand(BuildStartCommand()); + profileCommand.AddCommand(BuildStopCommand()); + profileCommand.AddCommand(BuildStatusCommand()); + profileCommand.AddCommand(BuildCaptureCommand()); + profileCommand.AddCommand(BuildVitalsCommand()); + profileCommand.AddCommand(BuildAssertCommand()); + profileCommand.AddCommand(BuildSnapshotCommand()); + profileCommand.AddCommand(BuildTargetsCommand()); + profileCommand.AddCommand(BuildConnectCommand()); + + return profileCommand; + } + + // ---------- list-stats ---------- + + private static Command BuildListStatsCommand() + { + var cmd = new Command("list-stats", "List available profiler stats (counters)"); + var category = new Option("--category", "Filter by category (e.g., Render, Memory, Internal)"); + cmd.AddOption(category); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "category", ctx.ParseResult.GetValueForOption(category) } + }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileListStats, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; + return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + + Console.WriteLine($"{result.Count} stats available"); + string? lastCategory = null; + foreach (var s in result.Stats) + { + if (s.Category != lastCategory) + { + Console.WriteLine(); + Console.WriteLine($"[{s.Category}]"); + lastCategory = s.Category; + } + Console.WriteLine($" {s.Name,-40} {s.Unit} ({s.DataType})"); + } + }); + + return cmd; + } + + // ---------- start ---------- + + private static Command BuildStartCommand() + { + var cmd = new Command("start", "Start a profiling session (returns sessionId)"); + var stats = new Option("--stats", + "Comma-separated stats or aliases (e.g., main,gpu,drawcalls,gc-alloc). Default: vitals."); + var maxDuration = new Option("--max-duration", + "Auto-stop after N seconds if not explicitly stopped (safety cap)"); + var target = new Option("--target", + "Target id from 'profile targets' (default: editor)"); + var savePath = new Option("--save", + "Also drive the Editor profiler buffer and save a .data file (Phase 2)"); + cmd.AddOption(stats); + cmd.AddOption(maxDuration); + cmd.AddOption(target); + cmd.AddOption(savePath); + + cmd.SetHandler(async (InvocationContext ctx) => + { + await HandleStart(ctx, stats, maxDuration, target, savePath); + }); + return cmd; + } + + private static async Task HandleStart( + InvocationContext ctx, + Option statsOpt, + Option maxOpt, + Option targetOpt, + Option saveOpt) + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var statsRaw = ctx.ParseResult.GetValueForOption(statsOpt); + string[]? statsList = null; + if (!string.IsNullOrWhiteSpace(statsRaw)) + statsList = statsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + + var args = new Dictionary + { + { "stats", statsList }, + { "maxDurationSeconds", ctx.ParseResult.GetValueForOption(maxOpt) }, + { "target", ctx.ParseResult.GetValueForOption(targetOpt) }, + { "savePath", ResolveSavePathForUnity(ctx, ctx.ParseResult.GetValueForOption(saveOpt)) } + }; + + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileStart, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; + return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + Console.WriteLine($"Started profiling session {result.SessionId}"); + Console.WriteLine($" stats: {string.Join(", ", result.Stats)}"); + if (result.MaxDurationSeconds.HasValue) + Console.WriteLine($" max-duration: {result.MaxDurationSeconds}s (auto-stop)"); + if (!string.IsNullOrEmpty(result.SavePath)) + Console.WriteLine($" save: {result.SavePath}"); + } + + // ---------- stop ---------- + + private static Command BuildStopCommand() + { + var cmd = new Command("stop", "Stop a profiling session and return the summary"); + var sessionArg = new Argument("session-id", "Session id from 'profile start'"); + var includeSamples = new Option("--include-samples", "Include per-frame sample arrays in output"); + var hitchMultiplier = new Option("--hitch-multiplier", "Hitch threshold = median × this (default 2.0)"); + var hitchAbsoluteMs = new Option("--hitch-ms", "Absolute hitch threshold in ms (overrides multiplier)"); + + cmd.AddArgument(sessionArg); + cmd.AddOption(includeSamples); + cmd.AddOption(hitchMultiplier); + cmd.AddOption(hitchAbsoluteMs); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "sessionId", ctx.ParseResult.GetValueForArgument(sessionArg) }, + { "includeSamples", ctx.ParseResult.GetValueForOption(includeSamples) }, + { "hitchMultiplier", ctx.ParseResult.GetValueForOption(hitchMultiplier) }, + { "hitchAbsoluteMs", ctx.ParseResult.GetValueForOption(hitchAbsoluteMs) } + }; + + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileStop, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; + return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + PrintStopHuman(result); + }); + + return cmd; + } + + // ---------- status ---------- + + private static Command BuildStatusCommand() + { + var cmd = new Command("status", "List active profiling sessions"); + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileStatus, null, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; + return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + if (result.Sessions.Length == 0) + { + Console.WriteLine("No active profiling sessions"); + return; + } + foreach (var s in result.Sessions) + { + Console.WriteLine($"{s.SessionId} elapsed={s.ElapsedSeconds:F1}s frames={s.Frames} stats=[{string.Join(",", s.Stats)}]" + + (s.MaxDurationSeconds.HasValue ? $" max={s.MaxDurationSeconds}s" : "")); + } + }); + return cmd; + } + + // ---------- capture (start + sleep + stop) ---------- + + private static Command BuildCaptureCommand() + { + var cmd = new Command("capture", "One-shot capture: start, wait, stop. Sugar for start+stop."); + var duration = new Option("--duration", () => 5.0, "Duration in seconds"); + var stats = new Option("--stats", "Comma-separated stats or aliases"); + var target = new Option("--target", "Target id (default: editor)"); + var save = new Option("--save", "Also save a .data file (drives Editor profiler buffer)"); + var includeSamples = new Option("--include-samples", "Include per-frame sample arrays"); + var hitchMs = new Option("--hitch-ms", "Absolute hitch threshold in ms"); + cmd.AddOption(duration); + cmd.AddOption(stats); + cmd.AddOption(target); + cmd.AddOption(save); + cmd.AddOption(includeSamples); + cmd.AddOption(hitchMs); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var statsRaw = ctx.ParseResult.GetValueForOption(stats); + string[]? statsList = null; + if (!string.IsNullOrWhiteSpace(statsRaw)) + statsList = statsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + + var dur = ctx.ParseResult.GetValueForOption(duration); + var savePath = ResolveSavePathForUnity(ctx, ctx.ParseResult.GetValueForOption(save)); + + var startArgs = new Dictionary + { + { "stats", statsList }, + { "maxDurationSeconds", dur + 5 }, // safety cap > duration + { "target", ctx.ParseResult.GetValueForOption(target) }, + { "savePath", savePath } + }; + + var startResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStart, startArgs, ContextHelper.GetTimeout(ctx)); + if (startResp == null || startResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error starting capture: {startResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + var startResult = Deser(startResp.Result); + if (startResult == null) { ctx.ExitCode = 1; return; } + + await Task.Delay(TimeSpan.FromSeconds(dur)); + + var stopArgs = new Dictionary + { + { "sessionId", startResult.SessionId }, + { "includeSamples", ctx.ParseResult.GetValueForOption(includeSamples) }, + { "hitchAbsoluteMs", ctx.ParseResult.GetValueForOption(hitchMs) } + }; + var stopResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStop, stopArgs, ContextHelper.GetTimeout(ctx)); + if (stopResp == null || stopResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error stopping capture: {stopResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(stopResp.Result)); + return; + } + + var stopResult = Deser(stopResp.Result); + if (stopResult != null) PrintStopHuman(stopResult); + }); + return cmd; + } + + // ---------- vitals ---------- + + private static Command BuildVitalsCommand() + { + var cmd = new Command("vitals", "Curated 5-number perf report (avg/p99 frame, GC alloc, draw calls, GPU)"); + var duration = new Option("--duration", () => 3.0, "Sample window in seconds"); + var target = new Option("--target", "Target id (default: editor)"); + cmd.AddOption(duration); + cmd.AddOption(target); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var dur = ctx.ParseResult.GetValueForOption(duration); + var startArgs = new Dictionary + { + { "stats", new[] { "CPU Main Thread Frame Time", "CPU Render Thread Frame Time", "GPU Frame Time", + "Draw Calls Count", "GC Allocated In Frame", "System Used Memory" } }, + { "maxDurationSeconds", dur + 5 }, + { "target", ctx.ParseResult.GetValueForOption(target) } + }; + + var startResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStart, startArgs, ContextHelper.GetTimeout(ctx)); + if (startResp == null || startResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error starting vitals: {startResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + var startResult = Deser(startResp.Result); + if (startResult == null) { ctx.ExitCode = 1; return; } + + await Task.Delay(TimeSpan.FromSeconds(dur)); + + var stopResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStop, + new Dictionary { { "sessionId", startResult.SessionId } }, + ContextHelper.GetTimeout(ctx)); + if (stopResp == null || stopResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error stopping vitals: {stopResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(stopResp.Result)); + return; + } + + var stopResult = Deser(stopResp.Result); + if (stopResult == null) return; + PrintVitalsHuman(stopResult); + }); + return cmd; + } + + // ---------- assert ---------- + + private static Command BuildAssertCommand() + { + var cmd = new Command("assert", "Run a short capture and assert thresholds. Non-zero exit on failure."); + var duration = new Option("--duration", () => 3.0, "Sample window in seconds"); + var p99Frame = new Option("--p99-frame-ms", "Fail if p99 frame time (ms) >= this"); + var avgFrame = new Option("--avg-frame-ms", "Fail if avg frame time (ms) >= this"); + var maxGcAlloc = new Option("--gc-alloc-per-frame", "Fail if avg GC alloc/frame (bytes) >= this"); + var maxDrawCalls = new Option("--draw-calls", "Fail if avg draw calls >= this"); + var maxHitches = new Option("--max-hitches", "Fail if hitches > this"); + var hitchMs = new Option("--hitch-ms", () => 33.3, "Hitch threshold in ms (default 33.3)"); + cmd.AddOption(duration); + cmd.AddOption(p99Frame); + cmd.AddOption(avgFrame); + cmd.AddOption(maxGcAlloc); + cmd.AddOption(maxDrawCalls); + cmd.AddOption(maxHitches); + cmd.AddOption(hitchMs); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var dur = ctx.ParseResult.GetValueForOption(duration); + var startArgs = new Dictionary + { + { "stats", new[] { "CPU Main Thread Frame Time", "GPU Frame Time", "Draw Calls Count", "GC Allocated In Frame" } }, + { "maxDurationSeconds", dur + 5 } + }; + var startResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStart, startArgs, ContextHelper.GetTimeout(ctx)); + if (startResp == null || startResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error starting assert: {startResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + var startResult = Deser(startResp.Result); + if (startResult == null) { ctx.ExitCode = 1; return; } + + await Task.Delay(TimeSpan.FromSeconds(dur)); + + var stopResp = await client.SendCommandAsync(UnityCtlCommands.ProfileStop, + new Dictionary + { + { "sessionId", startResult.SessionId }, + { "hitchAbsoluteMs", ctx.ParseResult.GetValueForOption(hitchMs) } + }, ContextHelper.GetTimeout(ctx)); + if (stopResp == null || stopResp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error stopping assert: {stopResp?.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + var stopResult = Deser(stopResp.Result); + if (stopResult == null) { ctx.ExitCode = 1; return; } + + var failures = new List(); + var mainThread = stopResult.Summaries.FirstOrDefault(s => s.Name == "CPU Main Thread Frame Time"); + var gcAlloc = stopResult.Summaries.FirstOrDefault(s => s.Name == "GC Allocated In Frame"); + var drawCalls = stopResult.Summaries.FirstOrDefault(s => s.Name == "Draw Calls Count"); + + var p99Limit = ctx.ParseResult.GetValueForOption(p99Frame); + var avgLimit = ctx.ParseResult.GetValueForOption(avgFrame); + var gcLimit = ctx.ParseResult.GetValueForOption(maxGcAlloc); + var dcLimit = ctx.ParseResult.GetValueForOption(maxDrawCalls); + var hitchLimit = ctx.ParseResult.GetValueForOption(maxHitches); + + if (p99Limit.HasValue && mainThread != null && mainThread.P99 >= p99Limit.Value) + failures.Add($"p99 frame time {mainThread.P99:F2}ms >= {p99Limit:F2}ms"); + if (avgLimit.HasValue && mainThread != null && mainThread.Avg >= avgLimit.Value) + failures.Add($"avg frame time {mainThread.Avg:F2}ms >= {avgLimit:F2}ms"); + if (gcLimit.HasValue && gcAlloc != null && gcAlloc.Avg >= gcLimit.Value) + failures.Add($"avg GC alloc/frame {gcAlloc.Avg:F0} bytes >= {gcLimit:F0} bytes"); + if (dcLimit.HasValue && drawCalls != null && drawCalls.Avg >= dcLimit.Value) + failures.Add($"avg draw calls {drawCalls.Avg:F0} >= {dcLimit:F0}"); + var hitchCount = stopResult.Hitches?.Length ?? 0; + if (hitchLimit.HasValue && hitchCount > hitchLimit.Value) + failures.Add($"hitches {hitchCount} > {hitchLimit.Value}"); + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(new + { + passed = failures.Count == 0, + failures, + summary = stopResult + })); + } + else + { + PrintStopHuman(stopResult); + if (failures.Count == 0) + { + Console.WriteLine(); + Console.WriteLine("PASS — all thresholds met."); + } + else + { + Console.WriteLine(); + Console.WriteLine("FAIL:"); + foreach (var f in failures) Console.WriteLine($" - {f}"); + } + } + + if (failures.Count > 0) ctx.ExitCode = 1; + }); + return cmd; + } + + // ---------- snapshot (memory) ---------- + + private static Command BuildSnapshotCommand() + { + var cmd = new Command("snapshot", "Capture a memory snapshot (.snap) via Memory Profiler package"); + var output = new Option("--output", "Output path (default: MemoryCaptures/.snap, project-relative)"); + cmd.AddOption(output); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "output", ResolveSavePathForUnity(ctx, ctx.ParseResult.GetValueForOption(output)) } + }; + + // Snapshots can take a while. + var timeout = ContextHelper.GetTimeout(ctx) ?? 180; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileSnapshot, args, timeout); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + var result = Deser(resp.Result); + if (result != null) + Console.WriteLine($"Saved snapshot: {ContextHelper.FormatPath(result.Path)} ({result.SizeBytes / 1024.0 / 1024.0:F1} MB)"); + }); + return cmd; + } + + // ---------- targets ---------- + + private static Command BuildTargetsCommand() + { + var cmd = new Command("targets", "List available profiler targets (editor + connected players)"); + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileTargets, null, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + if (result.Targets.Length == 0) + { + Console.WriteLine("No profiler targets visible (editor only)."); + return; + } + + Console.WriteLine("ID KIND CURRENT NAME"); + foreach (var t in result.Targets) + Console.WriteLine($"{t.Id,-5} {t.Kind,-9} {(t.IsCurrent ? "*" : " ")} {t.DisplayName}"); + }); + return cmd; + } + + // ---------- connect (DirectURLConnect) ---------- + + private static Command BuildConnectCommand() + { + var cmd = new Command("connect", "Connect Editor profiler to a remote target by URL (e.g. 127.0.0.1:54999 for adb-forwarded Android player)"); + var urlArg = new Argument("url", "Target URL (host:port). For Android over USB, set up `adb forward tcp:54999 tcp:` first."); + cmd.AddArgument(urlArg); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary { { "url", ctx.ParseResult.GetValueForArgument(urlArg) } }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileConnect, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + Console.WriteLine($"Connected: {JsonHelper.Serialize(resp.Result)}"); + }); + return cmd; + } + + // ---------- helpers ---------- + + private static BridgeClient? MakeClient(InvocationContext ctx) + { + return BridgeClient.TryCreateFromProject( + ContextHelper.GetProjectPath(ctx), + ContextHelper.GetAgentId(ctx)); + } + + private static T? Deser(object? raw) where T : class + { + if (raw == null) return null; + var json = JsonConvert.SerializeObject(raw, JsonHelper.Settings); + return JsonConvert.DeserializeObject(json, JsonHelper.Settings); + } + + private static string? ResolveSavePathForUnity(InvocationContext ctx, string? input) + { + if (string.IsNullOrEmpty(input)) return null; + // Unity-side resolves relative to its working directory (project root). Pass through as-is + // unless the user gave an absolute path, in which case keep absolute. + return input; + } + + private static void PrintStopHuman(ProfileStopResult result) + { + Console.WriteLine($"Profiling session {result.SessionId} stopped"); + Console.WriteLine($" duration: {result.DurationSeconds:F1}s ({result.Frames} frames)"); + if (result.TargetIsRemote) + Console.WriteLine($" target: REMOTE — {result.Target ?? "(connected player)"}"); + if (!string.IsNullOrEmpty(result.SavedPath)) + Console.WriteLine($" saved: {result.SavedPath}"); + + Console.WriteLine(); + Console.WriteLine($" {"Stat",-30} {"Avg",10} {"p50",10} {"p95",10} {"p99",10} {"Max",10} Unit"); + foreach (var s in result.Summaries) + { + Console.WriteLine($" {s.Name,-30} {Fmt(s.Avg),10} {Fmt(s.P50),10} {Fmt(s.P95),10} {Fmt(s.P99),10} {Fmt(s.Max),10} {s.Unit}"); + } + if (result.Hitches != null && result.Hitches.Length > 0) + { + Console.WriteLine(); + Console.WriteLine($" {result.Hitches.Length} hitch(es) detected:"); + foreach (var h in result.Hitches.Take(10)) + Console.WriteLine($" frame {h.FrameIndex}: {h.FrameTimeMs:F2}ms"); + if (result.Hitches.Length > 10) Console.WriteLine($" ... and {result.Hitches.Length - 10} more"); + } + } + + private static void PrintVitalsHuman(ProfileStopResult r) + { + Console.WriteLine($"Vitals ({r.Frames} frames, {r.DurationSeconds:F1}s)"); + var dict = r.Summaries.ToDictionary(s => s.Name, s => s); + + var main = dict.GetValueOrDefault("CPU Main Thread Frame Time"); + var render = dict.GetValueOrDefault("CPU Render Thread Frame Time"); + var gpu = dict.GetValueOrDefault("GPU Frame Time"); + var draws = dict.GetValueOrDefault("Draw Calls Count"); + var gc = dict.GetValueOrDefault("GC Allocated In Frame"); + var sysmem = dict.GetValueOrDefault("System Used Memory"); + + if (main != null) Console.WriteLine($" Frame time: avg {Fmt(main.Avg)} ms p99 {Fmt(main.P99)} ms max {Fmt(main.Max)} ms"); + if (render != null) Console.WriteLine($" Render thread: avg {Fmt(render.Avg)} ms"); + if (gpu != null) Console.WriteLine($" GPU frame: avg {Fmt(gpu.Avg)} ms"); + if (draws != null) Console.WriteLine($" Draw calls: avg {Fmt(draws.Avg)} max {Fmt(draws.Max)}"); + if (gc != null) Console.WriteLine($" GC alloc/frame: avg {Fmt(gc.Avg)} bytes max {Fmt(gc.Max)} bytes"); + if (sysmem != null) Console.WriteLine($" System memory: {sysmem.Avg / 1024.0 / 1024.0:F1} MB"); + + if (r.Hitches != null && r.Hitches.Length > 0) + { + var hitchThresh = 33.3; + Console.WriteLine($" Hitches: {r.Hitches.Length} frame(s) > {hitchThresh:F1}ms (or median × multiplier)"); + } + } + + private static string Fmt(double v) + { + if (double.IsNaN(v)) return "—"; + if (Math.Abs(v) >= 10000) return v.ToString("F0"); + if (Math.Abs(v) >= 100) return v.ToString("F1"); + return v.ToString("F2"); + } +} diff --git a/UnityCtl.Cli/Program.cs b/UnityCtl.Cli/Program.cs index 18fadd4..78580d7 100644 --- a/UnityCtl.Cli/Program.cs +++ b/UnityCtl.Cli/Program.cs @@ -2,10 +2,19 @@ using System.Collections.Generic; using System.CommandLine; using System.CommandLine.Parsing; +using System.Globalization; using System.Linq; +using System.Threading; using System.Threading.Tasks; using UnityCtl.Cli; +// Use invariant culture for argument parsing and output. Avoids locale-specific +// decimal separators (e.g. "0,5" vs "0.5") that break --duration / threshold flags. +CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; +CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; +Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; +Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + var rootCommand = new RootCommand("UnityCtl - CLI tool for controlling Unity Editor"); // Global options @@ -60,6 +69,7 @@ rootCommand.AddCommand(TestCommands.CreateCommand()); rootCommand.AddCommand(ScreenshotCommands.CreateCommand()); rootCommand.AddCommand(RecordCommands.CreateCommand()); +rootCommand.AddCommand(ProfileCommands.CreateCommand()); rootCommand.AddCommand(ScriptCommands.CreateCommand()); rootCommand.AddCommand(SnapshotCommand.CreateCommand()); rootCommand.AddCommand(PrefabCommand.CreateCommand()); diff --git a/UnityCtl.Cli/Resources/SKILL.md b/UnityCtl.Cli/Resources/SKILL.md index 5372917..987688a 100644 --- a/UnityCtl.Cli/Resources/SKILL.md +++ b/UnityCtl.Cli/Resources/SKILL.md @@ -57,6 +57,22 @@ unityctl screenshot list-windows # List open editor windows (type, title unityctl screenshot window # Capture specific editor window by type or title unityctl screenshot window SceneView out.png # e.g. capture Scene view to out.png +# Profiling (frame stats, hitches, regression gates) +# Sessions: start → run scenario via other commands → stop returns summary JSON +unityctl profile vitals --duration 3 # Curated 5-number report: avg/p99 frame, GC alloc, draw calls, GPU +unityctl profile capture --duration 5 # One-shot start+wait+stop, prints summary +unityctl profile capture --duration 5 --save run.data # Also save .data for the Profiler window +unityctl profile start --stats main,gpu,drawcalls,gc-alloc --max-duration 30 # Returns sessionId +unityctl profile stop # Returns summary JSON: avg/p50/p95/p99/max + hitches +unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 1024 # CI gate, exit 1 on fail +unityctl profile list-stats --category Render # Enumerate built-in counters +unityctl profile snapshot --output mem.snap # Memory snapshot (requires com.unity.memoryprofiler) +unityctl profile targets # List editor + connected players +unityctl profile connect 127.0.0.1:54999 # Direct-URL connect (Android via adb forward) +# Stat aliases: main, render, gpu, drawcalls, gc-alloc, system-memory, total-frame, batches, triangles +# Default vitals stats: CPU Main/Render Thread Frame Time, GPU Frame Time, Draw Calls, GC Alloc, System Memory +# Profile in play mode for meaningful frame data; edit-mode samples editor update ticks (very fast). + # Video Recording (requires com.unity.recorder package) # Note: record start auto-enters play mode if not already playing unityctl record start # Start recording (manual stop) diff --git a/UnityCtl.Protocol/Constants.cs b/UnityCtl.Protocol/Constants.cs index d15356e..59c2b13 100644 --- a/UnityCtl.Protocol/Constants.cs +++ b/UnityCtl.Protocol/Constants.cs @@ -40,6 +40,15 @@ public static class UnityCtlCommands public const string RecordStop = "record.stop"; public const string RecordStatus = "record.status"; + // Profiling + public const string ProfileListStats = "profile.listStats"; + public const string ProfileStart = "profile.start"; + public const string ProfileStop = "profile.stop"; + public const string ProfileStatus = "profile.status"; + public const string ProfileSnapshot = "profile.snapshot"; + public const string ProfileTargets = "profile.targets"; + public const string ProfileConnect = "profile.connect"; + // Snapshot public const string Snapshot = "snapshot"; public const string SnapshotQuery = "snapshot.query"; diff --git a/UnityCtl.Protocol/DTOs.cs b/UnityCtl.Protocol/DTOs.cs index e7acf2e..202e300 100644 --- a/UnityCtl.Protocol/DTOs.cs +++ b/UnityCtl.Protocol/DTOs.cs @@ -410,6 +410,186 @@ public class RecordFinishedPayload public required int FrameCount { get; init; } } +public class ProfileStatInfo +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("category")] + public required string Category { get; init; } + + [JsonProperty("unit")] + public required string Unit { get; init; } + + [JsonProperty("dataType")] + public required string DataType { get; init; } +} + +public class ProfileListStatsResult +{ + [JsonProperty("count")] + public required int Count { get; init; } + + [JsonProperty("stats")] + public required ProfileStatInfo[] Stats { get; init; } +} + +public class ProfileStartResult +{ + [JsonProperty("sessionId")] + public required string SessionId { get; init; } + + [JsonProperty("stats")] + public required string[] Stats { get; init; } + + [JsonProperty("startedAt")] + public required string StartedAt { get; init; } + + [JsonProperty("maxDurationSeconds")] + public double? MaxDurationSeconds { get; init; } + + [JsonProperty("target")] + public string? Target { get; init; } + + [JsonProperty("targetIsRemote")] + public bool TargetIsRemote { get; init; } + + [JsonProperty("savePath", NullValueHandling = NullValueHandling.Ignore)] + public string? SavePath { get; init; } +} + +public class ProfileStatSummary +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("unit")] + public required string Unit { get; init; } + + [JsonProperty("frames")] + public required int Frames { get; init; } + + [JsonProperty("avg")] + public required double Avg { get; init; } + + [JsonProperty("min")] + public required double Min { get; init; } + + [JsonProperty("max")] + public required double Max { get; init; } + + [JsonProperty("p50")] + public required double P50 { get; init; } + + [JsonProperty("p95")] + public required double P95 { get; init; } + + [JsonProperty("p99")] + public required double P99 { get; init; } + + [JsonProperty("samples", NullValueHandling = NullValueHandling.Ignore)] + public double[]? Samples { get; init; } +} + +public class ProfileHitch +{ + [JsonProperty("frameIndex")] + public required int FrameIndex { get; init; } + + [JsonProperty("frameTimeMs")] + public required double FrameTimeMs { get; init; } +} + +public class ProfileStopResult +{ + [JsonProperty("sessionId")] + public required string SessionId { get; init; } + + [JsonProperty("durationSeconds")] + public required double DurationSeconds { get; init; } + + [JsonProperty("frames")] + public required int Frames { get; init; } + + [JsonProperty("summaries")] + public required ProfileStatSummary[] Summaries { get; init; } + + [JsonProperty("hitches", NullValueHandling = NullValueHandling.Ignore)] + public ProfileHitch[]? Hitches { get; init; } + + [JsonProperty("savedPath", NullValueHandling = NullValueHandling.Ignore)] + public string? SavedPath { get; init; } + + [JsonProperty("target", NullValueHandling = NullValueHandling.Ignore)] + public string? Target { get; init; } + + [JsonProperty("targetIsRemote")] + public bool TargetIsRemote { get; init; } +} + +public class ProfileStatusEntry +{ + [JsonProperty("sessionId")] + public required string SessionId { get; init; } + + [JsonProperty("startedAt")] + public required string StartedAt { get; init; } + + [JsonProperty("elapsedSeconds")] + public required double ElapsedSeconds { get; init; } + + [JsonProperty("frames")] + public required int Frames { get; init; } + + [JsonProperty("stats")] + public required string[] Stats { get; init; } + + [JsonProperty("maxDurationSeconds")] + public double? MaxDurationSeconds { get; init; } + + [JsonProperty("target", NullValueHandling = NullValueHandling.Ignore)] + public string? Target { get; init; } + + [JsonProperty("targetIsRemote")] + public bool TargetIsRemote { get; init; } +} + +public class ProfileStatusResult +{ + [JsonProperty("sessions")] + public required ProfileStatusEntry[] Sessions { get; init; } +} + +public class ProfileSnapshotResult +{ + [JsonProperty("path")] + public required string Path { get; init; } + + [JsonProperty("sizeBytes")] + public long SizeBytes { get; init; } +} + +public class ProfileTargetInfo +{ + [JsonProperty("id")] + public required string Id { get; init; } + + [JsonProperty("displayName")] + public required string DisplayName { get; init; } + + [JsonProperty("kind")] + public required string Kind { get; init; } // "editor" | "player" + + [JsonProperty("isCurrent")] + public bool IsCurrent { get; init; } +} + +public class ProfileTargetsResult +{ + [JsonProperty("targets")] + public required ProfileTargetInfo[] Targets { get; init; } +} + public class ProjectStatusResult { [JsonProperty("projectPath")] diff --git a/UnityCtl.UnityPackage/Editor/ProfilingManager.cs b/UnityCtl.UnityPackage/Editor/ProfilingManager.cs new file mode 100644 index 0000000..cb42257 --- /dev/null +++ b/UnityCtl.UnityPackage/Editor/ProfilingManager.cs @@ -0,0 +1,750 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Unity.Profiling; +using Unity.Profiling.LowLevel.Unsafe; +using UnityEditor; +using UnityEditor.Profiling; +using UnityEditorInternal; +using UnityEngine; +using UnityCtl.Protocol; + +namespace UnityCtl.Editor +{ + /// + /// Per-session profiling state. Owns a set of ProfilerRecorders sampling Unity's built-in + /// counters once per editor frame; collects per-frame samples for statistical summary on stop. + /// Optionally drives the Editor's profiler buffer (for ProfilerDriver.SaveProfile). + /// + internal sealed class ProfilingSession + { + public string Id { get; } + public DateTime StartedAtUtc { get; } + public string[] StatNames { get; } + public double? MaxDurationSeconds { get; } + public string? Target { get; } + public bool TargetIsRemote { get; } + public string? SavePath { get; } + public bool DriveEditorProfiler { get; } + + // Local-mode state (ProfilerRecorder per stat). + private readonly List<(ProfilerRecorder Recorder, string Name, ProfilerMarkerDataUnit Unit)> _recorders; + private readonly List> _samples; + private int _frameCount; + + // Remote-mode state (read after stop from ProfilerDriver buffer). + private readonly bool _isRemoteMode; + private int _remoteStartFrame = -1; + private readonly Dictionary _statNameToUnit = new(); + + // Frame-time tracking for hitch detection (always sampled). Local only. + private readonly ProfilerRecorder _frameTimeRecorder; + private readonly List _frameTimesMs = new(); + + public int FrameCount => _frameCount; + public bool IsActive { get; private set; } + + public ProfilingSession( + string id, + string[] statNames, + double? maxDurationSeconds, + string? target, + bool targetIsRemote, + string? savePath, + bool driveEditorProfiler) + { + Id = id; + StatNames = statNames; + MaxDurationSeconds = maxDurationSeconds; + Target = target; + TargetIsRemote = targetIsRemote; + SavePath = savePath; + DriveEditorProfiler = driveEditorProfiler; + StartedAtUtc = DateTime.UtcNow; + _isRemoteMode = targetIsRemote; + + _recorders = new List<(ProfilerRecorder, string, ProfilerMarkerDataUnit)>(statNames.Length); + _samples = new List>(statNames.Length); + for (int i = 0; i < statNames.Length; i++) + { + _samples.Add(new List(1024)); + } + + // Pre-compute the unit for every requested stat from the LOCAL Editor's known counters + // (built-in counters share names + units across local and remote). Used to convert + // raw counter values (e.g. ns → ms) consistently for both local and remote modes. + for (int i = 0; i < statNames.Length; i++) + { + var (_, unit, _) = FindHandleByName(statNames[i]); + _statNameToUnit[statNames[i]] = unit; + } + + if (_isRemoteMode) + { + // Remote mode: don't sample per-frame via ProfilerRecorder (local-only). + // Instead, drive the Editor profiler buffer to capture remote frames; at Stop() + // we'll walk frames with RawFrameDataView and pull counter values per stat. + ProfilerDriver.profileEditor = false; + ProfilerDriver.ClearAllFrames(); + ProfilerDriver.enabled = true; + _remoteStartFrame = ProfilerDriver.lastFrameIndex + 1; + } + else + { + // Local mode: ProfilerRecorder per stat, sampled each editor tick. + for (int i = 0; i < statNames.Length; i++) + { + var name = statNames[i]; + var (handle, unit, category) = FindHandleByName(name); + if (!handle.Valid) + { + _recorders.Add((default, name, ProfilerMarkerDataUnit.Undefined)); + continue; + } + var rec = ProfilerRecorder.StartNew(category, name, capacity: 1); + _recorders.Add((rec, name, unit)); + } + + // Always-on frame time for hitches (local only — remote uses RawFrameDataView). + var (ftHandle, _, ftCategory) = FindHandleByName("Main Thread"); + _frameTimeRecorder = ftHandle.Valid + ? ProfilerRecorder.StartNew(ftCategory, "Main Thread", capacity: 1) + : default; + } + + IsActive = true; + } + + public void StartEditorProfilerCapture() + { + // Remote mode already enabled the driver in the constructor. Local mode only enables + // the driver when --save was requested (drives the editor's profiler buffer alongside + // ProfilerRecorder sampling so SaveProfile has data). + if (_isRemoteMode || !DriveEditorProfiler) return; + ProfilerDriver.ClearAllFrames(); + ProfilerDriver.profileEditor = true; + ProfilerDriver.enabled = true; + } + + public void TickFrame() + { + if (!IsActive) return; + _frameCount++; + + // Remote mode reads frames at Stop() — nothing to sample per editor tick. + if (_isRemoteMode) return; + + for (int i = 0; i < _recorders.Count; i++) + { + var rec = _recorders[i].Recorder; + if (!rec.Valid) { _samples[i].Add(double.NaN); continue; } + _samples[i].Add(rec.LastValue); + } + + if (_frameTimeRecorder.Valid) + { + _frameTimesMs.Add(_frameTimeRecorder.LastValue / 1_000_000.0); + } + } + + public ProfileStopResult Stop(bool includeSamples, double hitchMultiplier, double? hitchAbsoluteMs) + { + IsActive = false; + + if (_isRemoteMode) + return StopRemote(includeSamples, hitchMultiplier, hitchAbsoluteMs); + + return StopLocal(includeSamples, hitchMultiplier, hitchAbsoluteMs); + } + + private ProfileStopResult StopLocal(bool includeSamples, double hitchMultiplier, double? hitchAbsoluteMs) + { + if (DriveEditorProfiler) + { + ProfilerDriver.enabled = false; + TrySaveProfile(); + } + + var summaries = new List(_recorders.Count); + for (int i = 0; i < _recorders.Count; i++) + { + var entry = _recorders[i]; + var samples = _samples[i]; + var converted = new double[samples.Count]; + for (int j = 0; j < samples.Count; j++) + { + converted[j] = ConvertSample(samples[j], entry.Unit); + } + summaries.Add(BuildSummary(entry.Name, UnitDisplay(entry.Unit), converted, includeSamples)); + if (entry.Recorder.Valid) entry.Recorder.Dispose(); + } + + var threshold = ComputeHitchThreshold(_frameTimesMs, hitchMultiplier, hitchAbsoluteMs); + var hitches = new List(); + for (int i = 0; i < _frameTimesMs.Count; i++) + { + if (_frameTimesMs[i] > threshold) + hitches.Add(new ProfileHitch { FrameIndex = i, FrameTimeMs = _frameTimesMs[i] }); + } + + if (_frameTimeRecorder.Valid) _frameTimeRecorder.Dispose(); + + return new ProfileStopResult + { + SessionId = Id, + DurationSeconds = (DateTime.UtcNow - StartedAtUtc).TotalSeconds, + Frames = _frameCount, + Summaries = summaries.ToArray(), + Hitches = hitches.Count > 0 ? hitches.ToArray() : null, + SavedPath = !string.IsNullOrEmpty(SavePath) ? SavePath : null, + Target = Target, + TargetIsRemote = TargetIsRemote + }; + } + + private ProfileStopResult StopRemote(bool includeSamples, double hitchMultiplier, double? hitchAbsoluteMs) + { + // Snapshot the end frame before disabling the driver so frames received in the same + // editor tick aren't dropped. + int endFrame = ProfilerDriver.lastFrameIndex; + ProfilerDriver.enabled = false; + + // Save .data first if requested — buffer still has all the remote frames. + TrySaveProfile(); + + int startFrame = Math.Max(_remoteStartFrame, ProfilerDriver.firstFrameIndex); + int totalFrames = endFrame >= startFrame ? endFrame - startFrame + 1 : 0; + + var perStat = new double[StatNames.Length][]; + for (int i = 0; i < StatNames.Length; i++) perStat[i] = new double[totalFrames]; + var frameTimesMs = new double[totalFrames]; + + // Resolve "Main Thread" frame-time stat name. CPU Main Thread Frame Time is reported + // in ns; remote players expose it the same way. + const string FrameTimeStat = "CPU Main Thread Frame Time"; + + for (int idx = 0; idx < totalFrames; idx++) + { + int frame = startFrame + idx; + using var view = ProfilerDriver.GetRawFrameDataView(frame, 0); + if (view == null || !view.valid) + { + for (int i = 0; i < StatNames.Length; i++) perStat[i][idx] = double.NaN; + frameTimesMs[idx] = double.NaN; + continue; + } + + for (int i = 0; i < StatNames.Length; i++) + { + perStat[i][idx] = ReadCounter(view, StatNames[i]); + } + var ftRaw = ReadCounter(view, FrameTimeStat); + frameTimesMs[idx] = double.IsNaN(ftRaw) ? double.NaN : ftRaw / 1_000_000.0; + } + + // Convert per-stat values according to local-known units. + var summaries = new List(StatNames.Length); + for (int i = 0; i < StatNames.Length; i++) + { + var unit = _statNameToUnit.TryGetValue(StatNames[i], out var u) ? u : ProfilerMarkerDataUnit.Undefined; + var converted = new double[perStat[i].Length]; + for (int j = 0; j < perStat[i].Length; j++) + converted[j] = ConvertSample(perStat[i][j], unit); + summaries.Add(BuildSummary(StatNames[i], UnitDisplay(unit), converted, includeSamples)); + } + + // Hitch detection over the converted (ms) frame-time series. + var ftList = new List(frameTimesMs.Length); + foreach (var v in frameTimesMs) if (!double.IsNaN(v)) ftList.Add(v); + var threshold = ComputeHitchThreshold(ftList, hitchMultiplier, hitchAbsoluteMs); + var hitches = new List(); + for (int i = 0; i < frameTimesMs.Length; i++) + { + if (!double.IsNaN(frameTimesMs[i]) && frameTimesMs[i] > threshold) + hitches.Add(new ProfileHitch { FrameIndex = i, FrameTimeMs = frameTimesMs[i] }); + } + + return new ProfileStopResult + { + SessionId = Id, + DurationSeconds = (DateTime.UtcNow - StartedAtUtc).TotalSeconds, + Frames = totalFrames, + Summaries = summaries.ToArray(), + Hitches = hitches.Count > 0 ? hitches.ToArray() : null, + SavedPath = !string.IsNullOrEmpty(SavePath) ? SavePath : null, + Target = Target, + TargetIsRemote = TargetIsRemote + }; + } + + private static double ReadCounter(RawFrameDataView view, string statName) + { + int markerId = view.GetMarkerId(statName); + if (markerId == FrameDataView.invalidMarkerId) return double.NaN; + // Counter values arrive as long-encoded ints/ns. Conversion to ms etc. happens + // upstream based on the unit map. + return view.GetCounterValueAsLong(markerId); + } + + private void TrySaveProfile() + { + if (string.IsNullOrEmpty(SavePath)) return; + try + { + var dir = Path.GetDirectoryName(SavePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + ProfilerDriver.SaveProfile(SavePath); + } + catch (Exception ex) + { + Debug.LogWarning($"[UnityCtl] Failed to save profile to {SavePath}: {ex.Message}"); + } + } + + public void Cancel() + { + IsActive = false; + for (int i = 0; i < _recorders.Count; i++) + { + if (_recorders[i].Recorder.Valid) _recorders[i].Recorder.Dispose(); + } + if (_frameTimeRecorder.Valid) _frameTimeRecorder.Dispose(); + if (DriveEditorProfiler || _isRemoteMode) + { + try { ProfilerDriver.enabled = false; } catch { } + } + } + + private static (ProfilerRecorderHandle Handle, ProfilerMarkerDataUnit Unit, ProfilerCategory Category) FindHandleByName(string name) + { + var list = new List(); + ProfilerRecorderHandle.GetAvailable(list); + foreach (var h in list) + { + var d = ProfilerRecorderHandle.GetDescription(h); + if (d.Name == name) + return (h, d.UnitType, d.Category); + } + return (default, ProfilerMarkerDataUnit.Undefined, default); + } + + private static double ConvertSample(double raw, ProfilerMarkerDataUnit unit) + { + if (double.IsNaN(raw)) return raw; + return unit switch + { + ProfilerMarkerDataUnit.TimeNanoseconds => raw / 1_000_000.0, // ns → ms + _ => raw + }; + } + + private static string UnitDisplay(ProfilerMarkerDataUnit unit) => unit switch + { + ProfilerMarkerDataUnit.TimeNanoseconds => "ms", + ProfilerMarkerDataUnit.Bytes => "bytes", + ProfilerMarkerDataUnit.Count => "count", + ProfilerMarkerDataUnit.Percent => "percent", + ProfilerMarkerDataUnit.FrequencyHz => "hz", + _ => "value" + }; + + private static string UnitToString(ProfilerMarkerDataUnit unit) => unit.ToString(); + + private static double ComputeHitchThreshold(List frameTimesMs, double multiplier, double? abs) + { + if (abs.HasValue) return abs.Value; + if (frameTimesMs.Count == 0) return double.PositiveInfinity; + // Use median × multiplier as a robust baseline (avoids jitter from a few warmup frames). + var sorted = frameTimesMs.ToArray(); + Array.Sort(sorted); + var median = sorted[sorted.Length / 2]; + return median * multiplier; + } + + private static ProfileStatSummary BuildSummary(string name, string unit, double[] samples, bool includeSamples) + { + if (samples.Length == 0) + { + return new ProfileStatSummary + { + Name = name, + Unit = unit, + Frames = 0, + Avg = 0, + Min = 0, + Max = 0, + P50 = 0, + P95 = 0, + P99 = 0, + Samples = includeSamples ? samples : null + }; + } + + var valid = new List(samples.Length); + foreach (var s in samples) if (!double.IsNaN(s)) valid.Add(s); + if (valid.Count == 0) + { + return new ProfileStatSummary + { + Name = name, + Unit = unit, + Frames = samples.Length, + Avg = 0, Min = 0, Max = 0, P50 = 0, P95 = 0, P99 = 0, + Samples = includeSamples ? samples : null + }; + } + + var sorted = valid.ToArray(); + Array.Sort(sorted); + double sum = 0; + for (int i = 0; i < sorted.Length; i++) sum += sorted[i]; + + return new ProfileStatSummary + { + Name = name, + Unit = unit, + Frames = samples.Length, + Avg = sum / sorted.Length, + Min = sorted[0], + Max = sorted[sorted.Length - 1], + P50 = Percentile(sorted, 0.50), + P95 = Percentile(sorted, 0.95), + P99 = Percentile(sorted, 0.99), + Samples = includeSamples ? samples : null + }; + } + + private static double Percentile(double[] sortedValues, double p) + { + if (sortedValues.Length == 0) return 0; + var idx = (int)Math.Min(sortedValues.Length - 1, Math.Max(0, Math.Round(p * (sortedValues.Length - 1)))); + return sortedValues[idx]; + } + } + + /// + /// Singleton coordinator. Manages active profiling sessions, sampling them once per editor frame, + /// and serves list-stats / start / stop / status / snapshot / targets RPCs. + /// + public class ProfilingManager + { + private static ProfilingManager _instance; + public static ProfilingManager Instance => _instance ??= new ProfilingManager(); + + private readonly Dictionary _sessions = new(); + private bool _tickHooked; + + // Curated alias map → real ProfilerRecorder stat names. + // Keep names that exist on Unity 6 / 2023.x. Aliases give agents stable handles + // independent of Unity's slightly inconsistent counter naming. + private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) + { + // CPU/GPU frame timings (Render category — the public ones with stable names) + { "main", "CPU Main Thread Frame Time" }, + { "render", "CPU Render Thread Frame Time" }, + { "render-thread", "CPU Render Thread Frame Time" }, + { "gpu", "GPU Frame Time" }, + { "frame-time", "CPU Main Thread Frame Time" }, + { "total-frame", "CPU Total Frame Time" }, + // Internal/Main Thread fallback (slightly different units, sums all editor work) + { "main-internal", "Main Thread" }, + + // Render + { "drawcalls", "Draw Calls Count" }, + { "draw-calls", "Draw Calls Count" }, + { "setpass", "SetPass Calls Count" }, + { "batches", "Batches Count" }, + { "triangles", "Triangles Count" }, + { "tris", "Triangles Count" }, + { "vertices", "Vertices Count" }, + { "verts", "Vertices Count" }, + + // Memory + { "system-memory", "System Used Memory" }, + { "gc-reserved", "GC Reserved Memory" }, + { "gc-used", "GC Used Memory" }, + { "gc-alloc", "GC Allocated In Frame" }, + { "total-memory", "Total Reserved Memory" }, + { "total-used", "Total Used Memory" } + }; + + public static string ResolveAlias(string name) => + Aliases.TryGetValue(name, out var resolved) ? resolved : name; + + public ProfileListStatsResult ListStats(string? categoryFilter) + { + var list = new List(); + ProfilerRecorderHandle.GetAvailable(list); + + var stats = new List(list.Count); + foreach (var h in list) + { + var d = ProfilerRecorderHandle.GetDescription(h); + var category = d.Category.Name; + if (!string.IsNullOrEmpty(categoryFilter) && + !string.Equals(category, categoryFilter, StringComparison.OrdinalIgnoreCase)) + continue; + stats.Add(new ProfileStatInfo + { + Name = d.Name, + Category = category, + Unit = d.UnitType.ToString(), + DataType = d.DataType.ToString() + }); + } + + stats.Sort((a, b) => string.CompareOrdinal(a.Category + "/" + a.Name, b.Category + "/" + b.Name)); + + return new ProfileListStatsResult + { + Count = stats.Count, + Stats = stats.ToArray() + }; + } + + public ProfileStartResult Start( + string[] requestedStats, + double? maxDurationSeconds, + string? target, + bool targetIsRemote, + string? savePath, + bool driveEditorProfiler) + { + var sessionId = Guid.NewGuid().ToString("N").Substring(0, 12); + + // Resolve aliases. + var resolvedStats = requestedStats.Select(ResolveAlias).Distinct().ToArray(); + + var session = new ProfilingSession( + sessionId, + resolvedStats, + maxDurationSeconds, + target, + targetIsRemote, + savePath, + driveEditorProfiler); + + _sessions[sessionId] = session; + EnsureTickHooked(); + + session.StartEditorProfilerCapture(); + + Debug.Log($"[UnityCtl] Profiling session {sessionId} started: stats=[{string.Join(",", resolvedStats)}]" + + (maxDurationSeconds.HasValue ? $" max={maxDurationSeconds}s" : "")); + + return new ProfileStartResult + { + SessionId = sessionId, + Stats = resolvedStats, + StartedAt = session.StartedAtUtc.ToString("o"), + MaxDurationSeconds = maxDurationSeconds, + Target = target, + TargetIsRemote = targetIsRemote, + SavePath = savePath + }; + } + + public ProfileStopResult Stop(string sessionId, bool includeSamples, double hitchMultiplier, double? hitchAbsoluteMs) + { + if (!_sessions.TryGetValue(sessionId, out var session)) + throw new InvalidOperationException($"No active profiling session: {sessionId}"); + + _sessions.Remove(sessionId); + UnhookIfIdle(); + return session.Stop(includeSamples, hitchMultiplier, hitchAbsoluteMs); + } + + public ProfileStatusResult Status() + { + var entries = new List(); + foreach (var (id, s) in _sessions) + { + entries.Add(new ProfileStatusEntry + { + SessionId = id, + StartedAt = s.StartedAtUtc.ToString("o"), + ElapsedSeconds = (DateTime.UtcNow - s.StartedAtUtc).TotalSeconds, + Frames = s.FrameCount, + Stats = s.StatNames, + MaxDurationSeconds = s.MaxDurationSeconds, + Target = s.Target, + TargetIsRemote = s.TargetIsRemote + }); + } + return new ProfileStatusResult { Sessions = entries.ToArray() }; + } + + public ProfileTargetsResult Targets() + { + // Unity exposes connected profiler targets via ProfilerDriver.GetAvailableProfilers. + // We surface the raw list — selection during Start happens via ProfilerDriver.connectedProfiler. + var result = new List(); + try + { + var available = ProfilerDriver.GetAvailableProfilers(); + var current = ProfilerDriver.connectedProfiler; + foreach (var connId in available) + { + var name = ProfilerDriver.GetConnectionIdentifier(connId); + if (string.IsNullOrEmpty(name)) name = $"target-{connId}"; + result.Add(new ProfileTargetInfo + { + Id = connId.ToString(), + DisplayName = name, + Kind = connId == 0 ? "editor" : "player", + IsCurrent = connId == current + }); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[UnityCtl] Failed to enumerate profiler targets: {ex.Message}"); + } + + return new ProfileTargetsResult { Targets = result.ToArray() }; + } + + public void SelectTarget(int connectionId) + { + ProfilerDriver.connectedProfiler = connectionId; + } + + public string DirectConnect(string url) + { + // Url like "127.0.0.1:54999" for an adb-forwarded Android player. + ProfilerDriver.DirectURLConnect(url); + return ProfilerDriver.directConnectionUrl; + } + + public ProfileSnapshotResult MemorySnapshot(string outputPath) + { + // Use Memory Profiler package when available; otherwise return a clear error. + // The dependency is loaded reflectively to avoid forcing the package on users. + var asm = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "Unity.MemoryProfiler.Runtime" || a.GetName().Name == "Unity.MemoryProfiler"); + if (asm == null) + { + throw new InvalidOperationException( + "Memory Profiler package not found. Install com.unity.memoryprofiler via Package Manager."); + } + + var mpType = asm.GetType("Unity.Profiling.Memory.MemoryProfiler"); + if (mpType == null) + { + throw new InvalidOperationException("Unity.Profiling.Memory.MemoryProfiler type not found in loaded assemblies."); + } + + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var done = false; + string? finalPath = null; + Exception? captureError = null; + + // Find TakeSnapshot(string, Action, CaptureFlags) overload. + var captureFlagsType = asm.GetType("Unity.Profiling.Memory.CaptureFlags"); + var defaultFlags = captureFlagsType != null + ? Enum.Parse(captureFlagsType, "ManagedObjects,NativeObjects,NativeAllocations") + : null; + + var actionType = typeof(Action<,>).MakeGenericType(typeof(string), typeof(bool)); + var takeSnapshot = mpType.GetMethods() + .FirstOrDefault(m => m.Name == "TakeSnapshot" && m.GetParameters().Length == 3 && + m.GetParameters()[0].ParameterType == typeof(string)); + if (takeSnapshot == null) + throw new InvalidOperationException("TakeSnapshot(string, callback, flags) overload not found."); + + Action callback = (p, ok) => + { + done = true; + if (ok) finalPath = p; + else captureError = new InvalidOperationException("Memory snapshot capture failed."); + }; + + takeSnapshot.Invoke(null, new object?[] { outputPath, callback, defaultFlags }); + + // Pump until callback fires or timeout. The capture is asynchronous on the editor's main loop. + var deadline = DateTime.UtcNow.AddSeconds(120); + while (!done && DateTime.UtcNow < deadline) + { + System.Threading.Thread.Sleep(50); + } + + if (!done) throw new TimeoutException("Memory snapshot capture timed out after 120s."); + if (captureError != null) throw captureError; + + var path = finalPath ?? outputPath; + var size = File.Exists(path) ? new FileInfo(path).Length : 0L; + return new ProfileSnapshotResult { Path = path, SizeBytes = size }; + } + + private void EnsureTickHooked() + { + if (_tickHooked) return; + EditorApplication.update += Tick; + _tickHooked = true; + } + + private void UnhookIfIdle() + { + if (_sessions.Count > 0 || !_tickHooked) return; + EditorApplication.update -= Tick; + _tickHooked = false; + } + + private void Tick() + { + if (_sessions.Count == 0) return; + + // Snapshot keys to allow modification during iteration (e.g., auto-stop). + string[] ids; + { + ids = new string[_sessions.Count]; + int i = 0; + foreach (var k in _sessions.Keys) ids[i++] = k; + } + + foreach (var id in ids) + { + if (!_sessions.TryGetValue(id, out var s)) continue; + s.TickFrame(); + + if (s.MaxDurationSeconds.HasValue && + (DateTime.UtcNow - s.StartedAtUtc).TotalSeconds >= s.MaxDurationSeconds.Value) + { + Debug.Log($"[UnityCtl] Profiling session {id} reached max-duration; auto-stopping."); + // Auto-stop — caller will pull result via 'profile stop ' (returns the cached result). + // For simplicity, leave the session in place so 'stop' still works; just disable sampling. + // We model this by stopping internally and re-inserting a finalised wrapper is overkill, + // so instead we mark the session inactive — TickFrame guards against double-counting. + // The caller's stop() will get the right summaries because samples were collected up to now. + // To avoid sampling forever after the cap, dispose recorders now via a controlled stop and + // cache the result so 'stop' returns it. + var cached = s.Stop(includeSamples: false, hitchMultiplier: 2.0, hitchAbsoluteMs: null); + _sessions.Remove(id); + _autoStopped[id] = cached; + } + } + + UnhookIfIdle(); + } + + private readonly Dictionary _autoStopped = new(); + + public bool TryGetAutoStopped(string sessionId, out ProfileStopResult result) => + _autoStopped.TryGetValue(sessionId, out result!); + + public bool ConsumeAutoStopped(string sessionId, out ProfileStopResult result) + { + if (_autoStopped.TryGetValue(sessionId, out result!)) + { + _autoStopped.Remove(sessionId); + return true; + } + return false; + } + } +} diff --git a/UnityCtl.UnityPackage/Editor/ProfilingManager.cs.meta b/UnityCtl.UnityPackage/Editor/ProfilingManager.cs.meta new file mode 100644 index 0000000..2bce223 --- /dev/null +++ b/UnityCtl.UnityPackage/Editor/ProfilingManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 402e08f0567b4561946cff481eed04ee diff --git a/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs b/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs index 6ce97e3..b7cb4c9 100644 --- a/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs +++ b/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs @@ -587,6 +587,40 @@ private void HandleCommand(RequestMessage request) result = new { status = "pong" }; break; + case UnityCtlCommands.ProfileListStats: + result = HandleProfileListStats(request); + break; + + case UnityCtlCommands.ProfileStart: + result = HandleProfileStart(request); + break; + + case UnityCtlCommands.ProfileStop: + result = HandleProfileStop(request); + break; + + case UnityCtlCommands.ProfileStatus: + result = Editor.ProfilingManager.Instance.Status(); + break; + + case UnityCtlCommands.ProfileSnapshot: + result = HandleProfileMemorySnapshot(request); + break; + + case UnityCtlCommands.ProfileTargets: + result = Editor.ProfilingManager.Instance.Targets(); + break; + + case UnityCtlCommands.ProfileConnect: + { + var url = GetStringArgument(request, "url"); + if (string.IsNullOrEmpty(url)) + throw new InvalidOperationException("'url' argument is required"); + var connected = Editor.ProfilingManager.Instance.DirectConnect(url); + result = new { url = connected }; + } + break; + default: SendResponseError(request.RequestId, "unknown_command", $"Unknown command: {request.Command}"); return; @@ -2649,6 +2683,91 @@ private static string FormatQuaternion(Quaternion q) return string.Format(System.Globalization.CultureInfo.InvariantCulture, "({0:F1}, {1:F1}, {2:F1})", euler.x, euler.y, euler.z); } + private object HandleProfileListStats(RequestMessage request) + { + var category = GetStringArgument(request, "category"); + return Editor.ProfilingManager.Instance.ListStats(category); + } + + private object HandleProfileStart(RequestMessage request) + { + string[] stats; + try + { + stats = GetStringArrayArgument(request, "stats") ?? Array.Empty(); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Invalid 'stats' argument: {ex.Message}"); + } + if (stats.Length == 0) + { + // Sensible default vitals: frame time, render thread, GPU, draw calls, gc-alloc, system memory. + stats = new[] + { + "CPU Main Thread Frame Time", "CPU Render Thread Frame Time", "GPU Frame Time", + "Draw Calls Count", "GC Allocated In Frame", "System Used Memory" + }; + } + + var maxDuration = GetDoubleArgument(request, "maxDurationSeconds"); + var target = GetStringArgument(request, "target"); + var savePath = GetStringArgument(request, "savePath"); + var driveProfiler = !string.IsNullOrEmpty(savePath); + + // Optional: route to a connected target (e.g., Android player) by id. + // If no --target was passed, infer remote-ness from the current connection + // (autoconnect-profiler builds will already have switched to the device). + if (!string.IsNullOrEmpty(target) && target != "editor") + { + if (int.TryParse(target, out var targetId)) + Editor.ProfilingManager.Instance.SelectTarget(targetId); + } + // Editor sentinel is -1 in Unity 6 (other versions: 0). Anything else = remote. + var currentConn = UnityEditorInternal.ProfilerDriver.connectedProfiler; + bool targetIsRemote = currentConn != -1 && currentConn != 0; + if (targetIsRemote) + { + // Resolve display name for the connected player so the response carries it. + var connName = UnityEditorInternal.ProfilerDriver.GetConnectionIdentifier(currentConn); + if (!string.IsNullOrEmpty(connName)) target = connName; + } + + return Editor.ProfilingManager.Instance.Start( + stats, maxDuration, target, targetIsRemote, savePath, driveProfiler); + } + + private object HandleProfileStop(RequestMessage request) + { + var sessionId = GetStringArgument(request, "sessionId"); + if (string.IsNullOrEmpty(sessionId)) + throw new InvalidOperationException("'sessionId' argument is required for profile.stop"); + + var includeSamples = GetBoolArgument(request, "includeSamples"); + var hitchMultiplier = GetDoubleArgument(request, "hitchMultiplier") ?? 2.0; + var hitchAbsoluteMs = GetDoubleArgument(request, "hitchAbsoluteMs"); + + // If the session was auto-stopped due to maxDuration, return the cached result. + if (Editor.ProfilingManager.Instance.ConsumeAutoStopped(sessionId, out var cached)) + return cached; + + return Editor.ProfilingManager.Instance.Stop(sessionId, includeSamples, hitchMultiplier, hitchAbsoluteMs); + } + + private object HandleProfileMemorySnapshot(RequestMessage request) + { + var output = GetStringArgument(request, "output"); + if (string.IsNullOrEmpty(output)) + { + var name = $"memory-{DateTime.UtcNow:yyyyMMdd-HHmmss}.snap"; + output = System.IO.Path.Combine("MemoryCaptures", name); + } + // Resolve relative to project root (parent of Assets folder). + if (!System.IO.Path.IsPathRooted(output)) + output = System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), output); + return Editor.ProfilingManager.Instance.MemorySnapshot(output); + } + private object HandleRecordStart(RequestMessage request) { var outputName = GetStringArgument(request, "outputName"); diff --git a/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll b/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll index f9b83a13e730a5bb22df74029dd26ea0bb18d7ad..119f55b6a42e2bc71a02f20ce3b5f0b78cd0262f 100644 GIT binary patch literal 68608 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 O{o+~uDu@4nE$~10y-o}O literal 56832 zcmeFad0bu9{WbhOhv5PN!jKV?aDk9O1`=muj6z6a(8R)Ypd_{i}lhNK?H(RPUd+WVydCToWu!OG}w(t6n-^WRVjmcP!|<&1=#McK_pdBo$sa^2Y5R`%l_0k`xn9f?gP-OB;im8lhLb20)D4YWoevKYo#_(nv$3 z8p`wy+ol`(7Q=l|hTT;f3D$;@sC^amnPc!?UkXW?$lQUB$V?qq{L#L-k|1TXMcOb4 zP{aS|&jhzPOC)8=iRPy}`r;B)Rx~Q^`ZKiiQx33x-0o8MWlR*iB7TZjnfJ55Fh)Vc zr+GgUR2R5$XOJL(X=4l$7(UM+fq}haGDy%ww)Pu=IbMJS#zCGz0^?xMAc0Zl86+^u zJ%a=W2Hvzr64P3R7a&3AJkKD3fg{8eC4sTPGe~0UI>ZZ*AQOAl6eWRC=@}$24)qL@ zn7R)00wghkMP7g;Ca~BGkf83vJ%c1B69=5>7D-HCsTUwY6-Rgm35;c)K@wARxfdWo z<_gasfw9svNMIc486+`v9pwc`ka@Icki=vj;{`~Nd8}uU#AF`l1xR87$9n-1RB?i5 zki=x3=mkiSd6H+4#AH@^0g{+NzzdL|idCLL0t0J^**7FHMQgkO2{MD8K@yX>+6#~% zbB$+^z*y@UBr!!pUVsFdCwm47jCGzt0;AS5NMO`?1__LM&mf6uE$jtIkh$J7NMM}e z86+`9BVK?6nGK#n0;AD0NMJO11__J}oBvuBXRRQ@wBK!VH`&me(uwr7yQIL9+c zV4Uk2Br%nr=LJZRdA?_mz-aXhl9-|wcmWb*Ug#MlFfQ^85*QbI1__Lxdj?5N<(GH? z5@i0uGe}@u>KP<3F7pf$7?*nnNlfKecmWb*{?ao@U|i`LBr!#=@&Y8tyxKEJU|i!F zBr!#=^#UZwyv{R7U|jDRBrtC93=$Z>@(hxg%C~p{5@fb{1__LnfG}H35;KR1__M&J%a?s1D-(w<3Z0Lf$@-MkihtjXOO`7 zt!I$H*zOr5Fdp^{5*Uwo1__Mcc?L<$Mn38VNRauMXOO^n+%rgEba(~{j84xWf$@ZA zklg4-9Fg^BXbYBV-EJ}5sb^@J{>#K2UP%&E^`vKzzN!Xn#j~yc`k~`p7d#VC?b?5*QzQ z1__K$Jc9(rr=CILHLT_P8~)78Awi)(cm@fKUe6$b@kh@ff$=BLAlYCyP|I7YCHtHF z+)F1x`9FIG35>sZ1__M6dIkxMFFb?9J4m&>ZBk2Gs%83H|I#Z-f~vmq3=$ZB^9&Lg zUwZ}#jK6yZ35;(%g9OI6o&6*1T0_}0c?L;;mDp}>N4ys&L4gF%Ac2wS86+@#o;AqJ^S}|wj4LjHo01rr zgC2wn!HcHHPZ}{bAv_U$n2$THxCnguK0Xd6#Q`{2#23+#FuDen`69X*M!yBU|9CXg z@GSO2QDWo}uYOh>e#E#d2fB|NH{`joeF=A(V9Suf9VU45Zoy*AxvAl&Oz`pDf~F_G zH^C2w40a)yy8miyOv7s?Z|Lp7#5JR5>U`eh4tIta)9{NXnCAsek(Z4-!*lJX7su-c zyozAz{^+7Kd|>kat8Tn$a%XvUnwH+SHF&PqQi8Vht_iN(J(#G$_Yq8;dR)WDwuXTl zZFi~80Zt^-T93&Mcol}(%r4fH894>EuXr-1S3@!2)Fd-MEXMTv^gNwe%Q0)n+`@bDT&jk#W$6K4f7p<)4Ierl|!T^9WWO= zHoj>tL|XCzH|vze&XKqXrzI;(3_s>omKg2;oi;iy!ue^#>ah!n;U{dEGt`FFv*9N- zJSHx}IjZ4=xWurU;pq^G;iqkJPE%W4JzM-4&{;FtJmAdD-ahhMYnO{DV|||o|fsYj3ihpvpjFlkE`v6jtBF|}sfAT6c)oF5aQrZUzlRzYSiZ~hQ06tY%v0f6gu)Jr_bOrNY zAMt0p(^HH0MO_X10j4Ip{FySfAThi@40gC!8yw~JKNM-{0|J%FrTS|ogQLPruj^Af9IbVF-7;y zF=zuTPo~QeO(~vjw)H^#NDSlLml8e*P>kV9iDbGMY#dDM!(|#M2aK8!#wwAL8X1XL z5*Mtr4^n*75`Ezc`0ru#G2j?~SJI?xv7|uC0y`1%QHB!%Nx3+JvE1p$Q0U7K*rXw5 zN0QDC-LJzw5TCbSvn1W$zh8?`2PTo7Yut6k^U#w?rqa0LWYmGG=fl&!<>H)=%Psv3 z31w#K7FKxO@_LZh@OShFmwI35kH|@ghrd#q82%bRFnPz09@Ve_H4Yn?`QUDusqbH^ z-Rm2jCamp_o@Pc(O)7R>NgVY<+z*+POeLdjbD41in@jy*$!;0tgEFS4nL5VUI<_Mt zIgC?53U^U#?^8bzJ-IlTjILD3#5HCgIWkN3KTiryL5D?oyA)vmVDE!JC(|c^>)by9 zQ4DFO8(xUo{rb`7NhyhW{?dfRy!^e~(NkPD%gKVPrXRJdI9^hwPxeJ-$9p2j7dfd< zr1>Iso|uLjM(KWq~h;}y-R17?lp6-J#bX&46X&DzWy3^03ceb1{J2CZ%X!3~D2I<#tRoI178BDM?HZ%eLgM-{ zT2D((NnWB%oUC}^7{ynM6}!PxWMj^``6=>PuHs9;MD$Oid&=2p_w_=}Ik`yvqem;h zaja(Mh~jSx6t5~&+*EuN=(aF=T`P@ zzv)`@%4u59@r5_|Q)D0FZ^x<};3M?Kxp8@wD3vPB$ece>RkCDLg(j;ivg({XReMmbEuA!W2p|OIv#6`X1WDh z?nGHd)y?**WDV8J>`OpSrW(#zjhsUD8Czd18>kLoogq1c>T;F~%LP;u*g`}urD|p^ z8|8Yczfx_IEmV)P+!=B^)vj^cv!BU>RHrj`o^(*nVVxJq@2N^z%O&y()tyvVV6D*Y z8&7qWd`vZ$>RR~|RWf_MMZTe`V{DuJNcAnZ?@q~bw3ZtfyH|3lUT5r4*_Z0!V(sS+ zsi69S{d`v%sqUb9Up7-sovN81%9&J&R6Wu{mBq}@)bZie~}XWmDtE~gUbIjRss=&jzp$a$MUEe;e5lMeN(7?8R`7PnIv6;`bCgE+o1|FDXoNMiPgduHsfr!b6)#~=4$V`3Eo(bB zSNVtI6z_Kxr^GA1LOy(q@^6^gL!86tr;Ki4sg1022c7w)n*YfN#YXyH6e@p{nO$+p z_akngW7<8N++-e)EzOq?GZf8u-kzvq`^QvuD$^AIm8H(8bmgt|zxFA=g1B;+@*k2E zr}#AUjugd`;WL3ZF?xE6Mn7ad3o|roFePIj@Z*LnKEN6tN!GpjN~)r1#grYzUYWLH zYcPHR>on0rv$gC^!w&$O%)FRBKg)U!<;;n4F2!z(@i$^F<;y1tivK2Fn07FD`S7_w z)50v3;&+9UEl+NNfUzvV5_%j)bt6BeM zj`rAW&8a582M-$ZWiL8!q%Q-%gj;9!g~%(aor@|>29tFB+ z#gV0{if5qDj%YoOXgv;RuOnK@5iN!53FddjtD|G<$mQs*BTr#G9XSH~%aJ=X&ILY> z9y+p!IX9Elke&KFQXf#mGC-ma6?R zGtg|W8HZ~*4!batj?Cb;qz%6ayp?^vp7;UgjBd5oa|Ua>BI641*zMAq2ljAq^lfgJ z*@`{VuSLGD436B(`gMJ9c;q@<9~@bmeuHmc*;I5hM*d;WD>Db`xHpG!zWtT6J(blH!IA^8b3A~@j&I1YRJi~l*TwjP%iHWC+o*j-DcOP&C z@uB?tkojSTqRCvxZP!_PWb{6+qLGoxHvyOW_1g2?F$uVWcjV|k^$@moeV=*+>a0Gc zv7h%Ty+*yEPiZYT4^VgYsSLz^XH?RUBe&xU_DQR*n$YQUWv5lQOn3_Fb*sY8c3j=Q zZ`B#jPG6jSYE@v`7AIc*V%5phUiHPxw^p4Hzr{(AAFT?;zv@enqpm&5N?Z5Io>qMx*Xc`=1FZTn?kT7WtJ?FnAs>Rcvg4$pd_fe{xX=6tgY{%95xmMK_ z?DVC|C00E+VLP6DTxHec6L$L2q|K`NBeqMr+-B7gBX;`I%iif5 zE>9T6)^T-ZV_D<35_2Uyj^l)n$|Z>dTe8tXf*s>l-Hz zT6IFvUwz}{F{9X*3G%d!Rbf9&kQb~9VLwceH?3+NwZ+Mk53Rav)T_Qc`GZxL=WTKP z@`Y8u%6rx4mw#BLvnOAiLNmHLd-5g4D7Ic8SvIEQS%5ofld0obAca;Pj3cK|rdoA0 zj+{c-+bGsDQ4X{*oo5qeo>e-}Cdy)~bgYYHrByoCMRKB1tYwm{j>()P^)Z=~WMfR` zWI4;mv}cp$Jgc;4ljRpy-GKIrj`4v?>mVy|b* z_co^UWwy9QW*_N%nJvStDx0vyIZ(2#I&{LTz5`{PRqqsUapuTGt3D}y)i+0`8^sn5 zl6`D!8%F*hnPb(R82N)_fmJP7Ck~dwt-2WN#KCfuRoZ%)oMe@@UM6d;($>pmy;a(J zxoo!TZ*He=t~6WqgZmWJ1xB%F6>^!4WfvDFRmingJPwucW9i@44zg0R) z^W=9{X+P)7lU8Xz=gad}X+Iap>sDz$7sv-z9gJ1#5c$-q!?8*oB7e2&DvZlQ`PQmi zF)n!8j7QMk6-1>Z8I`2RU!`PNrN>{TjJ4{JlI?P+?nPSyPh1>NxmsOt^ z?(`idv#lzcxOdVbsjzCFiL;Ux%Oa~bP2M}{a9Ls1&nC}GS|TSH#nD|VK^x1(eqSne zRuyBvFO>~eJ%gj_2szWLH*i!PA?I0j+4SwQOfIo%%k-VTWpcGuH=^8fX|w7Mlv^(C zUZ!;VR>*x;-6>B&J!}+vwo;z(VrXHdJZsfMXkn$iYSn!c%ae|jcddGK;zFp8t$GP7 z^ilG;RUcr5K1%*>6k9l2{%vD=9dfiJ-~$9*`Sd#EXi2w9x8WEWWtDEjF_LFhE#}0r zGTEwAF(;0dnO1GV_8lktTcz7~oRnMjCdT@BskEvWV|~0VGm1SsL5}sxVcSoTRaWV? zpCIe3dJwgoC=FIUjap8W)2-6Ia*~{FmF|_3^N{Y%i>ztK@O3DzJX8l4q<+!kMI6Ub1R5&Lq|HwpDtzuaO?B^lV=v ze>93M1m!Cm(^`V^y;WLEP~z~Blt8_J8EhDTtb?O#pjf}IZb?U3WHBw|0 zTUaa8ZS30IPTyMD$Ew?NpMpBbD&4-2EU-$qFC(w?0xM_HvkJ6Wo%x-PCdX`QUK z>eje*P^VaR>eLNMwX)f&)~RPg{mdx#tWGYlF>Sq0F0)EouaoPn($?!`n^oF+z1(S) zj!Re`uu8`zERR~H~wvX@nvsB?oHXjQ|s4M`hio>f1ab|%zftLox6ByEzFR-G1qCe(>mJv5@zw^>$O z_40_Pp!97OZ@oTMHrkk;@lKVqtkN^ysdBzmzDav0ohHAqDtpo_sB5fRoxfd9m#tQ9 z$lvKZU2eDPt4Xa%XUMOu`f<{wP>&eJ{cxu2u(2C)rE{h{XVsm!(m7LJv#JX{J4@cP z>P__QEcwK$1=xR4`Lk6ku>Yd+ja6?T)+|3*^+&{-^}}l2_M~$rUZ3S zZq=B{4?!JoRdP{B(s@#2)rg|sC!H^KR$Yzbs8yP*x)sM!tDI@oiU}K%E|7Drs+sUI z)FoDZQM@7PLb=K+r{ra*HlsN57s+ilrlWh2+-udFBQv13Tcx9Wv2CfdAtA5ORC+X+%u2nh`m&h)wo}KE*CGxpdIugH-udNz0`9r9GTculjsU+Y98Qn*> zPJJ0F-6;0@GTG0@w#ydhGCA0)XYdr|GFfPqp5rc;rB-!di!PUAta=ZpKWO0JX^t1d3gfV#-4!zO>0bd_9T)v=TRl61A)VAb<@ zu5pdrY}Gsd45+)U`W$oqT6xH-Z$@N5J#JMC%3UYFx9S%tcb&Xs)xzTK_$=%#tBxqn zfaP&GR&%{5ZfXntV$hu zeNvl@vugavZ=i~-(qn9^Otvkir6jkGplAJc8gqS)jzR*-YS<{ zl|21rsOzmd9<%i}xyh<^n6072L`xwPq?v*(<_6d%Tdu6^=U*YJuR}Q!8PZNJkx=)U@>idaF z$-kD9jAAYKOT@-%aQxjbr&^_-_TDcoR;@?u0lCmB{j~Q1xx%U`*xCo>daL%roPSVm zwrU0LS00kPtg6AA84t-rR(*l{mEXu?Ryie({6>Cn)jpH6l7A~NT2(fAZ1Q$_%P98x zVfmAd>8kOt{LLy|H6E6KS*5GSBNC6dRdnsqRpSv!vr6~$@9-9biRqsHolLMwkDNzk zl2v--JSsD+(j(_F+0QCHavsAkuCo^Q>~T5K#d#i`^VX;2?^e|mWI+93m5V+7v?Stdf} zK>4lGXCBXpz6q)2jzGC*WDl$KnMaq*vg&Wy8BlYr>MWd+{Hz>m)pLb=K`l3mTlAcq zM|Gv7=FUlePOhGOh@#K8-b+Osg8 zX1^rKR-KBc*)K^J)!A(CWyzs>I!VjDEcsqJM^%YcdR6(d>}k~@*kiBA0ahK2r{J$h zg;mKY_o^Ib)dZA#RaRKlh5O>y@VHlrydJ zp}jZdJfpY`Z^l@_WL5Hql5N$plGRY-too?1F1cGKTJ?Eh1Jrb**q0vJhsyM& zM-H@^3#V?E9+_v=vZ*_LJ+j!UcbrwpAIVCqK6O?@ooE#6+$F1RYz&ToT~cpVKF$cc zWTRDjbbKslS*1?|KbG^YdKM%2iTuK|M$I( zZ84S)?%V(G{Le<<|MaN-pRPZ4tN)+2_21|J_nEQ%`G14A^V4N*-x1|_dwjIFdJL}r zzwhUNpZT9#@#@)uPgq?^Mc?A&8LYSQ@*7}++=9>X9O-6M*Wozp4810MwdIv&WZ<2i z@v*4&JUd>og!f~bYq&vI&dTj~zMGbePYoS>lCSTEj0L9P5n#f^419v8IEz?LtRx;m zJcbw`hJYjS%u#D-1?I{%zQ zhSX@lQG8hrDXa!QoO?3xfpP1BpX6=?R!urnE@jR&z*hY3%{}h5;^2)=yfN$e^L~w1 zE*iHT?}E-MeN^sq?knB_US8ZIoygCSWal3f5`kUCic3bRb7xKl-X2ZO{RTV=Kb1Y{ za;_WYb6$2Xnw$=N0cYUtPOvoHQNKsJoL}L*`?fQzw8$CDntL4m9OiB2k9FouEphUl z-;Unj>BZ+#2LWdl&2zqV{xR-Qyc>GaxFb+oNA^*`99&~+$24~)8${lsHDZGA?g#!(J^*&e@Wga^Qc8i($SmMaSpa-V4gM*`oH zAn-kj0K26b__16M{DW)*elEWTej$%0X35)eF9F{t_5hP)|9Hg%<6j4s#lM5h74aVe zkB^-VT@|t8nW)OxC27;@$T3z+AZmSSVKmr%E?)FZl~_w)_)VAw|PV^;19K za+w1>j#w=#hV3O;lA2l}>*V#s3cMrqdSW$swdD;mJ2fN+zz<1_tV?TV6bw!ab6TW9 zR;9KwrxiJ;rfp?(n?!IIEr^sbEe8b1GOvC37l~ z(~(xioNDG&GbhBH5Tb9TMHy{jw1v@DMq3f>O>1Lx8>8D8ZD+I{(eKha7~R3>4o15e z?Lsszy_?ZpjP7Ez7nqvbi)co=xZG-2w=T}rtxI;f?Jl03r~4T#WVDdcLPkr#vF*s2 zl3wPXCYKH?bLYyp!z!3r!ORL~RwC!g^eWa<&1f~F)r^J^eJef6XbYn)jJ7b^YOzI5 zAKu2CZOqxmoNbJ@TWpc@hj%b%2Xl5XX9uHQ7F*QJrWP`%kkOL(7P&H` zjL{0#P{C*gqm>qQUR5!tnmN_Xsb(}}QO7pQoEGM^FsFslR*Ra`#++@;*~Xl0jJC5U z9q?0AJD9VBIXjrMgE?Kuxi_PmIo-_J#hhKt*~Oe*i`r*N(9x3w9lbcb%9olNm%#B! z;P@o8$g>RcFXM6p&dTF7XLMa=e$GUik;r-C^Zj8?u zBA;bMnHgnf3o~1o*}|Mw@YK{+^eryEjXB$xvyD01nA2{tMgE@A!JHk;*}dDwtDgQF~RzoNDBxrdBhjn$eKO7Rk$uvc)J{Y+-FJ%xq;&E83cx z*~WUdv7T*=wlms}=zf_UjP79B9gOZ^w98_P%**U%E8T2m7c+M;a~E@Z6LmG{MQzJ6 z#m7;?dxKbY@t!DFT^~ou$5ofnLPiT2o#@lDCCn*7PE}?Zb1Ilq!JK)_sbo$ia@J*5 zF{he2)yxSo8bWkqX4I#9A?nk;(88P+=Cm@W6*|nH$ zxPv)6n6ra9JA4<))U+=0F66Jv>}I>&Y`2G{cA->i>MoYr#mrudEpkz&;C+PG6P+ZT z6%Kh^62}b=W*6Q(!K@tLOxXp=8|)A^88ujM8Z)p3VGLw%dxR zFRP9IR^m;JwzF(IqNB1p=ywvIWVDOXE<`70_0a#AelMKTEJ@*Z5tCCm6H;2_z$`zV z{1n}aeDV@TOAtLItBg)Lqvhn4@Klt&3&G!-6=iKv*4E5uEBqB%ZRA^t?aXh7e|J^~olasG z{Vw>AXLZx*A@<5^{N%88ZqE8)+}tfCVjhUkajznB%J(@boo-wOZV zS#5N-65Hvw!(W%yk)}s_2mQ`8ox`2vUCil1PI`7XbGqsG5PKQzMRXE=xj_3b>DqrM zUAHtjom-dQB6G6+bn=NM^h@9`&n}}=POOBpI=hNIKn%gzkR2s&CbqI{EBte^+t^|o zTinWMJEQH0HfMIw=_Gd1?}EQIyPHlAu{WJZ1pK?RWjOZ^F?l$~Jljv6Pb`7+e0JGz z9i=il<-|((Z)I1}uc8wmhTv};5go2$5*@B%(oDY<{-}{{v0P=AxkcZ&EJvvI>Ol*a-c}yGmR$@DxoUt9`oy0CUzZ=_4-b4JDt@Ofw zZmeXo|HSxA-HPPQ7I|l^Ka=x6lk=a^68O2J%gD=#m2hT`t|AW*LvYYT@@8TyoIi|h zBi~AF&(slUhhLr5L8lW=YHBBW7yR$Wc9ZuId*Q_ANEYXF7Uy#okJ&8F>Z}$?%kh)v z)6XX_$B!IvMO7*v?^P-G(bND z|Kgk|c{8yU&Yrn#^2@peY8Ye`_n~AL>bpE%(PtR+kvz6FRza9Sb*&TE` ziCy%&;1}j~)9E4h!r4DhMzWv8k>?-DnK_a(lm5h!+}FUu)Dq+zkyl2)oH&os zO89}iDmnpT2+rEPD0wrn6;3#>jeIMy9nR*w4)V^CoTcPl@Gr>gChsBkvXx%=*XPM7 z_L-PGO6R|yJfApm6#EIkI;)IMIdLBSO8B@trV}8BMrpet`1|{#qjcM&qjcMw8Eu9C ziocC~E3qBUAI5f&cM`kcRA+VL7tB&qyXo{0d*S1*cC`93ntdLv$EHIbKbrG)H1==4 zpH4oVeDaCpCCJB}8glmcm(edLR>C3_h{Yf9{Rm(w--@7zZ%1y6O+f_c}~8cJfBzsXJcj=c{#CijLy$W`1j{m z(FqVkV|3f2(bijZzZ%3=dtQIZN zv=9Cq9+f#fDj6+dv;@(M)5_?S6DxDHZ3yor80+iFMI6$Ks4>0q>z*u`iUq9+w}GulnRhuF(#FQT;tlFO|lCg*bNa{0W1 zPJXWTA)mYid`4;sq8kdz=#&#HS*nUWK&)YAG?zziE{|OLXVY&Z-%9Ktb`raZJwzGD zUX9ar!y)$*^ND4|a$?muZKZ0Qwi2KdB{mb=h+By_p|%;R9ps(hxWXiVl72UN5Aox1 zy1!&R`#|&)^ND5SwZ*dW+G6>5%`B%skGzUJK#UTbiEYHK#13L7@ky5IChsB21hzXt zYj()}#C&2Iv7A^%3=pHlX5!f_+crV_*)~D@xs`qgv6InG@@`@eQS!81KQW(JMl8?M zMDcS=iGE_fU+c^#FC&%{ ztB3)=?!f?gl-NvcBW@*j5Ib2N^B;!5jzTWwdo-5ChsPfLe@+yE9BfO)cULF zM2T(04q`V^CbAT>zd%rI`7|Dq@t_R;>MOBkv&ZAnzth2}=>n zh*iWWv5nY4>?X<-<`c_^Rm3Q?TSn^ND4|D6x&$LF^{V4Bam>L-&h+hL$QLRuQAbHs-W3 zr-M#6QD(9gVihq;Y$J9MyNR*~Gl^xyDq@t_M(iNUp3Eec5vzz%VjHnzPaY%W-Q?Zm zvKPw|tB6rz8?l4fO_aTvNh~8)?X7iIkw?j+FrqU^&`#4=(PF-mMBb`ZOXvM;Y3 z_GKw@KY1Ckir7KFY(LGZB1ZSqJrdnd_edMQBvFMA(j!Vh*4tOL7Lxo5Vwm?H?izs&J$vk*e0LJ4>G}-;Z!^I&RNd2j=pA@ zB5`=jCQ)!E!y5z{G6MgN#DAmk-)I?w|Hk6Kagrl`yjxHJHc`go`}ii{yG!%%PL3b% zAmH1H5Sf9u4)#QJe?({FSCz|>U_j>$80IwyV${t;U=`@-a$ z$yQ9}pIMK|f0o?THfPcgePs{f$mDXw%*Y#VM&RP9y1jRE|HfwC5too6a`DtOV5}co z>M_m)Gsg}Zr#=5>vTn--?DI%E=T6fa&WcxjeT3$iQ5`)=$Klj`&5xTr6484(1K;K7 zMOpJ-h+8I(NAzo94XpD8_IcdoBKTv9rUCC@Ki5vsoP-j^*m}(DjV)#Jiyhr|GqwpF zfzn*<&!ya7Cr;iA^~AG>-?ML5@YreKk#*K2&8(QF_z^LGr1sPFc?S1U{4{myn13{* z2NTn#YY)TRiq(0#6=nvSvSu7Mb8jBSx%~~tIZab_t2-wWvC%rX^x zU!aRK%XIKrKo@718Q`;lF22uo5AcJ4F3vD}ftLeaoMrS|U@L$w-dfxbd_K^{8D|#w zLZE&x`fTt+fiBK@`pvM5fUYdY`O1;QfiBL2<={sEU0H_u9Gn#^z*nL^M~(!#_^#MP zz>fyHax7|f@Sa{J_z9@h!8bG<27VH1b#NA44898g(yW8C=@Rf7)QxY&1G@OG*yZ4B zfiAvXK)-Qz9ni)3_9*Z=po^pC81OLA#oMySfk%KY-pA8#p=|`Z_$Jwtz&8S2yki*v z-wbqdtW|@b26XZLvO(}Ofq0jf-%=X|y7Ht;|f84zqtwgQuIZC6L}-}<>(8(6%Xj@?-7Gv33TNu ze&g-cKo@WRo(2C}po_OXo561Yy7=AO7VtKpi}!-h0lyLG$~N@K!4=4P;5Xy#VFyCw!U;10C7lPl8emQan(8Zggmw?{|bmeaJ&XIe7F0NTF2fq*K%CFHo{q42Fz_ zZQxI$r}*|npo`z~yA}KypetR{4*o3AmFLi32k)ug0scJt>&OfGJF&Qix(ECvpewJS zzYcx_?mqC>&|gPh2fFy(wguD}Tin zICx|BIq)yB1&(|Lbn%-^FM@v!bn%VBFN1#zbmcp2hl6*hUj_dj+u_JRfv)@u+u_K+ zfiAvN_-$};-UfG^cfnntD{;>I;PF8G>&4E8;E6z2d`=H|5)dn=vkN>0=*lqX6Yx|Z zzWLwz3_Km^%5bL_JOk+B8;k!0o(*(mg!5C|}<^Wy1Yd#YEU?A32XEb;@5Z}O$_tYJk2Xtk=qu=7Z0El(f84tb? zh;`M;13wgqbrtWgV_gMeU3Ch<4+pxk#3=$_3Uu)<|77rGKv$OIZFNUh0I|k8Q^Ah} zVvTjCgC7la@uvR_@MD3l9EbPX9XTF|_14)7{6rwuTW25eDxfO?XFu>&Kv$}rS>QE5 zSAx!L@YO)9z0Mr)wLn)w&cWa(16^6?l!Mm-U8!>_!0Umogq``|>w#E@okPGQKo{Ru zT?yU<#5(L82EGyK;(MzXgP#g?u3YS#1pafNi*LaWfL{u9aSv4uemT&U zE1V$sFM+OH>8t_23h2t!P6+%Opo{O!UI%_X(3KmUI`Cfsu?{<7@HQaUVdoU^8-ZAd zod)omfUex)G=bj=bmcZ@BX~Oy>#(yK{0<=2VdpgPyMS1Soio7i0b(7-{VvvFAl6~0 z8T@`A)?23q_^@*hoJW9IX`S%{&)_c`b0dum%aQX4nMsf`OcC*v=Z4{xfi4XmmSR_!5`72(?2V0B|CTn}|bNQDuEY zW1zk|SXpCY{ZgY#)}xY|1(9&wZh1onw8Z?HP-8fN10lBQSgr9D z!G^|VP4za~uQawa5anpfQn$SjH^PXB>jg4Lu z4%BeymIgN0D%X+o_|n?IX6&V4#o9pqnqZA*n=M!nst+}+?RT|Lp<0Z7xPJGLZq)8x zc>`Kn7F-<(Hmud6wLxr;?E_<#b?d{C#vwz?f}tUU^COXPq_2{i8rf|FWMI!_h%QTP zUpX-I!qw}7W?U@QL0u3C)mqn5b(gOTtzU0#k1uZw)6;lNgDWDTHEV(qn>=JdOU#Xg zYSsjCaIFrlY4WlMnVLDjHn6_IR_Af`mj*D>-hL%r8fa2SgDWCU_0@q!8}C;dTUj4! z+>Dm%F#~PFZkA@@fWuL5>i2le)_9fu^*p2nB0vL%h##D_`;k64Bjzw49PB&F2Z#EO>A@j; zP!A3)3)*9PaL67Fg9BzR5BAM$9_-ucJU9@uv-{@s;H=%J?BEPLX9ow&1Rm`7&DO!8 zf%9~5+|I-y0y-H7+n9rc&AtgaI5fXr&yzKk^>dp-wL=uenAHX9hX@=N8p=I9P#0`i zAE*uvZV6jby>3Nh=vcTZGE|ugEa`)5(uIBqH`ve+STjTb4G(1x9QhStGf0E$U0xl) zxqr}MTv?ARh(L9t4&UJD!cgN+M2-wKgoZ4#0L%2?idKXghq8~t=!ZA{L{N9a;F@?6 z9XhmP^ZMY>kp;m(BNp?Y2;wSh=!mWwLr0bcQ6H{gR)pD~!QEUGsvn~B+QY%Q%YqHz zS{w#LN9P5rYXkc4oedpc8VRlrtl|lL=ma~)L$mAULy$&~ohK`sWvGgb7^MVc4kq~#s z;09NOaB^x4)U6*JTCUe6xam5)scuy;GB~~{uz4sOdnG(XHm~jnS28aYSW_Qvz+GR% z;NZeQ!^-*wJ*k^}m?2UY2O6u_28Re#1l9*ug=#ToLq#zM*5iJ6$QTd5A!^iXE3ErN zM2-qXbafdbQXUz~ofm9GT|=bnL0E~~=^;XMn;IL#^+N;}2X*%h5h$;(UK<`Nz+Ev! z0StG{<={_cEpMu>#%0Cey@fGdgW(w>vSgLs2M-Zg8Nx-u5WaRJT#p;#Az}+dL)vdY(;R> zP$g=4J3n{x;L3Pgyrf>w)PsZe1Ro643=!1Jow`-ELqv{f3Py&omqtS22=1;2wWGJa z18*MqKxQ{fv+OMd!yZ>(?;vQB)CU`}HrLk#A~ksUvA!1f3B0w|%Shf)FtuN44VdKi zmV&_;$tZ6sOvF&ro0=d!y|-WsdOJafM6YE?Gv(Rl9=gj9{$kK6yBaN$1wF`Zd@3N)f(3w%m9fxJVo`{{faHv z8yC}vCo~ds<07m3Z(=wc3+#OhOZ5rXXYX4~eV#Z92Y^1o)U)X3zJBd?C&MDUds?U7 z&@f1X17MXJUhhpy=s6wCr@k8+TVaf$Wd_{TFlC^F1sj(?i^Ab`P3!fxM#4NFk!tyv z>t=dE?6dsl_u4qyb!Je{J>Rptnj!cvEu*9JPpLcD$J(BfI^T4&dwpLH9`mELj20o;v zrwid?T%Obf8Uy8x_@}W~;Yqd3$0bG$?o9d}tq9v2v2#ifJgr;nwdD_#CF+#x7kRSRHKKybPbW zH1zvs53u27!P>wkvY7aCoID0L6%+J^07r05Q*~p1t=@QJ!y0i4?hkk+wAn!Y=KcU~ zn;UTxLBBD?OYWGcZj`r7^p`P506fUog z5gefS$r2Av^TdEhhoyRGdruPR_lf2B$SF`8Iu*xTu%VA@KAtZyRevt11uVlgL+nCs z`ZmBZC!t0hTl$!vC9uVM#j77lS-08*&4tKPY>mvXuMX=6OYD8jBMjTszDF9=x?8yY z3voG6tEJ}QNewO<2b#KaVZPvl3Ea0~!CT*#t{Z21Z69;6+JTPd#9S`NCJtdA9@Y=O z^uEXYT+hygQhQ8k1kX0>&3&sXd!HnD(1n#n3pTB4Fl=79E)c56M8*>ktY^42Ls&Ow z0gpmTvqN>&^MZX(W@J$qwe7af^Lm#b6JeI&=4P$89P(o;j=MhbU^67zKkJmcw* zl4*(l=RoXWjMS+2B#5y-p%Fgpp>3FY=6u#4iPcNi$x!>Xyc)06RaVr7@KdYke^|u2 zW28nG>Ej{>48xu_L|3Fn@54l#+r&E%OEG$U!c$rUmy3xWKZE1ul--CkY@O){ZoTbf z7M(BUwQKZ3e{CHO)}}f+bVYbwu-=}p^^+zR@}66l*OvitTaa2HJY;4Pk4Iv}2ncU}%Q-fQ!xb z35|FUxESdZhX*(6!;SFj#!@|DlzIy^&uM+jOK`P)X@PzA9(b8evQNJlF;j3rd^f9d z$^1>#K|T_b$_Bj-(4#z9)9-3G2K1sow6Z?X)QBf5IDgcX^I1rLj2qwoyv=NXjNWZS zd9u)^%q%54S*87m5mR}TC}4(auYEU(9>#<-N^B1A_E+6PtCc)nH}UPJ0V zT%zE`Dqr28H0LFAMQh7NZ~_ZQHdA5va0P8HSmrf`d3n0LF}R+?-S-HQTidV98B9Mw zm)$nI6qo&{m|ZpC86ztg_}Gzer|3yI7!e$&`a?$vH&H+M zb5DwM@CLr!BIdlKM{~cY_4TW{A-uKVrQ=1J`bPak%ie@U*uz0)1#Zx4D{v2t4gYpcK|z6N_Ce4`NO`s6Y~U?Rm5st1Y$K0^a{Py;U}OCeTSyG1}rt#q`?Tb zXhR533B_E}<8-a}DGjV23+up#y6o;?S2u@eaFo-2cwGJ1hr4DtVxIQ06}u1+HA0S7~MZfO%KgOW^Z1Y>BB|e26V?`Y77qO z`ehbh7Pb$i*_6k1ld#|UOqzi{p%ME`ngNgZ-JqCTN3&7p88!1P)#BPC^Msm_80j4& zeAH#C;3I418OA#r6ViM4COZ=i^_ETg-orGTrY*C6vtGv(9_&`E4OXukZ0qH7Ol@Y# zHy!Y}Ia%s?Mc)MUT6bOUlP90@;`tt~1mL0mFBZ2J`n29#QE3W zBU?`o^?}a9V4${ftqt1?>;YBkHLEr31%_4LoC~kF1_r9{gpa2P++Ht9C#JOqO)u-? zNYm4fnP>e~+O3V5RbI^QVk`Svis*?~hn?5@=5du*gzrCMep;==p%wRq_0wq`TQh$> z+cv~~ZoL)ZMd6J>ybZbmZ$<>_8~d)R`VMOD^`!yaRhtRGLz@%IFr6U%AIdNgBQ@Ip zu?(XzQlmVz@pBhOW28oR`)GzKyL;YTW>1IusD`D64B*wy8oR%=R#|WM9l18ok91hv zu$Y|_`|7Y6BYc))Qk7_Fa}~4H^J8SpF2z|!UuMPLEbKedhSP;KA7lcAIxe>pgwqD(|Fn+%-gx`z{AtKr9p~Cpx zydZvu4vRK^dr!tTArgYxjNb+fBHjR{wMu>vagAslm2kA>0De{@&y=l(UXQ=4kXOsx z5MuZjbn{G0TDBU0Yj>|ZcRjo&+ppc)%^tX|Y-xA^xn}E()>`ZEpY{h^ksLrD@ze`j zYxm7mN{Zg}6O3T>HHlei%Pd!}OJ?X&wHC@q2#t;z!-OB@Kvb%YKfK zj_Cp5l3j}Wy>ZjAtQ8rpeX5t$7>70Z&CrukmgAVWlx=#Wz8rNl;y>LZ>~Yb+_`uO9 zMdl{_mAL8R4DwH~@_VDjnqs@V(bvpwXF`el;2mBZzoqamHYoK&q|3p9MS0JYIP4m1_)Y6Uk z8^NB@k<_h>?O*Ji<@}nx3OU}G>r6K7u3}$x&gYNW9;1lH&a0B!FaQ zs!UJtyG}X~Mmf2wJgd$(GONkSihh@ff0SqZ1W8DNc+H)FFa2=S$7O|*G8~BXtWbQ4 zGeMFhZd`g+T~;Vj%SIn$bt_e5Mz@JzxlS({NbEBbaix}2UMu8V%o)L#Hco8!}yQk;=l0dyuV#TlpFYfeQ@I{x_L zv!egXYL=|#_^jq6)7UxVl6>*$Sh$gWfjNy95jXgU#xvcGpX8v z=r6PIHH1<8^CR(2dS-J%W^;08b6UL5P13DQ!Y)BIJ5S60@%icRACWZkt|g!T`j)%@ zJSM>DtCO!3yIz+CUCbM$AU2?DWNfe;bEQ^tc28N2l6p9-l@Sd*GISo7B2!n4#@$V` ztd7uiUf0=D9>Md zLG_H+#Ow?$q6JIQl_r#$s9U3(rd89JJ`PtR?79?@9A&`BH7dp!Z^Nn_g3Dc<-T z6_eT^<4kghP3YjncF{KG1@%}B<5+D%@(PTlo`rTRuNzy3)36S2gN)H0>)9xRu9b3Q zbv?i^4-SM;UCv-z*9sl(j0S9<4um%_Xn3GeYOhNo=vhV+Hd2R9H(Hk$>{qROHL6&J zfHtP1JZ7M+`C+sWlu^1KdDk4J$Tn+OqQ=+A1aFoshSW8+f-9Y#nY7#_b5Wp$Vi&vE z0noJKpGwoSqptC~sZ5ML0%8vkUE=jv($kgBrafa5b$erz&C#@bktw?u=|8%5PaV@g zVa>6jds6$C(;wKq>r-?}>eZ2Mkxq0y&zVDVIZB(^V8(Xlf1g^;eL7*_Y}vi(6g{TQ zWk+i4HA=ir;S`;q<_av`+nJ^tsr?6djil>_>SWRB!VQg}&w6xf#pWDliX|>W-Wo|i zirb_O>ugAw-`7mmd>mSOZq#Ghl#JJ3>Wp_3TtL>~Yq?Re9q+%m8l^L5@D*NM;`jtN z5&tK-iRo^l8=p8nYmq{Y!X`H{Edf8H=M%3$b~@Qj#5)rcM`oVjq?sQ{=0~de;WFbj z;!DJCU!pmAQT@S9Oo4&onZdZktdN^HEhR#Lx(Q!=ws`>j-Kx&0P4QCoYhukzQ7@lv1h5;n=vHuSXV zxag&$1x#AEmEB&Fo1_;YdclAS?4^TA{ew;Y14brAt4h&S?hU4r;X24k$K?-x;JOD_ zL3+W1iv?$x*;I2W;+Jt|XvbmDu?NkdgADg4;JPMjGcJ;%-?>?54D-j~(%>BR&Oj>m zvq=R>_+RavOK%)S5XWa7Cvg&N5oDrBaF|1ajIh?5hav?jAQXoXkvNfJ9=FKrU9&5^ z4|aCr_=LH0t-gx)z+Z5l~iVknPKU!61bwea%B}=nE^c)gJJd3-Lus*LhrJioN`7FP37y}I7(ETu$Uh3sT$B)3$w5fOnOBDCoEat;6>fm4rr8?kV7Nxa3w z@gN||;*R6nW7KbPw>ONFQD3gb%|_hr#=}-Si7`RQ(?c0-18R*a)v#$*q~knsm?ltA z#Pl-e^PdFNQVlW1>9Gn?opf3}=f`J=0r9px^jD{5(KGe7ZlIEEgQMfdk!#%+2qO8= zlI;5YLnX5U_s=aBG5aTbA9D9gMqT+p;e&VgN?aMM$aBO%+11+BJ?*I}`@~7Q^f8q} z5SQpk&?4ft9vvYhj9Sc69O&a7EKVOv+}uHEmuSv<1)U){D--l3nys7tGmHil6P4%* zOEwNuAW94x5p(C4P7tM&N*5rF{5vQR)QdL_LSlo0`vM)Jj3NjPNm8t|QzK+zSr(fn!I))u@>WrB#Uu6gwLGQNUGKr@A++2)rjq2cEUTtn0 zrsUGO+FIlMM)N}b?AnF3X0vg8S21cpv8p-doC>vHGVt5MVNMipPmojH3n})W5Iw(F zNut}DTPj|ZSsIW6c@^B;yphx&XqTODXwZJHfh}p3Bw!Qwgz@?{6}>>pI+|lE_yc+0 z-@sJqX8`v&6F1f!>qE?3(%Q@NX(jYijjMI{PG;w|A9ZP1FYEE} z)m;32U@$Cz>1kMsP|Vuqlj=nE*DSz*5xC(I5k$-lF>^?iCfuk?lb`xdq-Tgl<0zJz|{cW$v5Ksg2Cn$2ri(EEgVe zLV*QaPzlQ}NM!FAnKTVni~!`ciQc5F-@hGX^piy6a(V1V2AUiu z$JiaNCHJ2q;^DZgPkqdtV8G_WfrSPw;HIphsDT^vtwHG5lH)s{BJUFk7d3F{M0QS| z7+*f}zCs-L&E72QlR-qwMev8Nk|FtU?QRyhv5~-a!wfmGj82A74 z;n;J(O$OOVTCmv+Ub@Qce8!|@Z|wJX#kftV`&8zz+67jq-bKFXv~w) zeqi_PkYt2&GoEBvd}1urwOsE)lTWSz9V^tn6TNQ z==NJaN+klk2eSeimXE%V*)81lAUEYB?i0pOE5zn0A9Y&Z)l;}m>B!TDWnFE>Gmh!7l5wvO*AIC( z0-JHsj^L(Xw{f!}7LRut+c{5Wox32v$$0*yX zO6iPBL>WoJ!v@o0a@qxp1Vw%Gi5=JCFx#VA1Qlv2`XK$*r1LA6hDUpmX0-5m zyl}45Q-9<)UeI}*Ul-46g>;cL=!50W%^P6*qfWx+zu7dWH$Cy-GxwVi8{U^Q%3kS4xH!gb>3-khyPc2hr(^Jk2iU{!X1t) zyani{rTk&~w?75iOk)$L#}`9B(;VGu6>e}?=Bsxw$Qu7LwrRl$u9Nz0IhzsnOV995n^%j6|I!!|j} zwy)21-Va4wcCN*dWxhqQOhB-aE=SOLc~#zaA#6cTEAJJ;rkLk7j;&N#HP8_k`RXX1 zT<5(a6>9~7D+bWJ1{FEQ`QM^-s!PHXb@Z8X$RuQO#@du)M>39C(`MG0DN4e-ZCE5wBmOwFKo69Lcej bfB%g092Wfh&+p6_ + /// One-off helper to produce a development+autoconnect Android build for profiling tests. + /// Use from unityctl: `./uc script eval "UnityCtl.TestProject.Editor.AndroidProfileBuild.Build()"`. + /// + public static class AndroidProfileBuild + { + public const string AppIdentifier = "com.dirtybit.unityctl.test"; + public const string ProductName = "unityctl-test"; + + public static readonly string MarkerPath = + Path.Combine(Application.dataPath, "..", "Builds", "Android", "build-status.txt"); + + /// + /// Schedule the build for the next editor tick and return immediately. The build + /// can run for many minutes; callers poll for status. + /// + [MenuItem("Tools/UnityCtl/Build Android Profile (Dev + AutoConnect)")] + public static void ScheduleBuild() + { + var dir = Path.GetDirectoryName(MarkerPath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(MarkerPath, "RUNNING\n"); + Debug.Log("[AndroidProfileBuild] Scheduled — running on next editor tick."); + EditorApplication.delayCall += () => + { + try + { + var apk = Build(); + File.WriteAllText(MarkerPath, $"OK\n{apk}\n"); + Debug.Log($"[AndroidProfileBuild] OK: {apk}"); + } + catch (Exception ex) + { + File.WriteAllText(MarkerPath, $"FAIL\n{ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}\n"); + Debug.LogError($"[AndroidProfileBuild] FAIL: {ex}"); + } + }; + } + + public static string Build() + { + // Identifier + product name — keep separate from other Unity test apps on device. + PlayerSettings.SetApplicationIdentifier(NamedBuildTarget.Android, AppIdentifier); + PlayerSettings.productName = ProductName; + PlayerSettings.SetScriptingBackend(NamedBuildTarget.Android, ScriptingImplementation.IL2CPP); + + // Modern Android (arm64-v8a is the only ABI Google Play accepts now). + PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64; + + // Enable Development + Autoconnect Profiler globally for the build. + EditorUserBuildSettings.development = true; + EditorUserBuildSettings.connectProfiler = true; + EditorUserBuildSettings.allowDebugging = true; + EditorUserBuildSettings.buildAppBundle = false; + + var outputDir = Path.Combine(Application.dataPath, "..", "Builds", "Android"); + Directory.CreateDirectory(outputDir); + var outputPath = Path.Combine(outputDir, "unityctl-test.apk"); + + var options = new BuildPlayerOptions + { + scenes = new[] { "Assets/Scenes/SampleScene.unity" }, + locationPathName = outputPath, + target = BuildTarget.Android, + targetGroup = BuildTargetGroup.Android, + options = BuildOptions.Development | BuildOptions.ConnectWithProfiler | BuildOptions.AllowDebugging + }; + + var report = BuildPipeline.BuildPlayer(options); + var summary = report.summary; + if (summary.result != BuildResult.Succeeded) + { + throw new Exception($"Android build failed: {summary.result} ({summary.totalErrors} errors)"); + } + + return Path.GetFullPath(outputPath); + } + } +} diff --git a/unity-project/Assets/Editor/AndroidProfileBuild.cs.meta b/unity-project/Assets/Editor/AndroidProfileBuild.cs.meta new file mode 100644 index 0000000..9d0fa72 --- /dev/null +++ b/unity-project/Assets/Editor/AndroidProfileBuild.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ec8fa3982694004da0161d480388632 \ No newline at end of file diff --git a/unity-project/Assets/Settings/UniversalRP.asset b/unity-project/Assets/Settings/UniversalRP.asset index fccfdda..e580da1 100644 --- a/unity-project/Assets/Settings/UniversalRP.asset +++ b/unity-project/Assets/Settings/UniversalRP.asset @@ -78,6 +78,7 @@ MonoBehaviour: m_UseAdaptivePerformance: 1 m_ColorGradingMode: 0 m_ColorGradingLutSize: 32 + m_AllowPostProcessAlphaOutput: 0 m_UseFastSRGBLinearConversion: 0 m_SupportDataDrivenLensFlare: 1 m_SupportScreenSpaceLensFlare: 1 @@ -98,35 +99,38 @@ MonoBehaviour: obsoleteHasProbeVolumes: m_Keys: [] m_Values: - m_PrefilteringModeMainLightShadows: 1 + m_PrefilteringModeMainLightShadows: 4 m_PrefilteringModeAdditionalLight: 4 - m_PrefilteringModeAdditionalLightShadows: 1 - m_PrefilterXRKeywords: 0 - m_PrefilteringModeForwardPlus: 1 - m_PrefilteringModeDeferredRendering: 1 - m_PrefilteringModeScreenSpaceOcclusion: 1 - m_PrefilterDebugKeywords: 0 - m_PrefilterWriteRenderingLayers: 0 - m_PrefilterHDROutput: 0 - m_PrefilterSSAODepthNormals: 0 - m_PrefilterSSAOSourceDepthLow: 0 - m_PrefilterSSAOSourceDepthMedium: 0 - m_PrefilterSSAOSourceDepthHigh: 0 - m_PrefilterSSAOInterleaved: 0 - m_PrefilterSSAOBlueNoise: 0 - m_PrefilterSSAOSampleCountLow: 0 - m_PrefilterSSAOSampleCountMedium: 0 - m_PrefilterSSAOSampleCountHigh: 0 - m_PrefilterDBufferMRT1: 0 - m_PrefilterDBufferMRT2: 0 - m_PrefilterDBufferMRT3: 0 - m_PrefilterSoftShadowsQualityLow: 0 - m_PrefilterSoftShadowsQualityMedium: 0 - m_PrefilterSoftShadowsQualityHigh: 0 + m_PrefilteringModeAdditionalLightShadows: 0 + m_PrefilterXRKeywords: 1 + m_PrefilteringModeForwardPlus: 0 + m_PrefilteringModeDeferredRendering: 0 + m_PrefilteringModeScreenSpaceOcclusion: 0 + m_PrefilterDebugKeywords: 1 + m_PrefilterWriteRenderingLayers: 1 + m_PrefilterHDROutput: 1 + m_PrefilterAlphaOutput: 1 + m_PrefilterSSAODepthNormals: 1 + m_PrefilterSSAOSourceDepthLow: 1 + m_PrefilterSSAOSourceDepthMedium: 1 + m_PrefilterSSAOSourceDepthHigh: 1 + m_PrefilterSSAOInterleaved: 1 + m_PrefilterSSAOBlueNoise: 1 + m_PrefilterSSAOSampleCountLow: 1 + m_PrefilterSSAOSampleCountMedium: 1 + m_PrefilterSSAOSampleCountHigh: 1 + m_PrefilterDBufferMRT1: 1 + m_PrefilterDBufferMRT2: 1 + m_PrefilterDBufferMRT3: 1 + m_PrefilterSoftShadowsQualityLow: 1 + m_PrefilterSoftShadowsQualityMedium: 1 + m_PrefilterSoftShadowsQualityHigh: 1 m_PrefilterSoftShadows: 0 - m_PrefilterScreenCoord: 0 - m_PrefilterNativeRenderPass: 0 + m_PrefilterScreenCoord: 1 + m_PrefilterNativeRenderPass: 1 m_PrefilterUseLegacyLightmaps: 0 + m_PrefilterReflectionProbeBlending: 1 + m_PrefilterReflectionProbeBoxProjection: 1 m_ShaderVariantLogLevel: 0 m_ShadowCascades: 0 m_Textures: diff --git a/unity-project/Assets/UniversalRenderPipelineGlobalSettings.asset b/unity-project/Assets/UniversalRenderPipelineGlobalSettings.asset index a02619e..0f716a2 100644 --- a/unity-project/Assets/UniversalRenderPipelineGlobalSettings.asset +++ b/unity-project/Assets/UniversalRenderPipelineGlobalSettings.asset @@ -60,7 +60,17 @@ MonoBehaviour: - rid: 4891237269306605570 - rid: 4891237269306605571 m_RuntimeSettings: - m_List: [] + m_List: + - rid: 7752762179098771456 + - rid: 7752762179098771457 + - rid: 7752762179098771459 + - rid: 7752762179098771461 + - rid: 7752762179098771462 + - rid: 7752762179098771464 + - rid: 7752762179098771466 + - rid: 7752762179098771468 + - rid: 7752762179098771472 + - rid: 7752762179098771476 m_AssetVersion: 8 m_ObsoleteDefaultVolumeProfile: {fileID: 0} m_RenderingLayerNames: diff --git a/unity-project/ProjectSettings/ProjectSettings.asset b/unity-project/ProjectSettings/ProjectSettings.asset index c0a475e..993a406 100644 --- a/unity-project/ProjectSettings/ProjectSettings.asset +++ b/unity-project/ProjectSettings/ProjectSettings.asset @@ -13,7 +13,7 @@ PlayerSettings: useOnDemandResources: 0 accelerometerFrequency: 60 companyName: DefaultCompany - productName: unity-project + productName: unityctl-test defaultCursor: {fileID: 0} cursorHotspot: {x: 0, y: 0} m_SplashScreenBackgroundColor: {r: 0.13725491, g: 0.12156863, b: 0.1254902, a: 1} @@ -70,6 +70,7 @@ PlayerSettings: androidStartInFullscreen: 1 androidRenderOutsideSafeArea: 1 androidUseSwappy: 1 + androidDisplayOptions: 1 androidBlitType: 0 androidResizeableActivity: 1 androidDefaultWindowWidth: 1920 @@ -142,7 +143,8 @@ PlayerSettings: visionOSBundleVersion: 1.0 tvOSBundleVersion: 1.0 bundleVersion: 1.0 - preloadedAssets: [] + preloadedAssets: + - {fileID: -944628639613478452, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3} metroInputSource: 0 wsaTransparentSwapchain: 0 m_HolographicPauseOnTrackingLoss: 1 @@ -164,6 +166,7 @@ PlayerSettings: androidMaxAspectRatio: 2.4 androidMinAspectRatio: 1 applicationIdentifier: + Android: com.dirtybit.unityctl.test Standalone: com.DefaultCompany.2D-URP buildNumber: Standalone: 0 From e0f542fc53d7ec193c25c76b1c0331038aa8e017 Mon Sep 17 00:00:00 2001 From: Martin Vagstad Date: Tue, 5 May 2026 15:14:43 +0200 Subject: [PATCH 2/2] Iterate on profile module: better spike detection, more drill-down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driven by two rounds of dogfooding against fire-5 / 001_Forest. The first agent run hit two walls: vsync-capped frame time hid every CPU spike on Android, and `hotspots` was 40x dominated by editor IMGUI/menu-item callbacks. Second round added the bits below; agent went from "I can't find the spikes" to "Update.ScriptRunBehaviourUpdate -> GameController.Update 0.56ms self" in the capture summary itself. New commands profile mark "" [--repeat N] Wrap an expression in ProfilerMarker + Stopwatch + per-thread GC accounting. Re-uses ScriptExecutor server-side. profile frame [--root M] Hierarchy *tree* drill for one frame with self-pruning (depth/threshold/top). --root scopes to a named subtree (e.g. PlayerLoop) to skip EditorLoop. Sampling refactor Local mode no longer ticks ProfilerRecorder per EditorApplication.update — that ran at editor framerate (often slower than the game's actual rendered rate, especially when unfocused) and over-reported FPS vs the Game view. Local now uses the same post-hoc RawFrameDataView walk as remote: enable ProfilerDriver, snapshot the start frame, on Stop walk frames from the buffer. view.frameTimeNs gives the actual wall-clock per frame; counter values come from view.GetCounterValueAsLong per stat. EditorApplication.update is kept only for the max-duration auto-stop wall clock. topFrames in capture/vitals Capture/vitals always include `topFrames` — the worst frames with top-3 driver markers attached. Solves the agent's "I had to dump samples and parse them locally" complaint. Ranking metric: - In play mode (PlayerLoop in hierarchy): rank by `playerLoopMs` so spikes reflect actual gameplay variance, not editor IMGUI repaint noise. Drivers descend from inside PlayerLoop for the same reason. - Otherwise: rank by `cpuMainMs`, drivers descend from frame root. Drivers descend past dominant-single-child levels (DominanceRatio < 0.7) until a real bifurcation, so attribution lands on actually-meaningful markers ("Update.ScriptRunBehaviourUpdate") instead of the wrapping loop ("EditorLoop"). Each driver carries a `hot` field — the descendant in its subtree with the highest selfMs. Surfaces "what's actually slow" without a follow-up profile frame call (Update.ScriptRunBehaviourUpdate -> GameController.Update 0.56ms self). Filters out noise below 0.05ms self. --root filter on hotspots and frame profile hotspots --root PlayerLoop Aggregation only descends into the named subtree. Essential in editor+play mode where unfiltered hotspots is dominated by OnGUI / IMGUIContainer / *.UpdateMenuItem (40x larger than gameplay markers). profile frame --root PlayerLoop Same scoping for the tree drill. Skill split Profile docs moved out of the base SKILL.md into Resources/profiling.md, an embedded resource written next to SKILL.md as a sidecar (profiling.md, not @-referenced). Stays out of auto-load context; agents read it when the topic is relevant. --- .claude/skills/unity-editor/SKILL.md | 21 +- .claude/skills/unity-editor/profiling.md | 142 +++ UnityCtl.Cli/ProfileCommands.cs | 304 +++++- UnityCtl.Cli/Resources/SKILL.md | 21 +- UnityCtl.Cli/Resources/profiling.md | 142 +++ UnityCtl.Cli/SkillCommands.cs | 27 + UnityCtl.Cli/UnityCtl.Cli.csproj | 1 + UnityCtl.Protocol/Constants.cs | 4 + UnityCtl.Protocol/DTOs.cs | 233 +++++ .../Editor/ProfilingManager.cs | 895 ++++++++++++++---- .../Editor/UnityCtlClient.cs | 59 ++ .../Plugins/UnityCtl.Protocol.dll | Bin 68608 -> 78848 bytes 12 files changed, 1652 insertions(+), 197 deletions(-) create mode 100644 .claude/skills/unity-editor/profiling.md create mode 100644 UnityCtl.Cli/Resources/profiling.md diff --git a/.claude/skills/unity-editor/SKILL.md b/.claude/skills/unity-editor/SKILL.md index 5ba1f37..722a034 100644 --- a/.claude/skills/unity-editor/SKILL.md +++ b/.claude/skills/unity-editor/SKILL.md @@ -57,21 +57,12 @@ unityctl screenshot list-windows # List open editor windows (type, title unityctl screenshot window # Capture specific editor window by type or title unityctl screenshot window SceneView out.png # e.g. capture Scene view to out.png -# Profiling (frame stats, hitches, regression gates) -# Sessions: start → run scenario via other commands → stop returns summary JSON -unityctl profile vitals --duration 3 # Curated 5-number report: avg/p99 frame, GC alloc, draw calls, GPU -unityctl profile capture --duration 5 # One-shot start+wait+stop, prints summary -unityctl profile capture --duration 5 --save run.data # Also save .data for the Profiler window -unityctl profile start --stats main,gpu,drawcalls,gc-alloc --max-duration 30 # Returns sessionId -unityctl profile stop # Returns summary JSON: avg/p50/p95/p99/max + hitches -unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 1024 # CI gate, exit 1 on fail -unityctl profile list-stats --category Render # Enumerate built-in counters -unityctl profile snapshot --output mem.snap # Memory snapshot (requires com.unity.memoryprofiler) -unityctl profile targets # List editor + connected players -unityctl profile connect 127.0.0.1:54999 # Direct-URL connect (Android via adb forward) -# Stat aliases: main, render, gpu, drawcalls, gc-alloc, system-memory, total-frame, batches, triangles -# Default vitals stats: CPU Main/Render Thread Frame Time, GPU Frame Time, Draw Calls, GC Alloc, System Memory -# Profile in play mode for meaningful frame data; edit-mode samples editor update ticks (very fast). +# Profiling (frame stats, spikes, hierarchy drill, microbench, regression gates) +# Read .claude/skills/unity-editor/profiling.md for the full surface and workflows. +# Quick reference: `unityctl profile vitals --duration 3` (5-number report), +# `unityctl profile capture --duration 10 -t 120` (full summary with topFrames + drivers), +# `unityctl profile mark "" --repeat N` (microbench an expression). +unityctl profile --help # List subcommands # Video Recording (requires com.unity.recorder package) # Note: record start auto-enters play mode if not already playing diff --git a/.claude/skills/unity-editor/profiling.md b/.claude/skills/unity-editor/profiling.md new file mode 100644 index 0000000..2f6c914 --- /dev/null +++ b/.claude/skills/unity-editor/profiling.md @@ -0,0 +1,142 @@ +# unityctl profile — Unity profiler from the CLI + +Capture frame stats, find spikes, drill into hierarchies, microbenchmark expressions. +Read this when the user mentions performance, FPS, hitches, GC, draw calls, profiling, frame budget, or asks "what's slow". + +## Lifecycle + +Sessions are start → run scenario → stop. The buffer survives after stop, so `explain`/`hotspots`/`frame` can drill into captured frames until the next session clears it. + +```bash +# One-shot (most common) +unityctl profile vitals --duration 3 # Curated 5-number report +unityctl profile capture --duration 5 # Full summary + topFrames + drivers +unityctl profile capture --duration 5 --save run.data # Also save .data for the Profiler window + +# Manual session +unityctl profile start --stats main,gpu,drawcalls,gc-alloc --max-duration 30 # Returns sessionId +unityctl profile stop # Returns summary JSON + +# CI gate (non-zero exit on threshold breach) +unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 1024 +``` + +**Always profile in play mode** unless you explicitly want editor-tick stats. Edit-mode samples editor update frequency, which doesn't reflect game performance. + +## Capture output: topFrames + +`profile capture` and `profile vitals` always include a `topFrames` array — the worst frames in the window with top-3 driver markers attached, each carrying a `hot` field that points at the descendant with the highest self time inside the driver's subtree. **This is the spike-detection primary output**. No need to dump per-frame samples, chain `explain` calls, or drill with `profile frame` for the typical "what's slow" question. + +Ranking metric depends on what's available: +- **In play mode** (PlayerLoop in hierarchy): ranked by `playerLoopMs` — gameplay-only time, ignores editor IMGUI repaint variance. Drivers descend from inside PlayerLoop. +- **Otherwise**: ranked by `cpuMainMs` (full main thread). Drivers descend from frame root. + +Both `cpuMainMs` and `playerLoopMs` are populated when available, so consumers can rerank or read either signal. + +`hitches` (frames over an absolute total-frame-time threshold) is separate — useful for CI gates, but blind to CPU spikes when total frame time is vsync-capped (Android/iOS clamp at 16/33 ms regardless of CPU time). + +## Drilling a spike + +Pick an `absoluteFrameIndex` from `topFrames` (or any frame in the buffer): + +```bash +unityctl profile explain --top 15 # Flat top-N markers by self time + GC +unityctl profile frame --depth 3 # Hierarchy *tree*, self-pruned +unityctl profile frame --depth 4 --root PlayerLoop # Tree scoped to gameplay subtree +``` + +Use `frame --root PlayerLoop` in editor+play to skip past the EditorLoop / Application.Tick wrapping that otherwise dominates the tree. + +## Hotspots aggregate + +```bash +unityctl profile hotspots --top 20 # Across the whole captured buffer +unityctl profile hotspots --top 20 --root PlayerLoop # Gameplay-only — essential in editor+play mode +``` + +In editor+play, the unfiltered `hotspots` is dominated by `OnGUI` / `IMGUIContainer` / `EditorApplication.update:*` (editor menu polling) which can swamp game work by 40×. Always pass `--root PlayerLoop` when you care about game perf. + +## Microbenchmark a single expression + +`profile mark` wraps an expression in a `ProfilerMarker` + Stopwatch + per-thread GC accounting and runs it N times. No capture session needed. + +```bash +unityctl profile mark "GameObject.FindObjectsByType(FindObjectsSortMode.None)" --repeat 100 +# → mean / p50 / p95 / min / max ms + gcBytesPerCall +``` + +Useful for "is this hot path actually slow?" without the start/wait/stop dance. + +## Stat aliases + +`profile start --stats` accepts these aliases (resolved server-side): + +| Alias | Resolves to | +|---|---| +| `main` | CPU Main Thread Frame Time | +| `render` | CPU Render Thread Frame Time | +| `gpu` | GPU Frame Time | +| `total-frame` | CPU Total Frame Time | +| `drawcalls` | Draw Calls Count | +| `setpass` | SetPass Calls Count | +| `batches` | Batches Count | +| `triangles` / `tris` | Triangles Count | +| `gc-alloc` | GC Allocated In Frame | +| `gc-used` / `gc-reserved` | GC Used Memory / GC Reserved Memory | +| `system-memory` | System Used Memory | + +Default vitals stats: main, render, gpu, drawcalls, gc-alloc, system-memory. List all available counters with `unityctl profile list-stats [--category Render]`. + +## Memory snapshot + +```bash +unityctl profile snapshot --output mem.snap # Requires com.unity.memoryprofiler package +``` + +## Remote / Android profiling + +```bash +unityctl profile targets # List editor + connected players +unityctl profile connect 127.0.0.1:54999 # Direct-URL connect (Android via adb forward) +``` + +When the editor's profiler is connected to a remote target (autoconnect-profiler dev build, or via `connect`), captures automatically come from the remote process. The summary's `targetIsRemote: true` flags this — the human output also prints `target: REMOTE — `. + +Remote captures use the same code path as local (post-hoc `RawFrameDataView` walk), so `topFrames` / `explain` / `frame` / `hotspots` all work. + +## Typical workflows + +### "Where are my spikes?" + +```bash +unityctl play enter +sleep 2 # let it settle +unityctl profile capture --duration 10 -t 120 --json +# Inspect topFrames[] — pick worst absoluteFrameIndex +unityctl profile frame --depth 4 --root PlayerLoop +unityctl play exit +``` + +### "What's hot on average?" + +```bash +unityctl profile capture --duration 10 -t 120 +unityctl profile hotspots --top 20 --root PlayerLoop +``` + +### "Is my new code slow?" + +```bash +unityctl profile mark "MySystem.DoExpensiveThing()" --repeat 200 +# Compare mean / p95 / gcBytesPerCall before/after +``` + +### "Did this PR regress perf?" + +```bash +unityctl play enter +sleep 2 +unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 4096 --duration 10 -t 120 +unityctl play exit +# Exit 1 on breach — wire into CI +``` diff --git a/UnityCtl.Cli/ProfileCommands.cs b/UnityCtl.Cli/ProfileCommands.cs index 4426027..10b510f 100644 --- a/UnityCtl.Cli/ProfileCommands.cs +++ b/UnityCtl.Cli/ProfileCommands.cs @@ -27,6 +27,10 @@ public static Command CreateCommand() profileCommand.AddCommand(BuildSnapshotCommand()); profileCommand.AddCommand(BuildTargetsCommand()); profileCommand.AddCommand(BuildConnectCommand()); + profileCommand.AddCommand(BuildExplainCommand()); + profileCommand.AddCommand(BuildHotspotsCommand()); + profileCommand.AddCommand(BuildFrameCommand()); + profileCommand.AddCommand(BuildMarkCommand()); return profileCommand; } @@ -607,6 +611,197 @@ private static Command BuildConnectCommand() return cmd; } + // ---------- explain ---------- + + private static Command BuildExplainCommand() + { + var cmd = new Command("explain", "Top-N markers by self time on a specific frame (use a hitch's absoluteFrameIndex)"); + var frameArg = new Argument("frame", "Absolute frame index (from a hitch's absoluteFrameIndex, or from a recent capture)"); + var threadIndex = new Option("--thread", () => 0, "Thread index (0 = main thread)"); + var topN = new Option("--top", () => 15, "Number of markers to return"); + cmd.AddArgument(frameArg); + cmd.AddOption(threadIndex); + cmd.AddOption(topN); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "frameIndex", ctx.ParseResult.GetValueForArgument(frameArg) }, + { "threadIndex", ctx.ParseResult.GetValueForOption(threadIndex) }, + { "topN", ctx.ParseResult.GetValueForOption(topN) } + }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileExplain, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + PrintExplainHuman(result); + }); + return cmd; + } + + // ---------- hotspots ---------- + + private static Command BuildHotspotsCommand() + { + var cmd = new Command("hotspots", "Aggregate top-N markers by self time across a range of recently-captured frames"); + var startFrame = new Option("--start", "First frame in range (default: oldest in buffer)"); + var endFrame = new Option("--end", "Last frame in range (default: newest in buffer)"); + var threadIndex = new Option("--thread", () => 0, "Thread index (0 = main thread)"); + var topN = new Option("--top", () => 20, "Number of markers to return"); + var root = new Option("--root", "Restrict accumulation to a named subtree of the frame hierarchy (e.g. PlayerLoop to exclude editor IMGUI)"); + cmd.AddOption(startFrame); + cmd.AddOption(endFrame); + cmd.AddOption(threadIndex); + cmd.AddOption(topN); + cmd.AddOption(root); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "startFrame", ctx.ParseResult.GetValueForOption(startFrame) }, + { "endFrame", ctx.ParseResult.GetValueForOption(endFrame) }, + { "threadIndex", ctx.ParseResult.GetValueForOption(threadIndex) }, + { "topN", ctx.ParseResult.GetValueForOption(topN) }, + { "rootMarker", ctx.ParseResult.GetValueForOption(root) } + }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileHotspots, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + PrintHotspotsHuman(result); + }); + return cmd; + } + + // ---------- frame (hierarchy drill-down) ---------- + + private static Command BuildFrameCommand() + { + var cmd = new Command("frame", "Hierarchy tree drill-down for one frame (use a hitch's absoluteFrameIndex)"); + var frameArg = new Argument("frame", "Absolute frame index (from a hitch, or any frame in the profiler buffer)"); + var threadIndex = new Option("--thread", () => 0, "Thread index (0 = main thread)"); + var depth = new Option("--depth", () => 3, "Max tree depth"); + var thresholdMs = new Option("--threshold-ms", () => 0.2, "Prune nodes whose totalMs is below this"); + var topPerNode = new Option("--top", () => 8, "Keep at most this many children per node"); + var root = new Option("--root", "Start the tree at a named subtree instead of the frame root (e.g. PlayerLoop to skip past EditorLoop in editor+play captures)"); + cmd.AddArgument(frameArg); + cmd.AddOption(threadIndex); + cmd.AddOption(depth); + cmd.AddOption(thresholdMs); + cmd.AddOption(topPerNode); + cmd.AddOption(root); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "frameIndex", ctx.ParseResult.GetValueForArgument(frameArg) }, + { "threadIndex", ctx.ParseResult.GetValueForOption(threadIndex) }, + { "depth", ctx.ParseResult.GetValueForOption(depth) }, + { "thresholdMs", ctx.ParseResult.GetValueForOption(thresholdMs) }, + { "topPerNode", ctx.ParseResult.GetValueForOption(topPerNode) }, + { "rootMarker", ctx.ParseResult.GetValueForOption(root) } + }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileFrame, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + PrintFrameHuman(result); + }); + return cmd; + } + + // ---------- mark (microbenchmark) ---------- + + private static Command BuildMarkCommand() + { + var cmd = new Command("mark", "Run a C# expression wrapped in a ProfilerMarker; report timing + GC alloc per call"); + var exprArg = new Argument("expression", "C# expression to time (must be reachable in editor context, e.g. \"GameObject.FindObjectsByType(FindObjectsSortMode.None)\")"); + var name = new Option("--name", () => null, "Marker name (default: unityctl.mark)"); + var repeat = new Option("--repeat", () => 1, "Run N times, return per-call percentiles"); + cmd.AddArgument(exprArg); + cmd.AddOption(name); + cmd.AddOption(repeat); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var client = MakeClient(ctx); + if (client == null) { ctx.ExitCode = 1; return; } + + var args = new Dictionary + { + { "expression", ctx.ParseResult.GetValueForArgument(exprArg) }, + { "name", ctx.ParseResult.GetValueForOption(name) }, + { "repeat", ctx.ParseResult.GetValueForOption(repeat) } + }; + var resp = await client.SendCommandAsync(UnityCtlCommands.ProfileMark, args, ContextHelper.GetTimeout(ctx)); + if (resp == null) { ctx.ExitCode = 1; return; } + if (resp.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {resp.Error?.Message}"); + ctx.ExitCode = 1; return; + } + + if (ContextHelper.GetJson(ctx)) + { + Console.WriteLine(JsonHelper.Serialize(resp.Result)); + return; + } + + var result = Deser(resp.Result); + if (result == null) return; + PrintMarkHuman(result); + }); + return cmd; + } + // ---------- helpers ---------- private static BridgeClient? MakeClient(InvocationContext ctx) @@ -646,10 +841,38 @@ private static void PrintStopHuman(ProfileStopResult result) { Console.WriteLine($" {s.Name,-30} {Fmt(s.Avg),10} {Fmt(s.P50),10} {Fmt(s.P95),10} {Fmt(s.P99),10} {Fmt(s.Max),10} {s.Unit}"); } + + if (result.TopFrames != null && result.TopFrames.Length > 0) + { + bool ranksPlayerLoop = result.TopFrames.Any(t => t.PlayerLoopMs.HasValue); + Console.WriteLine(); + Console.WriteLine(ranksPlayerLoop + ? $" Top {result.TopFrames.Length} frames by PlayerLoop time (gameplay-only ranking):" + : $" Top {result.TopFrames.Length} frames by CPU main thread:"); + foreach (var t in result.TopFrames) + { + var playerPart = t.PlayerLoopMs.HasValue ? $" player {Fmt(t.PlayerLoopMs.Value),6}ms" : ""; + if (t.Drivers.Length == 0) + { + Console.WriteLine($" frame {t.AbsoluteFrameIndex,8} cpu {Fmt(t.CpuMainMs),7}ms{playerPart} → (no hierarchy)"); + continue; + } + Console.WriteLine($" frame {t.AbsoluteFrameIndex,8} cpu {Fmt(t.CpuMainMs),7}ms{playerPart}"); + foreach (var d in t.Drivers.Take(3)) + { + var hotPart = d.Hot != null + ? $" → {Truncate(d.Hot.Name, 38)} {Fmt(d.Hot.SelfMs)}ms self" + : ""; + Console.WriteLine($" {Truncate(d.Name, 32),-32} {Fmt(d.TotalMs),7}ms total{hotPart}"); + } + } + Console.WriteLine($" (drill in: profile frame )"); + } + if (result.Hitches != null && result.Hitches.Length > 0) { Console.WriteLine(); - Console.WriteLine($" {result.Hitches.Length} hitch(es) detected:"); + Console.WriteLine($" {result.Hitches.Length} hitch(es) over total-frame-time threshold:"); foreach (var h in result.Hitches.Take(10)) Console.WriteLine($" frame {h.FrameIndex}: {h.FrameTimeMs:F2}ms"); if (result.Hitches.Length > 10) Console.WriteLine($" ... and {result.Hitches.Length - 10} more"); @@ -675,6 +898,18 @@ private static void PrintVitalsHuman(ProfileStopResult r) if (gc != null) Console.WriteLine($" GC alloc/frame: avg {Fmt(gc.Avg)} bytes max {Fmt(gc.Max)} bytes"); if (sysmem != null) Console.WriteLine($" System memory: {sysmem.Avg / 1024.0 / 1024.0:F1} MB"); + if (r.TopFrames != null && r.TopFrames.Length > 0) + { + Console.WriteLine(); + Console.WriteLine($" Worst frames (CPU main thread):"); + foreach (var t in r.TopFrames.Take(3)) + { + var top = t.Drivers.FirstOrDefault(); + var driver = top != null ? $" ({Truncate(top.Name, 30)})" : ""; + Console.WriteLine($" frame {t.AbsoluteFrameIndex}: cpu {Fmt(t.CpuMainMs)}ms{driver}"); + } + } + if (r.Hitches != null && r.Hitches.Length > 0) { var hitchThresh = 33.3; @@ -689,4 +924,71 @@ private static string Fmt(double v) if (Math.Abs(v) >= 100) return v.ToString("F1"); return v.ToString("F2"); } + + private static void PrintExplainHuman(ProfileExplainResult r) + { + Console.WriteLine($"Frame {r.FrameIndex} thread={r.ThreadIndex} ({r.ThreadName}) frame_time={Fmt(r.FrameTimeMs)} ms"); + Console.WriteLine(); + Console.WriteLine($" {"Marker",-50} {"Self ms",10} {"Calls",8} GC alloc"); + foreach (var m in r.TopMarkers) + { + var gc = m.GcAllocBytes.HasValue ? $"{m.GcAllocBytes.Value} B" : ""; + Console.WriteLine($" {Truncate(m.Name, 50),-50} {Fmt(m.SelfTimeMs),10} {m.Calls,8} {gc}"); + } + } + + private static void PrintHotspotsHuman(ProfileHotspotsResult r) + { + Console.WriteLine($"Hotspots frames=[{r.StartFrame}..{r.EndFrame}] processed={r.FrameCount} thread={r.ThreadIndex} ({r.ThreadName})"); + Console.WriteLine(); + Console.WriteLine($" {"Marker",-50} {"Self ms",12} {"Calls",10} GC alloc"); + foreach (var m in r.TopMarkers) + { + var gc = m.GcAllocBytes.HasValue ? $"{m.GcAllocBytes.Value} B" : ""; + Console.WriteLine($" {Truncate(m.Name, 50),-50} {Fmt(m.SelfTimeMs),12} {m.Calls,10} {gc}"); + } + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s.Substring(0, max - 1) + "…"; + + private static void PrintFrameHuman(ProfileFrameResult r) + { + Console.WriteLine($"Frame {r.FrameIndex} thread={r.ThreadIndex} ({r.ThreadName}) frame_time={Fmt(r.FrameTimeMs)} ms"); + Console.WriteLine($" depth={r.Depth} threshold={Fmt(r.ThresholdMs)}ms pruned={r.PrunedNodes}"); + Console.WriteLine(); + if (r.Tree.Length == 0) + { + Console.WriteLine(" (no nodes above threshold)"); + return; + } + foreach (var n in r.Tree) PrintFrameNode(n, 0); + } + + private static void PrintFrameNode(ProfileFrameNode n, int indent) + { + var pad = new string(' ', 2 + indent * 2); + var gc = n.GcKb.HasValue ? $" gc={n.GcKb.Value:F2}KB" : ""; + Console.WriteLine($"{pad}{Truncate(n.Name, 60),-60} self={Fmt(n.SelfMs),8}ms total={Fmt(n.TotalMs),8}ms calls={n.Calls,4}{gc}"); + if (n.Children == null) return; + foreach (var c in n.Children) PrintFrameNode(c, indent + 1); + } + + private static void PrintMarkHuman(ProfileMarkResult r) + { + Console.WriteLine($"{r.Name} ({r.Repeat} call{(r.Repeat == 1 ? "" : "s")})"); + if (r.Repeat == 1) + { + Console.WriteLine($" time: {r.MeanMs:F4} ms"); + } + else + { + Console.WriteLine($" mean: {r.MeanMs:F4} ms"); + Console.WriteLine($" p50/p95: {r.P50Ms:F4} / {r.P95Ms:F4} ms"); + Console.WriteLine($" min/max: {r.MinMs:F4} / {r.MaxMs:F4} ms"); + } + Console.WriteLine($" gc: {r.GcBytesPerCall} bytes/call ({r.GcBytes} total)"); + if (!string.IsNullOrEmpty(r.Result)) + Console.WriteLine($" result: {Truncate(r.Result, 120)}"); + } } diff --git a/UnityCtl.Cli/Resources/SKILL.md b/UnityCtl.Cli/Resources/SKILL.md index 987688a..6986780 100644 --- a/UnityCtl.Cli/Resources/SKILL.md +++ b/UnityCtl.Cli/Resources/SKILL.md @@ -57,21 +57,12 @@ unityctl screenshot list-windows # List open editor windows (type, title unityctl screenshot window # Capture specific editor window by type or title unityctl screenshot window SceneView out.png # e.g. capture Scene view to out.png -# Profiling (frame stats, hitches, regression gates) -# Sessions: start → run scenario via other commands → stop returns summary JSON -unityctl profile vitals --duration 3 # Curated 5-number report: avg/p99 frame, GC alloc, draw calls, GPU -unityctl profile capture --duration 5 # One-shot start+wait+stop, prints summary -unityctl profile capture --duration 5 --save run.data # Also save .data for the Profiler window -unityctl profile start --stats main,gpu,drawcalls,gc-alloc --max-duration 30 # Returns sessionId -unityctl profile stop # Returns summary JSON: avg/p50/p95/p99/max + hitches -unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 1024 # CI gate, exit 1 on fail -unityctl profile list-stats --category Render # Enumerate built-in counters -unityctl profile snapshot --output mem.snap # Memory snapshot (requires com.unity.memoryprofiler) -unityctl profile targets # List editor + connected players -unityctl profile connect 127.0.0.1:54999 # Direct-URL connect (Android via adb forward) -# Stat aliases: main, render, gpu, drawcalls, gc-alloc, system-memory, total-frame, batches, triangles -# Default vitals stats: CPU Main/Render Thread Frame Time, GPU Frame Time, Draw Calls, GC Alloc, System Memory -# Profile in play mode for meaningful frame data; edit-mode samples editor update ticks (very fast). +# Profiling (frame stats, spikes, hierarchy drill, microbench, regression gates) +# Read .claude/skills/unity-editor/profiling.md for the full surface and workflows. +# Quick reference: `unityctl profile vitals --duration 3` (5-number report), +# `unityctl profile capture --duration 10 -t 120` (full summary with topFrames + drivers), +# `unityctl profile mark "" --repeat N` (microbench an expression). +unityctl profile --help # List subcommands # Video Recording (requires com.unity.recorder package) # Note: record start auto-enters play mode if not already playing diff --git a/UnityCtl.Cli/Resources/profiling.md b/UnityCtl.Cli/Resources/profiling.md new file mode 100644 index 0000000..2f6c914 --- /dev/null +++ b/UnityCtl.Cli/Resources/profiling.md @@ -0,0 +1,142 @@ +# unityctl profile — Unity profiler from the CLI + +Capture frame stats, find spikes, drill into hierarchies, microbenchmark expressions. +Read this when the user mentions performance, FPS, hitches, GC, draw calls, profiling, frame budget, or asks "what's slow". + +## Lifecycle + +Sessions are start → run scenario → stop. The buffer survives after stop, so `explain`/`hotspots`/`frame` can drill into captured frames until the next session clears it. + +```bash +# One-shot (most common) +unityctl profile vitals --duration 3 # Curated 5-number report +unityctl profile capture --duration 5 # Full summary + topFrames + drivers +unityctl profile capture --duration 5 --save run.data # Also save .data for the Profiler window + +# Manual session +unityctl profile start --stats main,gpu,drawcalls,gc-alloc --max-duration 30 # Returns sessionId +unityctl profile stop # Returns summary JSON + +# CI gate (non-zero exit on threshold breach) +unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 1024 +``` + +**Always profile in play mode** unless you explicitly want editor-tick stats. Edit-mode samples editor update frequency, which doesn't reflect game performance. + +## Capture output: topFrames + +`profile capture` and `profile vitals` always include a `topFrames` array — the worst frames in the window with top-3 driver markers attached, each carrying a `hot` field that points at the descendant with the highest self time inside the driver's subtree. **This is the spike-detection primary output**. No need to dump per-frame samples, chain `explain` calls, or drill with `profile frame` for the typical "what's slow" question. + +Ranking metric depends on what's available: +- **In play mode** (PlayerLoop in hierarchy): ranked by `playerLoopMs` — gameplay-only time, ignores editor IMGUI repaint variance. Drivers descend from inside PlayerLoop. +- **Otherwise**: ranked by `cpuMainMs` (full main thread). Drivers descend from frame root. + +Both `cpuMainMs` and `playerLoopMs` are populated when available, so consumers can rerank or read either signal. + +`hitches` (frames over an absolute total-frame-time threshold) is separate — useful for CI gates, but blind to CPU spikes when total frame time is vsync-capped (Android/iOS clamp at 16/33 ms regardless of CPU time). + +## Drilling a spike + +Pick an `absoluteFrameIndex` from `topFrames` (or any frame in the buffer): + +```bash +unityctl profile explain --top 15 # Flat top-N markers by self time + GC +unityctl profile frame --depth 3 # Hierarchy *tree*, self-pruned +unityctl profile frame --depth 4 --root PlayerLoop # Tree scoped to gameplay subtree +``` + +Use `frame --root PlayerLoop` in editor+play to skip past the EditorLoop / Application.Tick wrapping that otherwise dominates the tree. + +## Hotspots aggregate + +```bash +unityctl profile hotspots --top 20 # Across the whole captured buffer +unityctl profile hotspots --top 20 --root PlayerLoop # Gameplay-only — essential in editor+play mode +``` + +In editor+play, the unfiltered `hotspots` is dominated by `OnGUI` / `IMGUIContainer` / `EditorApplication.update:*` (editor menu polling) which can swamp game work by 40×. Always pass `--root PlayerLoop` when you care about game perf. + +## Microbenchmark a single expression + +`profile mark` wraps an expression in a `ProfilerMarker` + Stopwatch + per-thread GC accounting and runs it N times. No capture session needed. + +```bash +unityctl profile mark "GameObject.FindObjectsByType(FindObjectsSortMode.None)" --repeat 100 +# → mean / p50 / p95 / min / max ms + gcBytesPerCall +``` + +Useful for "is this hot path actually slow?" without the start/wait/stop dance. + +## Stat aliases + +`profile start --stats` accepts these aliases (resolved server-side): + +| Alias | Resolves to | +|---|---| +| `main` | CPU Main Thread Frame Time | +| `render` | CPU Render Thread Frame Time | +| `gpu` | GPU Frame Time | +| `total-frame` | CPU Total Frame Time | +| `drawcalls` | Draw Calls Count | +| `setpass` | SetPass Calls Count | +| `batches` | Batches Count | +| `triangles` / `tris` | Triangles Count | +| `gc-alloc` | GC Allocated In Frame | +| `gc-used` / `gc-reserved` | GC Used Memory / GC Reserved Memory | +| `system-memory` | System Used Memory | + +Default vitals stats: main, render, gpu, drawcalls, gc-alloc, system-memory. List all available counters with `unityctl profile list-stats [--category Render]`. + +## Memory snapshot + +```bash +unityctl profile snapshot --output mem.snap # Requires com.unity.memoryprofiler package +``` + +## Remote / Android profiling + +```bash +unityctl profile targets # List editor + connected players +unityctl profile connect 127.0.0.1:54999 # Direct-URL connect (Android via adb forward) +``` + +When the editor's profiler is connected to a remote target (autoconnect-profiler dev build, or via `connect`), captures automatically come from the remote process. The summary's `targetIsRemote: true` flags this — the human output also prints `target: REMOTE — `. + +Remote captures use the same code path as local (post-hoc `RawFrameDataView` walk), so `topFrames` / `explain` / `frame` / `hotspots` all work. + +## Typical workflows + +### "Where are my spikes?" + +```bash +unityctl play enter +sleep 2 # let it settle +unityctl profile capture --duration 10 -t 120 --json +# Inspect topFrames[] — pick worst absoluteFrameIndex +unityctl profile frame --depth 4 --root PlayerLoop +unityctl play exit +``` + +### "What's hot on average?" + +```bash +unityctl profile capture --duration 10 -t 120 +unityctl profile hotspots --top 20 --root PlayerLoop +``` + +### "Is my new code slow?" + +```bash +unityctl profile mark "MySystem.DoExpensiveThing()" --repeat 200 +# Compare mean / p95 / gcBytesPerCall before/after +``` + +### "Did this PR regress perf?" + +```bash +unityctl play enter +sleep 2 +unityctl profile assert --p99-frame-ms 33 --gc-alloc-per-frame 4096 --duration 10 -t 120 +unityctl play exit +# Exit 1 on breach — wire into CI +``` diff --git a/UnityCtl.Cli/SkillCommands.cs b/UnityCtl.Cli/SkillCommands.cs index 49486d5..5f2a59e 100644 --- a/UnityCtl.Cli/SkillCommands.cs +++ b/UnityCtl.Cli/SkillCommands.cs @@ -27,6 +27,16 @@ private static readonly (string Folder, string Resource)[] AdditionalSkills = ("unityctl-plugins", "UnityCtl.Cli.Resources.SKILL.plugins.md") ]; + /// + /// Sidecar files written alongside the main SKILL.md inside the unity-editor skill folder. + /// Not auto-loaded — the base skill mentions them by relative path so agents can read them + /// when the topic is relevant. Each entry is (fileName, embeddedResourceName). + /// + private static readonly (string FileName, string Resource)[] Sidecars = + [ + ("profiling.md", "UnityCtl.Cli.Resources.profiling.md") + ]; + public static Command CreateCommand() { var skillCommand = new Command("skill", "Claude Code skill management"); @@ -427,6 +437,15 @@ private static async Task WriteComposedSkillAsync(string skillsDir, bool j var skillFolderPath = Path.Combine(skillsDir, SkillFolderName); Directory.CreateDirectory(skillFolderPath); await File.WriteAllTextAsync(Path.Combine(skillFolderPath, SkillFileName), content); + + // Write sidecar files (e.g. profiling.md). They aren't @-referenced by SKILL.md so they + // stay out of the auto-loaded context, but agents can read them when the topic is relevant. + foreach (var (fileName, resource) in Sidecars) + { + var sidecarContent = GetEmbeddedResourceContent(resource); + if (sidecarContent == null) continue; + await File.WriteAllTextAsync(Path.Combine(skillFolderPath, fileName), sidecarContent); + } return true; } @@ -456,6 +475,14 @@ private static bool RemoveSkill(bool global, string? claudeDir, bool json) File.Delete(skillPath); + // Remove sidecars co-located with the main skill. + foreach (var (fileName, _) in Sidecars) + { + var sidecarPath = Path.Combine(skillFolderPath, fileName); + if (File.Exists(sidecarPath)) + File.Delete(sidecarPath); + } + // Remove additional skills foreach (var (folder, _) in AdditionalSkills) { diff --git a/UnityCtl.Cli/UnityCtl.Cli.csproj b/UnityCtl.Cli/UnityCtl.Cli.csproj index ddbc6de..46d5fef 100644 --- a/UnityCtl.Cli/UnityCtl.Cli.csproj +++ b/UnityCtl.Cli/UnityCtl.Cli.csproj @@ -28,6 +28,7 @@ + diff --git a/UnityCtl.Protocol/Constants.cs b/UnityCtl.Protocol/Constants.cs index 59c2b13..bc1512b 100644 --- a/UnityCtl.Protocol/Constants.cs +++ b/UnityCtl.Protocol/Constants.cs @@ -48,6 +48,10 @@ public static class UnityCtlCommands public const string ProfileSnapshot = "profile.snapshot"; public const string ProfileTargets = "profile.targets"; public const string ProfileConnect = "profile.connect"; + public const string ProfileExplain = "profile.explain"; + public const string ProfileHotspots = "profile.hotspots"; + public const string ProfileFrame = "profile.frame"; + public const string ProfileMark = "profile.mark"; // Snapshot public const string Snapshot = "snapshot"; diff --git a/UnityCtl.Protocol/DTOs.cs b/UnityCtl.Protocol/DTOs.cs index 202e300..d4b1536 100644 --- a/UnityCtl.Protocol/DTOs.cs +++ b/UnityCtl.Protocol/DTOs.cs @@ -498,6 +498,95 @@ public class ProfileHitch [JsonProperty("frameTimeMs")] public required double FrameTimeMs { get; init; } + + /// + /// Frame index in the profiler buffer (usable with `profile explain`). + /// Null for remote captures or when the profiler buffer wasn't driven for this session. + /// + [JsonProperty("absoluteFrameIndex", NullValueHandling = NullValueHandling.Ignore)] + public int? AbsoluteFrameIndex { get; init; } +} + +/// +/// Descendant of a driver where the wall-clock is actually spent. The driver itself is usually +/// an intermediate node (selfMs ~ 0); `hot` points at the marker inside its subtree with the +/// highest selfMs — the "what's slow" answer for the agent without a separate `profile frame` call. +/// +public class ProfileFrameHotLeaf +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("selfMs")] + public required double SelfMs { get; init; } + + [JsonProperty("totalMs")] + public required double TotalMs { get; init; } +} + +/// +/// Top-level system marker driving a frame. "Drivers" are descendants of the hierarchy root past +/// dominant-single-child levels, ranked by total time — answers "which subsystem is responsible +/// for this frame's cost", not "which leaf has the highest self-time". +/// +public class ProfileFrameDriver +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("totalMs")] + public required double TotalMs { get; init; } + + [JsonProperty("selfMs")] + public required double SelfMs { get; init; } + + [JsonProperty("calls")] + public required int Calls { get; init; } + + [JsonProperty("gcKb", NullValueHandling = NullValueHandling.Ignore)] + public double? GcKb { get; init; } + + /// + /// Hottest descendant by self time inside this driver's subtree. Null when the driver itself + /// is the leaf or no descendant has meaningful self time. + /// + [JsonProperty("hot", NullValueHandling = NullValueHandling.Ignore)] + public ProfileFrameHotLeaf? Hot { get; init; } +} + +/// +/// One of the worst frames in a capture. Carries both the relative index (into samples arrays) +/// and the absolute index (into the profiler buffer, usable with `profile frame` / `profile explain`). +/// +/// Ranking metric depends on what's available: when the capture was taken in play mode (PlayerLoop +/// is in the hierarchy), frames are ranked by `playerLoopMs` — the time spent in actual game work. +/// Otherwise ranked by `cpuMainMs` (full main thread including editor IMGUI). Both fields are +/// always populated when available so consumers can see both signals. +/// +public class ProfileTopFrame +{ + [JsonProperty("frameIndex")] + public required int FrameIndex { get; init; } + + [JsonProperty("absoluteFrameIndex")] + public required int AbsoluteFrameIndex { get; init; } + + [JsonProperty("cpuMainMs")] + public required double CpuMainMs { get; init; } + + [JsonProperty("frameTimeMs")] + public required double FrameTimeMs { get; init; } + + /// + /// Time spent in PlayerLoop on this frame (gameplay-only, excluding editor IMGUI/repaint). + /// Null when PlayerLoop isn't in the hierarchy (editor mode without play, or top-level + /// remote captures where the hierarchy is rooted differently). + /// + [JsonProperty("playerLoopMs", NullValueHandling = NullValueHandling.Ignore)] + public double? PlayerLoopMs { get; init; } + + [JsonProperty("drivers")] + public required ProfileFrameDriver[] Drivers { get; init; } } public class ProfileStopResult @@ -517,6 +606,15 @@ public class ProfileStopResult [JsonProperty("hitches", NullValueHandling = NullValueHandling.Ignore)] public ProfileHitch[]? Hitches { get; init; } + /// + /// Top frames by CPU main thread time, with the top-3 driver markers attached. Always + /// populated for non-empty captures so agents can see spikes without dumping samples. + /// Distinct from `hitches` (which gates against an absolute threshold and uses total + /// frame time — useful for CI but vsync-blind). + /// + [JsonProperty("topFrames", NullValueHandling = NullValueHandling.Ignore)] + public ProfileTopFrame[]? TopFrames { get; init; } + [JsonProperty("savedPath", NullValueHandling = NullValueHandling.Ignore)] public string? SavedPath { get; init; } @@ -590,6 +688,141 @@ public class ProfileTargetsResult public required ProfileTargetInfo[] Targets { get; init; } } +public class ProfileMarkerEntry +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("selfTimeMs")] + public required double SelfTimeMs { get; init; } + + [JsonProperty("calls")] + public required int Calls { get; init; } + + [JsonProperty("gcAllocBytes", NullValueHandling = NullValueHandling.Ignore)] + public long? GcAllocBytes { get; init; } +} + +public class ProfileExplainResult +{ + [JsonProperty("frameIndex")] + public required int FrameIndex { get; init; } + + [JsonProperty("threadIndex")] + public required int ThreadIndex { get; init; } + + [JsonProperty("threadName")] + public required string ThreadName { get; init; } + + [JsonProperty("frameTimeMs")] + public required double FrameTimeMs { get; init; } + + [JsonProperty("topMarkers")] + public required ProfileMarkerEntry[] TopMarkers { get; init; } +} + +public class ProfileHotspotsResult +{ + [JsonProperty("startFrame")] + public required int StartFrame { get; init; } + + [JsonProperty("endFrame")] + public required int EndFrame { get; init; } + + [JsonProperty("frameCount")] + public required int FrameCount { get; init; } + + [JsonProperty("threadIndex")] + public required int ThreadIndex { get; init; } + + [JsonProperty("threadName")] + public required string ThreadName { get; init; } + + [JsonProperty("topMarkers")] + public required ProfileMarkerEntry[] TopMarkers { get; init; } +} + +public class ProfileFrameNode +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("selfMs")] + public required double SelfMs { get; init; } + + [JsonProperty("totalMs")] + public required double TotalMs { get; init; } + + [JsonProperty("calls")] + public required int Calls { get; init; } + + [JsonProperty("gcKb", NullValueHandling = NullValueHandling.Ignore)] + public double? GcKb { get; init; } + + [JsonProperty("children", NullValueHandling = NullValueHandling.Ignore)] + public ProfileFrameNode[]? Children { get; init; } +} + +public class ProfileFrameResult +{ + [JsonProperty("frameIndex")] + public required int FrameIndex { get; init; } + + [JsonProperty("threadIndex")] + public required int ThreadIndex { get; init; } + + [JsonProperty("threadName")] + public required string ThreadName { get; init; } + + [JsonProperty("frameTimeMs")] + public required double FrameTimeMs { get; init; } + + [JsonProperty("depth")] + public required int Depth { get; init; } + + [JsonProperty("thresholdMs")] + public required double ThresholdMs { get; init; } + + [JsonProperty("prunedNodes")] + public required int PrunedNodes { get; init; } + + [JsonProperty("tree")] + public required ProfileFrameNode[] Tree { get; init; } +} + +public class ProfileMarkResult +{ + [JsonProperty("name")] + public required string Name { get; init; } + + [JsonProperty("repeat")] + public required int Repeat { get; init; } + + [JsonProperty("meanMs")] + public required double MeanMs { get; init; } + + [JsonProperty("minMs")] + public required double MinMs { get; init; } + + [JsonProperty("maxMs")] + public required double MaxMs { get; init; } + + [JsonProperty("p50Ms")] + public required double P50Ms { get; init; } + + [JsonProperty("p95Ms")] + public required double P95Ms { get; init; } + + [JsonProperty("gcBytes")] + public required long GcBytes { get; init; } + + [JsonProperty("gcBytesPerCall")] + public required long GcBytesPerCall { get; init; } + + [JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)] + public string? Result { get; init; } +} + public class ProjectStatusResult { [JsonProperty("projectPath")] diff --git a/UnityCtl.UnityPackage/Editor/ProfilingManager.cs b/UnityCtl.UnityPackage/Editor/ProfilingManager.cs index cb42257..e6f6daa 100644 --- a/UnityCtl.UnityPackage/Editor/ProfilingManager.cs +++ b/UnityCtl.UnityPackage/Editor/ProfilingManager.cs @@ -13,9 +13,15 @@ namespace UnityCtl.Editor { /// - /// Per-session profiling state. Owns a set of ProfilerRecorders sampling Unity's built-in - /// counters once per editor frame; collects per-frame samples for statistical summary on stop. - /// Optionally drives the Editor's profiler buffer (for ProfilerDriver.SaveProfile). + /// Per-session profiling state. The session enables ProfilerDriver, snapshots the start + /// frame index, and on Stop() walks the profiler buffer with RawFrameDataView, reading + /// counter values per actual rendered frame. Same code path for editor and remote players — + /// the only difference is ProfilerDriver.profileEditor. + /// + /// Why post-hoc buffer reads, not per-tick ProfilerRecorder sampling: EditorApplication.update + /// ticks at editor framerate (often throttled, especially when unfocused), which mismatches + /// the actual rendered frame rate. Reading from the profiler buffer gives one record per real + /// frame — matches what the user sees in the Profiler window. /// internal sealed class ProfilingSession { @@ -28,23 +34,18 @@ internal sealed class ProfilingSession public string? SavePath { get; } public bool DriveEditorProfiler { get; } - // Local-mode state (ProfilerRecorder per stat). - private readonly List<(ProfilerRecorder Recorder, string Name, ProfilerMarkerDataUnit Unit)> _recorders; - private readonly List> _samples; - private int _frameCount; - - // Remote-mode state (read after stop from ProfilerDriver buffer). - private readonly bool _isRemoteMode; - private int _remoteStartFrame = -1; + // Built-in counter names share units across editor and player. Resolve once at start + // from the local Editor's ProfilerRecorderHandle catalog. private readonly Dictionary _statNameToUnit = new(); - // Frame-time tracking for hitch detection (always sampled). Local only. - private readonly ProfilerRecorder _frameTimeRecorder; - private readonly List _frameTimesMs = new(); - - public int FrameCount => _frameCount; + public int AbsoluteStartFrame { get; private set; } = -1; public bool IsActive { get; private set; } + public int CurrentFrameCount => + IsActive && AbsoluteStartFrame >= 0 + ? Math.Max(0, ProfilerDriver.lastFrameIndex - AbsoluteStartFrame + 1) + : 0; + public ProfilingSession( string id, string[] statNames, @@ -62,168 +63,85 @@ public ProfilingSession( SavePath = savePath; DriveEditorProfiler = driveEditorProfiler; StartedAtUtc = DateTime.UtcNow; - _isRemoteMode = targetIsRemote; - - _recorders = new List<(ProfilerRecorder, string, ProfilerMarkerDataUnit)>(statNames.Length); - _samples = new List>(statNames.Length); - for (int i = 0; i < statNames.Length; i++) - { - _samples.Add(new List(1024)); - } - // Pre-compute the unit for every requested stat from the LOCAL Editor's known counters - // (built-in counters share names + units across local and remote). Used to convert - // raw counter values (e.g. ns → ms) consistently for both local and remote modes. for (int i = 0; i < statNames.Length; i++) { var (_, unit, _) = FindHandleByName(statNames[i]); _statNameToUnit[statNames[i]] = unit; } - if (_isRemoteMode) - { - // Remote mode: don't sample per-frame via ProfilerRecorder (local-only). - // Instead, drive the Editor profiler buffer to capture remote frames; at Stop() - // we'll walk frames with RawFrameDataView and pull counter values per stat. - ProfilerDriver.profileEditor = false; - ProfilerDriver.ClearAllFrames(); - ProfilerDriver.enabled = true; - _remoteStartFrame = ProfilerDriver.lastFrameIndex + 1; - } - else - { - // Local mode: ProfilerRecorder per stat, sampled each editor tick. - for (int i = 0; i < statNames.Length; i++) - { - var name = statNames[i]; - var (handle, unit, category) = FindHandleByName(name); - if (!handle.Valid) - { - _recorders.Add((default, name, ProfilerMarkerDataUnit.Undefined)); - continue; - } - var rec = ProfilerRecorder.StartNew(category, name, capacity: 1); - _recorders.Add((rec, name, unit)); - } - - // Always-on frame time for hitches (local only — remote uses RawFrameDataView). - var (ftHandle, _, ftCategory) = FindHandleByName("Main Thread"); - _frameTimeRecorder = ftHandle.Valid - ? ProfilerRecorder.StartNew(ftCategory, "Main Thread", capacity: 1) - : default; - } - IsActive = true; } public void StartEditorProfilerCapture() { - // Remote mode already enabled the driver in the constructor. Local mode only enables - // the driver when --save was requested (drives the editor's profiler buffer alongside - // ProfilerRecorder sampling so SaveProfile has data). - if (_isRemoteMode || !DriveEditorProfiler) return; + TryBumpBufferSize(); + // Local: profile the editor itself. Remote: leave profileEditor false so the streamed + // remote frames populate the buffer. + ProfilerDriver.profileEditor = !TargetIsRemote; ProfilerDriver.ClearAllFrames(); - ProfilerDriver.profileEditor = true; ProfilerDriver.enabled = true; + AbsoluteStartFrame = ProfilerDriver.lastFrameIndex + 1; } - public void TickFrame() + // ProfilerUserSettings.frameCount is internal (Unity 6) — default 300 frames evicts frames + // mid-capture for any session longer than ~1.5 s at 200 FPS. Bump to 2000 for the duration + // of a session so `profile explain` against early hitches still works after stop. Restored + // at session stop via RestoreBufferSize(). + private static int? _originalFrameCount; + private static void TryBumpBufferSize() { - if (!IsActive) return; - _frameCount++; - - // Remote mode reads frames at Stop() — nothing to sample per editor tick. - if (_isRemoteMode) return; - - for (int i = 0; i < _recorders.Count; i++) + const int Target = 2000; + try { - var rec = _recorders[i].Recorder; - if (!rec.Valid) { _samples[i].Add(double.NaN); continue; } - _samples[i].Add(rec.LastValue); + var t = Type.GetType("UnityEditor.Profiling.ProfilerUserSettings, UnityEditor"); + if (t == null) return; + var prop = t.GetProperty("frameCount", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + if (prop == null) return; + if (!_originalFrameCount.HasValue) + _originalFrameCount = (int)prop.GetValue(null); + var current = (int)prop.GetValue(null); + if (current < Target) prop.SetValue(null, Target); } - - if (_frameTimeRecorder.Valid) + catch (Exception ex) { - _frameTimesMs.Add(_frameTimeRecorder.LastValue / 1_000_000.0); + Debug.LogWarning($"[UnityCtl] Failed to bump profiler frame buffer: {ex.Message}"); } } - - public ProfileStopResult Stop(bool includeSamples, double hitchMultiplier, double? hitchAbsoluteMs) - { - IsActive = false; - - if (_isRemoteMode) - return StopRemote(includeSamples, hitchMultiplier, hitchAbsoluteMs); - - return StopLocal(includeSamples, hitchMultiplier, hitchAbsoluteMs); - } - - private ProfileStopResult StopLocal(bool includeSamples, double hitchMultiplier, double? hitchAbsoluteMs) + private static void RestoreBufferSize() { - if (DriveEditorProfiler) - { - ProfilerDriver.enabled = false; - TrySaveProfile(); - } - - var summaries = new List(_recorders.Count); - for (int i = 0; i < _recorders.Count; i++) - { - var entry = _recorders[i]; - var samples = _samples[i]; - var converted = new double[samples.Count]; - for (int j = 0; j < samples.Count; j++) - { - converted[j] = ConvertSample(samples[j], entry.Unit); - } - summaries.Add(BuildSummary(entry.Name, UnitDisplay(entry.Unit), converted, includeSamples)); - if (entry.Recorder.Valid) entry.Recorder.Dispose(); - } - - var threshold = ComputeHitchThreshold(_frameTimesMs, hitchMultiplier, hitchAbsoluteMs); - var hitches = new List(); - for (int i = 0; i < _frameTimesMs.Count; i++) + if (!_originalFrameCount.HasValue) return; + try { - if (_frameTimesMs[i] > threshold) - hitches.Add(new ProfileHitch { FrameIndex = i, FrameTimeMs = _frameTimesMs[i] }); + var t = Type.GetType("UnityEditor.Profiling.ProfilerUserSettings, UnityEditor"); + var prop = t?.GetProperty("frameCount", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + prop?.SetValue(null, _originalFrameCount.Value); } - - if (_frameTimeRecorder.Valid) _frameTimeRecorder.Dispose(); - - return new ProfileStopResult - { - SessionId = Id, - DurationSeconds = (DateTime.UtcNow - StartedAtUtc).TotalSeconds, - Frames = _frameCount, - Summaries = summaries.ToArray(), - Hitches = hitches.Count > 0 ? hitches.ToArray() : null, - SavedPath = !string.IsNullOrEmpty(SavePath) ? SavePath : null, - Target = Target, - TargetIsRemote = TargetIsRemote - }; + catch { } + _originalFrameCount = null; } - private ProfileStopResult StopRemote(bool includeSamples, double hitchMultiplier, double? hitchAbsoluteMs) + public ProfileStopResult Stop(bool includeSamples, double hitchMultiplier, double? hitchAbsoluteMs) { + IsActive = false; + // Snapshot the end frame before disabling the driver so frames received in the same // editor tick aren't dropped. int endFrame = ProfilerDriver.lastFrameIndex; ProfilerDriver.enabled = false; - // Save .data first if requested — buffer still has all the remote frames. + // Save .data first while the buffer still holds the frames. We leave the buffered + // frames in place (driver is just paused) so a follow-up `profile explain` / + // `hotspots` / `frame` can walk them. Next session's start clears the buffer. TrySaveProfile(); - int startFrame = Math.Max(_remoteStartFrame, ProfilerDriver.firstFrameIndex); + int startFrame = Math.Max(AbsoluteStartFrame, ProfilerDriver.firstFrameIndex); int totalFrames = endFrame >= startFrame ? endFrame - startFrame + 1 : 0; var perStat = new double[StatNames.Length][]; for (int i = 0; i < StatNames.Length; i++) perStat[i] = new double[totalFrames]; var frameTimesMs = new double[totalFrames]; - // Resolve "Main Thread" frame-time stat name. CPU Main Thread Frame Time is reported - // in ns; remote players expose it the same way. - const string FrameTimeStat = "CPU Main Thread Frame Time"; - for (int idx = 0; idx < totalFrames; idx++) { int frame = startFrame + idx; @@ -239,11 +157,13 @@ private ProfileStopResult StopRemote(bool includeSamples, double hitchMultiplier { perStat[i][idx] = ReadCounter(view, StatNames[i]); } - var ftRaw = ReadCounter(view, FrameTimeStat); - frameTimesMs[idx] = double.IsNaN(ftRaw) ? double.NaN : ftRaw / 1_000_000.0; + + // Use the view's own frame time — actual wall-clock for this frame, which is what + // determines perceived FPS. More accurate than the CPU Main Thread Frame Time + // counter (which excludes GPU stalls) and immune to editor tick rate. + frameTimesMs[idx] = view.frameTimeNs / 1_000_000.0; } - // Convert per-stat values according to local-known units. var summaries = new List(StatNames.Length); for (int i = 0; i < StatNames.Length; i++) { @@ -254,17 +174,26 @@ private ProfileStopResult StopRemote(bool includeSamples, double hitchMultiplier summaries.Add(BuildSummary(StatNames[i], UnitDisplay(unit), converted, includeSamples)); } - // Hitch detection over the converted (ms) frame-time series. var ftList = new List(frameTimesMs.Length); - foreach (var v in frameTimesMs) if (!double.IsNaN(v)) ftList.Add(v); + foreach (var v in frameTimesMs) if (!double.IsNaN(v) && v > 0) ftList.Add(v); var threshold = ComputeHitchThreshold(ftList, hitchMultiplier, hitchAbsoluteMs); var hitches = new List(); for (int i = 0; i < frameTimesMs.Length; i++) { if (!double.IsNaN(frameTimesMs[i]) && frameTimesMs[i] > threshold) - hitches.Add(new ProfileHitch { FrameIndex = i, FrameTimeMs = frameTimesMs[i] }); + hitches.Add(new ProfileHitch + { + FrameIndex = i, + FrameTimeMs = frameTimesMs[i], + AbsoluteFrameIndex = startFrame + i + }); } + // topFrames: the worst frames by CPU main thread time. This is the spike axis agents + // actually want — total frame time is vsync-capped (16/33ms) on most platforms and + // hides CPU variance. We always populate this regardless of hitch threshold. + var topFrames = ComputeTopFrames(StatNames, perStat, frameTimesMs, startFrame, _statNameToUnit, topN: 5, driversPerFrame: 3); + return new ProfileStopResult { SessionId = Id, @@ -272,12 +201,289 @@ private ProfileStopResult StopRemote(bool includeSamples, double hitchMultiplier Frames = totalFrames, Summaries = summaries.ToArray(), Hitches = hitches.Count > 0 ? hitches.ToArray() : null, + TopFrames = topFrames, SavedPath = !string.IsNullOrEmpty(SavePath) ? SavePath : null, Target = Target, TargetIsRemote = TargetIsRemote }; } + private static ProfileTopFrame[]? ComputeTopFrames( + string[] statNames, + double[][] perStat, + double[] frameTimesMs, + int startFrame, + Dictionary unitMap, + int topN, + int driversPerFrame) + { + int total = frameTimesMs.Length; + if (total == 0) return null; + + // Find a CPU main thread stat. Prefer the precise counter; fall back to the broader + // "Main Thread" if the precise one wasn't requested. + int cpuStatIdx = -1; + foreach (var preferred in new[] { "CPU Main Thread Frame Time", "Main Thread" }) + { + for (int i = 0; i < statNames.Length; i++) + if (statNames[i] == preferred) { cpuStatIdx = i; break; } + if (cpuStatIdx >= 0) break; + } + + var candidates = new List<(int idx, double cpuMs, double ftMs)>(total); + for (int i = 0; i < total; i++) + { + double cpuMs; + if (cpuStatIdx >= 0) + { + var raw = perStat[cpuStatIdx][i]; + cpuMs = double.IsNaN(raw) ? double.NaN : raw / 1_000_000.0; + } + else + { + cpuMs = frameTimesMs[i]; + } + if (double.IsNaN(cpuMs)) continue; + candidates.Add((i, cpuMs, frameTimesMs[i])); + } + if (candidates.Count == 0) return null; + + // First pass by CPU main thread to find the candidate pool. We over-pick (3x topN) + // so that re-ranking by PlayerLoop time has enough room to surface a different set. + // Per-frame hierarchy walks are ~1ms each; 3*topN keeps it bounded. + candidates.Sort((a, b) => b.cpuMs.CompareTo(a.cpuMs)); + int poolSize = Math.Min(candidates.Count, Math.Max(topN * 3, topN)); + var pool = candidates.GetRange(0, poolSize); + + // For each candidate, walk the hierarchy once to collect: + // 1. PlayerLoop nodeId + totalMs (gameplay-only signal) + // 2. Top-N drivers — descended from inside PlayerLoop when present (so attribution + // reflects gameplay variance), otherwise descended from the frame root. + var enriched = new List<(int idx, double cpuMs, double ftMs, double? playerLoopMs, int? playerLoopId, ProfileFrameDriver[] driversFromRoot, ProfileFrameDriver[] driversFromPlayerLoop)>(pool.Count); + foreach (var p in pool) + { + int absFrame = startFrame + p.idx; + using var hv = ProfilerDriver.GetHierarchyFrameDataView( + absFrame, 0, + HierarchyFrameDataView.ViewModes.MergeSamplesWithTheSameName, + HierarchyFrameDataView.columnTotalTime, + sortAscending: false); + double? playerLoopMs = null; + int? playerLoopId = null; + ProfileFrameDriver[] driversFromRoot = Array.Empty(); + ProfileFrameDriver[] driversFromPlayerLoop = Array.Empty(); + if (hv != null && hv.valid) + { + playerLoopId = FindDescendantIdByName(hv, hv.GetRootItemID(), "PlayerLoop"); + if (playerLoopId.HasValue) + playerLoopMs = hv.GetItemColumnDataAsDouble(playerLoopId.Value, HierarchyFrameDataView.columnTotalTime); + driversFromRoot = ResolveDrivers(hv, hv.GetRootItemID(), driversPerFrame); + if (playerLoopId.HasValue) + driversFromPlayerLoop = ResolveDrivers(hv, playerLoopId.Value, driversPerFrame); + } + enriched.Add((p.idx, p.cpuMs, p.ftMs, playerLoopMs, playerLoopId, driversFromRoot, driversFromPlayerLoop)); + } + + // Re-rank by playerLoopMs when most candidates have it (typical of editor+play and + // remote-player captures). Otherwise stay with CPU main thread ranking. + int withPlayerLoop = enriched.Count(e => e.playerLoopMs.HasValue && e.playerLoopMs.Value > 0); + bool rankByPlayerLoop = withPlayerLoop >= enriched.Count / 2 && withPlayerLoop > 0; + if (rankByPlayerLoop) + enriched.Sort((a, b) => (b.playerLoopMs ?? 0).CompareTo(a.playerLoopMs ?? 0)); + // (else already sorted by cpuMs from the first pass) + + int take = Math.Min(topN, enriched.Count); + var entries = new List(take); + for (int k = 0; k < take; k++) + { + var e = enriched[k]; + // When ranking by PlayerLoop, drivers should also come from inside PlayerLoop — + // editor IMGUI/repaint dominates the root view but isn't what the agent's tracking. + var drivers = rankByPlayerLoop && e.driversFromPlayerLoop.Length > 0 + ? e.driversFromPlayerLoop + : e.driversFromRoot; + entries.Add(new ProfileTopFrame + { + FrameIndex = e.idx, + AbsoluteFrameIndex = startFrame + e.idx, + CpuMainMs = Math.Round(e.cpuMs, 3), + FrameTimeMs = double.IsNaN(e.ftMs) ? 0 : Math.Round(e.ftMs, 3), + PlayerLoopMs = e.playerLoopMs.HasValue ? Math.Round(e.playerLoopMs.Value, 3) : (double?)null, + Drivers = drivers + }); + } + return entries.ToArray(); + } + + internal static double? FindDescendantTotalMs(HierarchyFrameDataView hv, int parentId, string name, int maxDepth = 10) + { + // PlayerLoop in editor+play mode is buried under + // EditorLoop > Application.Tick > UpdateScene > UpdateSceneIfNeeded > UpdateScene > PlayerLoop, + // and nested "PlayerLoop" markers can exist (e.g. a small outer one + the substantial + // gameplay one). We return the LARGEST match so we get the gameplay tick, not noise. + var queue = new Queue<(int Id, int Depth)>(); + queue.Enqueue((parentId, 0)); + var children = new List(); + double? best = null; + while (queue.Count > 0) + { + var (id, depth) = queue.Dequeue(); + if (depth > maxDepth) continue; + children.Clear(); + hv.GetItemChildren(id, children); + foreach (var c in children) + { + if (string.Equals(hv.GetItemName(c), name, StringComparison.Ordinal)) + { + var v = hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnTotalTime); + if (!best.HasValue || v > best.Value) best = v; + } + queue.Enqueue((c, depth + 1)); + } + } + return best; + } + + internal static int? FindDescendantIdByName(HierarchyFrameDataView hv, int parentId, string name, int maxDepth = 10) + { + // Find the descendant with this name that has the LARGEST totalMs — handles nested + // markers of the same name (e.g. multiple PlayerLoop occurrences) by picking the one + // that actually wraps significant work. + var queue = new Queue<(int Id, int Depth)>(); + queue.Enqueue((parentId, 0)); + var children = new List(); + int? bestId = null; + double bestTotal = -1; + while (queue.Count > 0) + { + var (id, depth) = queue.Dequeue(); + if (depth > maxDepth) continue; + children.Clear(); + hv.GetItemChildren(id, children); + foreach (var c in children) + { + if (string.Equals(hv.GetItemName(c), name, StringComparison.Ordinal)) + { + var v = hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnTotalTime); + if (v > bestTotal) { bestTotal = v; bestId = c; } + } + queue.Enqueue((c, depth + 1)); + } + } + return bestId; + } + + private static ProfileFrameDriver[] ResolveDrivers(HierarchyFrameDataView hv, int startId, int n) + { + // Walk down through dominant-single-child levels until we reach a real bifurcation, + // then return top-N children at that level. Caller picks the starting node — usually + // the hierarchy root, but PlayerLoop's id when we want gameplay-only attribution. + int currentId = startId; + const double DominanceRatio = 0.7; + const int MaxDescend = 10; + + for (int step = 0; step < MaxDescend; step++) + { + var children = new List(); + hv.GetItemChildren(currentId, children); + if (children.Count == 0) break; + + var ranked = children + .Select(c => new + { + Id = c, + Total = hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnTotalTime) + }) + .Where(x => x.Total > 0.01) + .OrderByDescending(x => x.Total) + .ToList(); + if (ranked.Count == 0) break; + + double totalAtLevel = ranked.Sum(x => x.Total); + double biggestShare = totalAtLevel > 0 ? ranked[0].Total / totalAtLevel : 1.0; + + if (biggestShare < DominanceRatio) + return RankToDrivers(hv, ranked.Select(r => r.Id), n); + + currentId = ranked[0].Id; + } + + var fallback = new List(); + hv.GetItemChildren(currentId, fallback); + if (fallback.Count == 0) + return new[] { ItemToDriver(hv, currentId) }; + return RankToDrivers(hv, fallback, n); + } + + private static ProfileFrameDriver[] RankToDrivers(HierarchyFrameDataView hv, IEnumerable ids, int n) => + ids + .Select(c => ItemToDriver(hv, c)) + .OrderByDescending(d => d.TotalMs) + .Take(Math.Max(1, n)) + .ToArray(); + + private static ProfileFrameDriver ItemToDriver(HierarchyFrameDataView hv, int id) + { + var name = hv.GetItemName(id) ?? ""; + var total = hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnTotalTime); + var self = hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnSelfTime); + var calls = (int)hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnCalls); + var gcKb = hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnGcMemory) / 1024.0; + return new ProfileFrameDriver + { + Name = name, + TotalMs = Math.Round(total, 3), + SelfMs = Math.Round(self, 3), + Calls = calls, + GcKb = gcKb > 0.01 ? Math.Round(gcKb, 2) : (double?)null, + Hot = FindHotLeaf(hv, id) + }; + } + + /// + /// Walks the driver's subtree and returns the descendant with the highest self time — + /// the "what's actually slow" answer. Drivers are usually intermediate nodes (selfMs ~ 0) + /// so without this an agent gets a system-level totalMs but no clue where the wall-clock + /// goes inside it. + /// + private static ProfileFrameHotLeaf? FindHotLeaf(HierarchyFrameDataView hv, int rootId, int maxDepth = 12) + { + string? bestName = null; + double bestSelf = 0; + double bestTotal = 0; + var stack = new Stack<(int Id, int Depth)>(); + stack.Push((rootId, 0)); + var children = new List(); + while (stack.Count > 0) + { + var (id, depth) = stack.Pop(); + if (depth > maxDepth) continue; + if (id != rootId) + { + // Only consider descendants — the driver itself is reported separately. + var self = hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnSelfTime); + if (self > bestSelf) + { + bestSelf = self; + bestTotal = hv.GetItemColumnDataAsDouble(id, HierarchyFrameDataView.columnTotalTime); + bestName = hv.GetItemName(id); + } + } + children.Clear(); + hv.GetItemChildren(id, children); + foreach (var c in children) stack.Push((c, depth + 1)); + } + // Filter out hot leaves that are essentially noise — anything below 0.05ms self is + // not worth reporting to the agent (Round to 3 dp would show 0.000 anyway). + if (bestName == null || bestSelf < 0.05) return null; + return new ProfileFrameHotLeaf + { + Name = bestName, + SelfMs = Math.Round(bestSelf, 3), + TotalMs = Math.Round(bestTotal, 3) + }; + } + private static double ReadCounter(RawFrameDataView view, string statName) { int markerId = view.GetMarkerId(statName); @@ -306,15 +512,7 @@ private void TrySaveProfile() public void Cancel() { IsActive = false; - for (int i = 0; i < _recorders.Count; i++) - { - if (_recorders[i].Recorder.Valid) _recorders[i].Recorder.Dispose(); - } - if (_frameTimeRecorder.Valid) _frameTimeRecorder.Dispose(); - if (DriveEditorProfiler || _isRemoteMode) - { - try { ProfilerDriver.enabled = false; } catch { } - } + try { ProfilerDriver.enabled = false; } catch { } } private static (ProfilerRecorderHandle Handle, ProfilerMarkerDataUnit Unit, ProfilerCategory Category) FindHandleByName(string name) @@ -350,8 +548,6 @@ private static double ConvertSample(double raw, ProfilerMarkerDataUnit unit) _ => "value" }; - private static string UnitToString(ProfilerMarkerDataUnit unit) => unit.ToString(); - private static double ComputeHitchThreshold(List frameTimesMs, double multiplier, double? abs) { if (abs.HasValue) return abs.Value; @@ -425,8 +621,8 @@ private static double Percentile(double[] sortedValues, double p) } /// - /// Singleton coordinator. Manages active profiling sessions, sampling them once per editor frame, - /// and serves list-stats / start / stop / status / snapshot / targets RPCs. + /// Singleton coordinator. Owns active profiling sessions, watches max-duration auto-stop, + /// and serves list-stats / start / stop / status / explain / hotspots / frame / mark / snapshot / targets RPCs. /// public class ProfilingManager { @@ -566,7 +762,7 @@ public ProfileStatusResult Status() SessionId = id, StartedAt = s.StartedAtUtc.ToString("o"), ElapsedSeconds = (DateTime.UtcNow - s.StartedAtUtc).TotalSeconds, - Frames = s.FrameCount, + Frames = s.CurrentFrameCount, Stats = s.StatNames, MaxDurationSeconds = s.MaxDurationSeconds, Target = s.Target, @@ -618,6 +814,379 @@ public string DirectConnect(string url) return ProfilerDriver.directConnectionUrl; } + // ---------- explain / hotspots / frame ---------- + + /// + /// Walks the profiler buffer for a single frame and returns the top-N markers by self time + /// across all hierarchy depths. The agent uses this to diagnose what made a hitch frame slow: + /// hitches carry an absoluteFrameIndex that can be passed straight into Explain. + /// + public ProfileExplainResult Explain(int frameIndex, int threadIndex, int topN) + { + int first = ProfilerDriver.firstFrameIndex; + int last = ProfilerDriver.lastFrameIndex; + if (frameIndex < first || frameIndex > last) + throw new InvalidOperationException( + $"Frame {frameIndex} is outside the profiler buffer [{first}, {last}]. " + + "Run a `profile capture` first or pass a frame inside that range."); + + var bucket = new Dictionary(StringComparer.Ordinal); + string threadName = ""; + double frameTimeMs = 0; + + using (var raw = ProfilerDriver.GetRawFrameDataView(frameIndex, threadIndex)) + { + if (raw == null || !raw.valid) + throw new InvalidOperationException($"No frame data for frame {frameIndex}, thread {threadIndex}."); + threadName = raw.threadName ?? ""; + frameTimeMs = raw.frameTimeNs / 1_000_000.0; + } + + using var hv = ProfilerDriver.GetHierarchyFrameDataView( + frameIndex, threadIndex, + HierarchyFrameDataView.ViewModes.Default, + HierarchyFrameDataView.columnSelfTime, + sortAscending: false); + if (hv == null || !hv.valid) + throw new InvalidOperationException($"No hierarchy data for frame {frameIndex}, thread {threadIndex}."); + + AccumulateMarkers(hv, hv.GetRootItemID(), bucket); + + var top = bucket.Values + .OrderByDescending(m => m.SelfTimeMs) + .Take(Math.Max(1, topN)) + .Select(m => m.ToEntry()) + .ToArray(); + + return new ProfileExplainResult + { + FrameIndex = frameIndex, + ThreadIndex = threadIndex, + ThreadName = threadName, + FrameTimeMs = frameTimeMs, + TopMarkers = top + }; + } + + /// + /// Aggregates self time across a frame range and returns the top-N hottest markers. + /// Defaults to the entire profiler buffer when start/end are unspecified. + /// When `rootMarker` is set, accumulation only descends into the named child of the frame + /// root — useful for filtering to PlayerLoop in editor+play captures (excludes editor + /// IMGUI / OnGUI / menu-item update callbacks that otherwise drown out gameplay markers). + /// + public ProfileHotspotsResult Hotspots(int? startFrame, int? endFrame, int threadIndex, int topN, string? rootMarker) + { + int first = ProfilerDriver.firstFrameIndex; + int last = ProfilerDriver.lastFrameIndex; + int s = Math.Max(first, startFrame ?? first); + int e = Math.Min(last, endFrame ?? last); + if (e < s) + throw new InvalidOperationException( + $"Empty frame range [{s}, {e}]. Profiler buffer holds [{first}, {last}]."); + + var bucket = new Dictionary(StringComparer.Ordinal); + string threadName = ""; + int framesProcessed = 0; + int framesWithRoot = 0; + + for (int f = s; f <= e; f++) + { + using var hv = ProfilerDriver.GetHierarchyFrameDataView( + f, threadIndex, + HierarchyFrameDataView.ViewModes.Default, + HierarchyFrameDataView.columnSelfTime, + sortAscending: false); + if (hv == null || !hv.valid) continue; + + if (string.IsNullOrEmpty(threadName)) + { + using var raw = ProfilerDriver.GetRawFrameDataView(f, threadIndex); + if (raw != null && raw.valid) threadName = raw.threadName ?? ""; + } + + int walkFrom = hv.GetRootItemID(); + if (!string.IsNullOrEmpty(rootMarker)) + { + int? rootChild = ProfilingSession.FindDescendantIdByName(hv, walkFrom, rootMarker); + if (rootChild == null) continue; // skip frames that don't have the requested root + walkFrom = rootChild.Value; + framesWithRoot++; + } + AccumulateMarkers(hv, walkFrom, bucket); + framesProcessed++; + } + + var top = bucket.Values + .OrderByDescending(m => m.SelfTimeMs) + .Take(Math.Max(1, topN)) + .Select(m => m.ToEntry()) + .ToArray(); + + return new ProfileHotspotsResult + { + StartFrame = s, + EndFrame = e, + FrameCount = framesProcessed, + ThreadIndex = threadIndex, + ThreadName = threadName, + TopMarkers = top + }; + } + + + /// + /// Hierarchy tree drill-down for a single frame. Prunes nodes below thresholdMs and keeps + /// the top-N children per parent, so the response stays bounded for deep hierarchies. + /// Pairs with hitches[].absoluteFrameIndex — pass a hitch's index to see what's inside it. + /// When `rootMarker` is set, the tree starts at that named subtree (e.g. PlayerLoop) instead + /// of the frame root — useful for skipping past EditorLoop noise in editor+play captures. + /// + public ProfileFrameResult Frame(int frameIndex, int threadIndex, int maxDepth, double thresholdMs, int topPerNode, string? rootMarker) + { + int first = ProfilerDriver.firstFrameIndex; + int last = ProfilerDriver.lastFrameIndex; + if (frameIndex < first || frameIndex > last) + throw new InvalidOperationException( + $"Frame {frameIndex} is outside the profiler buffer [{first}, {last}]."); + + // MergeSamplesWithTheSameName collapses repeated calls to the same marker into one + // node per parent, which is what an agent wants for a readable tree. + using var hv = ProfilerDriver.GetHierarchyFrameDataView( + frameIndex, threadIndex, + HierarchyFrameDataView.ViewModes.MergeSamplesWithTheSameName, + HierarchyFrameDataView.columnTotalTime, + sortAscending: false); + if (hv == null || !hv.valid) + throw new InvalidOperationException($"No hierarchy data for frame {frameIndex}, thread {threadIndex}."); + + string threadName = ""; + double frameTimeMs; + using (var raw = ProfilerDriver.GetRawFrameDataView(frameIndex, threadIndex)) + { + if (raw != null && raw.valid) + { + threadName = raw.threadName ?? ""; + frameTimeMs = raw.frameTimeNs / 1_000_000.0; + } + else + { + var rootId = hv.GetRootItemID(); + frameTimeMs = hv.GetItemColumnDataAsDouble(rootId, HierarchyFrameDataView.columnTotalTime); + } + } + + int treeRoot = hv.GetRootItemID(); + if (!string.IsNullOrEmpty(rootMarker)) + { + int? scoped = ProfilingSession.FindDescendantIdByName(hv, treeRoot, rootMarker); + if (scoped == null) + throw new InvalidOperationException( + $"Marker '{rootMarker}' not found in frame {frameIndex}'s hierarchy. " + + $"Try without --root, or pick a different subtree (PlayerLoop / EditorLoop / Application.Tick)."); + treeRoot = scoped.Value; + } + + int prunedNodes = 0; + var tree = BuildFrameTree(hv, treeRoot, 0, maxDepth, (float)thresholdMs, topPerNode, ref prunedNodes); + + return new ProfileFrameResult + { + FrameIndex = frameIndex, + ThreadIndex = threadIndex, + ThreadName = threadName, + FrameTimeMs = frameTimeMs, + Depth = maxDepth, + ThresholdMs = thresholdMs, + PrunedNodes = prunedNodes, + Tree = tree ?? Array.Empty() + }; + } + + private static ProfileFrameNode[]? BuildFrameTree( + HierarchyFrameDataView hv, int id, int depth, int maxDepth, + float thresholdMs, int topPerNode, ref int pruned) + { + if (depth >= maxDepth) return null; + + var children = new List(); + hv.GetItemChildren(id, children); + if (children.Count == 0) return null; + + // Snapshot all children with their times, then prune. + var entries = new List<(int Id, string Name, float SelfMs, float TotalMs, int Calls, float GcKb)>(children.Count); + foreach (var c in children) + { + var name = hv.GetItemName(c) ?? ""; + var selfMs = hv.GetItemColumnDataAsFloat(c, HierarchyFrameDataView.columnSelfTime); + var totalMs = hv.GetItemColumnDataAsFloat(c, HierarchyFrameDataView.columnTotalTime); + var calls = (int)hv.GetItemColumnDataAsFloat(c, HierarchyFrameDataView.columnCalls); + var gcKb = hv.GetItemColumnDataAsFloat(c, HierarchyFrameDataView.columnGcMemory) / 1024f; + entries.Add((c, name, selfMs, totalMs, calls, gcKb)); + } + + var kept = entries + .Where(e => e.TotalMs >= thresholdMs) + .OrderByDescending(e => e.TotalMs) + .Take(Math.Max(1, topPerNode)) + .ToList(); + pruned += entries.Count - kept.Count; + + var result = new List(kept.Count); + foreach (var n in kept) + { + var sub = BuildFrameTree(hv, n.Id, depth + 1, maxDepth, thresholdMs, topPerNode, ref pruned); + result.Add(new ProfileFrameNode + { + Name = n.Name, + SelfMs = Math.Round(n.SelfMs, 3), + TotalMs = Math.Round(n.TotalMs, 3), + Calls = n.Calls, + GcKb = n.GcKb > 0.01f ? Math.Round(n.GcKb, 2) : (double?)null, + Children = (sub != null && sub.Length > 0) ? sub : null + }); + } + return result.ToArray(); + } + + private sealed class MarkerAccum + { + public string Name = ""; + public double SelfTimeMs; + public int Calls; + public long GcAllocBytes; + + public ProfileMarkerEntry ToEntry() => new() + { + Name = Name, + SelfTimeMs = SelfTimeMs, + Calls = Calls, + GcAllocBytes = GcAllocBytes > 0 ? GcAllocBytes : (long?)null + }; + } + + private static void AccumulateMarkers(HierarchyFrameDataView hv, int itemId, Dictionary bucket) + { + // Walk every node in the tree; aggregate self time / calls / GC alloc by marker name. + // Self time is additive across nesting (each call's self contribution is disjoint from + // its children's), so summing across the tree gives the right per-marker total. Total + // time is intentionally not aggregated — it would double-count along the parent chain. + var children = new List(); + hv.GetItemChildren(itemId, children); + foreach (var c in children) + { + var name = hv.GetItemName(c); + if (string.IsNullOrEmpty(name)) { AccumulateMarkers(hv, c, bucket); continue; } + + if (!bucket.TryGetValue(name, out var acc)) + { + acc = new MarkerAccum { Name = name }; + bucket[name] = acc; + } + // Unity 6 only exposes Single/Double/Float column readers — long would overflow for + // very large GC allocs, but Double has 53-bit mantissa which covers 8 PiB cleanly. + acc.SelfTimeMs += hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnSelfTime); + acc.Calls += (int)hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnCalls); + acc.GcAllocBytes += (long)hv.GetItemColumnDataAsDouble(c, HierarchyFrameDataView.columnGcMemory); + + AccumulateMarkers(hv, c, bucket); + } + } + + // ---------- mark ---------- + + /// + /// Wraps a user-provided C# expression in a ProfilerMarker + Stopwatch + per-thread GC + /// allocation accounting, runs it `repeat` times, and returns timing percentiles + GC bytes. + /// Re-uses the editor-side ScriptExecutor so the expression has access to the full editor + /// API surface and any runtime types the agent has loaded. + /// + public ProfileMarkResult Mark(string expression, string markerName, int repeat) + { + if (string.IsNullOrWhiteSpace(expression)) + throw new InvalidOperationException("Expression is required."); + if (repeat < 1) repeat = 1; + if (string.IsNullOrWhiteSpace(markerName)) markerName = "unityctl.mark"; + + // String literals must escape via Newtonsoft JSON — same trick fire/profile/mark.cs uses + // to safely embed user-supplied marker name inside generated source. + var nameLit = Newtonsoft.Json.JsonConvert.ToString(markerName); + + var code = $@" +using System; +using System.Diagnostics; +using System.Linq; +using Unity.Profiling; +using UnityEngine; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +public class Marked +{{ + public static object Run() + {{ + var marker = new ProfilerMarker({nameLit}); + int repeat = {repeat}; + var samples = new double[repeat]; + long totalGcBytes = 0; + object lastResult = null; + for (int i = 0; i < repeat; i++) + {{ + long gc0 = System.GC.GetAllocatedBytesForCurrentThread(); + var sw = Stopwatch.StartNew(); + marker.Begin(); + try {{ lastResult = ({expression}); }} + finally {{ marker.End(); sw.Stop(); }} + samples[i] = sw.Elapsed.TotalMilliseconds; + totalGcBytes += System.GC.GetAllocatedBytesForCurrentThread() - gc0; + }} + var sorted = samples.OrderBy(x => x).ToArray(); + double pct(double q) => sorted[Math.Min(sorted.Length - 1, (int)Math.Round(q * (sorted.Length - 1)))]; + return new + {{ + name = {nameLit}, + repeat, + meanMs = Math.Round(samples.Average(), 4), + minMs = Math.Round(sorted[0], 4), + maxMs = Math.Round(sorted[sorted.Length - 1], 4), + p50Ms = Math.Round(pct(0.50), 4), + p95Ms = Math.Round(pct(0.95), 4), + gcBytes = totalGcBytes, + gcBytesPerCall = totalGcBytes / repeat, + result = lastResult?.ToString() + }}; + }} +}} +"; + + var res = ScriptExecutor.Execute(code, "Marked", "Run", null); + if (!res.Success) + { + var detail = res.Error ?? ""; + if (res.Diagnostics != null && res.Diagnostics.Length > 0) + detail += "\nDiagnostics:\n " + string.Join("\n ", res.Diagnostics); + throw new InvalidOperationException($"profile mark failed to compile/run expression: {detail}"); + } + + var json = res.Result ?? "null"; + var parsed = Newtonsoft.Json.Linq.JObject.Parse(json); + return new ProfileMarkResult + { + Name = (string)parsed["name"]!, + Repeat = (int)parsed["repeat"]!, + MeanMs = (double)parsed["meanMs"]!, + MinMs = (double)parsed["minMs"]!, + MaxMs = (double)parsed["maxMs"]!, + P50Ms = (double)parsed["p50Ms"]!, + P95Ms = (double)parsed["p95Ms"]!, + GcBytes = (long)parsed["gcBytes"]!, + GcBytesPerCall = (long)parsed["gcBytesPerCall"]!, + Result = parsed["result"]?.Type == Newtonsoft.Json.Linq.JTokenType.Null ? null : (string?)parsed["result"] + }; + } + public ProfileSnapshotResult MemorySnapshot(string outputPath) { // Use Memory Profiler package when available; otherwise return a clear error. @@ -695,11 +1264,13 @@ private void UnhookIfIdle() _tickHooked = false; } + // The tick is now only used to enforce max-duration auto-stop. Per-frame counter + // sampling moved into Stop() (post-hoc RawFrameDataView walk), since editor ticks + // don't line up with rendered frames. private void Tick() { if (_sessions.Count == 0) return; - // Snapshot keys to allow modification during iteration (e.g., auto-stop). string[] ids; { ids = new string[_sessions.Count]; @@ -710,19 +1281,11 @@ private void Tick() foreach (var id in ids) { if (!_sessions.TryGetValue(id, out var s)) continue; - s.TickFrame(); if (s.MaxDurationSeconds.HasValue && (DateTime.UtcNow - s.StartedAtUtc).TotalSeconds >= s.MaxDurationSeconds.Value) { Debug.Log($"[UnityCtl] Profiling session {id} reached max-duration; auto-stopping."); - // Auto-stop — caller will pull result via 'profile stop ' (returns the cached result). - // For simplicity, leave the session in place so 'stop' still works; just disable sampling. - // We model this by stopping internally and re-inserting a finalised wrapper is overkill, - // so instead we mark the session inactive — TickFrame guards against double-counting. - // The caller's stop() will get the right summaries because samples were collected up to now. - // To avoid sampling forever after the cap, dispose recorders now via a controlled stop and - // cache the result so 'stop' returns it. var cached = s.Stop(includeSamples: false, hitchMultiplier: 2.0, hitchAbsoluteMs: null); _sessions.Remove(id); _autoStopped[id] = cached; diff --git a/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs b/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs index b7cb4c9..ee3faa5 100644 --- a/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs +++ b/UnityCtl.UnityPackage/Editor/UnityCtlClient.cs @@ -621,6 +621,22 @@ private void HandleCommand(RequestMessage request) } break; + case UnityCtlCommands.ProfileExplain: + result = HandleProfileExplain(request); + break; + + case UnityCtlCommands.ProfileHotspots: + result = HandleProfileHotspots(request); + break; + + case UnityCtlCommands.ProfileFrame: + result = HandleProfileFrame(request); + break; + + case UnityCtlCommands.ProfileMark: + result = HandleProfileMark(request); + break; + default: SendResponseError(request.RequestId, "unknown_command", $"Unknown command: {request.Command}"); return; @@ -2768,6 +2784,49 @@ private object HandleProfileMemorySnapshot(RequestMessage request) return Editor.ProfilingManager.Instance.MemorySnapshot(output); } + private object HandleProfileExplain(RequestMessage request) + { + var frameIndex = GetIntArgument(request, "frameIndex"); + if (!frameIndex.HasValue) + throw new InvalidOperationException("'frameIndex' argument is required for profile.explain"); + var threadIndex = GetIntArgument(request, "threadIndex") ?? 0; + var topN = GetIntArgument(request, "topN") ?? 20; + return Editor.ProfilingManager.Instance.Explain(frameIndex.Value, threadIndex, topN); + } + + private object HandleProfileHotspots(RequestMessage request) + { + var startFrame = GetIntArgument(request, "startFrame"); + var endFrame = GetIntArgument(request, "endFrame"); + var threadIndex = GetIntArgument(request, "threadIndex") ?? 0; + var topN = GetIntArgument(request, "topN") ?? 20; + var rootMarker = GetStringArgument(request, "rootMarker"); + return Editor.ProfilingManager.Instance.Hotspots(startFrame, endFrame, threadIndex, topN, rootMarker); + } + + private object HandleProfileFrame(RequestMessage request) + { + var frameIndex = GetIntArgument(request, "frameIndex"); + if (!frameIndex.HasValue) + throw new InvalidOperationException("'frameIndex' argument is required for profile.frame"); + var threadIndex = GetIntArgument(request, "threadIndex") ?? 0; + var depth = GetIntArgument(request, "depth") ?? 3; + var thresholdMs = GetDoubleArgument(request, "thresholdMs") ?? 0.2; + var topPerNode = GetIntArgument(request, "topPerNode") ?? 8; + var rootMarker = GetStringArgument(request, "rootMarker"); + return Editor.ProfilingManager.Instance.Frame(frameIndex.Value, threadIndex, depth, thresholdMs, topPerNode, rootMarker); + } + + private object HandleProfileMark(RequestMessage request) + { + var expression = GetStringArgument(request, "expression"); + if (string.IsNullOrEmpty(expression)) + throw new InvalidOperationException("'expression' argument is required for profile.mark"); + var name = GetStringArgument(request, "name"); + var repeat = GetIntArgument(request, "repeat") ?? 1; + return Editor.ProfilingManager.Instance.Mark(expression, name ?? "unityctl.mark", repeat); + } + private object HandleRecordStart(RequestMessage request) { var outputName = GetStringArgument(request, "outputName"); diff --git a/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll b/UnityCtl.UnityPackage/Plugins/UnityCtl.Protocol.dll index 119f55b6a42e2bc71a02f20ce3b5f0b78cd0262f..138caec7e7f9e1b69f8c39b118d89248443a40e4 100644 GIT binary patch literal 78848 zcmeFa30R!<`7ZqY1_BI(0AURzVF-jJkN_dktU81kgInBU)TkMZ8mUG!aktUIxFPOq zo4O1pvEo+OR;|lmq7|)cs@A%6Xkr!P-rBlVzx%o0=MA*B|L=dU>pS0d&UH>~Z=QR3 zf7`pv%WUj@=oOMABFXsw_uq>=grE8^O#H7Q3VT%PBctSj;PYh|YqWYs#vlkMBTG$0`^z*I?=oa`T35#`1T%uK1^83&;rh zaK6Y-Qk?wrmW=$jU_zieS0r5~9ce|N@X}flH?bh;hBat3dD1Svk@N$u`*XOD{gFIr zM~gd(dDemilGb3qCr=t5ILrSz!rLTi4GS1Jezc9GH7p>c`T`3G*iBx-it0R001g(# zb67xV^#v9X_V5K35cc#177+IG1r`wY_5~}3y4%NhSRjf6)9arFgnfO11%&;4!HS{A z_V*nYh#uezEFc`{3oIZk_5~}38av2$tQc~Z_>L7r&cVLJ0?i-d3sww85A_`@hMc9o z!vc*Q<_jz!9PSHN3~hUa@3260nJ=(_5b*^T5ZZjfilN4i^c@z6;#~9gjTJ-DqkM-2 zqDT9J6+_Wue8-9*XNB*uKqJTcf)zv2cHd!v=#PBCilOLnzGKCZbG+}cKqD)CfdzyU ze8GyLZ99C21)?YVf)zv2lYEB-qN{v?1%%bUV8zh3xLkTG&H~X>e1QdoQ+pNBqIct4~1sXZe7g#_z-xpXwxWE@!K)BEstQczjBHv+wXqPXrfN-%duz=9* z3oIa9;tN&`wSKAZut4-OUtj^@a$jHp;R;`10pUttuwtn7t9*wAqF4I@3kcWvf)zvC zUh6w75WUV9SU|Ym7g#{}i7&8#@Kax~VyN|XzQY31pZNj{2siiw3kWy*0t*N?`GOTg zt>5fBED(+P0t*N|zF@`Bwzv2W3q;rZ0t*Nme8GyLZ8!Q33q)`A1r`u~?h7m++~x}` zAoTiz6+^Ax?mH|Hy~7t+K)BNvSU|YT7pxd+>~7y-f#@%Mfdzzne1Qdodws!*p~mj> z9Tte*?+aE8MIZ1T7KlFR3oIb~(id1j_?0iPfbftnuz>KeFR*~{h%Z<%)c>!2hXtaK z`T`3GkNE-%2#@;$3kXm60t*OnUtj^D&lgxgc+wYGK-lC9EFf(51uKSz{FLvoK=f%} zU;*JbzF@`Bw$Jzu3q<>UfdzzTeZh*MZJ+ZU7Kr}V7g#`e-WOOvc)=G~KzPv?SU`Bm z7pxfS?q%O$f#@r~zyiV+Utj^@cfP;^!mGZ(0>W#)V8u{(ulo)QM1SuKEFiq$3oIb~ z!53ISc+(eHKzPd+SU`B&7g#`e#}`;Y_@ggaF*L%2@3260z!z9R_>(WNfUwmUSU}k3 z3oIbK>kBL({Mi>+KzPp=tQZ>5`@UnvkTd8zEYQdYzF@^rbi41cK=eajU;*JHUtj^@ zV_&dhsIgCc$BH56Q{Q2MMn3Ze77#x71r`wg;tN&`HTGBEVS(ruzQ6*)-+X}ugunX& z3kYBOf)zupf8{$Y5dGR0SU~v37g#{}))!bn_=hjBfbg9!STWSy_rAjdQT*EL-D0wU z;Cz7vgn%!wfRN-1EFdKNf)zvEjqn{7h~oEbuR9hHQhk91gfw4Z0U_uMEFh%&0t*Nk zzQ6*)NMEpGXh501!vfJ!zQ6)PmM^e?knIaBAmsQ03kaiqfdzzIUtj?t&lgxg$oB;n z5DI+3ilHGF`VI?3i+q6vgfYIr0z$Dbuz*nF3oIav^#v9X#`yvZ2;+T$1%y&xuwrP0 zA>UzvXqhjtfH1)qSU@QE1uKRctMDBbh)(nc77!|ZfdzyrUtj^D+80!vaw}apg6}0z#87 zuz)bl7g#`;?h7m+?Bok9ZVjw*A}6lV9<0>116UKOU8CCshQ_nAzbOl}HNzKJK$z(Z zEFjGC1r`uy`vMCHb9{jXgt@-J0>Uo7zyiXqzQ6)P*cVtpX!Zpb5a#&;3kWT~zyiX2 zUtj@YH(y`@VSz8OfUvtSuz;}87g#`O^#v9X_V5K35cc#177+IG1r`wY_5~IY_VEQ4 zKeFAXyC6`wMi+kAE?eYhSzzOReZh*My}O_9ut0QwU$9~*dVue+K=eRgV6l%4N!?*% zS?q^cV5@_CfdzymzQ6*)!M?x(!XduE0>Yubz{2lYw;$H?Qa{83TOH;LEFc{23oIZU z;R`GvEb|2xXWA5Xdn^CV810WMCrW*{EyHMjJ^o8h#S}Bri}j zeEVcs0AK&D9s_;i>SAC=2|y+mc8ulEf{t;<$Ma`LDSoUD0SZ@_0XrtB9M!?;lj|$> zr#^>ng`FV9t1yZaHE@C-U;&}S7g#_z(HB@iILQ}SKv?AqEFi4*1uLp=3EU}Izyd=B9ah`% zu)3Ku@N1^5-i-RcJ(kC;*_nQ`UT;sBjK}ML+W*xfw6{KU@$mG|Zi>2_)Xl6tV)e5q z{_{ZesNkXH_4pfW6W45$*;T*SSk5Y(5WfPJ8rXNT2301Jly@HnmdxWTQZmvtQ(6qdFV4a zA}O^)-Hs_bX*7DLMbmTA*(lEgzfm=JG>U5j2bz(S!6eTKKdGiAXW(9jQ|3YM1=2e1 z+Yw0Xc)(g3nKP0t@ig#*YFbN4t9T0d&M$X3X&nz)6PY=goFPvN21g`$uFY`9Q#d1Z z2Milcu;bAkO(m`3f$#MZ?09^K5$uRtAERAHj|%Et-{+#VF5<9Z_{H zT02w6!Xw}7GAFs?=^cG0t>VFN8^U^K$ClWvqh9x!zM9>@0+;gh{YyCu2p9MQ3kVnb zf)zt|rWg5+73N3@?hk&7MZ>9#`-fFGQt<>9{H1#@@i9alB?gs=&Pr}#3sO2}gQa^%x=!VVyv$k&YJb}P(*KwCr~aGv zy~X2vb?N?NPn)eLy+1n>{iOXD^H^m4RR0l6oRNy_M`o~YL@KU_^KF9vyrLu1xSqP! zbs4rT!iE1dU99@#pXcqkrs{F94C4rL9Nbsw)pNWh!(BmoO2;n1j$JvY>M(w+$_ZdM z;_N@MqgkzafP(QIE%=eSDj!~um+#!m7q-XuvxLz5Cn}><=7VdKtt1uHJ@~_@|?D1P+y2+}G|nJh7m)!ww=^5g0~u4U zmep@!loJa&Uc(QWxM~?<9lvLt((woVIdaBZI)6zSQ&_MXk4Wj6|GEwC4*qqUw{OE1 z!MfC-t>`IukaT)?P*9t#=VSHzXj=Du!FNgD=Q;XqOFAc>yPn`dO*OIfqq6{i!tJ z0iWmhDeyD7&K<4jA*Am&Tb7OIx2Q_n(kuNHWiL!gk)jETyN5~yjuy)Op4r|kQNxo}eO_P%U+VEPMFTEXym3D(M_&;C-Xtt8_y z$mts;%R8!CvDHZFM6!8`vY_OWo!_La!B}>+vN^^+rMHW*S*7ZQjb&9Sn-8Wv{B$Q} z3ypQnQMQkS=p8#r+5S>PRzE}8K{Az0xZM(&PL@q}u*@JE$K;`+Ym+VA0o`sX`d8M< zHjj|q$&R3Rr0h%f8re~@nCvsMW8_e>N61#l;bfPxm3BFjY*+SooU9m zS%;iNwu;-GB&U+y$~I4yHDs+!o+%fTJ;&r4xsvSZoplc9$_-@uu)no(Gudixcahxg zB`Y*}sXRzFmEKhnC;NbHUMs&L8^<=+$tz@ElHDwClYPzYZjo(dtLbf&?PRBL)VIsu z$a>l4-SRzIBa`<@n$xvT=QtmbOtRn4){*^M#*)3oaXuxDWCIh_dq!rEeO#;TIoXvg zI!@Wkc&D$nnZX`jmqW-l@_0$$DyZHC^xl^>WPc(X#1XIF!yM;!IiGAHy-(yava8t2 zr*b9P>zw;vWgXe!OnxOB$=)i~v3xK0k(Dwz!aYIu09mGciR?aZSLog)JD0tO+?Qm7 z^eUVS=*S}U8r@j3%IVs}&aQ^+X|}n@9ZJ?lwx3%@b}?7?0CyDG(PWEVJK1`+vecbQ zb}P4wxK6TjIoqS%B>_FB*K)0ocRvesVl0Iv+WSg(Gm}kV3*8BBJ=xx5C%b#e%E?Z5 zza|Tl{n$N4HY!P5Iomx)b~}2&HQ2opz~rT2T1VBDV6W0!$CWtW*koR9E^=>iWKY&< zD_!o7fl9f%;r_Hr8BzW#;D^5l=|jEVynr)Yg)k?J3$K9Y7&bpEWzfJIe` zUsNdWOJz<#>sYPgtJ4+dO;gM-Rs4FA;-O3)Izj81R6ZH6^*ZWDGrt$zBdNdKsO~2- z6-&LAo3#FPs^T})CsF?s^)*x)SufxiwsV}fQCU4sw|co!@jNEKpRIL2m0vRHINC$X zRlkq3D^1e+x@5&&SYOJ_$;4&EUb_3!UB#{LWt;U>HqX&*Z!S^1oBE@5THns(bZ)zz z$yO>}ze`wq$@gk>FU%`Y^!CWQRNXuES*lz{JY$S1OS4t^=_tjo(-hAmj>*(InR~W4 zO_PU?RNRoe3AmPS#z@T^#&*uiSJ%V2`Ol!P&Qp9OsF{WgJst|P6un-&ZBsZZudm@P zc=}?t>A3@AblZ#ao(Fo#qG4lxgzd~q(Y|isn1|2B(^ueVER$DL6h9)ql=TYgM{{2V zdc8~?rEYGa;(`oq?In(Xxc*p*&U8(|o4_A#^*e6$9=GzwxvWsz+01p$8a;rzwm|VB zjw*qf*zFpkE!;|}@eufs_D*O^STpzxbH>%%ow5^=!VH`s*+q^sfdu&_E-tW%W zSzpIva^+}k`5V>~yyI%v@#eL2Qm*cW#fWW*JOQOCED?hvCO~-@8s9R^=8+?F?;j+BzHP=Z)LXenq16ny%~LyHwW%t(sc#iAbLl_@T8ad z;Yto)x0goE`!9MqhUZj}qq=gm_O&fd^In_x7N~Ls-P?#?65q_$wf&s7Y4 z+4MNq*1NJj#pCBt9!qsRQ*NblW04*M-=`{03hF9(=kjBDdK~V;^YNPeg=jOEXX{Q} znPb`Vo5Z_EFM|8X2yJ1IZRT+epUOW7`i9)YfM4S5aMC(Tv6i*A<3!tWykf)*$sR?j{E_({x)&0gabDo_Q8+i9yfjM3;~ibze7$wZ;`zG;E9qoEj&M%y zu}Dtin0F_>fGd^GTHC2*Yk$u_5t-rh(w6^ugg=DkOggRxag%AKNjH5C;X2M>x?5t}DnjO4rmMK8Gr z=dMTTLD4mQ?5-QD^;F=#A^kP-f})q}QgEMs$PRWnX1{6&y9Vss9ZbEeb};>&^OhY< zTe;((*aJIQHoT`imiGGCE%=S}d1G%^t_ud_RbwAk-URlJu{YfoJXi5&V}ovEFiAc! zmN#p=OP0SI8#n9DAb!U!6a2d%tv`9Y8zESHvX_(Z433Z-k8!psQf%H6O;z-a zHf;>1NUbsLJyn{FY452r$JoKi>w;-A-`H` zImXzp;iXH5vG&>Pf*Ep}u`_1h1QzueM?F%y%uA}>g5SQcG?r7l5l;xMGxkm87Chf` zi?I<^8-tm0hq3RAx8PaT`;BFkYz&T)M~$5YFH1HXySR8`FiW2I7<XX4@dC=IAQ=bWz$m7NyZFn+>=ar20H#`#@Cog)8V;L{MH?J7` zVZ0=am194QmqBB%joa=@W-V21AlzOphKsMj6wi zr%c9rjJ;2giRS5gPLRpQbUi1?PR8y?S{E#rU5sr`x(RG|kFk{s+1ETh&MM?!V|tub zNSiTT>xt5COxJp%tnwIJsg$#ZB`am^uwBy?&R%0vCU$xwA z>;m*xEx+;@`>T<-c{=JEdB&KIx<+0$_UxSPZj!uV?DaW!;+3R7d5o>p%69XPY|$K&>ScdpPv9EaAcq=z9@oeQS?)2m(kRE9mpN{WG|I`w zipOmXHp&`fI+m$&zA+ukRJqKUUYDBWI%9fWYLc5h#vZ20ZRY7%rpZ0VbS%^4VPmCu zM3^pp#wOtrVY)nP?3ASKZYO!gSTyNQJhH!OtQ+3Wvenr2@OG9Djs3Mc5u71^HRfuz zf&IhS^oH%hnKELcw~yvEd>))7qddk@&z3^-^mv&qrN;DlnJv}Ej;Y-4=17CF6D#iw z&XJkMj+?&S&6Q?jr%t~!I9K-Y7<<@74lwUito$yr)Yw;8`Ca5FV{>tx*j0`y0hLc{ERMHufW&NAu)1k8xxza<6%NPy065 zA`croWLi>si#$0jIbWVLPgiNaY%!**G+*8_rsLdAwi(lL?j|1@({V14FO2Cp7sz+U zZXLe`&-kWPdi&_X@f(A?OO~^Nf8lePeJB+0)qFbz5XlIl$OMbsK|w%3;RJrevn?B}W-+oH9CnZ&_*Vnx@S3 zedH8lH#Ln;UnFOHj3e7uE;R2in8UtuxiP_h-&cNOY#Gj~{Ul~=CC;k-q}SNvv$x>+ z^?QvyH+y4ne|f~%Ol)_6JZWqmwmU$cGd3XWf(ObLV;{>+U~hShBU>!*nztQ2ES8Uq zeSsbp%ioN())%KAB;OfZTwey3TIKDdIk*xnkt}0-;7YVaiaf?14wf?W^giTZsWGPa zAqUG;V>*XJWR@|V!y(dQYzvNwLuF5612`rQl>>c-`7M>hjOqNA%F)Jl!FGqqN@M$C zyTjyEk8xy&%Q@!h+z*!vjp^JEmn)2oMJq?hPmE1QD@RC=G2JW6q}Q14m1T0Dv4
    LY-h1n#{ZW$bG4^n@j4@AJIaYDd^J|wAjOqN^-)vr^bRa7o{IBJ;sV=E&;pUV;tE^x!*kPeWm=` znD)L>HXGC4Pmtdl)80>zSB>eqbjaJrbX_{+&&G6JPLz+0>AIXKfA<)BKS^*a!o8yB z!%326OwWgtB*$3hoGr3S#uzJ}voW|zCK%g8 zXgyixdyKuGB72+n`i$-F6j^L+V8)%nQ{)I^Z%sWi{Zu)|*q^7igLQa}t(+#Oo3|b- zc$!3w-HR1GO}dP|H8nH+bh*;lpQnxnTW9R7X3eS zYRY`DM~yAU+|QED#v+*eS@OKGp3-%}ALA8cZ*?Ciy$S3cW7pxaYK{Ea*m^uxt&vYW z#u=S0Uz(?T1#b<&TN`veb+4T5y}1ZXSNC<8@ppxBDhu-8@qqjHn1a%IY|W1lVgkp zWgD2jnNml+2;TW}nz1GD&X=gM4tN*HMaE8pcY$1K?A7|!=@-gRjSbYF4t9&jxJnnv z9p=4>-}En%`;C2o-}En%M~ziZP6WGTv$35fZv%VY*jKX_rC%(s8q1!&1neD;v4?JX z&ph1^-SUYs-4EUJrLm*X!zFl&k2l*6^l*vbW}I_B6W*ngW9$NWmrAk6*u!N~Zl3mV znbi8*p@++4n$OV6ZwsN`bW=wm(LiRSMyNSZjlaS6L6ilMNT)S*PitfHKx~|_0nZbuRR;&N@IHM*&ypY#+BG8 z>&?@Z*eG`x)0NmL4;a&{;;r(iF}*6@Do+{PAFK3pdEVI3Sf!uKYsPeyZj*P6=_=hO z?-|ppaIbt~Os~Sd@})7o(%vrkb(BY>UTJR^{5ndev%N!djOlFekYZyx+dE}~F`ez5 zQtL6!_AZ%b-i3`@((jTv#;$4n1K4iEl6T8K=IL(}cgsP>^tXw-Wtp+AhHdG;kQK(R zZ+IWqjEAJe*y#;vU=<$Y zI3JeD=IJ%$VVQ1BuOSc1F2;Th?-5yG>^XRk$RcBFG3sB-5@T0k)W61;mw6-8YsjN= ztTDZYJSry{`(tBn#$$4Zv5y;z!OryWuDCpCOz*nl^0+abQJ*|*OlQ<5FB;PsJt?mn(-}P}31d3yP4d1m9rY&p z%wrt&X8GDY-J6>wfH!~p_Y9jQ!B}sW4dO)m2P9YX3xvj#&pe|mm7@fQSgFnFs4Vr3v!pSb$CwTMS0NJ20SP5 zqC9Ra1>3zOPa7-1b}z|`#{My7yL(w)H&O&UTA@ zZJzEUyvBidZ~FUai)0wPY1(%8JG`UW^LnS<8T_4$HFgz7_Nr7EyAdOMRVEv|4c==q z-PnEbUXxvneGTt*Szs&~*X!41k+FB`62afg5@YYzZ3BxK`yAdIa;&j$;k_Xz8C!(w z;2-1+V@q)z{DYiptfp#P`kQjGu{l-mgI#S*pYwQ2er8Oc^LR@(c#Ny`w%ldjOSlTZ zEe{$?;41vKJZ?;1dwEBmHm0w=ydy6f`x}18`=h*WEVJS!u!OPi@p?`|-Zz$yN411} zX6$V|FEt=v8T%a1OASb%(c2HNpv^x?y0LBez41?yYpfFYkz1w2*z{RTz$%Qrk-R8< zo75S5FL?>rbYmx$tP8#?bB%SE+yu73V_b z*j&vSq#^2;vW1Z6%rT<+{GGuLGNL*DloNBy0Q#^-u;KLp)&_nj0QE5aN2zLRoedgc3G zYK@(V8GSF)jQtFw7B|P(tr)eq-Hd%PVT(Aox3Par!0W~CAY+U0OliO!VeF8)Y_Ju^ zRy19mk>omzo!Yc6BiWs9>|mT7BV5$j(KXp%UB+(3F`weDG4uiI>F&#Bp9&l`JlCSLb-uNr%ICf*0@-Z9oO z3y%)&&&E!lh4-_%PmDcXvM^($`@6B%OKt(<$6T-<^c>4{Y544f?v?DyY_J?-XXCk< zQErT}tHx)8O)z%p%!L_QZj!NEXWjzVWbA3oFWb#F_A2I=?dBT`j9r+K6LGcd(UHRrPzIH-rcyLFLqxVdlL8a#SSkhapfPxGpr>JFDQ{cfoE7t+-Q%nm9ehG zyvJ~MjCJM4p2gWQ*3}vNRs9_qf3PuD+?G2YGf7+WcI`cNU2Cipuc4H?8;xBx z-ATE-)!2Sbk7rc4yNw;z^i;+~_bZQa)Rpcz^Yp4w>9!ctt45`J%a~p@s@yhXdex|M z9~sjM1oEn$1lehAE=G3?>V|wOHa)rk9%$eju9^=Sr-LB^8$ZFm0 z#&l%0ZeL?MvO0ILF&$Z*Ycr-Jo9x<+>BuI#RmN7~8ZyP5Y3zJlL#DX3#&p#6uG^T7 zy53#mF^;UkJ#OBfwcFAg+|$O6sC_=8!M$iq-viR~vRR>|(Sz-BlR7wC+9YGo9;2rXlHjk*>&>Vgm*J`c2^jCb;5@kGu%Bp*k3Ycx|hhVEPB1}>x?;0 z$0hP~roM(b$4$V;H+7s}S1FrhYy{p#GsiWNoyY#>x;bP|r|EWcU5h34u5zy1)0o~> z&UFVGd!-t$lDfl;ZAF{AxTB37g6(#7D~+9i?RIsi8oRbWGd=9iHg;?M==5fHp|OL} z<~(<~vE$L^Jogi0QFtvbX6!0>Ew0yNoWp!~ADK6Y`R-v$h5~pc)b$yg62L2=?pb4L z@OE>r7|VmVn|sq(8TQ`-x7FBWJnOK)eQ4~XhC4ELcYihZw}$UB7P^0UjALnaDf~p6 zHCy7vFz;*CG*Czw`;THrWspgZ`W?@`57C7d%IP}76j5q?&Hohwtrw0 z*jkUV%|-4K^L~L;xVy@jj^%LoGh+?7E*;_48*9dW`4R3;kFkek?m_ePH?U>yF=Gi_!IrtF zhb1HKC9>;ePWe0+aTkSkoY%>obLP1t-TTIN23zj-ZT7q;X3ld*x!)W6yPM~Zb_dS$ zyn80jbH}*Tjis0GKXQe8##l-D!6Vz9w0OzdY0E|)=Z>AP>`K{CbIi!&aV>E2-dM$J z@ouI6bx_CXk7s!Ozs?B%(@6gRAMNlShu8mUg#TCn-**Lu@899||GqQ&A07GsnX~p* z>3{az|7W*9d|&^M_VwS-|M!!_$MgS1J#CWLw?1-d9gxlV>bC#X@_&Dv|NZ0-`x?Hj zU;h}d@djiy#+D=(;&DG&P6dvTRd`*|$xrF({Xmi_|Nh>> z0-e0bdo;~@wTA)OAMd>Nm5SLxdAZKvUGDn1i7~)*yvgQeJS){moJnjZ;uFfy_ah!c zY$LV<^YMNhZJ`TTB0az|=>=Bdbwl0iGvH*rZ$&d#65HH|b7o7s`)1lAV8-;N#Fa8Y z_avvu0m-d96?kd+8sLQ$Yk?1yUkogodZqL*(+j)--%n7IbeH6~=D;T4B~`ygFUu;n zNWXh%&g;_W#!Y`4_4U)fk(ZIr#`7dc;}g4M-1O;+yN^@lI6O}mbk9u)xiRj;Y5I)- zI)*pg`{Q(U1Tsrg&_dOm zU!uoLXFcX-qUBdztJC}BR`(8`!RU8qH-6$q;T`2Efl*Q|rNBCw2W*hTfzxCaaE4q6 zoFg{?!}1H@e0dtUP~HUYB_9Lvy#Pt0idw}IElx4@rB$%s+-g#RpHOqK#S z$f>~FBL0v5U5fMeahX``iU#B$)J#4CYml9r-4GUZrccFIa5D^gYgt5ePg)}`E# zR)sf59g{r;Ukh_o<`l`5mB13Y1XwQD0&C@MV3TYI&X&IcTLfPvf_K(V1|A?YfJ=$X zWznc6eA8uWR*M`b$ELO5b2!JQEoZ&l>eaGqR=doC-j4SJb!2za?Zmslayf-PtCz`MBrnSDqua;iCb$n|_0#Q#du8?j-2u9T z)CV~d=XA`@=}3~?YI!p&=yaSx*DW_@hv=5O36hyr?nX->yBwLc>>AXmSvAPqn;mAR zg_#y+TG&D>Gp)#M&W^R*`bT`rMr`r!VH77xLE8VSh2Z5qtCMUvnmeXBMcRAg5xNqe|>8_=_ zmhM`*U54Ef9UWt4Ju~Z>Sx>juuv;z}9cN|}Gn<&%M7Q6tTY5$(nAu8Y5WU|yT9UW| zNu0G|w;YfgN}7NF_+sbQujNi$*UVP;xTXJ)l9)56UBq+%JH)oR!+k=zLL z5$2aOznuBy%pb*MyJ5E+pBrU9%KTbp&ZFCf-p|a9QQttN7s`3LaVneG@+P*tiCgu< zy(~A8qCS>?=>)2&JF zmUX#dx-D#>g>DPoRzp3mBFrpjW;rv<>9!l{+D4gK%gkD4*3#`V)J%+-^~|hiWGY}*~-kIp^jNb=<3M`UA?3c+H%qeuFnXr z&xme$AvZKaSFao{t`&64>DCzHXwMBZ)51&(Gc9ym4Ru}-wjW_~Ig`toT+U3pVYh70 zjWQW!axIf3q zFk5S3riGanW?Bt(R1s#DGqaqT<#gK(yQMrY${wTaaV?W;nOw_EmtnWm=f&9CdMdq! z-7-5bPJI)%-NbD-(d{?vmW6o<_L5*PTbbO-So}_Wz(ztH8-@(z9#&t97mK*Xy%#<@z&P+Mo8be*%Ff%R8 zv@p{`x7AQb9${uVGkR|o$X-sj-B3p!Wo9iiYnfR~x680wdh=q;tY>CDGwbR08g|RW zdGRz|^LU!B`6ebeF}aDEenY)7B-q+kWKy%XGP9NLU|P34n8GJevkL>s~ zFdo_Q&g<9mLUhXvHB*iZuH?+r1bK8JIgl4-rrA)FEo`lY$rgO-?%1?e=39~fJTJmz z8{E{aHmh~3<=kpHw_47v+6}wqA9+zGJGoV-)wzV8|?3R=A<4pD$YJL;*o0#9kWItvxH9x`3fT1S0 zGP#w>txOK0#=R@@(R|6^`WtFKDTC{uQH*bx2xf5oGrD#CGx&&;p))OGzAQuYH5uAk z4Ki2fhp9Iks@sCr@EZ*>f$SEx*2;V<^2PZPCff`(znuBy%(pY&j^xJtD0`2x_fCv$ zY*r_?>f{*Ka?ESFRTuIH=f~*wFxg|Zw%^M(dy%;>KTf^RP~CpI{ctzsC#Vk?syj$` z5N^+C8Oe1sR5v)1Yc*20Eu&sGQuP|@HP9F4g{e0is@qDp74DY&2=z8Yb=&E-!`+%6 zrQT_%ZWr|X@?%tb3{~%iz9~OGlGnqLy1)A9_HjJ@aQ~X0pfX^nTMa_rk}sK@m!ayx zOwKD)`wC_97|7%?z)V@DW@?zJL1t5an0m9Jx~+6u;V#UJP;WC-x1DY~+%5T0>Yaw_ zcG2yEyCOeEy~j}9Ub?+-T|qokkF+@5zDzwP`>fVvKa>4PW)&ouOwb)L)XX3=gUF02 zkWo5Q8KpCIqqrucG!q=f)fm++RRtmHWrph3(5-ubrm--J=QD5zm8I+7$xLUKX1bW^LS}AZjCzluy1jIJ;VvkQQ|~iW zx1Vl5+(m^6>H~)A4$>WjdvKv-a~%xT4Q6w{XLn0mVTgK}p}IA6YvBHH~)A4$>Wj`$m!EaHfXp26H&moNjryD3rtVJ%{HzGi5oNsbQuD8SHWD&4%i>(rsl6 z5$bI@dd#$0t(kUa+L3vqC`!GPnNF)U)5S~|GM^X4sP`DE+Y9|4MR6*9IXvI3R=1x@ zKQh;kNl+g!RCkaq55mnZlF>ZVNApY{&D9vKnc!%y!{}~lC=O9CGgP;RZVlXp#bN5r zhU&J`ZG{^uicoJeRJR@alHw?pPD9nZxK$V2uZm;Tdkod>rP~X4Zb6)SpP{<_bo=3^ zlq9GR7^*u+cMxu4q2zLohUx}$In&&3DJcn2FU!^SEVEiOHMzW}MP^b-n0m9Jx~+6u z;r^y1lB??($<_62V>@k3w&iNFok}~B$Bd0q@1)XcwPw1gbRjchT#R~;p}M`$&nbwr zmpFUrqua;!H*+NYoKZiLa|;r52Mo1^ZOjZZGlDt(5k_p`5lxDSp?P#-W(rtzNhj9_=ZHDT$(`|=aogby%X{c@&^zGwfRC)|m@8wp#aK9cG zr`~6%Za>|AxVXMiA23vRknSMd)bUclnHs7aEZ{yZ(5p&_dYPfRHP8#khp99hs@}@2 zTH$6FMX0wKs@qPt9qz;9qXoK#(E?q=PG&j_G}FaQ7c$R|k5TV2RJWIIFWgX3oO++3 zy8U$f;r?NKqCg*E6Lbd*H8aS}ATl3}mqN`*A?IGG$C9&Jlfgo+Kq1z4e299Pp}IA6 zYv69l4^wYu3(Zz*rj?miWF8(Lq26YwZadv}xX+G{QtvcWw~KBU+&_$uQSUKSx0h}& z+!gt8>V1al_S5Z$`$kcsP}ez8sOvny%s`=L2ALT|=G`JG;tCk58!Y17i@5ixml>*C z1N|RGVJgjrs<#&D)u0t_T4{uOo1wbxY_lCM9>J-17U`OITCJHbX1b7hczld{kD$-|!P>nx=q z>Sc!NR*d1botYYBo-7U1Z8p@*d}dmiX+>sBX@qW@p=R3Yw!_8m#nd|u)$O9&1^4aJ z81)`Qb$jXd!W}G)Q|~iWx1Vl5+%HNKV|e`29Wd0)ATxu=NJzh$4aa{m*P)olztx%y z7IQU<@f=7fRLm=0F|T;cloe~HhM5{<3PNG(&4%i>(rtxX7K%`BGgP;oZads5p(sZZ zrQ2z!nJ$j13z>PL7&9@tJ%*a;Wjnpdyc3Eu6Q|o}sF{9d`jP3#PB4?8J7B1pL1qS# z`6MJIToXfegC$&z5?n3ILe$F))vcjh19yB`n0m9Jx~+6u;Z7@yP;V>IW2wz*&9uYa zy)0V7t6~YSip+E}(^;aKE@YOJ#i;a@=>F=lTHRju(u>S7WpV0#%=B5UnSN&akvTsv zL4Ckb-9fs8aL>h8!f^!*)eVl-9)n}M|KRd0oUcUfet&MPuj z=ha5HZLGTOa33y?-0>5m)H@B;?SlTu#2A$xL)CksA6glw(r2i8KlE=Z6I2EaRUd?Y zW0j2O91T?uj>jvHRUs;6hN{;<|8-TEO0%KrtS`x+6+}cD#U#R_wN&;)H|tkTCJJ$sB|H7LUoLKkDJGv^e3F!LUWTd%%W&^BDMY2rQ1u$ikJK^hJ%;LTK$~N;dXd?8a-5ks-M$HWt?sj0lbf0BNAl3g z3F-re>JCCbZnBhfj)tlS%eDPrdAF>Y94gl_gy@zTYNiHme^r=nn0m9Jx~*_mIN%$+*aZpLG>Z(Wrph3K>tI1m`byu>aEZ}sgF=;GgLjwy3h@OZn)kxZX^2zrGgP;q+xEj9-;m(=6CD2lGXos|AjaR6HHb`YgH&-| zRl3g3YHcT(N{C9Cq3RXT@k&#bjJHK!gd1*< zYR=kF-Q;TS(`ubpu)16JZV1sWGt^84Gd0ZAAd{LCrrT_&nO0<)fVh`#h){1cRJWbm zMyYffsvcwAW2j1;b)TUs3DyIKs%&FlQp33zs-9ea<$j80#KGRpPAs3{~0877|nj3{{s}UIA)(1*ql7 zYgG@iE;Cdm%(}T&*QD8M)#umh*dkQg3{{V^?le>-#=6H)l{o7@w$Nv_>Iv2ZhN?&% z$7ZNXa-H@YqEcq4dYE;yp(+v9ZHB5uS$DF9PODXq)$u-oN{^xHaq4j@eTJ$hs3)il z7^=RFEz4wWO(t{XhU$h`ml>)OX5BnlXVh%9>XFGjVkh&6rQSAK^(gBYvByyLIO{&N zlbO|Lwdx7h1BR-|6pqSJl@RN)DY}2ltX4hDy4g^b2akk& zIO{$`RT8WR&~H=LfYqu?Jy)-u<1|z~L_I{M%uw|(>*ji{q1CEKShrDcvs(3Ny{>Jv zUe~shdS|`rG1fhXs>E6M8LE=twh3-KKz)GQN&`pRpmT9nYd*v}OpFkt4eY%^dyi4+ zF=UUd`wZD5>j6Vmq>+6YvKQ82VuTna#u~M+Sfln8r;;GbRMlmw>Y=IHT9_CiMu{Uwd zLW~k)#5gfQl$p#E!^8+NN{kWX#KcVPD=}01l3Cn}7$!!DQDTf3Cnkt8n|WfG7$HW9 zF=CvUAj%x(iQzeV#)Vl&SVve#=Wrj*;Xa}gCnkt8mn{&(#0W7;j1l9+1W|Tjo){)Z zh*4sU7$+un;X3TXb=Z|#5yQj?F}kacIm$Z5I>tIqOb{ik?MPVL35B(tFfl@m5@WkCnkt8pIZ^b#0W7;j1l9+1TnOm=EKCuZaU5g>nQ6e>liUkOb}%Odm)C25n_}W zBgTmdqU_E*F-(jQqr@07PD~JGA@jsAF+z+IW5hTyL6lbJiD6=d7$wGtabkifdoWK7 z6C=bZF-D9N6GYil^I>9y7$wGt@jZ3F#P`(wlAt1cv3+8g7}<;aZZGaTDluZ5m>`Ds zW-r7DF-nXPt3=<>7=pvpYtYfTWtmDK4F|;qoLyQok z#27J7Ob}&1_OhS08Dbq~9cCRR#)xrZf++iQD`J=!Ax4QYVtjvXGtN4}I>A~F;I_o* z0h*BmsSv{l>S~Aa3~Oi7{fFm>|kQ+KU{d zy@U_aZ6n0!L0ntbG1f8GabkifOV}4Nyo94&!efI|lb-0D!xhYrV=NPM`8J~DufYhEy?JKqTWLzCSyEqwtiC_vow^)zw z_HV#v78~)2#i{tbVw0@G=ONF)-%(yGJIMvIGd`s{LoSz@axFfSb`x5?6`zs(IsTS% zFaEN~UHA(kccYj4(95r6o;)TkvI(D?d=7t?{8jY&j_fXf!Y3#{kyiNve-9(m?I~Gq zFMR5BZ^?E0;B%ylWW3uK-zv4gOmY~RI}qPVv>1Q)_aJ;*&k_l{L-9>HOY!YBB5#ge zIa1`)%9DWKIjwh{bqesaWW@#3RPpqVWXsRb3B%GW8tP&o?N};0U*l)A3BMR`h!DwtXf^ zxAOWL-h!uJ%r-su%{jX5$&Kd&z2xEE2s!4vIJ=j*0^>R6;dAlyDO{8DlC+(l5l7T? zqh8Q(Ine9nno4zVn6CK5c%9vG9RG0r1de(2wCn!uRu6Nlqq&tg&Q&wCok!~wr%$;N zcuSLFGRJumSNp};>MmgYx#*gt&Qax%`dfheaCQeW?`7U)FJAIM)?U7mwb$C~jlDaz zy`C%6%oX!i-mAS87&}wv+gYyt4o?;)-vedr%!mF(AHJ10s_`7{OVf4Cxg5g^w)xG} zIFtw3`!`c{)|c{_{Ph%V`6%WeD%E~}&Lhfe&1^$`qpf{t(r)Xc^IJ@iLKz&ku0+`?M9SZ|&vWxf zMDIu#p7b(5T*=|<_DWUOf6>crJg15|s+@XV?XQb9@3nc?G*x=&{*su+mHDtv*H)-3 zXvb=KDO7XU@`NV0*IS^*9_cNmp{q z)B=p;N4$pI&y~4^E$8t}*|~l^-1$yhxQ)FW&Z9b~sS^5>hRMKLJUcF{)Xa6$6^FOu zT{DMo<>gOzI%{tQHuJjb%5^-4^D1#>(=@bvd5tO$aBQWl|HwHmnyUKXOvP(wDZW{v z`@%ujAD+(lj5L;q&rZ9zMb!x?P-?mpQUl_pkSObQ|0M;iJ-Rb$g;0 z@5)^0J(h94-jVPgl~ugj++MGH`9vz4hb5=-3@RcvvX`$Lbaq~wp6fksu9|Tm^hfIs z-r>H?D|v1bb#CiD$}DKq78Xo90_Z8b@hH-xG=RT-nIr++&y2ubO$IQ4JDOC~nZN-4 z{-oaRWB~)X%fVN`;;ttHbuN5-`zJ7fyPho6c)L96Lii4MKlm6p?tgMo7Xt&h2g*l1 z78t;NP$6o(Ee~}FDTh0uF{mdXg>S0_26(sRa7Q!_btUeY9PgU~xF^EjCFZ?T0QW`Z zsOx|M+#5|qJp~xR{ZSQayr6=5DzFkS3#^g)JQJlIl^< z#a)%dT~Z_JFt&5JOKL(r4_oWkolQqQA9q*|cS}2?-W_c?+%3&S-HJBwH*|pk+_}v~ zy%#Vbd*hDF$v(gU?&g|N?+3*9l%PF_d%G6Yi_xCL9p`SSm!MUL`@P*!ABtA-*KL6T z-2Lr|`UqeEe_LvA)Dd7nj+8~H@r? zpk4_K$O-t{T24BE0er*BQq-$}_y!mBhQBTh49KbI&EaqPEJJ;|w4pu&7?3m3r^9z2 zEJwWteLDPgoTE{ngPt9}FJlGjPTWH~ITskf-RW_t&jSYJeDv>dueuWTh3MbmJ2g5` zUyM;W!Kb`XUxHCMxfB?X%j6W)mjeU%=9AMccZ=y7{GPrQq*?<1NdH#%TeD24B*cCO4Ppq2JlTESEIfc7{J~3wW#k02JqJ% zu1Eb#U;uaEKSlizFdz@h&rm-C4B&4m+=%*7U;uaMH=}+W7{IrL^q}qo2INVM)ZyNJ zJ?hOEDgI_JFd)CdNFBaIkJIY zD;TfCx8dK5`gi)<4}K@OAN6Y(wUgI@0elO|ul}F*&IU@dqdLs>YIkQwEA2=-`ay^l z8bAU8(r6J#Xc4lRotfPov_GSrS^XSDL{InInPz*sTm7@U6GE6V!3JX;Vl09&!GJ9= z1TeM5>kdiOO-9dSp=z2M()9|iv=&~snO$H2c0^xUET zaq#Z|JvW$q0{pu`@4iP3B5pJJB>4BKLFE1l=vk5cDfnLlz55|Gh}_=*z55Y0h`90O z&%yuJeGdHZfS%h=J`esApm%>yJ>+ifFM@N6Huxhz&wbYqfV=1c@F@BUxCeT-C3+Bi zE6{Tf_1D0MfS#LEz5#v)5S~zwh+gG3BlIfJ zyHfOQ@I|0^<>(sl3edYadM@~JAUc-2i_oz^&;3rW`@v5E(YM@+guVr$Z=)N*Uj{_q;*&t%0=-*{j)1QN zz5Au;Ch%7R(Xr7y_%8#|vC%Q`R|CC!O>_(RuL8Y$ZFC#>uL04q(Tl)e2Smq4F9v@- z5FH!c0e&|S9g8Of9ScOqa*rrF7U1)^i42KYOH=-B8^@OJ^ddw0|Z|2IJI-V=4f-wX8aebFiK-v@g4{%95a z4}j>{=w;v^0DAXtqnCq!5a`{%i(Uc#A)t5vK3WI=FwndI5WN!oBS7!|IC>TMy+H3i z8vP3Re*${w!~XN&=K#^e{)^yaK=iQx68L#Q^ss*b`~o0) z*nb85LLhqBKL~yi5IyX_27WORJ?y^$ehCmg?7s#6Bp`a&e;fQMK=iQx4)|q2^sxUf z_|t&cuKs)A&j4b(`tO5Z0mOFoKLDQqV!Qeug6{^Rhy9PhuLh!r{g1()14Iw|zXQJ( z=-qSuPr$DOdRE{60RB9nch~zzz@HDqruC14{{j%5>?0q!7XZ=8ehc_sAUfG^1D^(> zll?IGJ|H^Tp8mLVxGZ0(XKOX#6Ahxc50{Dx7-repm27fUSo$N0Ge+dws?4Ja_2*lR)PXUjC z-W~UsfiD5k!~SXDF9mvc!ap6n2J~*lKNGwT^seEr1aAVdd;L}5Eg*KUe-?NLh#vOO z20sNv5BqDtSApnZ|6K5w0loWYeh>J|f!_Ude?9msfY`nM2Jl}3V)uGl-Tez7I@wQx z|1uDr?5DtA4MZpV8Sq~PV)y#};CBJh$$l36bwKa_rN0sU*MZ)>-X8-04WM^-`y=3Q z0DAYY{7v9*1bX+IejfZyKx|!q4EQ#G3zWA5v2y)wz-rM-dw|%wz6}08AhxcL!QT(`?hpME@Q-{I$_IdGV1EMqgFrN}UjhFR5Dn}b z;2#EJ<@!5;f9%^(?ge`HAAJ}6qd;t3e+v9#Kx|#V3jT2*y4Sx9_$U75P(BIt?o<91 z;GYJfb^SW{XMkv3|4Q&b17hX+SApLT^zMuPSHS-((7P}B*MNT+=-mVUwcvjNM7#RC zz#jxYn|0hhZtEKU9@fFxU*lMc2w!eJi zqLLK}v9t2FC-%qnxK%By@N9d&Q|b_qN~QtHX0~Fqipka6ol?CVC)6w`vPYUES=rZW ztjuN%NwJV2F=i^&PNUT)JwO!6qj9;>s#NPsA?tit`15hQ&1(qUEF`>zxZOG0O?X*I zcyq1BOL>DKUKW~atTelwI3tMgxYJ#!+-t0=V(drbkj>gqO=1i;AXr+l%Dz2{ruD9f_pvW#?R_#^Ojq-`u+9jaUsQXIQ zTA&70sPiYP&1N7c_eih&eTfH zc1SM4g+5oJrIYun=v=8Q6k#s3y7h9Y6Zlz3c*pA1&MG48C@k0eqIO*D$7pWwOL zBqV9p1lMS#lSw4Sgf>?V#pEert7byYLeEFx{lbYm?$}!@pJ2@GtH!m8d;Z*>Yl?__ z28p*0l5QI$iDnw4ChDhvQz@XOnr4t!q;>|S`qj;#L{8TXGE-GE$VxTMAS=}?gRG!f z1{tZA8DxbKImoc_G)T7*HAoLbYLJoZaOt=jlaquVNqGsUzrn37C3gVcpqTtGdomMMjhlHsCG8s++1x}3#05~0vSwbq1q`T-@-d-oZ5gXucjcS*Q zHsfN>zPQw33fh2)-C4{LJyXn?uC|+47{$z)I<{J2#dP3oz&jdKM9jQ}hE{Q~RSs6` zMQuf;2W6*Aosu-iAm?b@Zqyj1#oXz*Tr0JhnTpwSt@wCpQEi-Jf$*{mbDpm*)%7(D z7K(Q16qI$L)M7H7Z6A$S8b$m`?EjMm@n+i_aTZETgSkrv!3Z8?+E~NNtP$g zeo`!Oq}yQ?P|Q0N*Sp1>TdI}9mMNoU>l-kq<7QEu+4hm9*!!j0U`rp2PsRo0{pG!@ zowz+0x28(90{+x8lT|CO4<>l1T0lI46yqDwZ0*Q%KZneQDNJ`;x`Y}m+tG$Bf(uLCXjh9l^ZHWfHsnvo?Q*NC?{F}~g(@?C zr?k=>WX;Qxl9l%1?#g1^8sr}=trn4aD~$qaUA7G-f|Y-$T|nM*O%Zv4>8`!ps8x!X zI$KD6ikS0p?fB2)6~$TLWz>oohvHJbkX|h$Pc^%TO2sTucX8`rqtPr(uXMVIJY6j< z)f;V`B?T=PtlN3?biGo*Px{`50*6YcA5&(I6=hsGj3HN$!+}!!SiLPe*jx$)B8N(y z@^WF^>IRJYE{1HYS`=rh)GRGlYmAN}E~CAPJFJj5Prt7fMbfTRQ0|3Bb0d~c4FyqT znS()9zzJG%u=DRPPu6M;)1iah`RYH7RlN?fZYi~7?k&h`vQi7W*cn7M5WGPd( zKs;xZZ6sobYC&8gh*M~?GuSvs<0k8fB6_)iY7-ciWdY9`XE3D$adm0AK;F{Yh%40{ zObbK5TPvW?wx_x+%#;Gw%*jIXRHIuYt@h3aJk=)~@KjB1z&qM#bgWBlAaI~jP;Lxt zU6>^PZb@aZLFTF_3(2@@2Ze)oqL8-`pDu{O3^SOFOzkBc8I`zzsg1M&Z*q}k8h-Mh zC9@ZGihpR`U=Cp}V^iFlA1mMavjfGLZ+mBm^C=caDlDj-j>bX#f^apqdpMhi#m zKncV{+Uw&{-+doRq=KhM*$FOmar7urk$T)|vwc!2wJP|@S(uk&^}@>XTpd3unuR0< z#(D7kC^HvSuKIqAW2lv;5-UwyK$?SieUzezticpPjUETrmnZf_a9GDCD^4McZGIq2 zAmDpT?Kt>{s7o7_n1v6-%%BNV2Wt`tF3cntD!63%Q8zH5sbWn1;)x?k^WT7_DT!N2 zh19|1X2tu_Xp1{ZQ_cIARIG;}DI`87<)#7Q1Rs+!5?tugluA^@Mx$*dC_zNjrKB>3 z2`%SrQcgZBeCY$qP()KneNC2B78I`9KWU^aq%niL$>Jpe;pE&+?s(>U(#F~syiFP_ z4QP77+hpk_0paAlP0GxNZ()SVZkm{b>W|V~5*L(;r-6L8bRm!!e;SRR(&}ih)rh&E z6r;~Wr6T<_krPLiG8JLiE($@bNGmBvyhln&992q91D0Fns0vx-2qMwrtg;01?kmTv z$pVpO$cb)KGi<1_lIGp#&C(S5X_8Oo)KZaLup`xw4mrPEYFUL+FPF+@A)CTk=5?^_ z#Tm&95GTo0t4cy);JewxzwW9uJ(B!EBerDW@@IzBq){UBT-(+3B>Zi_BbZ z21B@ldnuzxb~IKij1N&-RW=3(1#}CY%H>$8&@pkHr=d`N(M1_8IZ2RF1{x`2B@>=1 zk&JvqCSaN-qgcgtbew9mPGpQE1?dREJ2J?etsif+R@4nrN}M!EyVaZf99JrpXuB+qjr5t*aalT^Ea(zk)|5_l z!Rgb$e@AUX`}V0Zgfy#F|Dyv4-d+$|(!_5c~gl(Zt7-ic14ose>zA zlkKI5j8>+{XRd+;L6ubqFZ4?xdMC>lM4V!MP z;IN`|Bhbupv#+Hg_DC8d+cZ$&Tu^GBY#S&q z4G2ef29#`=wNtuhpd6c?Grn#cD913aXejDFlVv_Unw_duc~aTq%mCu#3Ee2!V$gK5 z9S3_EvdN$f-ENT1Wr!%oPI63$Ia;NA_Z~D_A1Tog6BI)?0wyZNRm~>@+YRu|Zs}lV ztXOezfrU}#q_0laSk+ZK%PVM+?uvWiLgPeS4@Qd|0MSUvo`;qw4e0Br)|JTKN76Dg zr&*d26t?Yts6;@++9iZU#&$w1Y26gjnAQoy_*Z5xMPepxkeBMGsa$@)L(}Tn0nz-l z;d;rUGYZI(qLTC3_7FrOaYJi%X+Sv1Hi>f5z~T6mcp4kWI}ZNW6k0oLe#wStnNPr4kIMf9TOC>Wx$N{86~jhs4%$^K}lDVQ#Aq zREk6lj%y9G<4|iXDVq3zHJ}E)o(k1q7}sJ}=flAcmaJ?pvvq-rOk>uTIe@vUA)6!2 zJe%$`)X1Oj#7%u6=~k7tM;5ZVSx(?3!$_!L+=Dyqt&F4w3yk4W;$6E5dE-ERh`jCss=Q%J zlPy4_DJHbs{-1L4Vc~0|**2f@^I_pSZ=~+m4Vm~DtfXeP6Xo|84XelZd&Qa$!0kxRWKq*_6BhfyWe1Pytp1qv>uPH>S=Hy)Lfg@l*ic~o`}K_tRR zWcN||xuB+P`;V4#z=f{e)?x%ysEPl{7+FYD5@XgFs>Q9{lD$XGAPtCoFo~3r;6~53 z9s@H+(43O(M=Nz2+K(FoP|VxVvAYHl5I|nn+7C$ z*ydD5f?Kk>%c=CR;~ADhNl2u4n_8 z77qq4N@}F;lhALdSU0O9XkxV}J;apSD$6!ALQqR9$Mns33RKZkTBaWA)N))tF-Vq4 zC6}7nUsjw1H``XsKxy}cDtQyp_Ib6cS%|L3bgu8*_Is5dP>DztJAnqGN$F9glx}%y z@P+nH&4R@ZAA+e(62dvJQJ^d|647AAhM<~gTG&7cZA_`uCz9e?60ALm;>Y15H$7FXcd7s&$qFJ1p+g zB-15+N|8)tJ)9C$B&>AbP;W^WuZ@B_uF8gyx|RH{IF$w3SB5|cq2s7b~$ z9(x6C7zNtENXul!-tCdK{?a$VGI`N>NiPc2v=A*Z{H0T5dLkvM6U6o-f{=9=25|_> zdy}R}+2YTUa@ZhG$YJf4uqtIH=8I&i_ zq{&0`gj$;WEq{taBNQF4z5% zrIM!G`nm^%d&j4rS+p!W}%`p;858ZGc)h=%GUCu@5ar?xcru^Wn)>tF)?8 z!P`!aS5+z>P8Znhb7QQsuxICOX*F5Wutw_2DUOM$)@rkZwNq*?TcXJpjukze8_B)g zj;$+9SVy2r8kT4U3rQ`LgCr`H25cWRb*N~+W42=#q?b0`=&0T*cWN19f7}U&oibV_ z@y&=!^msL{;CaQ*(OOMPZYFCF$yA<^8qGUq?(DMN(^)mAs=bDA)reX$O=#UvuD*QF?X9ckban|@tDS`< z)fN7e@=%L2N|cek7HVvz?2x*Itev{#M!V2Pc(k-W?rbUdD)a_VQn>OMDV^Y7gFChA z+{w10HEF|@oX53=q)nd(?#`Vc{iNNbEo;s_PRdkw$7zQpZee>V`D;5~GN*Yatv*j0 z9sZLyq9wkpm*3kO6G)!spWAY!i_T2mANb=INwrGJrF6O6&R8J4!dr1qkQ&*WJxEE) z)K1D)gYS-Iym9VZ%ii-i5^ZWI?a<-b(t1idQrdCu5wuX?PsdrRt0J(j%m*AwJdqZbs_vqXzYPiL6`zXB$$AY(%M!iH@yGX+ojo5&@t zD6u>IYw>2JC8d`6^5y%i_OI&~kxANIda~uasAZKtFK`~8~4#D5W=LwLrU?2k9CL7AA5N;wtisf94?J{qT9Bez@=M;qf!4vE{yi0 zyTrzr|68aYyLP;bEZ((QhB#HafQUri-@` zfoq-D-lXsmss)MCU%c(&ZQDo=I4)%qI5ILkymi!%T|P=Z#xCDBEIAKJmERz>B=4QW z!|>Oa$JX95w)S>Ew)T!4<6EQAo$EVxuJ7Ere*WEJFl&y zOvc0nlA?V&{X|pI=gFKQI(q_HndhYUkL8J{iQaJ+WTgDK%0(B|Z-XiqU1Vb_H71I` z4nHf-g&8SE8_Vq3Rq1miibR~CC|zP*Dz%ZSNfJ^$8K)D_WS*2cNqA2#7CJ@gWipcq z=!#;IqZ`pX${9#H~Y^;Z|5*85kv+KOU^F z7&|Rq-8pT#iS(zW3cSBw!bC6?TfE?Ny7U$4fzo$G<1;+u6&)vuMHte8(pj~6GqT7x z$RTzY$P|&7ffGpEv@#_8C20T~l!pk)G&iM_j~ELQ@2S#UqAJAh$mXAXKN7LaHxiS+ zBGax^RR!~kB(GxR-{}*32VH;G+eI~*C3sfeY}_iEV7rWSGm1x( zmubZ~E7Ow|H!2mC&LZ7KD=HmTl$<1NW`#u?I*8no8@&a7OEZ?;TIHsUExE1XuBd0 zTVxCTR7mS1MmozLUTK#q99Ot|c*x8T4P8fwLhM}GGBkFlAKFFU>$h(m+PPj4^NYzp z!W3Y-_SgiM?25ueECfO$*cDWGL<1litR%sT22Ur!)1iTFJ!_Z}m>HzmAx7*kE!a=XS*G>}Fz(uhU7FO7Dei6nxF?GC}+iI5)= z%UDcU5(vhuiCz~(uaji9iUq66>`Kq(Vw_t$YrPNm;P0uxHkZstemrEwm=-A~$1lE_u zUM1Sww_NT<`RDmJvh8wO=b*xh!qZT93<;Hu${r32RJ-%*_hsbHWp$~_2Lhz?b3|x5w@ggtX->e4T`~ zxk-MPlx6*PpfnAadWsI@l4D#y3;u#z@SYuR)YC&zcnvwVA0am^YLbhB`5orNGYdGJ z^mC>w!cnptPF_9nEK(RfPaTPJ{yg^)$WDnep2QhPbO#yZ^6IQEvKPa3&Tg9?KyciZ z>@L-h6)NL+Z1tevc&)nF;u5`a4ycc-4~;_=&YeY>c2De?*gdh^QqX|oW@ooEkxCC6 zI<7}{JJ=@H4}8!J~OMcU7qp7vE;+5W>{=eT#+ z;WX#QsTPOF`QCNBaeQ39t!R&*BJy}Ed?+x!VjtIzYS?))W1XlTQYn)v5lYoWH&rxtUt3m{L4faQaL8K$&U5fY>0YURP z%%^hiu+Q7;#(E#J)g-9<#+}43Pf@8 z<9U_b<*NeHU%`nc1EQt}aJ8F5myb$t$a3*6BFTq=R!TXc?bd#i0@vxb48mh4`;RXS z9G$;nyieqc@nj@F>)A4-CU##jK1Gk|w&EM=aTlL^?TYca?jk3NZ;V&VOL1J;IgpLKKxwMD=fW&X`#X&PYPbbQO1#=voQa~N{r^`AeO^&cQf__S zcECqRrku*p-9=+BN%{ltW$hij(5Bq|XWz7L(kh9{Y}=>xR=-$obrgp+uj5p>#=!c% zY>XPlmlj~s8f81=%uV_3JkwixQYF0>4!1s><)ULOnnN?$gV652!Um~cYhc++P*2ip zkEdPr>O&hC3=6)ikYOnW<+zjHdS!)bme|+{qw~1k9BCM5Iw3{e8N!u9h>3(v@igeUXr)Xt}YU@&ernN04 zKL1gKUDJTDH>{K<*BTqbO~e7&gBp9YC z5^TDP=)__tomEqH!trJuv3$y2Z@P-eEM2h+cWh0N%wEZ9a8qZ=)l6a7F54YWo31u%ZxPcrxxs037Ee^V zUR}8PiuYS6b(@AZUD?#Ix5+x~nPQ)Xc{iDb)3fU0Lh!PXlGRToyP(vDXf?U^$E*OW zGim6OC%r~4jNhf)U0s{5U8H)Q>|PzKf)UJ|lPWPHlB4IzsxG40qNAyIL-n(d9%-ud6bAMep`A6Ap7lVoS;x zX$gwSlA`)$lFpq=c3+#BYauXa7huwIGI-4J-gNCIog}y$Y}J#x3c}I1@L9RKv($xP z^!ea`IG>-_F=vAvHe)@*$jV$0te5n&bnRRws0S2v?g(KSi%;MuczPWf>8(53z}%D} zUw4K5oo35m?K&>X%Fq_ra>N_H>!?9u483x^^gRH&jDzo<6 zOmOON&~^UiE8kmDzHR*|MlV#@diWShlblG$zREJ8U;)8!hf;N|l6_|K;&HUj@ zzU23VbnfS?YHk!Y*8{VL&Gxz|GkI|@hA+ZwqDk%uu!%-5d{YK{M0U}DI+W1UL9FD+R|SgU_AAyU2)}Yx>jEkeTJIQ*&+WK#O5ohr~vE1 zd|0vhoCWIXI%&TjvH2QLEPmGW`m-0CuXM7YUrfIE*mT8{#g(p>f{$5rQL=hsn9DX< zb2EBxW%IYs>i*0_|#j@s> z)=Nf|%;h=U6Jec3SB~j!x2#ERS38-TWbZ{bjmo*p;;mQHPxC(qVN2I$&vi$-RF$I@ z?Dt7RI<1Ev;?n>|g8a;qP3&yjgxQ{bbJ-0N@n-ndJ2eitYL3pY-c`GSUAFzRzjHEF zu}|Ho#}5?$rr_g!0wMT?oSc(mBCY9KO@4X6t+e^omN?dnu0;6J%SfX?n~(}k2X-MN zFA!3?l!D!w0lEkr;NPt;;n%U{2uS#=YOn}p7USt={jlB(L2Z!}v!tm=OE;;hea*M5wH-%p zLGwHk=yCv8@=WS4A#1JwoxG>D4Xr&YoZOd#?pZB<-nQdBUue@Ljs6zQ+RwS`w5=z3 oTXN=HK0mPUvNn!mZB7&X~((WD*7CgfK-xQE(nOF+_|4!33PGCt}rzR&lDc ziJ&#&ShYBNB5E6Fi$kpgCk2NTtJa}vo!)h?eQ%PQ`1|@@*Y|$c`^QV`%3ABbpK(9? z+55~3kGxFcL?j;n{q&Q_L-?!zLd5?WA_xy0^~gYZAn;=TL(YO1^Ou}Z-xOTk*s#2@ zW@WIpX4R^OHNoTRf{kle1?yJ@XCJsYxUykcT}4`2${4G9(HxNlPMpM_@tbbHwU~?! zraBYQDMuvHbI%_Q9t02JuSf>D>-Ad=?LYrT5Qne-;w1cQP1XPR{57c-{!K=`2P#3m zQJsjN{|h@Z0NxPf4gY!Uf6_sb^AoP(4;d9!*IBRxUq75}tvt|Z9L5ay&yYT$S|q4snQs{BNv-oEBuG8pH;nY8F83owdLk$I5fW5V?;9jAPV@~UJ!MbwBP2*& z;TuMJQdjyB5~Qy34H6g)zG0-N>}o$kg4C0Jg9JvSZ;-%f@(mIg*mAvTC4sTlH;nW& zcZwe&LF%c#K?376-!Rfs_H;i&g48p7g9OHzzCi-xEZ-o3akg(5>8br!e#A&ml-96*7=5!p4!jzBP2-uwQrEXINvu&U|irEBrq=Y4I@3ZxA+kf zq+aA3Brw+d1__LdeS-wXCB9*#r}p3Y5fY?c>Ki05F7pi|J!Loe5fY?c?i(a9uJ8>K z7+3lR35=_J!$?o<&Ldg4E5vK?37W-ynf;mv0#9sqAh)LW0zLe8Wgj>b-u11gZD= z1__Mc`vwV&`+b81#sj`V0^>p7Ac64*-!RhC{uVz%g4Bn6g9OIIzCi-x5#Jzz@u+W* zzFIKZA0a{NQ@&xOCv~eIAwlZXzCi-x8Q&m* z@vLuG)Jsq7;^LW0z8-ynhUci$j^vCB6|V0`QwBryKr8zeA3 z@eL9fpZW#~jL&?71javog9OIszCi-x3*R7t@uhDV=^5o$euM<6|MCqI7+?Da35;)i zg9OI6zCi-x-@ZX|o7?D!tUg;?FsgOCMQ}^c)-rBSf4=jJlAx~deS-wX557SH<44~h zf$@`XkiftrY447c1cu`qBrxy{fR{l6BhEKSVBmQWFM|X|f^U$(z%v3~1__KL-yngJ z>>DI70=_{41J4$Cb&$YF^$ijj1AK!7Mw)Msz!>NoBrwu_g9Ju~Z;-&q^bHaiS-wGX zq8&Cp1n%Iob>e#+vTQ$>1ch^a!${B39ppzykecfoMtV{Q`w8zeAB`UZ*LvX<}F@+d!v1f_z$K>{P+H%MUMX&>)k zlfWqO4U#i#h+5vLmh9E$7(br`6^!)_5*Xurg9JvQZ;-&iS?G0w#6MTHytSz%jcS=* z%@_McNl;gbZ;-$!^$ijjWxhcIque)0U{v@935@Z+LGowoind@>>o%&j8rAyzzKTfp z1XptL7kf^oxEH%3!+tdZDMc_-Qc5MU#LLM<4uZ(>!r-MAMjdgIg?Jp}abCiZ-sR(E zF5>#HX(;@$O?kkEVE`FBxM4W|<~EElJ(7PLM&VCW5HPqYAJ{Nj?TB_xpH$mJ|LSvR zM(hm9YeX+rYr;f7fds}R-ynf8**8dFOz{m87*lNG!r z1SR(J4H6i8`vwV&>AqoPcb%93j%0?PK!Otc_y!4#eSL!j#!TNZvb)Ybtj>_1K!OsL zzG1{lATrC3kie+&4H6i$eZzShl*Gn;(1V6C;A6|<$K{kJ zG>ipLP2zDY8V6oJC5h8Xu{W;FlGLbE;px>_sY#7G3{Q7MPul~nH0_B)F*dPro?kyJ zZYa_N1pAQxw9Cw{_wC-jX{fO1x_+ZLWmx5{KZ_P&>6JE2wCIyRi+bIeY|-ugMyF~t zZCVq?)Km%n%frEKfH#X?wR>B&xRUjq+=dwz#TMMV(7tA41No0aML1xk4Iz!DO~d`4 zH0=-mU-xCURf`4RyS2Gy&+~1Y@uCE6ZN5bh`FS)^qx)$zt@PNYgFt?{Bf2WMw7f|< z!0N2=>ug|Ci@eG*8&8IxR8)i|*HjERBQPL2JHAvhNertBB{C&G=SL?cu`EsE z8W2j6X`P6~HIyok^A>a&d3?h-RNheT1-KIZK;iB{e0E}HVs=7i!rJ|?r=}*A?*Y0$ z=z!$Cak&?$1yE^$y|4DvLa6Nc%=o>t(CjqKqm-gqE@-dzq=wSnwI?-{gXjDhmz|WE zR5lEWT;hJB2EG1CQp0%5OwLSZCYQUPslnZuIKfzFATv-l9CdQ(GczGBsX?O+6*_2C zI>}N~GE-P7&jY_w4eqXVie;u|rZSV~grBKF%bd0t^PofKqW2P#8}{0rklZlcY8sF^ zfHmi1VU0!fDUojQCk^0u5EWtBPyn z!uQ$;G|br@2sF&IHU?%6WE(sctPL+}Z3G&sEj2weovA!8cBg98QcL&JzHsGxO=iY7 zEZp5@Tq7616-4O3-6c`1VZ0tQeHCj03AXZTe=8?}v7c{{z}Vk6jP%@^F7P8pEF!~j ze{d}(4W}~h9~y7QY1eo^pa{Lj6`^P%{^b7Xq$ZWc=L{%agT$gqu+joWNtkI}`Q>pr zrAe9bMYZUC(PYH9xRL$dTo;3)Jq32rRE^=(YJ6TB+2uE)XPn0lnzKrF2YSRPDh+C@qz~$`g*yR!$!Bd5&KI&=Sz_^9fw|y&22aae`IXqQlvEi z^!}uwM*j|&_KuEU5{C}XZNe2PCGF>B9_(A@-P=(jP?i+18C{Euq@&mC+|mp^AB!4L zwI2K2ALD+^9A`BS_Ir^T*QXcn9nmMNvY)I;*@;%!5WljAk(Qa*Fdtp#!T#Cs{Bsuv z&XvVdbf$N_9lUE`-%T>^d=4J`IT?p@4zmjWnM}e3pXc{l#M3y>4b^BNsP8jbnvysw zSdo-CD*s2P)ETVj*Wk3$MK1fmHHh2QpS1V;p-6dQQseY^U*sh~IB( z*EH6zTHd4?58N+u7Vg&y4p}S{9`fElQZVn3YMcoYg8mXmu3*-RhT}9D|8Vo*95&*X zKtLP&w=+o}{Auf0keEG@10is51rM48qZ6&?CTxJ1BdNrHc4mQMx>XDNmG~5x)v1%5ft@V-scd2*pc@<;4An6Byk! zT%#q#Q-^Ckw~oB2AW;r0SG;_<;)L;vn}@w$lqj#Dl|*qz+*FVv*N#jbmm;xIik|}$ zaUyBvEO`KZxUfib_AFI@^f2X5kI>8#Q9P+oalazP*DEFzrpSeG66O8^t>KMv7ovux zvU75xTvVX^^0A6tV>S9Rv6KD0Z8Cc@N$W{38a*~eJ}OlFYfzn3V501e{-nr@Z1IK3 z8hyC1tPJgfC*nOl8hxl>LSdrZ8`Rn!7^|i39jz@69IANV6vZpqtNTZNISI2=pnZG0 zQ2TS)5XBb;YtA3a6w8KP$Rl(Pkt+||*6iVt(tU6|arB^`z-+6+afPbJG290niwx>* z9M^qt^b@GEM}|=@9`!~LyLJ4n#nn()E>L$e(^rSG&Ujpd9K7YJVbo~_)hIPUnvprA zSXDr>sctA&Rc0z>jH+p-zGiH1Q#TIN*bGzq7N`nAX$z-LP&M1s3sY3>Cqc%J8>i|3 zDW-a54^;=tM5=#|SG8ECP&uq+3EI~>pJ3*pqH~iWr@NYY7+O?S#X66ac~lD+J60A^ zJxvvsL#Vb>9VbUp-A+|2$536sTI%FDsuKcC&|fFD_Cx&oI-Ua z>s&2oQx!7v6uF4%8D^d?8>p7Egq#njA%SKU;qT zZ&uUxeqt?um3peh%zRr`P@TohopKJ7trR zHOTpZs&t~x^OB&SU_ns4CzGW9BC3 zOJ+(n@Qt(u!16@p9fQ<=Drs)=p^?`oRRiZ0D9+AT?4ncRD!;B!@!9c;)fI}T4O9Gb zoZ>EKK0Z?UBsvE(S~^*s70kbf(P#418Cj+nVX2|4`JHlgl86`6A49*J&Okcz$@gR* z2GN-{MN7rA&rcGUvEAE8sqb(UX>rQWi&rco|BN|r5}zXemeCNS9YM|fc(mdOI$yGt zwL{eDroXXB`M1n0VA&1KyoQd~?rr2=X4hDq?e=U%Z{{15^eCJ#P@O*#8*|mUGF_d| z(-a>|Ry>;6GC=vW0Y+K&xm3;nP3D2XFBm;DRilTnp1);j)Wc0#OTgdGR6L6{w5I5B zUo=qBYsD-3UZR%r+Ui|{=Rd|ez35+awd|OzBY3xeh7|CP0;#RaK^lu_qaC$r93OYO!=bIT`ABEv_Q>>PgPL3jU`V*8^|n>h0aSm+$rMfL^7%KKJeI%&a!l zpnsIoWprD>ypuOq?=6{SMAUJdbQHoi{npZzkz%f1Ip2 z-r74NOIOROTtDAs-3$IY*U39vV}IdT_GkU~XKc|@30lJuth1I!wk-PzIKRn!8aNNF zIP!3s;u$%LT8|@Ik0W}HJEEl=(Nd1(pOT=C9vw%1&zzlDb&kA@UO93~_H)2}$a9!e zKt2S=*OB#EigyfB{DN2-*aEJ7cBFvyoHt0F3g*``T20(LS9_Sw{sabUf4n*O#_P?) zIh^y&m`O*zX6u!iuYiwcpQjLS!;I;ubrfe{WjbE597GJQQVXM zJLK#B>BwoUUw2W5`#c!Zuv}rH4kA6)Czy6_bIBg8BgC{2>qFF_|Mr>{ef6tc|1 zowPuld}`{}NxPhQ`P$UolX3!h^ph|6cYgAg_+3tdU=paFjL!)qNT#Pa+C<5-*t6x^ z@EB*bsaMO>0*O*;N?T8oNv5>*B$;7qZoC^vmf5C`h);xCXsTk&Hat=~#8hZZS|A|D znz{+G6gl41gNUU_gQ+_wyMa_W)zo8?6QLrWVy_2Ci^YBzw+)ZWHke8+Ov6(J*O@9Q z*oJ2UZZS2vAT5w4n@znwcpIK?yWdpT;IzO%dCb%r#M0$yQ|k~*mlr+7)-&V{iyf4| z4R@>WnL08*Es!A}nwp1Wn<<}~S~N5*kSX7o`ZCuI;5pmT-cd;!oCp;#l{3l>WJ{*0 z{85Qed7fhHIZ|M;M@ohTa-`JMGbQ;@lY3wW+IdR0hlGo?_32$hj8lD02ftx-_lF;Zhn=X$Kvo6@-+D~+CFE#u_OUYX-$U9Ze>a&fQBLb=Lf+OtBr(UkVAP;N7I z9@;CCyG`AQ_KIYSr`TSxL@lPhE|x!;(q0$KtET=vWtUSTZ<$J(nuFIE{^lvxQYyPF zc2DVEfl~Rx)Z?Wyp}y~xStbc%ym`@?D3df(Ium7*YwG&Z+oW7Zn!0^-TA*ACP3h6B zknyJUXjaHHPq8oKrP5+6u-A>3YE!3SuNyB1o4U){CKKdHQ;#}nfeBLUDb})wtgzTK z*ctYaHKzWIona3-+m!ZYqWs#F_GO~{#+2?$ljLesx-U(Vn?1!ACd=&>)4oiWdrfIy zCdSx$E+;Tm-ZphQVtdLCQ@=)RPx;u?Cu8OZ zrpcG4z8kXu>IYM=lq?SHB?)7_WAtvxk%7Hspr_dD=`z@2x?ZNsC{wy#rc05j2S@L6 zX2=9nPmazB%#giI9X)=RvyW7oT0TA}u#fELDYme$EVkIhC zsLYfVrbghX%#^jJwDpjjZAx1Y$@!+V^-B4TDQ&${erswW&ZAj!v#G;z9?g>5J;k0? z$$b`EQn4&hB@delSDXa(RIkk0^1Q`#mS)Q~Q#wntLUJ~pF9M+)I3QyRfu_+FN00(iFuhXK~HfE)lzIRJ^%2mjVV3< zs%39eZ;#)GXEbM-`e1xoU_aU4)Kx{>WPe#~>gJ-f!2WWysc(wb1{TONQ_00=1QyCl zQ=7}z1`d$5rXDOmBXFSn%2VvwBDuiN#26OIrKTRj7#7JjrVhthb&#~0Iv!`$LDFu@ znX(PfwcclH;FPq$!SaZyi72;No-#EZOHAqbj*_EI>G+P4I#X>p$Bvej zrtZf%cC?)0Dfa9bImco;_G9D%Q#$rz7?0%3W?R94w_P+LvuoYlzlrgY9~Cb zR{mw`Vq8gT}BfZ)$lMGL>g*q8(F|DOe@=a+iby8wVchlo#qAA@? zkC*AD?wGjCSuRzk9-f#JSS|}Z#THJGLoD_f?jui-V@$n(`^XdIcvCvQdRb*k$5$_> zn$n(~DCd~co}DNcn))hkQ{W`I%#;&nr7HQ`-6}dBK#nzDiyAb9#PfY2&td_4m#nw+2+=}q1 z==pH6B%9Ln;bh756;@WG3^jEOR#u~oHuV5@uqG)n^*DC0CcZGMN4gHHb&X87SR+>J z8ky}Ww!T&tTI@9RbFCa=>Rj}5tsHCWi?Zher^xZ9ekgkts=-sNb>h(gLT;W>cpYZwj0t_nZ1n@tshQ znVO5SpD9n9ItXJwQ(iO`KimzRC9j(rJUkKVJyY-JZ^P?4ADH?)KP_;!eCjEV=vVTM z#q_A)MdK3h80k^@mG`z2%&yM-Ig)8Ho%wSl&(x-pT~0(so4ThYClHZRQwjO=1I;qY zR9gN5s2Qeq7R(QvE3-{~T(AIYp{W^@=LgowA*Nym%JUgK&~})O-T&u7Ef`OE|kp{TQGT- zbD`XC>Zr*%feYm^Q_mF5546bBrd}^v0QI7$Sj$E7hQ)OCTqN(A($#a3d}!*x;`xE~ z@~NrX;ssFOn9{kv7;paYMyqpuvEXK$W1mp+QQ#8EG_`NZr%-vOhLn68_>GJ8jfx3r*>&yIc-2rK|38Io4Ag?G>`zVmjI@ zMBo0X$4Ezeg`8$e&xb1|VoJ}4E2YI$F=pZ_*3QYaIWLV0LQflg(l2K5T zJ;hpXl6@@JE!*TKnPciRNekR02bkK9bNXgE)YPXqr*D>}ru5FERhFC5JC9abZEF5R zH_#@hnL2c0B2=@d*!nGUk;NvIk4w2lE;lu!yd3KKUYQ$Zlf_2iy0TI3G&LUAm5uVC zDSdWplRR!ppWWId&zhPObOX1_OQwzvCPKYwDiN#aHrZ}!I9AVX@{y_Map(U#`OH)d zcmBVVZ%yT+&fCQ)_m0sd)OkDJnB%D}lePrfCCk*alb(bcX6ldeTLO1TfvMNypM)wi zHFt;`*esJx)eK35+Q(CzGdx3TF@2nKryO8PALrbOcd2--e^R=Xc@}Q|Utk&PlR@ z$&`+EtGr?A&57rvY?bYvVy~YTJh8%=|5|oAPfMn$M7(tWwB(u6dxmFZw5dfH(KAwN z>c^38;8~euDu7-;D>F>Z9<>ec{+MlQ!Ke(Vg{G$BvFM-V5L2O|45(vG{i^(`lukL` z)FtINq&z43res|q2jlE`UQRW&bZiDx#MD_>^DoGSrp_Od0ky$YGRnOu*P0rHaxcm) zrf$Xl@{-(P>h6jRsQXPFTDDDImPbt;SC#?ww5b)7w#h5b^19@k+H=w-s1j3KFupfrqN!&wzBgpLslT8vZ%UP^o#@M(vcS|6i2X&Dn0g+u zzsNDBPMo|Y@Rrn>I&JbfDR0RtQx{{k{#8yfbv;(=U*#NA&lMb#^0r)H>dk^nQr?lv zOzD;KUAe~8kvKcvl{QatwZ12hSxjf?J$c%c?nLj&i>7qv+b*x0(w%R+yyq#_5|a-t zmWla{$)~2q;?YD*zA?2Ck72t6PaksT@4{o)F2U1>s#we4B+FuJad!Mo@=TqJv*T}4 zU}{y#T`4=H)YRD}e@NLWlRd>+-k13nyAtQ$`?AQ?b$IRSeL2EZD`Fo=jj7uZ`#?@K z^$4!-ABw)yUsuT1!FXLn&NS7DXQ@7tb*A1L?Z`)Rv8hHpOVuq`np%g?HFV33rcNl2 zru8|myJZDOGjgMuUDcv>xA#a<~UE?3J)07_RPvm1$ zdZa&*uT1Hg^Qrt`O3$26B@ypv*YW9@^O+1ZrDx7(GT2k>**|5f#k6PtlzmNU&;BX% zOli+Pmjg{{&pwyKO=-`*kgzH3*%wl8Y6bR?FXd!Y=U@-{QqC}?z5YthHKo1&O4fUd zJ^PnDXt6nk!&3ewkDEHY@MWlHP3fy_U&~9TQVTPn-ZXVNj`TOO-PDaZ(%;BOrt}rK zZ{;&nlM6DSzBQ%KbpKnNN!|>0q1?YE#gsnN{hef)T8G$oGR%(^y_WL56qx!|(YsJ( zp5lmpkY!X?OU0-UQ+|*OO-&p1Ny?9M`)>7B%1`nqstrS)DEdAHZv^ANXVdgqV%N#j zH`n9Xeu*)-&S+CV;Z;P}DWy7(?Zr8hsh&;Na&gXd%j7GzPL(OXV(Tn0^#We|jCYop zdKa&K#yiKDn&)hj1gFl_(YPB;a8{XGP_j0V=$vBen36LBNzOT@4o02H&IP89N1e&e zWv0$REZ|&Y>SBEz)@kz;$B^ROLFJ7h#ktQi2fBET)_K^}a2K!9I!~GU7y6m%JZH*9 zEY;a&Di6nRfb+Jgak#r5;OsPYKkOyR)Z60+q-HwfO?@yv18SP7tBMAvW;r2KHy4e7 zs`eE7lI z3Y;rUos*AOCY|d|U55MeG0rAWv4yeDT^7?vU}K#JO^wGL#8~HvUYX;ZKT%yR>qpIU z#yJsu)<)!N`OTDBPLUHcbp}+iGanzo(b%F%vz!v=6;tm!v+xOs1^8Hw#(v75<&-(A zO&u|6Nou(hHFe^sBU8sa-?hi1olhcvVhoXaNqBHXz==2`t2Of?C@@f#rDh zN^>?4YnbvE#g;5uMZ@FL*6vJKM4 zoOa+1`21QR?k>TdqPrD%RRNx(k-LN2q|^Cm%A3;R3>^P1_)Qhx$*aiEkfF}10*u!g zH(v24JX#y-yfiWc_t_Prg3eIq-3tB0nD*fbhZRp0S zVtEXBnY;_UQoaXXBf}B~;&qEMV5?LEH_4H}+vQ~7opKTIUTFh9Ah!b_l0O0;lTUzA z8Jsu}&mV?>f08EP3vv_i71;uOP5uo0i~In5M=FvA;yIXwz@2h9@FS@Mek>;fKa;h< zFXcSoH?jfvy|e7WGisGyacS0Z-Dzt zLRy6^mJHxg#9Ap#n}lyX+MZS=C&`ydRe0C!mr1qcwZ@xd@W6U}zVpkZdRZ^?(wiA= z#%sR2($36dyj-!2c9PLS*(})fU{oKrUw}Xf za$*CE!MCRsBc~-j#GESTR57QDHB>XF8aX$lhnZ8$oLc78Gp8QW2ht;qu48l^qb-cK zAo@&tE2A43-NkmL6qvE2CQ(?PRnQ(e3FmMt3l}gVAo__Oxz9KTa1{#~$bE z=;B-*UBKno-SzT)dXUiqMhh4%V6+$b@_b_n=;y7x<^-`J@j2n$H1{p0NFNo9mDP~S_oaTh+hnQ0Z z{(f2&bE=p#J1$SYOsh6rFVnKZ%nviamie{JuVwx+X4V_7m)Thn=0}*njydNs+Je>( z&T6H7RRrb#;g#dRji?k(JDr(4Ru|GnN!Q0TISR;T5qUx z8)42m=B#7RI!0RzHK&z18=13_IU5;mXHTN=x2Hv!vz0kpnX{ESoyfU3E5@7{b9OLi z2Xl5Xr`u5bED1V$lAyB}m!LJrC2)QcI6n#N<+iL~g3ew6qSz}KEnu|R5UV{a#GEST zR57QD(P~2-SD5vOnOV!sT4vTVr`~YAY|V-=Gs4Vu%v{IJbP!X`btVg#S-{K!<`jctZ%NeMDa6{Um{Y}^D&|xhYOlh~sbx+rb7~o_ zH(W2@WkuLxge|UP<~nAsV@`|VdP&M|Wo;Yjv>UFM?CdE0tt`8hWw$cgX}Dg3*)g^f zV=FtDxr3QInA4r8yFoX`Rgo=8oTVhr60X~rr6kUhA@^S96fmcNIR%W4Nz!_X$%~WL z%Z%(0Gpm?c#mp*3XEU>!yc(JFvct@*Wo9iiYZwisiJJ6PKeW_B~D8$CZE zTar0r$vp3pb;bh8oU!EfvL-voXaS-)?-(s$wAfJRGsK)K=2S7KiqUFA?QNJjwalqy zPCcXbXk}h@gwbY0-nTGw9Wz^)*@DdG>{jNq8EWQ6W^QC=J2Tsnxgk56tg{_W*4gf0 zen+zAZ)N^g=65o`6ZxfCG3Io^-=5ZGT=RD@e+Tosk$FqD;C++0tHGy!wx>D9H8T#M z4#Bk^KIRbbyt+F($Y{Qy<`giefH?(tFFp1&yrU^8y*R+Nhf)t`hnQb!sHLh<3U`1k zRmD=(%&$iNbJ<~L));DjEnBH&el7Fs4cE)-*%4+o8*2VK=C5P^I%b~7`di>&mqRN% zvRj$oMyJiVmfgs*8(DTE%Wh)XcJg+V{X9F${0>7cwUwo|veZ^)KFv~{12oo<$rBmfC^LN$ES8-;K;^QU%N` zHe4?SIU#0N8ftzO^Q)L&jXEdfgc+?d)SO!6ybshm>sebpGH2#QQh3&<=sIj>eshZE zw=ky#Ig-;#zs*pi?Toe~TAdT6-(jfHPWUM~F*;p_>UX1c+MURv%~aj3^iKMR_YNw zJ14?cB5b9ZInAuU1MUPIbr%Wh8nGBv>wq%b0YMc z4K>;V|Dv2$I&FsPx5K|aCpv(8)Bx^LjCQa;oru1W6Qk2*sHM8$Z_bf4j>}N}KpMxD zrfmiBm=E^=X*{OP$xqXqV&)Vh=lYxw{mL{wzLmx`rDL%)w4Tv=L^tO| z=rsdBJzu8cuEsVAxI%ZHS`_{_7wGGU}nb^k6HukU`Ig2w%s4V?q^ zxO5`A|DYKCE<=rW!#`}0q;sT(>Ic%bR3IH^>!2Y0d_#>Er|WDKBRVrDl&9-kbw4Kp* zL`UXE>30}vw3E?JL<@6c^t%i-+RbP;q7!l@gEL^L(Le@|c*c6!D>q0#-%z8)j20t0 zD>p>H(omz-89IB_i0+>ore9;I(R!AxNA#oI2>oV5jkYk_g6KE7t@PUrHQLT-JEDUI zMd^1KYP6HlPDBHPWAwW+be(h=*PL$VbZbtoWOCG*n&TMPoIobWo{3o<9L(enkjWi@ zIr*8IQ_NDu$SEHjqF9WPZ|`aQ)8%peU{ErJ)#&j{boarw!p`z>9jGk z&A3L}*4HShlc1^8fvtf(P~5`C!DSG8P3-EtYJ<~w&v6$df?Cqon}MzTUfRQ z(PM|U(r+`=Xgj0rh@Lbw%9^9Bxq~?!thtjpoyeJ&9;4r7s5Nvm+KuR0LnVh}H`Hh# zhojC}FINr?($6>4XfdP3h;AMlqF-sK(P~Dk5$zZnre9;I(R%o=4UN!gHdMa_{?4JT zblMEnZ)a`oh<-6NO25NUqn(U)B6>`AjDDA)M!Om9Ml?N7262pr8VwB6V;vZ@UWVrd z2l0#^#50;X`GYj47}4^)5S>ax^{e4e&kNJ3F;u@E{@lC>on}MzTj0N+*Gi|&Q2loJ z=M9U}=`d8k6MpgV7@aOd^}FG}GF);w7DM#|xjbIE>*b6QLHhZI8ZBnD7}1syA^Meu z8m-RF!hLl$bE=VZ(}*ymHHMl~pPPsMupZIPBO;iy=yUN>X(y9_njjc7?w25VFXbL@s14GhNhC>W%Z zZ>av5!P@6yMCS)X^eYWDI-AjIL|aCL>DL%)v>yIVBO-K~4b^XfkI~U-GgQAF{-MDr zoeo3wI|u9Bb|N}6CpK8;EjC!^t&2HbgEglc(c^Z5p+@WBUmc9lX*N{91^%XBE1fn&_1od!6O0bgo=1mh z&pR0H7^2ZmL>~{v=yb8&F5?>QM)Z$C8Om`D<+zM%G%ytVKrl!rpV55d8XYr~#|}B$ zgCY8rh8msCXf>kU!7%+ALygwMKW|usPP3u#9AehHpC6BvGp3Xo%bMo^vrx-b7$Asuq z8meCn|LHMdIyHvs*XQvZMD(>W5w;s)wAoN|S`hu)m{vwx>9-kbv^{TcoTu%`Icsc` zIZ;MC3^k{dIi1Lvo)=?IjL|Mb&FN-NH*z+Nm0=vap+*D4@O<*vAf0?e^^4)(H#S74 z(op^CVR`ZsaJ@V^HcY?9P@~Hb#mQ}>`nG+taJr57pp4Tv1GhCy~Q0Gr+^~m`?Jwm_PP^0HD+A>^MP788w8`sKco1x~k z!w(fk>2xr&!?;E}5j`{*qtj)memDG?IWmG{G*mw@g3sU5$v0HL82;Z1LnCw)p%FTY zN=7S3Xms`notf$p+Rtia-Zn1GXpN!fEMrbRqW>z4&~G->XbXJ2R!FDKQ2loJDMe8_ z9fs<6!hbD2MyJbA{ciYpC1@naHB$Fp$GAoVaB#&Rsn5>`>F1Bsp646aoMJ?Ci$ZiN z4b`uPzc@Qgr^ZnI`jLC%oT^7OzbHb#*-)b`>_h8FowwGJI&W=^wvE(iJEQHap&d2M zOOGHs@XL#0bh-@H?}k4UZ%g1v4b=~f;wl}r9-nj%($6>4XfgcdMIkzshU!bH#2QMaJfrA4ju+YB|@j{NOFTtkbZqjc=iQ9AYx=5&nG zoTpi5C+q1%=H{Xpqg{rYvy=67V??O~yOHxykpwy3ppMZou9-pdd_#4LgWAL5p!P6C zztT{n)r?jnTAv=KUt_4z`k?l|9?@H}BlMdMHQK^x3!-;tx6*Gj)M%8v!%&^4+1pOG z*ojh46~*vA8{7#5bxyjN*%j2xoy_cJW;ZflERuYV*HCi;`8uvZ{(AXqQILMVp+<`t zEk<;EdWe3dp+>9obtbD3-B}c-Ut_4zdX|mkYu_UI+P7v#oAWi=O5SFu&L-9trPE=k z{?qhhbh-@Hm(d)Hp*lhG{Ly+e^Np(?BCj-5Crn;rs7{2u*-)L<(RwXzrPF4pew2Qc zPKTlTG4d`$b#|gBsRN~e{VdR(*)guscsQv8g9X}3kbb_QM#mKBnh7x)qF-sK(Qtvz zYPdjWwT6C8f%*~hWi?l6RmFU#4{!S3gGHWvGsfVgC))i65hV3)0Cq zR6j&s$!Mi<^~2;fhU!Gfn+?@zC2uoSCraL7sLs=@IYy_;P<F4Lv>>0U54sN zA!pK1ogjIBq0Vi-arHywm4@ns$!iLARo56-KSJJYs7@<+o1r>U@(#ArVO;$fc^CaI z0P57DVKR6k5VOsB?B{Rnw;k&dO=xcaT+(IP#9(IP#9 zF*;JLJqZ%?4cRVvr6F4+uQ6m>Fu@dcP ztVH`MrL2J%DpfyJs(zSGgxE@q5@XDXF-OW+mKY+2i4kHeF-nXPrJO6goGZLs%Z7+y z`eFJJI<3SgF-DXMwnYpP!^8-&l^7+)h%%n}#1Jt|j1XIi(eXT9<9WR3$OM)mhKS(_ zoKNxyd4#-`7$wFga6Tt+KKEb^#1Jt|j1XIiQDTfJ6PZs85yQj?v6UDl#)vXWU!9am zEJYq94-vz}2(gtICB}#{nWc!K$voDRd93L~h^@pZF-DXrEJX|v!^8-&l^7+;RAv%G zQ?;KV@-TUrJVIad|kBjgcgwi2Vn z7*Xc324aX9CPs*@#3(T~S6hkA)mDP@v{Z-~p2xW*kB~>mTZvI(j41Qj)_nGMKGz1F z2(gtICC27+jO0?ST&h`?JV+iQ50Qtfwa#$0*4auYN{kU@KbGB(?e51}q7x=Yh^@pZ zF-DaAHDC7E{K)=VLn|>#j1gr4YbJ&kXntq`$3-VXj4fdQ7b*sc^W{){tJ?kI;PZ+L zof>DQv(dTN+3M_Y0{Hv{etZ$1kx0Zlu?OJ&+!>OC{|&u}eVKRQdoj15mB-P_>oQC5y$tdeJ_qnNeut?Gt$u*-j@%{H z@;ScY@k{jJJA5ucoQ3!l^8t7tJbu#5Iheo1bj8qyRFP2yO~8*G<*!US6}UZKabblz zo_|cd=FA?W&cnp$WOdRD)u}8{EE%GB!C=LEM=K77XZD? zJK008w(B{%CpiOeu*KeE@%-VOlg2o$r-k^_m`lLBN;Uw!R(6cm=q2M7M{{(QtgW|y zH2Zu;#Wnqw8qQMFS<35Y_C%efjYWzBif;x!T(0;Sb57)JXHC)QO!AoMoa{#@qhu5K z435q_7GBPLw&G=O;W6^^uO#}|(*9mOf^Lo(N4R{2wKHnR0Bj`~Wj$;*qi=0li2Y`Bj1UtCdM zZC*Y1muRmt^Ys{gR-ib4vSOMj9z{Hpctc4ioTZaB|E(g8dM#d6tlX<-R>@1?MJ2BT z*RrkNt$X=i-$K1gd42BN+xJV~K|S7@`MzWac%iFf|ANQVJ4Q!N)NzH&KLk$V%zI~I zIH>-foQIuUOMB9JZj#RPTk(o@Lo~-*dq0-yYWZob*8lgiPkg$t*8fTI zzYv}1XboSp&L4SXi^_k5(^--*K;&&6&38v@&IRKYd)MQgT&n>&oWosK2PGSKf4>iz{v_cqY)r^*0S1Z-WEJ8==5+ zfiAAYnczc!F0RG;+iZD27uVxK;KP9~uE~SJM*>}3lZS%m16^E~hk+LWU0%5zT%AXN zkHgj5;WgaF^*JA0uj4MR(FNdTKo{5PvEUUz7uV`S@CiT{*Xm;MNkA9Z>QeBjKo{5Q za`3%?F0R+(!KVXVT(9>4-xuiOdOZoe66oTZt-lag1$1%U-V=Nd(8YCoFYx(57uWCU z;QIkxT*LKO;}!s2S%|B@BL@Io+*wqD9|UyeVASW}KBEf!5Y*@3?q?48VW`!?y~jN8 zBT=h^-LD$_7}Sd2Bmuhk_TGiyVW2C=$${XtKo_4CJP5oF=;C+e7K5Jvbn$((`fGG2 z0bSg|90tA;=*lX5d$A)8Ko_4LJqo-L=;G65`U`byfG&PzZYlVwKo_4jtN}kA=*k&r z)4}J>YQfJ&n-0Ezv=00n{(@Zu=t?v0=Nvf~=;BW4B=GZqF7B9Cf}am`JbmdlQ0ly9C%J0xG z2cMH%4}J&w<;Z5Bi_c763Vs*RmAlb9NA3Z-_!Q+8;P(Mt`8|5);Li3c@CVU5NB#hG zaaVXP_(MQf9+vCD9|5}ZDEf$Bi2=H}YrGl!380HlV77sG09|cAGueIM zuj!pQ?pN;ze}lgS_$JWBCp@>ne+%f!U(ss^_puLyzl&Zw`1Y7O5UHJ%o*KewN27DJrpx-z3NAQ1O1de=x*>VAofgW7kCa3d#3Xb@LVAFOy^VZAwcYz_--rgnLro6aQFrI2%sw? zov*-00kLN~UxViZv1dBpf)@a>XFA`3j|F1SbbbIY1iDh>`~+SMbfv_>KPd&cQs%^g zmjhj?a1y}B16}-TAU-aN`-&v+NlpNKGSJ1ZDyD+(3B>;C=&v#E1;qa8q=Qcfx-!Gb z1m6ef%DzrE_)H-7QfCl&B@lb5GZ?%Ih`kh_^Tu8Z#9rzQ1D_AXUh0eh-w%kr)ENc7 z0EoTR$p=3G=;F6)3&0lv@w?s5Snz{^_*G!15PS&``zk&Wj(rvA;+G#WZaD&o-{E%3 z!H))FUvP!N!1!7-yrhwN0UED?N3BDZY;`8!*fu9J(zUoW| zUkSv%>g)sF0K~5?J2Syg2D;MdRDw4FU0LH)fv*K(k9FpNp9;ht>*z05o({wwi%*OS6CNl_4r9uRx1vl#q*peq+Thk&;LvBx@x zfv*Q*k9Cd!zXXUq*3ncvSAlRX|s+c51u@5`4UHQ^^6#Oe7_F?C7@UMZ`hn*+EzXf6+b~?bn1G@49p4oBaMbkzVyAV}!50B>*Xm}1F9y1@#LWgj z1nA14?jZ2PfUX?v4hBC4=*qF~Q1GQd?5^%G@EV{iweAS;Wxz?ePu?W)5qwDs9%uCY zJFP&!rvurK{4JGPxSwB!d*U7V?<4&8PyF|l z%#EMt%#Gjd%*KCAUS79m>7pqUq{;psuy)0Yn&Vc~h4+x^s)iLS>T1{2H>`r%uc={G zRl}-N>KfP7HEMK0ebXBJon6n2n#R+^6NRwLFIYVLunQRT9^jSVZSdjeWxzX2^V zXIcH4hQ^;|_c1l|;JVs|#%1_PduzJ4*7)MOrY20fMSGOSmee(^Ie4uf>ron8)Yxz$ zj+d3|QB@5qSFc@D*JEgMDa~c~P8h2N+Y?=IQ0P>$bmwvh|z5B{lHM^nq zq&jb2jOwJ$t*KvOu2FRtpH#nkwb?#jyrzMk#(Oomq_KYa^14P#?l+(%W;NC?TV99L z^!WPaYyIp#re@ArQM03V(rNWMi`AUs7a~({QN48&e^qsBn28H@Is(x16kU-R(3))9Rzf1~LqvaBC(_ny)>*53xxH|yscOW%xM zyGq~S?oFj{X!oAdH?(^P=^L^wq;FvNe$qE&=U(4{cjolq9pZzoURK(D2>`wZ%v z_45;?Z-$*AeFNU<(%0X8ZuAZP=ULG=ZtK3EfG+*MHdcIJbNBM^8=A99ukXvMSIt^m zzoMU_n6s5NtNID-U*Dg5K+Vd!rqwmIb$wgHNNP`7(%65jVQpi7WvXxi>06UtR{C*q zovc~jPXG<~XaDESFKO^5sc*fDYin>%(r3%4UWGfKn%XrweSM?z>(~535=Y7lL{a`qsotcK@Lzr?0N-KQgzjW(_VKzYxUTVgC``HTsXt zu5Vh6JA?j%2iKu7+-NOnU}yV|VL|<>enzI<@0&ZjW=&21k%Q}+8dl)!=|4KVu69LD zBd!hohZi;09ba=C?=t#NutU>tpBC3IU&RUS+g+^EHT{fYNlhazXVpyy*R5>mFJ6iJ z?NjCs$U3JHyyZIZ>?)q^qtZLb*I$zV^^Jkt3hMks=fsl*7su` zh#S)Wd^{>!yRx5rZ$%wi*Qk$d`nG|IUDHt8u;P~!R;)#PzZm9t-VzLm^v#N|Xgv9PIc@PD;;eLrp;*LleOskN5Y(z4^& zvco!#tE398I7X19Fq+lw+EOZ6idTyJ&^F*M?`j!wc`5QzyW67Z?n3|vXxA=M4Pc<|LjmU@FNI%<`~z;?@B7Zo{XyP^+y((yAZv@9Ip@sGncrv5 zIWt`Ev~Ks}Ar9CjNG~UCVrA*IOYotQPb9e3I=C;*<<{_4Z^(tgPMRgpE@4lLQVE=bFE-^8%x32g zqDzNK8S2&6fXjDDqSZkebUjK?ObI#HrYlg-l%V`Mq6Bkklq7Ml1aU3m&@Dl%_O^H9 zGK7X{NdgRnoPd84)#hk>8-n8GNJh`yMw^ykxHFf4uWwPkQi8B8y99Bog8^3pC);rk z3YZeyJdH}ob9?bgBqpDfq(uUr##fWc#({890<~qW9A}L%nbYN{b9?v1yiUTh zT9bJ}jvIAL;8%ugqX9Or64b`MQt(wcqiuM`Zm*Ceq&|CQoyMh7J+u<}l3qbgtNwWAW;rLI(6mkuYBNo91sM*(mW z>E*_#)h>MwWTr^>>937jxt$|f2J=lvji`f=A+>H~+jnt$#D{8G94;4z6L3=js9Crl0jz+W+k@)d9IBUbp~N^Xsm%B%3MJ@utrQ0$Zm(qHZ)BJv5IT!+m8o#GDOIM1HqpoCy`fr zJMmyo2gX)7&Q4OFhhoBGyyt;7c8?G7-M3ywG$7iotljgR2v5UwnkZu;lxZ(DZYP7+4K@rdwgmpJW}h#8`Bg zaI`hF(0aVr>hv&>q13~Ih=VxLtj>#?g^F5-ay1ReR5|Ic#@3L~Nh%q?$M+Kn_gKc` z9ZY;_5M&F+5X1>|H!QZYO{-I=$5Unsu=G@oDR34eF0aQFx&TmWn%>h)Q^lpq8dGNa z3|e^wr|5!CGi5;ey%Z(+AsJwlEK=V6m`uyh$NM&3bF>8&s0z#Nw3Y{ZDNT{-L`9s7 zvgSc6DqI>NMv6~?EoAMKszimct=RC_@3^v|vbqhQ@GEQG4!)U1rZ`m&xhSzxoeE(g z3?2(`kxPP~0rp!L=5`aUCBip9z^X7 zN;V2yWT;!c)+m7z4O>)uRcfycr^;t)XRCiMK2}lfM=Z)ltwLXC)cXfu2;b?&`@Otd zj2}Z3f!t;f4knO-wOk3pgkF)UI5f%K__l&U!>EYlZ25yJih;v*m(p%N?!{9UW!sL4 z>mpf)w7CsutV6|YIX9_kJ&7fIZzfS+9g^zW)gD>Qwo!WHgoR(>u|{g*D4G8psA zVwXB_hYIBIC4+?L0j3EIB22L^gh57_y|lR^un-ucWQ4iULdUu$yBDAOBE7_Qvsoxr zKJJ|hbCwGgk&kmp$;Y*9tWh?TGsMt%GM_1LyfEg9Ak8r^)#u5=Iul@|n>2MO#}csT zKZr>tz()hL_Pq{N+Ysq^GK6fy!=kZ@Eu&~7QQnhKV?;I)U%G_}m!yOI*ft?dcxoyU zCW{f5x0MK80Fc6uEZ9qg&qbBDSxhWvA0}MgX(@bA)D(;is%6IN9*iZ*B#nqZ7)vbw zH1-n2Algt|IK@zex6FbOMJduqaj@x__^u!tfOlQU*W#@y0vf5?}5+)Z7 zYml_D*2c08iOxbg;`$y(MMhbj5DZ(G;GRL&PAJp<`lz_0|EBGv9-4|-1?bvtw0%dc z6~H`Q<#Myl*w_hS6LcsOu$=>xWh0^4p;Wajh9QOJUVV(pB*H47L^83baLZlMFI#V! z;Ih$c`)Im5`FQRO?;$tiKUF3OY$SumhB<0&IK?1w&mnBbjeC+RJ>qDX4Xlhel5o zbc{Y~vQnl>&A@Or$tg8MYT2!%+RP}Si}hL=591nDgj|kV-DKCJ{rRhqC66>7;N?vp zLkGimp0Zd<@5RFuVs-GVR*@>5;6<>Xr5+fH;7lpf*#P&ji8+wbQc@|{fu7n ztsin8nldq<$i`00W^fA9vD37<_Vd6*z^lvU$#b^huw^?#`_WO zGu(q)M5~vi0)4tdX@uQq(d?>VYJ}Bz%VL}dTEhgl5Mi>03T7d}=!KL+WKm+Z^=H^z zl-SG$t4woIV#lnoA{mE;CiczYYh?o(TTFy&f02AGwJ)i(k>7tIX|Y~j4Nfe|2WsRCXmp&3{iDZs^E0UlAH!BAN1 zUWjC4*v5|1LRuA9{3cWC5*dsI&PBy?*PD!mOby6NnX?&NJ$dcGY{+hPFlZf$%Jv-c zcqpoTJUzU?<}+OM8FRbW3KMkBH)Q(9#;Ti$t`?ugU-@! zBi_fqEzosE+W~Hf=c246rrpNB?s(ph_W>JuzQ)RZs90B(^k56LRy&I`*FF44eo%{3 zE$|3sGHPsGCdi#JYo`%PL%X1jpa)QW+=Jx%5bzk^$l=qskkcLfi}A)o&oz)YYcd3u zB{$SUY|}3y>>g?&|Ilu1%dT@DAx}NGgLb%$S3$mxv}(tX-B6jK)i;qxg8$ef;`kFq ze6cl}pgh1oH}h#%c_8#3!XMc}t{rer?sBD_x{3H6dc{4?8d=X?1tYIhL)4J%kl=fudNLn!+rWEe#5B}y%(0Q?`AIyO^ncIQdX2oDkoU9q%e%;_ z+rJ+@$Lk%G*TpC(t7i)>#*xl3&_D}u^l@~z5#zW%fzORK;5gn$7g~|26Z{*XXV{Xg zWuCwMnAQ0D%`MP`Hs?sTa<{}Q$2@z7>%#y!*{BG-x{nWm;d}_`?;*rF*g_gVx0gBY z*?-K}Et9gL8H2ImetqlrfA*D+|7hcl-p{KS|LtGja|tT0-eYg8*W zd;+kl&ufBI8Lp2O9ZAJId1iFPP=xT?F8# z1d#qA_u#@@{pg!DaA5y0I^e!pKRQMZ^`oEFP?_2su6v^f7x^p@ihrWExKw$#-T`wn zOOJoT=82&)1JH)Q>OLkDs?ZtKrXh_}01Qg;O<<*N-pPYSrqzn?cgr zEUuRG10}H$E}tT2-v(WE1~1pmH5_6OEG!U4V0CF>u2w(#?h|uJ;DG$DKK~2V0tnnc zG55sW9J0e_gS_!`adBZ`wpOj5uc03G^K%OSdF=RjzoQi zNtmz+Rz^tmG;71EF$-3Y^Rx*V_et(a#649`v=8pNnQ;TUP)-us=hYnP(48~lj*4L8 zYu`-}kZ9;08z-?IWO zRYjPe)cHBT=F8!3&I$5do{;s;6WanYp5#;GNwNiFob=Ib4rYUe{5_T-###RqXBmY@ z)`)YLVt}nOHWoSHtZgrTc+yq%>666?FkbW$mkE=~PUl6qSD(nmdB6T z3O}m&s85QzkK`H8WY0%9awY}fnfWK?s`L1Nu{vL?&R1vWpQvBuN1Go9)%oQ)d>_50 z&(}d+Q|JwSzBxbt@Jdu!wy#CO-U5xKd0X*!^!aXeeo2A%jP|sBRS7$G!o08W>4kaQ z<`p}O7z$X~o0+fQty(%|K{RBs+E?*|D>H-VcDI_tAb8R)~U0r3z*%OknAdqoSt+QFW2BA%!?( z;Key3J?lx&GMgomsoO=S5MtLs4=FD8i6@TXXjZ+rB**bkI3;lB{n4c!OVGA z!@ny2EzX@s>s-}mTb~DjF3%H+F{7{7XYp0D@Ei8^=E8X!4gP!4z;BV#Cw|*N@7UM7 z$aTqr@A)*R{dZMhw6_X--(sg1QGnXR;%6CGBO;@7U=;0vPgc0%7P+3_F*&F-ZdM<8 z6)K>Y<6i0_adVfhwH44UaLI8mRoq9LFK^t0Dp%h97rbN13-_LDeg-+z&Pu;UC()c! z^$L?TLYf9hmr*eEHp$@@RP=Z@&&|nV>+mL>ds-gF?FJNu@)K%wJ6i)hrqh5IX+w%2 zSfTM2EqJ=v{6h0$^P=UTh{FqdZY5BqzD9UL8E-)0Dgr%E;01AO(Ea3YlJtjPeD>Mf z@F5y)HMirvX9G(w!RSAHc0~IRlume!7ta+zGX)1bczEJMRwLeN&;feb*hk{VzF%pj7zRpV<8@HbTthRZ|_1&;z>uhyd}pD zKPjMe!b6WDD|&0;B}izy^f;2~ZB;s5f06$lltY{l-3XVGZI}a2&u=Ii@#H;7!aW(| zlCG8R=sRGzhhd9JcuCKLSqdhW;BRi=tUzaj=L8c zH%41<3;gZqP_A|_eD-3i{rpb!xvl3~pKC=?du5^+b|9N7Dm<_OZvP7NzvFM`ljFlN zcA8~AxxW;q&z6-$zSZ64O`(}dciP+?dFv#O2lUI)57D9hOg%rPRpOF4w7Z{HzliTW zQ4x#o*!Op1ZS2$jW+AvpBgEF&HkU{DQ9PAr>y}&-(r09#t_Ehe%x^&@^n-3)!+j5S zI9;K&;u)0-tQK~(s~$zNiGgVWPajT8G77I0y{lnN^;>$9Neu4Tn^Km8e@RM7+TH=l zG9VeoHR{cIw$H}|EU#U&M{8Q?ev1eazCL0EZ5eB9Qtrd9g}u=xrA2hTMw8pevy>=~ z7E5KQR(GOnXIIA!x67-po=un>bX7`?z`1Q#=Tsxsns-mEkMN3P;oxdYTj;Ql`B$q{SLSVqVR)Ovma}%@QW2i6b*E90>~4};+HZPRp+R^-gT2M&Do$T` zu=oyd(wo6jEzrpx-7rRR0YlGlshaS0&|OBWCYMrcn|8P^rD|^T=4v(}(SBUbhvwr$ zMijLnwF*Km%X<^o>aB}G1-qOrTNV!Qj$oSqIffsv*>j*y-9pPLs@6sQAt}tX;J2>; zOn*KAlcXShJa1?Lyh!K+UqJo-j0=SMm`z>vIG9vDv!!bADDGVxwMd7F9=r;z=fgt= zzJ|N@;=$C_@aTISwS2{4D#zE+pJIU7l$g4vlaNt#Jk|Q)*MP7hr0$J8&QvMXQ4(us z&oS9j(Fu3wZAG#=BM%aL8PT??RKD{@cYldTXAsGK2H*5jt1Y>%X1l5qtc>P#_pl{xwg?5}$i5+$5XAAf&|#djz#QC(k~J_f%J z>DU-FuhpsJP*QMM;=NGJsO@DuT7@IIiR$p256?mCc!j$%cxKP!MeG_TaKq0QcU!b$ z@l5Fbwr!&c*Su__(YqExK(S|c#dP|>kpc2H&!%{Ln$I0s#(h5SVsd{%zdW5ja5_pK zoWy(@TXuQ=nz~j$;(CT7k zVbvj#`v>zJepJX+a7yQwSMKcLc9eNSDU&XRv&U5pFYmGZ|C=w zspF7`TkGlcmbA3$!{P;7(^%W%SY9p0tD^$r{xfbv+Mvq zndcGuDt-s?Yxvy2?=Eh_XZ|{Vk@0Kze%ZZx<2)g;$?iZJpQdCFFI)*B1bws@$V{rr5nF92#Ix>QK+cTwX<=e3o?)Zew4-KYp`KuHv^yE9wP$ozc~*g_>j>-@nWz zT8+N4_yu9~IDf|z50ohvwsapgwVNEfs%-;wH&y0!52wQy%MA5r%v$SzQ}(*r(AuL7 zuT45RX0`aRZ3j;l-=-mt@fOV5&*67b+19J*E&4dp_0)XwPflZPjwblU{~Fkbkjrb& O{o+~uDu@4nE$~10y-o}O