From e75d50c1c6da0194008bc11bbec732f04870384f Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 21:36:32 +0900 Subject: [PATCH 1/5] Report the time outside the three phases as Timing.OtherMs Reading where a slow run spent its time meant subtracting the three phases from TotalMs by hand, and on large assemblies that remainder (file resolution, planning, the unchanged-method checks) is the longest part of the run. - HotReloadTimingBreakdown now computes OtherMs and refuses a total shorter than its phases, since the phases are spans inside the total. - The response copies OtherMs next to the phases. - The group processor test helper passed a total of 0 after phases the stubs really waited for; it now times the call it makes, as the production run does. --- .../HotReload/HotReloadGroupProcessorTests.cs | 8 +++- .../HotReload/HotReloadRunTimingTests.cs | 43 +++++++++++++++++++ .../Editor/HotReload/HotReloadToolTests.cs | 1 + .../HotReloadApplyResponseBuilder.cs | 1 + .../HotReload/HotReloadTimingBreakdown.cs | 19 +++++++- .../HotReload/HotReloadTimingResponse.cs | 7 ++- 6 files changed, 75 insertions(+), 4 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs index 38126f129d..5bd2e560be 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadGroupProcessorTests.cs @@ -734,6 +734,10 @@ private static HotReloadGroupFile CreatePhaseFile() private static async Task RunGroupWithPhaseStubsAsync(PhaseStubs stubs) { HotReloadRunTiming timing = new HotReloadRunTiming(); + // Why time the call instead of passing a fixed total: the stubs really wait, so the + // phases hold measured times, and only the time around the call is a total that + // covers them, as in the production run. + Stopwatch total = new Stopwatch(); using (HotReloadServicesTestScope.BeginWithDependencies(collaborators => HotReloadGroupProcessorDependencies.Create( files => true, @@ -745,11 +749,13 @@ private static async Task RunGroupWithPhaseStubsAsync( { try { + total.Start(); await HotReloadCompositionRoot.Services.GroupProcessor.ProcessGroupAsync( new[] { stubs.GroupFile }, "phase-timing-test", timing, CancellationToken.None); + total.Stop(); } catch (OperationCanceledException exception) { @@ -759,7 +765,7 @@ await HotReloadCompositionRoot.Services.GroupProcessor.ProcessGroupAsync( } } - return timing.Complete(0); + return timing.Complete(total.ElapsedMilliseconds); } private static TransformWorkerEntryDto CreateAtomicEntry(string projectRelativePath) diff --git a/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs b/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs index ca3aefd9ce..3e561ef3c3 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadRunTimingTests.cs @@ -70,5 +70,48 @@ public void Complete_NegativeTotal_Throws() Assert.Throws(() => timing.Complete(-1)); } + + /// + /// What: the time outside the three phases is reported as the total minus the phases. + /// + [Test] + public void Complete_ReportsOtherAsTotalMinusThePhases() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + timing.AddAnalysis(100); + timing.AddShimCompile(200); + timing.AddPatch(30); + + Assert.That(timing.Complete(1000).OtherMs, Is.EqualTo(670)); + } + + /// + /// What: a total that the phases fill exactly leaves nothing outside them. + /// + [Test] + public void Complete_TotalEqualToThePhases_ReportsZeroOther() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + timing.AddAnalysis(100); + timing.AddShimCompile(200); + timing.AddPatch(30); + + Assert.That(timing.Complete(330).OtherMs, Is.EqualTo(0)); + } + + /// + /// What: a total shorter than the phases inside it is refused instead of reporting a + /// negative time outside the phases. + /// + [Test] + public void Complete_TotalBelowThePhases_Throws() + { + HotReloadRunTiming timing = new HotReloadRunTiming(); + timing.AddAnalysis(100); + timing.AddShimCompile(200); + timing.AddPatch(30); + + Assert.Throws(() => timing.Complete(329)); + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs index 7e17637ab9..a948b60142 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs @@ -851,6 +851,7 @@ public void BuildApplyResponse_WithTiming_CopiesEveryPhase() Assert.That(response.Timing.AnalysisMs, Is.EqualTo(5), "AnalysisMs"); Assert.That(response.Timing.ShimCompileMs, Is.EqualTo(7), "ShimCompileMs"); Assert.That(response.Timing.PatchMs, Is.EqualTo(1), "PatchMs"); + Assert.That(response.Timing.OtherMs, Is.EqualTo(37), "OtherMs"); Assert.That(response.Timing.TotalMs, Is.EqualTo(50), "TotalMs"); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs index 4eef7b679d..1a3683591a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs @@ -140,6 +140,7 @@ private static HotReloadTimingResponse ToTimingResponse(HotReloadTimingBreakdown AnalysisMs = timing.AnalysisMs, ShimCompileMs = timing.ShimCompileMs, PatchMs = timing.PatchMs, + OtherMs = timing.OtherMs, TotalMs = timing.TotalMs }; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingBreakdown.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingBreakdown.cs index 8939dd0395..55e5e2fe2a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingBreakdown.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingBreakdown.cs @@ -13,6 +13,19 @@ public HotReloadTimingBreakdown(long analysisMs, long shimCompileMs, long patchM ShimCompileMs = RequireElapsed(shimCompileMs, nameof(shimCompileMs)); PatchMs = RequireElapsed(patchMs, nameof(patchMs)); TotalMs = RequireElapsed(totalMs, nameof(totalMs)); + + // Why refuse instead of clamping: the phases are spans inside the run's total, so a + // shorter total means a phase was measured outside the run. + long otherMs = totalMs - analysisMs - shimCompileMs - patchMs; + if (otherMs < 0) + { + throw new ArgumentOutOfRangeException( + nameof(totalMs), + totalMs, + "Total milliseconds must cover the phases."); + } + + OtherMs = otherMs; } // Transform worker runs, introduced-type preparation included. @@ -24,7 +37,11 @@ public HotReloadTimingBreakdown(long analysisMs, long shimCompileMs, long patchM // Applying the patches. public long PatchMs { get; } - // The whole run, which also covers file resolution and planning, so it exceeds the sum. + // Outside the three phases: file resolution, planning, the checks that find unchanged + // methods, and the like. + public long OtherMs { get; } + + // The whole run, the three phases included. public long TotalMs { get; } internal static long RequireElapsed(long milliseconds, string parameterName) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingResponse.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingResponse.cs index 47fd88593b..7e8f04e885 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadTimingResponse.cs @@ -15,9 +15,12 @@ public sealed class HotReloadTimingResponse public long PatchMs { get; set; } /// - /// The whole run inside the Editor. It also covers file resolution and planning, so it - /// exceeds the sum of the phases. + /// What is left of TotalMs after the three phases: file resolution, planning, and the + /// checks that find unchanged methods. /// + public long OtherMs { get; set; } + + /// The whole run inside the Editor. public long TotalMs { get; set; } } } From ede22e7ebdac513f0a97af7c2e1d22601e416c7c Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 21:42:14 +0900 Subject: [PATCH 2/5] Say how many Skipped rows came from re-applied siblings in Message An Applied run could end its message with "Skipped: 1." when the only Skipped row belonged to a sibling file the run re-applied on its own. Outcome leaves those rows out, so the bare count read as if one of the requested edits had been skipped. - When some Skipped rows are sibling rows, the clause now reads "Skipped: n (m in sibling files the run re-applied on its own; Outcome does not count those)."; otherwise it is unchanged. - The clause is built once and used by both the ordinary applied message and the type-only message, which reported the same count. - SkippedTotal and Outcome keep their rules. --- .../Editor/HotReload/HotReloadToolTests.cs | 68 ++++++++++++++++++- .../HotReloadApplyResponseBuilder.cs | 61 +++++++++++++---- .../HotReloadIntroducedTypeResponseSection.cs | 14 ++-- .../HotReloadRequestedFileOutcomeSummary.cs | 37 ++++++++++ .../HotReload/Shared/HotReloadConstants.cs | 5 ++ 5 files changed, 161 insertions(+), 24 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs index a948b60142..a50e847b5a 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs @@ -2369,7 +2369,8 @@ public void BuildApplyResponse_AddedAndSkippedOutcomes_CountsSkippedAfterAdded() /// /// What: an applied run that re-applied a sibling's earlier changes says how many of the /// Patched and Added rows came from those siblings, right after the counts they are part of, - /// so a reader who edited one method is not left wondering where the rest came from. + /// so a reader who edited one method is not left wondering where the rest came from. Its + /// Skipped count says the same of the Skipped rows. /// [Test] public void BuildApplyResponse_SiblingRowsReapplied_SaysHowManyOfTheCountsCameFromSiblings() @@ -2393,7 +2394,8 @@ public void BuildApplyResponse_SiblingRowsReapplied_SaysHowManyOfTheCountsCameFr response.Message, Does.StartWith( "Hot reload applied. PatchedTotal=2, ActivePatchTotal=2. Added: 1. " - + "2 of the patched and added rows re-applied changes from earlier reloads in sibling files. Skipped: 1.")); + + "2 of the patched and added rows re-applied changes from earlier reloads in sibling files. " + + "Skipped: 1 (1 in sibling files the run re-applied on its own; Outcome does not count those).")); } /// @@ -2434,7 +2436,7 @@ public void BuildApplyResponse_SiblingRows_AreMarkedReappliedFromSiblingWhatever /// /// What: a pulled-in sibling whose rows were all Skipped re-applied nothing, so the message - /// adds no re-applied count for it. + /// adds no re-applied count for it, while its Skipped count names the row as a sibling's. /// [Test] public void BuildApplyResponse_SiblingRowsAllSkipped_AddsNoReappliedCount() @@ -2453,6 +2455,34 @@ public void BuildApplyResponse_SiblingRowsAllSkipped_AddsNoReappliedCount() HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); Assert.That(response.Message, Does.Not.Contain("re-applied changes from earlier reloads")); + Assert.That(response.Message, Does.Contain(" Skipped: 1 (1 in sibling files")); + } + + /// + /// What: when the requested file and a sibling both have Skipped rows, the Skipped count + /// still covers every row and says how many of them are the sibling's. The sibling also has + /// a Patched row, so counting every sibling row instead of its Skipped ones fails. + /// + [Test] + public void BuildApplyMessage_RequestedAndSiblingRowsSkipped_SaysHowManySkippedRowsAreSiblings() + { + HotReloadOrchestratorResult result = new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Patched("Type.Edited", "Assets/Requested.cs"), + HotReloadMethodOutcome.Skipped("Type.Skip", "reason", "Assets/Requested.cs"), + HotReloadMethodOutcome.Patched("Sibling.Earlier", "Assets/Sibling.cs"), + HotReloadMethodOutcome.Skipped("Sibling.FirstSkip", "reason", "Assets/Sibling.cs"), + HotReloadMethodOutcome.Skipped("Sibling.SecondSkip", "reason", "Assets/Sibling.cs") + }, + new List(), + patchedTotal: 2, + activePatchTotal: 2, + reappliedSiblingPaths: new[] { "Assets/Sibling.cs" }); + + HotReloadResponse response = HotReloadTool.BuildApplyResponse(result); + + Assert.That(response.Message, Does.Contain(" Skipped: 3 (2 in sibling files")); } /// @@ -2836,6 +2866,38 @@ public void BuildApplyResponse_IntroducedTypeBesideSkippedMethods_CountsTheSkipp + " Skipped: 2.")); } + /// + /// What: a run that only introduced a type also says when its Skipped row belongs to a + /// sibling it re-applied on its own, as the ordinary applied message does. + /// + [Test] + public void BuildApplyResponse_IntroducedTypeOnlyWithSiblingSkipped_SaysTheSkippedRowIsASibling() + { + HotReloadResponse response = HotReloadTool.BuildApplyResponse( + new HotReloadOrchestratorResult( + new List + { + HotReloadMethodOutcome.Skipped("Sibling.Skip", "reason", "Assets/Sibling.cs") + }, + new List(), + patchedTotal: 0, + activePatchTotal: 0, + reappliedSiblingPaths: new[] { "Assets/Sibling.cs" }, + introducedTypes: new[] + { + HotReloadIntroducedTypeOutcome.Introduced( + "Example.Introduced", + "IntroducedAssembly", + "Assets/Requested.cs") + })); + + Assert.That( + response.Message, + Does.StartWith( + string.Format(HotReloadConstants.IntroducedTypesOnlyApplyMessageFormat, 1) + + " Skipped: 1 (1 in sibling files")); + } + /// /// What: a run that skipped an edit outside Play Mode asks the CLI for a compile and leaves /// the run's own next action alone. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs index 1a3683591a..15c29801ea 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs @@ -75,6 +75,10 @@ public static HotReloadResponse Build( result.Methods, result.ReappliedSiblingPaths, toProjectRelativeScriptPath); + int skippedSiblingCount = HotReloadRequestedFileOutcomeSummary.CountSkippedSiblingOutcomes( + result.Methods, + result.ReappliedSiblingPaths, + toProjectRelativeScriptPath); string message = BuildApplyMessage( result, hasFailure, @@ -85,7 +89,8 @@ public static HotReloadResponse Build( warnings, HotReloadReappliedSiblingFiles.ForActivePatches(result, toProjectRelativeScriptPath)), allRequestedSkipped, - reappliedSiblingCount); + reappliedSiblingCount, + skippedSiblingCount); HotReloadOutcomeTally tally = HotReloadOutcomeAggregation.CountMethodOutcomeKinds(result.Methods); return new HotReloadResponse { @@ -170,8 +175,11 @@ private static string BuildApplyMessage( int warningCount, bool appendCompileResolution, bool allRequestedSkipped, - int reappliedSiblingCount) + int reappliedSiblingCount, + int skippedSiblingCount) { + string skippedCountSuffix = BuildSkippedCountSuffix(result, skippedSiblingCount); + // Why asked first: the file a run introduces a type into usually holds untouched // methods as well, and every message below would then report the methods only. if (HotReloadIntroducedTypeResponseSection.TryBuildMessage( @@ -179,7 +187,7 @@ private static string BuildApplyMessage( hasMethodFailure, result.PatchedTotal, CountAddedOutcomes(result), - CountOutcomesOfKind(result, HotReloadMethodOutcomeKind.Skipped), + skippedCountSuffix, out string typeMessage)) { return AppendWarningCount(typeMessage, warningCount, appendCompileResolution); @@ -197,7 +205,12 @@ private static string BuildApplyMessage( appendCompileResolution); } - string message = BuildApplyOutcomeMessage(result, hasFailure, allRequestedSkipped, reappliedSiblingCount); + string message = BuildApplyOutcomeMessage( + result, + hasFailure, + allRequestedSkipped, + reappliedSiblingCount, + skippedCountSuffix); message = AppendUnchangedAndLifecycleNotes(message, result); message = HotReloadIntroducedTypeResponseSection.AppendTypeSummary( message, @@ -206,6 +219,34 @@ private static string BuildApplyMessage( return AppendWarningCount(message, warningCount, appendCompileResolution); } + // The Skipped clause the applied messages end their counts with, or an empty string when + // no row was Skipped. Built once so the type-only message and the ordinary one agree. + // Why the sibling rows are named: the count covers every row while Outcome only judges + // the requested files, so an Applied run beside a bare count reads as if one of the + // requested edits had been skipped. + private static string BuildSkippedCountSuffix(HotReloadOrchestratorResult result, int skippedSiblingCount) + { + int skippedCount = CountOutcomesOfKind(result, HotReloadMethodOutcomeKind.Skipped); + Debug.Assert( + skippedSiblingCount <= skippedCount, + "skippedSiblingCount must not exceed the Skipped rows it was counted from."); + if (skippedCount == 0) + { + return string.Empty; + } + + return skippedSiblingCount > 0 + ? string.Format( + CultureInfo.InvariantCulture, + HotReloadConstants.SkippedCountWithSiblingRowsApplyMessageSuffixFormat, + skippedCount, + skippedSiblingCount) + : string.Format( + CultureInfo.InvariantCulture, + HotReloadConstants.SkippedCountApplyMessageSuffixFormat, + skippedCount); + } + // Whether the Message may say one compile clears every warning and none has to be cleared // first: only when the warnings allow it, and never when a declared type needs a compile // before it exists, which contradicts it. @@ -254,7 +295,8 @@ private static string BuildApplyOutcomeMessage( HotReloadOrchestratorResult result, bool hasFailure, bool allRequestedSkipped, - int reappliedSiblingCount) + int reappliedSiblingCount, + string skippedCountSuffix) { int addedCount = CountAddedOutcomes(result); if (hasFailure) @@ -306,14 +348,7 @@ private static string BuildApplyOutcomeMessage( // Why counted here: the totals only count what was applied, so a run that skipped // some of the edits otherwise reads as if every one of them took effect. - int skippedCount = CountOutcomesOfKind(result, HotReloadMethodOutcomeKind.Skipped); - if (skippedCount > 0) - { - message += string.Format( - CultureInfo.InvariantCulture, - HotReloadConstants.SkippedCountApplyMessageSuffixFormat, - skippedCount); - } + message += skippedCountSuffix; return AppendStaleSummary(message, result); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypeResponseSection.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypeResponseSection.cs index 24e9b1b2eb..caa8857283 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypeResponseSection.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadIntroducedTypeResponseSection.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using System.Globalization; +using UnityEngine; + namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// @@ -51,9 +53,11 @@ internal static bool TryBuildMessage( bool hasMethodFailure, int patchedMethodCount, int addedMethodCount, - int skippedMethodCount, + string skippedCountSuffix, out string message) { + Debug.Assert(skippedCountSuffix != null, "skippedCountSuffix must not be null."); + message = null; if (outcomes.Count == 0) { @@ -94,13 +98,7 @@ internal static bool TryBuildMessage( : CountOfKind(outcomes, HotReloadIntroducedTypeOutcomeKind.AlreadyActive)); // Why counted here: "no method body needed patching" otherwise reads as if every // method edit of the run was unchanged, while some of them were Skipped. - if (skippedMethodCount > 0) - { - message += string.Format( - CultureInfo.InvariantCulture, - HotReloadConstants.SkippedCountApplyMessageSuffixFormat, - skippedMethodCount); - } + message += skippedCountSuffix; return true; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs index 00dc6ff4f8..71a5ce5420 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadRequestedFileOutcomeSummary.cs @@ -90,5 +90,42 @@ public static int CountReappliedSiblingOutcomes( return count; } + + /// + /// Counts the Skipped rows that belong to a sibling file the run pulled in to re-apply its + /// earlier changes, as opposed to the files the caller asked about. + /// + // Why apart from the Patched and Added count: the message reports Skipped rows in a count + // of their own, which includes the sibling ones while Outcome leaves them out. + public static int CountSkippedSiblingOutcomes( + IReadOnlyList methods, + IReadOnlyCollection reappliedSiblingPaths, + Func toProjectRelativeScriptPath) + { + Debug.Assert(methods != null, "methods must not be null."); + Debug.Assert(reappliedSiblingPaths != null, "reappliedSiblingPaths must not be null."); + Debug.Assert( + toProjectRelativeScriptPath != null, + "toProjectRelativeScriptPath must not be null."); + + HotReloadReappliedSiblingFiles siblingFiles = + new HotReloadReappliedSiblingFiles(reappliedSiblingPaths, toProjectRelativeScriptPath); + int count = 0; + for (int index = 0; index < methods.Count; index++) + { + HotReloadMethodOutcome outcome = methods[index]; + if (outcome.Kind != HotReloadMethodOutcomeKind.Skipped) + { + continue; + } + + if (siblingFiles.Contains(outcome.FilePath)) + { + count++; + } + } + + return count; + } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 1bfd0db4f8..8fd655d591 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -506,6 +506,11 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE // Format: how many method outcomes of this run were Skipped. public const string SkippedCountApplyMessageSuffixFormat = " Skipped: {0}."; + // Format: how many method outcomes of this run were Skipped, and how many of those belong + // to sibling files the run re-applied on its own. + public const string SkippedCountWithSiblingRowsApplyMessageSuffixFormat = + " Skipped: {0} ({1} in sibling files the run re-applied on its own; Outcome does not count those)."; + // Format: how many types this run introduced. public const string IntroducedTypesOnlyApplyMessageFormat = "Hot reload introduced {0} type(s); no method body needed patching."; From 80b5d484547ca13f3352b72914db012bb3a29b72 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 21:48:20 +0900 Subject: [PATCH 3/5] Note on an added test method's row that the Test Runner cannot see it yet A run that adds a [Test] method reports Outcome=Applied, and the fact that the Unity Test Runner will not discover the method until a compile was only in Warnings. Its Added row in Methods had an empty LifecycleNote, so a reader of the row alone missed it. - The worker now gives the entry of an added method with a test attribute a LifecycleNote saying so, under the same condition as the existing warning (added method and a test attribute). - The one-shot lifecycle note still wins, so the rows that carry it keep it unchanged. Edited existing test methods get no note. --- .../TransformWorkerAddedMemberTests.cs | 85 +++++++++++++++++++ .../TransformWorker~/ShimMethodEmitter.cs | 27 +++++- .../TransformWorker~/TestAttributeNames.cs | 5 ++ 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs index 33f98c43d0..b3e0dd92b2 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedMemberTests.cs @@ -719,6 +719,91 @@ public async Task Warn_AddedTestMethod_KeepsEntryAndEmitsWarning() Has.Some.Contain("AddedProbe").And.Contain("Unity Test Runner")); } + /// + /// What: an added method with a test attribute says on its own entry that the Unity Test + /// Runner will not discover it until a compile, so a reader of its row alone learns it. + /// + [TestCase("[Test]\n public void AddedProbe()\n {\n }", "AddedProbe")] + [TestCase( + "[UnityTest]\n public System.Collections.IEnumerator AddedUnityProbe()\n {\n" + + " yield break;\n }", + "AddedUnityProbe")] + public async Task Emit_AddedTestMethod_CarriesTestRunnerLifecycleNote(string addedMember, string methodName) + { + // Why written out: the worker's constant lives in its own program, which the tests + // cannot reference. + const string expectedNote = + "Test method: not discovered by the Unity Test Runner until 'uloop compile'; " + + "'uloop run-tests --skip-compile' will not find or run it."; + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = WithHostMembers(onDisk, addedMember); + string sourcePath = WriteEdited("AddedTestMethodNote" + methodName + ".cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto added = FindEntry(result, methodName); + Assert.That(added, Is.Not.Null, methodName + " must be an entry."); + Assert.That(added.patchKind, Is.EqualTo(HotReloadConstants.PatchKindAddedMethod)); + Assert.That(added.lifecycleNote, Is.EqualTo(expectedNote)); + } + + /// + /// What: a compiled method whose body was edited and that gained a test attribute is not + /// an added method, so its entry carries no Test Runner note, as it gets no warning. + /// + [Test] + public async Task Emit_EditedExistingTestMethod_HasNoLifecycleNote() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + const string originalExistingValue = + " public int ExistingValue()\n {\n return 1;\n }"; + Assert.That(onDisk, Does.Contain(originalExistingValue)); + // Why the body changes too: an unchanged body is recorded as unchanged and has no + // entry, so the note could not be checked. + string edited = onDisk.Replace( + originalExistingValue, + " [Test]\n public int ExistingValue()\n {\n return 99;\n }", + StringComparison.Ordinal); + string sourcePath = WriteEdited("EditedExistingTestMethodNote.cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto entry = FindEntry(result, nameof(HotReloadAddedMemberHost.ExistingValue)); + Assert.That(entry, Is.Not.Null, "The edited ExistingValue must be an entry."); + Assert.That(entry.patchKind, Is.Not.EqualTo(HotReloadConstants.PatchKindAddedMethod)); + Assert.That(entry.lifecycleNote, Is.Null.Or.Empty); + } + + /// + /// What: an added method without a test attribute carries no Test Runner note. + /// + [Test] + public async Task Emit_AddedPlainMethod_HasNoLifecycleNote() + { + string onDisk = File.ReadAllText(ResolveHostPath()); + string edited = WithHostMembers(onDisk, "public void AddedPlainProbe()\n {\n }"); + string sourcePath = WriteEdited("AddedPlainMethodNote.cs", edited); + + TransformWorkerClientResult result = await RunWorkerOnSourceAsync( + sourcePath, + HostProjectRelativePath, + snapshotSource: onDisk); + Assert.That(result.Success, Is.True, result.ErrorMessage); + + TransformWorkerEntryDto added = FindEntry(result, "AddedPlainProbe"); + Assert.That(added, Is.Not.Null, "AddedPlainProbe must be an entry."); + Assert.That(added.patchKind, Is.EqualTo(HotReloadConstants.PatchKindAddedMethod)); + Assert.That(added.lifecycleNote, Is.Null.Or.Empty); + } + /// /// What: added methods with a qualified [TestCase], [global::NUnit.Framework.Test], /// [UnityTest], or [SetUp] each produce a Unity Test Runner warning that names that method. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs index 348f7551f8..ba638dbb3d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs @@ -113,10 +113,7 @@ internal static void EmitQueuedMethods( CalledAddedMethodKeys = calledAddedMethodKeys, SourceStartLine = queued.SourceStartLine, SourceEndLine = queued.SourceEndLine, - LifecycleNote = ComputeLifecycleNote( - queued.MethodDeclaration, - queued.MethodSymbol, - typeState.TypeSymbol), + LifecycleNote = ComputeEntryLifecycleNote(queued, typeState.TypeSymbol), ReplacesCompiledMethod = queued.ReplacesCompiledMethod, HomeAssemblyName = typeState.HomeAssemblyName }); @@ -186,6 +183,28 @@ internal static string ComputeLifecycleNote( return string.Format(LifecycleNotes.SelectDirectFormat(methodName), methodName); } + /// + /// The note a queued method's entry carries: the one-shot lifecycle note when the method is + /// one, else the Test Runner note when it is an added test method, else none. + /// + // Why the one-shot note first: the rows that carry it today keep it unchanged. Why the + // added-test condition is the warning's: a row gets the note exactly when the run warns. + internal static string ComputeEntryLifecycleNote(QueuedShimMethod queued, INamedTypeSymbol typeSymbol) + { + string oneShotNote = ComputeLifecycleNote(queued.MethodDeclaration, queued.MethodSymbol, typeSymbol); + if (oneShotNote != null) + { + return oneShotNote; + } + + if (queued.IsAddedMethod && TestAttributeNames.HasTestAttribute(queued.MethodDeclaration)) + { + return TestAttributeNames.AddedTestMethodLifecycleNote; + } + + return null; + } + internal static bool IsOneShotLifecycleMethodName(string methodName) { for (int index = 0; index < LifecycleNotes.OneShotLifecycleMethodNames.Length; index++) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TestAttributeNames.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TestAttributeNames.cs index 4ad505a21b..976708727a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TestAttributeNames.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TestAttributeNames.cs @@ -23,6 +23,11 @@ internal static class TestAttributeNames + "'uloop run-tests --skip-compile' will not find or run it. " + "Run 'uloop compile' (or 'uloop run-tests' without --skip-compile) first."; + // The note on the entry of an added test method, so its row says what the warning says. + public const string AddedTestMethodLifecycleNote = + "Test method: not discovered by the Unity Test Runner until 'uloop compile'; " + + "'uloop run-tests --skip-compile' will not find or run it."; + private static readonly HashSet Names = new HashSet(StringComparer.Ordinal) { "Test", From da413a3a9d05ea8169155892cf14fb4f4647ba28 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 21:51:35 +0900 Subject: [PATCH 4/5] Document OtherMs, the sibling Skipped count and the added-test note The hot reload output reference now describes the three response changes: Timing.OtherMs replaces the sentence that TotalMs exceeds the sum of the phases, Message names how many Skipped rows came from re-applied siblings, and an added test method's row carries a LifecycleNote. The generated skill copies are regenerated from it. --- .agents/skills/uloop-hot-reload/references/output.md | 6 +++--- .claude/skills/uloop-hot-reload/references/output.md | 6 +++--- .../FirstPartyTools/HotReload/Skill/references/output.md | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.agents/skills/uloop-hot-reload/references/output.md b/.agents/skills/uloop-hot-reload/references/output.md index 5ccda85e7a..586599bc4e 100644 --- a/.agents/skills/uloop-hot-reload/references/output.md +++ b/.agents/skills/uloop-hot-reload/references/output.md @@ -9,7 +9,7 @@ - `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. - `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. -`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. +`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. When some `Skipped` rows are sibling rows, `Message` says how many (`Skipped: 3 (2 in sibling files …)`). ## Fields @@ -18,7 +18,7 @@ Returns JSON with: - `Success` (boolean): `false` on parameter validation failure or when any method outcome is `Failed`, or when any `IntroducedTypes` row is `Failed`. `Skipped` outcomes alone never force `false` - `ErrorCode` (string, optional): Present on parameter validation failure. Values are `HOT_RELOAD_FILES_REQUIRED` when an omitted apply has no compile snapshots, `HOT_RELOAD_NO_CHANGED_FILES` when snapshots contain no changed `.cs` files, `HOT_RELOAD_INVALID_FILES` when `--files` contains a null or empty path, and `HOT_RELOAD_STATUS_CONFLICT` when `--status` is combined with `--files` or `--revert-all`. - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. -- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` +- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. On an `Added` row whose method carries a test attribute (`[Test]`, `[UnityTest]`, `[SetUp]`, and the like), `LifecycleNote` says the Unity Test Runner will not discover it until `uloop compile`. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` - `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. - `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run @@ -29,7 +29,7 @@ Returns JSON with: - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. Introduced types are not counted here; `ActiveIntroducedTypeTotal` reports those. `--revert-all` clears the patched methods and added members counted here and reports their combined count in `ClearedCount`; introduced types stay loaded until the next Domain Reload and remain in `ActiveIntroducedTypeTotal`. Does not include `AddedField` rows. Validation failures (`HOT_RELOAD_NO_CHANGED_FILES` and the other `ErrorCode` cases) also report the live ledger value, not the default 0. - `AutoRefreshHeld` (boolean): True while Auto Refresh is held because at least one hot-reload change is still active. The first apply that arms the hold appends a Message sentence telling the caller to run `uloop compile` to release it, and that `--revert-all` releases it only when no introduced type remains — a revert cannot unload the assembly carrying an introduced type. A release during Play adds a Warning that pending script edits import on the next focus return or `uloop compile`. If the post-release Refresh is skipped because an open dirty scene also changed on disk, Warnings include the sentence telling the caller to resolve that scene and then run `uloop compile`. `--status` and `--revert-all` report the live value. - `AutoRefreshHoldMessage` (string, optional): The hold sentence this run appended to `Message` when it armed the hold; omitted when the run did not arm it. After a successful fallback compile, the CLI removes that sentence from `Message` and keeps this field as the record of what it removed. -- `Timing` (object, apply runs only): milliseconds this run spent per phase — `AnalysisMs` (transform worker runs, introduced-type preparation included), `ShimCompileMs` (signature gate and shim compile, isolation retries included), `PatchMs` (patch application), `TotalMs` (the whole run inside the Editor; it also covers file resolution and planning, so it exceeds the sum). When the CLI ran the fallback compile, it adds `FallbackCompileMs`, the wall time of that compile, success or failure. Omitted on `--status` and `--revert-all`. +- `Timing` (object, apply runs only): milliseconds this run spent per phase — `AnalysisMs` (transform worker runs, introduced-type preparation included), `ShimCompileMs` (signature gate and shim compile, isolation retries included), `PatchMs` (patch application), `OtherMs` (`TotalMs` minus the three phases: file resolution, planning, and the checks that find unchanged methods), `TotalMs` (the whole run inside the Editor). When the CLI ran the fallback compile, it adds `FallbackCompileMs`, the wall time of that compile, success or failure. Omitted on `--status` and `--revert-all`. - `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`, added field-like events included. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` - `DroppedByPlayModeEntryCount` (number): Remaining patched-method, added-member, and introduced-type identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added` methods, `Introduced` / `AlreadyActive` types — a recovered type also recovers the patches and added members inside it), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `RestoredWiredValueCount` (number, `--status` only): Values written through the added-field wiring call that the last scene reload (entering or leaving Play Mode with domain reload disabled) gave back to the rebuilt objects. It also counts the values `--status` itself gave back by reading them for a host that is back at its place. Omitted when 0. See [added-field-wiring.md](added-field-wiring.md) diff --git a/.claude/skills/uloop-hot-reload/references/output.md b/.claude/skills/uloop-hot-reload/references/output.md index 5ccda85e7a..586599bc4e 100644 --- a/.claude/skills/uloop-hot-reload/references/output.md +++ b/.claude/skills/uloop-hot-reload/references/output.md @@ -9,7 +9,7 @@ - `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. - `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. -`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. +`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. When some `Skipped` rows are sibling rows, `Message` says how many (`Skipped: 3 (2 in sibling files …)`). ## Fields @@ -18,7 +18,7 @@ Returns JSON with: - `Success` (boolean): `false` on parameter validation failure or when any method outcome is `Failed`, or when any `IntroducedTypes` row is `Failed`. `Skipped` outcomes alone never force `false` - `ErrorCode` (string, optional): Present on parameter validation failure. Values are `HOT_RELOAD_FILES_REQUIRED` when an omitted apply has no compile snapshots, `HOT_RELOAD_NO_CHANGED_FILES` when snapshots contain no changed `.cs` files, `HOT_RELOAD_INVALID_FILES` when `--files` contains a null or empty path, and `HOT_RELOAD_STATUS_CONFLICT` when `--status` is combined with `--files` or `--revert-all`. - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. -- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` +- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. On an `Added` row whose method carries a test attribute (`[Test]`, `[UnityTest]`, `[SetUp]`, and the like), `LifecycleNote` says the Unity Test Runner will not discover it until `uloop compile`. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` - `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. - `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run @@ -29,7 +29,7 @@ Returns JSON with: - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. Introduced types are not counted here; `ActiveIntroducedTypeTotal` reports those. `--revert-all` clears the patched methods and added members counted here and reports their combined count in `ClearedCount`; introduced types stay loaded until the next Domain Reload and remain in `ActiveIntroducedTypeTotal`. Does not include `AddedField` rows. Validation failures (`HOT_RELOAD_NO_CHANGED_FILES` and the other `ErrorCode` cases) also report the live ledger value, not the default 0. - `AutoRefreshHeld` (boolean): True while Auto Refresh is held because at least one hot-reload change is still active. The first apply that arms the hold appends a Message sentence telling the caller to run `uloop compile` to release it, and that `--revert-all` releases it only when no introduced type remains — a revert cannot unload the assembly carrying an introduced type. A release during Play adds a Warning that pending script edits import on the next focus return or `uloop compile`. If the post-release Refresh is skipped because an open dirty scene also changed on disk, Warnings include the sentence telling the caller to resolve that scene and then run `uloop compile`. `--status` and `--revert-all` report the live value. - `AutoRefreshHoldMessage` (string, optional): The hold sentence this run appended to `Message` when it armed the hold; omitted when the run did not arm it. After a successful fallback compile, the CLI removes that sentence from `Message` and keeps this field as the record of what it removed. -- `Timing` (object, apply runs only): milliseconds this run spent per phase — `AnalysisMs` (transform worker runs, introduced-type preparation included), `ShimCompileMs` (signature gate and shim compile, isolation retries included), `PatchMs` (patch application), `TotalMs` (the whole run inside the Editor; it also covers file resolution and planning, so it exceeds the sum). When the CLI ran the fallback compile, it adds `FallbackCompileMs`, the wall time of that compile, success or failure. Omitted on `--status` and `--revert-all`. +- `Timing` (object, apply runs only): milliseconds this run spent per phase — `AnalysisMs` (transform worker runs, introduced-type preparation included), `ShimCompileMs` (signature gate and shim compile, isolation retries included), `PatchMs` (patch application), `OtherMs` (`TotalMs` minus the three phases: file resolution, planning, and the checks that find unchanged methods), `TotalMs` (the whole run inside the Editor). When the CLI ran the fallback compile, it adds `FallbackCompileMs`, the wall time of that compile, success or failure. Omitted on `--status` and `--revert-all`. - `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`, added field-like events included. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` - `DroppedByPlayModeEntryCount` (number): Remaining patched-method, added-member, and introduced-type identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added` methods, `Introduced` / `AlreadyActive` types — a recovered type also recovers the patches and added members inside it), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `RestoredWiredValueCount` (number, `--status` only): Values written through the added-field wiring call that the last scene reload (entering or leaving Play Mode with domain reload disabled) gave back to the rebuilt objects. It also counts the values `--status` itself gave back by reading them for a host that is back at its place. Omitted when 0. See [added-field-wiring.md](added-field-wiring.md) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md index 5ccda85e7a..586599bc4e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md @@ -9,7 +9,7 @@ - `Failed` — at least one `Failed` row, so `Success` is `false` — unless a fallback compile then succeeded, which turns the whole answer into `ReplacedByCompile`. - `ReplacedByCompile` — a fallback compile ran in this same command and succeeded (`Compile` holds its response): every edit is compiled in, and `Success` is the compile's. The compile reloaded the domain, so none of this run's patches survive; the totals and `Methods[]` describe the reload that ran before the compile — read `--status` for the state after it. -`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. +`Outcome` is written on apply runs only and judges the files you asked about; rows of a sibling file the run re-applied on its own (`ReappliedFromSibling: true`) do not change it, while `CompileFallback` may still be `Requested` for a retried sibling row. The totals beside it (`PatchedTotal`, `SkippedTotal`, `AddedTotal`, `FailedTotal`, `AlreadyActiveTotal`, `StaleTotal`) count every `Methods[]` row by `Kind`, siblings included. When some `Skipped` rows are sibling rows, `Message` says how many (`Skipped: 3 (2 in sibling files …)`). ## Fields @@ -18,7 +18,7 @@ Returns JSON with: - `Success` (boolean): `false` on parameter validation failure or when any method outcome is `Failed`, or when any `IntroducedTypes` row is `Failed`. `Skipped` outcomes alone never force `false` - `ErrorCode` (string, optional): Present on parameter validation failure. Values are `HOT_RELOAD_FILES_REQUIRED` when an omitted apply has no compile snapshots, `HOT_RELOAD_NO_CHANGED_FILES` when snapshots contain no changed `.cs` files, `HOT_RELOAD_INVALID_FILES` when `--files` contains a null or empty path, and `HOT_RELOAD_STATUS_CONFLICT` when `--status` is combined with `--files` or `--revert-all`. - `NextActions` (array, optional): Ordered recovery steps, present only with `ErrorCode` on a parameter validation failure. Omitted from every other response, including successful apply, plain `--status`, and `--revert-all` runs. -- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` +- `Methods` (array): Per-method `{ Kind, Method, Reason, FilePath, InvocationCount, LifecycleNote, ReappliedFromSibling }` where `Kind` is `Patched`, `Skipped`, `Failed`, `Added`, `AlreadyActive`, or `Stale` on apply runs, and `Active`, `Added`, or `AddedField` on `--status` runs; empty on `--revert-all` runs. `AlreadyActive` means this file's source matched the last fully applied reload (a run with no Skipped or Failed outcomes), so the existing patch was left in place and the row carries the live `InvocationCount`. `Stale` means the method was deleted from the edited source while its patch is still installed: compiled callers keep running the patched body until `uloop compile`, `--revert-all`, or a later reload whose source restores the method to the compiled baseline clears it; a reload that declares the method with a different body replaces the patch instead of clearing it. Stale rows keep counting toward `ActivePatchTotal`, and the Message summary includes `Stale=N`. `InvocationCount` is meaningful on `Active` and `Added` rows of `--status` and on `AlreadyActive` and `Stale` apply rows (calls into the patched or added body since it was applied); it is `0` on other apply/revert outcomes, including the `Added` rows of the run that applied them. On `--status`, an `Active` row with `InvocationCount` 0 sets `Reason` to explain that the method has not run since the patch: finished calls do not re-run, the patched body takes effect on the next call, and how to retrigger an initialization-only path. When the edited source later declares a different signature, that `Active` row's `Reason` instead explains it is superseded by a new declaration of that signature and is no longer the entry point for new calls (superseded wins over the never-invoked sentence). On `--status`, an `Added` row counts calls into the added member's body since it was applied; while that count is 0, its `Reason` explains that compiled code cannot call an added member, so only a hot-reloaded body that calls it, or the hot-reload proxy delivering a forwarded Unity message in Play Mode, can run it. An added iterator counts when its enumeration starts, whereas a patched iterator, and an async method of either kind, counts when it is called. An `AlreadyActive` row for an added member carries that member's count. `AddedField` rows list a live added field, or a live added field-like event, as `Type.field` with an empty `Reason`; they are not method patches. `LifecycleNote` is set when a patched method is a Unity one-shot lifecycle message (`private void Awake`/`Start`/`OnEnable`/`OnDisable`/`OnDestroy` on a `MonoBehaviour`), or when every compiled call path into the patched method (callers of callers are followed a few levels within the compiled assemblies) starts at such a message; empty otherwise — it does not change `Kind`. On an `Added` row whose method is a Unity message, `LifecycleNote` instead says whether the engine will reach it: a forwarded message (`Start`, `Update`, the collision/trigger/mouse messages, and the rest listed in [scope-and-limits.md](scope-and-limits.md)) carries the note that a hot-reload proxy component delivers it to live instances while Play Mode runs, that the proxy is rebuilt only when a later reload changes which messages the type adds or their signatures, that execution order relative to other components is not guaranteed, and that it is gone on any compile or domain reload (only an added `Start` row also says that it runs once on each existing instance when the proxy attaches, and again when the proxy is rebuilt); a message this feature leaves to the compiler (`Awake`, `OnEnable`, `OnDisable`, `OnDestroy`, the editor-only messages, and any non-void message) carries the note that the engine does not invoke it until `uloop compile`, and the run adds one `Warnings` line naming every such message together. On an `Added` row whose method carries a test attribute (`[Test]`, `[UnityTest]`, `[SetUp]`, and the like), `LifecycleNote` says the Unity Test Runner will not discover it until `uloop compile`. `Added` rows carry the added member's signature and file. `ReappliedFromSibling` is `true` on every apply row, whatever its `Kind`, that belongs to a sibling file the run pulled in to re-apply changes from earlier reloads rather than to a file passed in `--files`; it is `false` on the other rows and on every `--status` and `--revert-all` row. Message's re-applied count covers only the `Patched` and `Added` rows among them. `Method` spells parameter types as .NET metadata does, the same on every method row: a constructed generic as ``System.Collections.Generic.List`1``, a multidimensional array as `System.Int32[0...,0...]`, and a nested type with `+`. Example `--status` row: `{ "Kind": "Added", "Method": "Ns.Host.NewHelper(System.Int32)", "Reason": "", "FilePath": "Assets/Scripts/Host.cs", "InvocationCount": 3, "LifecycleNote": "", "ReappliedFromSibling": false }` - `Warnings` (array): Non-fatal notes — one aggregated line listing the patched methods at risk of being already JIT-inlined into existing callers — those marked `[AggressiveInlining]`, plus (only when Code Optimization is Release) those with tiny pre-patch bodies — meaning the change may not show at those call sites, the pause-point interaction (see [pause-point-interaction.md](pause-point-interaction.md)), and the const drift, outside-body drift, missing-baseline, and left-out enum file entries described in [scope-and-limits.md](scope-and-limits.md). Skipped outcomes are echoed here as `Skipped : `, or as one `Skipped N methods: ()` line per reason when several share it, so checking `Warnings` alone is enough to see that an edit was not applied. When a reload re-applies unchanged files so their patches bind to this run's shim, Warnings includes `Also re-applied N unchanged file(s) with active patches in assembly '...' so their patches bind to this reload's shim: ...`. When a pulled-in sibling fails in that reload, Warnings includes `'...' was pulled in to re-bind its active patches but this reload failed for it; see its rows for which patches changed and run uloop compile to clear the run.` instead of the re-applied line. When every row of that sibling was `Skipped` and none failed, Warnings instead includes `'...' was pulled in to re-bind its active patches, but every method there was Skipped this time; see its rows for the reasons. Any earlier patches there stay active until uloop compile clears the run.` When the reload stopped before re-applying anything at all, so that sibling has no rows, Warnings instead includes `'...' was pulled in to re-bind its active patches, but this reload stopped before re-applying them, so its active patches are unchanged. Fix the refused declaration and rerun, or run uloop compile to clear the run.` When the whole reload was refused, so that sibling's only rows are `Method` = `(file)` `Failed` rows repeating the refusal and none of its unchanged patches were reverted, Warnings instead includes `'...' was pulled in to re-bind its active patches, but the whole reload was refused before re-applying them, so its active patches are unchanged; its rows repeat the refusal reason. Fix that and rerun, or run uloop compile to clear the run.` When a sibling still has active patches but its source changed since they were applied, Warnings includes `'...' has active patches but its source changed since they were applied, so it was not re-applied; pass it to hot-reload to update it.` When a patch or added member that an earlier reload applied still calls an added member that is no longer registered — a later reload changed its signature, deleted it, or skipped it while the caller did not apply again — Warnings includes `Methods that earlier hot reloads patched or added still call added members that are no longer registered: calls , .... Those calls still run the members' earlier bodies, which match neither the compiled assembly nor the source on disk. Reload until the calling methods apply again, or run 'uloop compile'.` on every reload that includes the caller's file or the member's file; see [troubleshooting.md](troubleshooting.md). When a run carries two or more warnings and all of them are hot reload warnings, the Message ends with "A single 'uloop compile' clears all of them at once when you want them gone; none of them has to be cleared before you keep working." — it is the shortest recovery, not an obligation to compile immediately. Pause-point warnings carry their own recovery steps, so that line does not appear when they are present. Nor does it appear when an `IntroducedTypes` row is `Failed` or a warning says a declared type requires a compile, because that type does not exist until one. It is also left off when any `Methods` row is `Failed`, or when a `Methods` row of a file you passed, or of a sibling retried after an earlier Skip, is `Skipped`: that body is not running yet, so it needs a fix or a compile before you keep working. A `Skipped` row of a sibling pulled in only to re-bind its active patches does not leave it off, because the earlier patches there keep running. - `Outcome` (string, apply runs only): `Applied`, `PartiallyApplied`, `NothingApplied`, `NothingToApply`, `Failed`, or `ReplacedByCompile` — whether the edits of the files you asked about are live; see "Is my edit live?" above. Omitted on `--status`, `--revert-all`, and validation failures. - `PatchedTotal` (number): Methods patched in this run @@ -29,7 +29,7 @@ Returns JSON with: - `ActivePatchTotal` (number): Active changes after this run — patched methods plus added members. Introduced types are not counted here; `ActiveIntroducedTypeTotal` reports those. `--revert-all` clears the patched methods and added members counted here and reports their combined count in `ClearedCount`; introduced types stay loaded until the next Domain Reload and remain in `ActiveIntroducedTypeTotal`. Does not include `AddedField` rows. Validation failures (`HOT_RELOAD_NO_CHANGED_FILES` and the other `ErrorCode` cases) also report the live ledger value, not the default 0. - `AutoRefreshHeld` (boolean): True while Auto Refresh is held because at least one hot-reload change is still active. The first apply that arms the hold appends a Message sentence telling the caller to run `uloop compile` to release it, and that `--revert-all` releases it only when no introduced type remains — a revert cannot unload the assembly carrying an introduced type. A release during Play adds a Warning that pending script edits import on the next focus return or `uloop compile`. If the post-release Refresh is skipped because an open dirty scene also changed on disk, Warnings include the sentence telling the caller to resolve that scene and then run `uloop compile`. `--status` and `--revert-all` report the live value. - `AutoRefreshHoldMessage` (string, optional): The hold sentence this run appended to `Message` when it armed the hold; omitted when the run did not arm it. After a successful fallback compile, the CLI removes that sentence from `Message` and keeps this field as the record of what it removed. -- `Timing` (object, apply runs only): milliseconds this run spent per phase — `AnalysisMs` (transform worker runs, introduced-type preparation included), `ShimCompileMs` (signature gate and shim compile, isolation retries included), `PatchMs` (patch application), `TotalMs` (the whole run inside the Editor; it also covers file resolution and planning, so it exceeds the sum). When the CLI ran the fallback compile, it adds `FallbackCompileMs`, the wall time of that compile, success or failure. Omitted on `--status` and `--revert-all`. +- `Timing` (object, apply runs only): milliseconds this run spent per phase — `AnalysisMs` (transform worker runs, introduced-type preparation included), `ShimCompileMs` (signature gate and shim compile, isolation retries included), `PatchMs` (patch application), `OtherMs` (`TotalMs` minus the three phases: file resolution, planning, and the checks that find unchanged methods), `TotalMs` (the whole run inside the Editor). When the CLI ran the fallback compile, it adds `FallbackCompileMs`, the wall time of that compile, success or failure. Omitted on `--status` and `--revert-all`. - `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`, added field-like events included. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` - `DroppedByPlayModeEntryCount` (number): Remaining patched-method, added-member, and introduced-type identities discarded by the Play-entry domain reload that have not been recovered by a later apply (`Patched` / `Added` methods, `Introduced` / `AlreadyActive` types — a recovered type also recovers the patches and added members inside it), `--revert-all`, or a successful compile. Omitted when the count is 0. Re-apply `uloop hot-reload`, or edit the files and run `uloop compile` - `RestoredWiredValueCount` (number, `--status` only): Values written through the added-field wiring call that the last scene reload (entering or leaving Play Mode with domain reload disabled) gave back to the rebuilt objects. It also counts the values `--status` itself gave back by reading them for a host that is back at its place. Omitted when 0. See [added-field-wiring.md](added-field-wiring.md) From b021947aa29cea88452e0ea6b195c76ee874274f Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 6 Oct 2026 22:03:23 +0900 Subject: [PATCH 5/5] Bring comments in line with the added-test note and the Skipped clause - Both lifecycle note field comments said the note is null unless the method is a one-shot lifecycle method, which no longer holds now that the worker also notes added test methods. - The comment above the Skipped clause in the applied message said the count was taken there; it is now built once and only appended. Comments only. --- .../HotReload/HotReloadApplyResponseBuilder.cs | 4 ++-- .../FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs | 4 ++-- .../FirstPartyTools/HotReload/TransformWorker~/WorkerEntry.cs | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs index 15c29801ea..a5e81132ba 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadApplyResponseBuilder.cs @@ -346,8 +346,8 @@ private static string BuildApplyOutcomeMessage( + " of the patched and added rows re-applied changes from earlier reloads in sibling files."; } - // Why counted here: the totals only count what was applied, so a run that skipped - // some of the edits otherwise reads as if every one of them took effect. + // Why the Skipped count goes here: the totals only count what was applied, so a run + // that skipped some of the edits otherwise reads as if every one of them took effect. message += skippedCountSuffix; return AppendStaleSummary(message, result); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs index e8d717731e..94b4fcd59c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/TransformWorkerDtos.cs @@ -372,8 +372,8 @@ internal sealed class TransformWorkerEntryDto public int sourceStartLine; public int sourceEndLine; - // Null/empty when the method is not a one-shot lifecycle method and is not only called - // from them inside this file. + // Null/empty unless the method is a one-shot lifecycle method or an added method with a + // test attribute. public string lifecycleNote; // True when this addedMethod entry replaces a compiled method whose return type changed. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerEntry.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerEntry.cs index 042f9d532a..6579a036c6 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerEntry.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/WorkerEntry.cs @@ -43,7 +43,8 @@ internal sealed class WorkerEntry public int SourceEndLine { get; set; } - // Null when the method is not a one-shot lifecycle method and is not only called from them. + // Null unless the method is a one-shot lifecycle method or an added method with a test + // attribute. public string LifecycleNote { get; set; } public bool ReplacesCompiledMethod { get; set; }