From 2c0f86c69a93676b23c7c5b0b95263caa584a850 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 19:44:54 +0900 Subject: [PATCH 01/26] Record which added-member emulations hot reload takes (ADR 0011) Every added member is an emulation because Mono cannot extend a loaded type, so the line is drawn by whether ordinary code and the Unity messages it relies on give the same result as after a compile. Added field-like events pass that test and reuse the shipped added-field store. Forwarding added Awake/OnEnable/OnDisable/OnDestroy changes when game code runs, so it stays refused; a --re-enable option is replaced by toggling enabled around an apply; method groups naming added methods and a stable forwarding stub are deferred until a normal usability round shows them blocking work. --- ...-where-ordinary-code-sees-no-difference.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 docs/adr/0011-hot-reload-added-members-only-where-ordinary-code-sees-no-difference.md diff --git a/docs/adr/0011-hot-reload-added-members-only-where-ordinary-code-sees-no-difference.md b/docs/adr/0011-hot-reload-added-members-only-where-ordinary-code-sees-no-difference.md new file mode 100644 index 000000000..9edb514d3 --- /dev/null +++ b/docs/adr/0011-hot-reload-added-members-only-where-ordinary-code-sees-no-difference.md @@ -0,0 +1,108 @@ +# Hot Reload Emulates an Added Member Only Where Ordinary Code Sees No Difference + +Date: 2026-10-01 + +## Decision + +The Mono runtime in the Unity Editor cannot add a field, method, or event to a type that is +already loaded. Every member hot reload calls "added" is therefore an emulation: an added +method runs as a static method on a generated type, and an added field keeps its value in a +store owned by hot reload. Since some emulation is unavoidable, the line is drawn by one +question: + +> Does code written the ordinary way — and the Unity messages it relies on — give the same +> result as it would after a compile? + +An emulation is taken only when the answer is yes and the remaining differences show up only +through reflection, threading, or states that exist solely because hot reload was used (a +member removed in a later edit, `--revert-all`). An emulation that changes when or in what +order ordinary game code runs is not taken; that change keeps its refusal and a compile. + +Applying this question to the requests from the 2026-10-01 usability round: + +- **Taken — adding a field-like event to a compiled class.** The event's delegate lives in the + added-field store under the same key form as an added field. Subscribing, unsubscribing, + raising, comparing with null, and assigning work as they do after a compile, from the + declaring type and from other types in the same run. What differs: reflection does not see + the event, `+=` is not atomic (the store is main-thread only, as for added fields), and + subscriptions are dropped when Play Mode ends or `--revert-all` runs. Event declarations + with accessors, events on structs, events whose delegate type is not visible, and names that + clash with a compiled member stay refused. +- **Not taken — forwarding an added `Awake`, `OnEnable`, `OnDisable`, or `OnDestroy`.** Unity + calls these only on methods that exist when the object is created. Forwarding them through + a hidden component runs them after the compiled lifecycle methods, ignores Script Execution + Order, and runs late on objects created by `Instantiate`, on scene load, and on objects that + are inactive. Closing each gap needs another hook into Unity's own API (`Instantiate`, scene + loading, activation), and the order still differs from Unity's. Ordinary game code — "spawn, + then call `Initialize` on the next line" — would get a different result. Adding these + messages keeps its refusal, and the existing reason still tells the user to compile. +- **Not taken — a `--re-enable` option that calls `OnDisable` then `OnEnable` on live + instances.** Calling the methods without changing `enabled` produces calls Unity never + makes, and getting the pairing right (the old `OnDisable` must run before the new body is + applied) needs a hook in the middle of an apply. The same effect is already available with + Unity's own semantics: set `enabled = false` with `execute-dynamic-code`, apply with + `hot-reload`, then set `enabled = true`. Unity calls the old `OnDisable` and the new + `OnEnable`, and coroutines keep running because only the component is toggled. +- **Not taken in this change — method groups that name an added method + (`publisher.Hit += OnHit;`), and a stable forwarding stub that would keep such delegates on + the latest body.** Without the stub, a delegate created from an added method keeps running + the body of the run that created it, which differs from a compiled method whose body is + patched in place. The stub fixes that, but creates states plain C# cannot have: a removed + added method still running through an old delegate, and a delegate that silently does + nothing after `--revert-all`. These stay refused (`AddedMethodMethodGroupReference`); a + lambda (`publisher.Hit += h => OnHit(h);`) is the workaround. + +## Context + +The 2026-10-01 usability round asked three testers what still made them stop Play Mode. Adding +an event and subscribing to it was refused for all three. Adding lifecycle messages and +re-running `OnEnable` on live objects were also requested. A follow-up asked whether the +proposed forwarding rules would work for their code; the answers turned on hypothetical next +steps ("if I later add an `Awake` to the spawned block"), not on code they had written that +day. In each tester's code the objects created at runtime had no `Awake` or `OnEnable` at all. +The compiles they actually made were mostly caused by new `MonoBehaviour` / +`ScriptableObject` types and by wiring new `[SerializeField]` references — which none of these +changes address. + +The added-field store already ships, is covered by tests, and has a documented lifetime. An +added event reuses it, so the event change adds no new runtime mechanism. + +## Rejected Alternatives + +- **Forward added lifecycle messages with synchronous hooks on `Instantiate`, scene loading, + and activation.** Narrows the timing gaps but still runs added messages after compiled ones + and outside Script Execution Order, while hooking Unity's object creation for every project + that uses hot reload. Rejected because ordinary game code still sees a different order. +- **Accept the late delivery and document it.** The case the testers described — initialize on + the line after `Instantiate` — would be overwritten by an added `Awake` that runs a frame + later. Documenting a different order does not make code written in Unity's order work. +- **Implement `--re-enable` with the old `OnDisable` before the apply.** Correct, but rebuilds + inside the tool what toggling `enabled` around an apply already gives with Unity's own + semantics. +- **Take the stable forwarding stub for added-method delegates now.** All three testers + preferred it to "the old body until you subscribe again", and it does not change Unity + message timing. It is deferred, not rejected on principle: the states it creates exist only + after a removal or a revert, and the question is whether method-group refusals block real + work often enough to justify a new runtime mechanism. + +## Consequences + +- An added field-like event on a compiled class can be raised and subscribed to without leaving + Play Mode. Subscribing with a lambda or a compiled method works; subscribing with a method + group that names an added method stays refused. +- An added `Awake`, `OnEnable`, `OnDisable`, or `OnDestroy` still needs a compile, as before. + Code added to an existing compiled lifecycle method is patched as any other body edit; Unity + does not call it again for objects that already ran it. +- The skill documents the `enabled` toggle around an apply for re-running edited `OnEnable` + subscriptions on live objects. +- A future request to emulate another member kind is triaged by the question in the Decision + section before any design work. + +## Reversal condition + +Reopen the lifecycle-forwarding decision if a usability round in which testers develop +normally (not answering questions about the design) shows added-lifecycle refusals as a +leading reason for compiling, or if Unity provides a supported way to register a Unity message +on an existing type. Reopen the method-group decision if such a round shows +`AddedMethodMethodGroupReference` refusals blocking work that the lambda workaround does not +cover. From f367ccce22c02e5776785cd9b4f3a56c06696e04 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 20:02:31 +0900 Subject: [PATCH 02/26] docs(hot-reload): Explain re-running an edited OnEnable by toggling enabled around a reload Added OnEnable/OnDisable messages stay unforwarded (ADR 0011), so the supported way to re-run a subscription added to a compiled OnEnable on live objects is Unity's own enabled toggle around the apply: the old OnDisable runs, then the patched OnEnable. --- .../skills/uloop-hot-reload/references/scope-and-limits.md | 7 +++++++ .../skills/uloop-hot-reload/references/scope-and-limits.md | 7 +++++++ .../HotReload/Skill/references/scope-and-limits.md | 7 +++++++ 3 files changed, 21 insertions(+) diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 73f55f33a..1f970a1a7 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -258,6 +258,13 @@ method's row says which answer it got in `LifecycleNote` (see Output). its `Skipped` row names `uloop compile` as the only step: no rewrite of the body would make the engine call it. +To re-run an edited compiled `OnEnable` on live objects — for example after adding a +subscription to it — toggle the component around the reload instead of compiling: set +`enabled = false` with `execute-dynamic-code`, run `hot-reload`, then set `enabled = true`. +Unity calls the old `OnDisable` (so the old subscription is removed) and then the patched +`OnEnable`; coroutines keep running because only the component is toggled. This works only +for an `OnEnable` / `OnDisable` the compiled class already has; an added one is not called. + The proxies exist only for the running session: nothing is attached outside Play Mode, and a compile or a domain reload drops them along with every other patch. Execution order relative to other components is not guaranteed — a proxy is its own component, so an added `Update` diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 73f55f33a..1f970a1a7 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -258,6 +258,13 @@ method's row says which answer it got in `LifecycleNote` (see Output). its `Skipped` row names `uloop compile` as the only step: no rewrite of the body would make the engine call it. +To re-run an edited compiled `OnEnable` on live objects — for example after adding a +subscription to it — toggle the component around the reload instead of compiling: set +`enabled = false` with `execute-dynamic-code`, run `hot-reload`, then set `enabled = true`. +Unity calls the old `OnDisable` (so the old subscription is removed) and then the patched +`OnEnable`; coroutines keep running because only the component is toggled. This works only +for an `OnEnable` / `OnDisable` the compiled class already has; an added one is not called. + The proxies exist only for the running session: nothing is attached outside Play Mode, and a compile or a domain reload drops them along with every other patch. Execution order relative to other components is not guaranteed — a proxy is its own component, so an added `Update` diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 73f55f33a..1f970a1a7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -258,6 +258,13 @@ method's row says which answer it got in `LifecycleNote` (see Output). its `Skipped` row names `uloop compile` as the only step: no rewrite of the body would make the engine call it. +To re-run an edited compiled `OnEnable` on live objects — for example after adding a +subscription to it — toggle the component around the reload instead of compiling: set +`enabled = false` with `execute-dynamic-code`, run `hot-reload`, then set `enabled = true`. +Unity calls the old `OnDisable` (so the old subscription is removed) and then the patched +`OnEnable`; coroutines keep running because only the component is toggled. This works only +for an `OnEnable` / `OnDisable` the compiled class already has; an added one is not called. + The proxies exist only for the running session: nothing is attached outside Play Mode, and a compile or a domain reload drops them along with every other patch. Execution order relative to other components is not guaranteed — a proxy is its own component, so an added `Update` From 078e67d16187b6b44142c6803575975c934bd750 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 20:30:40 +0900 Subject: [PATCH 03/26] feat(hot-reload): Raise and subscribe to field-like events added to a compiled class An event added in an edit used to skip every body that raised it (EventAddedInThisEdit) or subscribed to it (EventSubscriptionToAddedEvent), so adding an event and wiring it up always needed a compile. ADR 0011 takes the emulation: the delegate lives in the added-field store under the key an added field of that name gets. - AddedEventStorePolicy decides from the event symbol and the compiled declaring type alone (field-like, class or struct host, visible delegate and declaring type, no compiled member of that name), never from a catalog, so the answer does not depend on the order of --files. - The classifier registers a binding per event declarator; a struct host or an initializer the shim lambda cannot run makes it unavailable, and the guard stage skips the using bodies with the added-field reason. - '+=' / '-=' become Set(Delegate.Combine/Remove(GetOrInit(...), (T)h)); reads, raises, null checks and '=' reuse the added-field store calls. '(E) += h' counts as a subscription. - Store-backed events no longer force delegation, are not registered on the accessor plan, and stub an introduced-type body like an added field. - Refusals kept: nameof, '?.' receivers, by-ref, '??=', receivers with side effects (AddedFieldDoubleEvalReceiver, now worded "field or event"), non-visible delegates, and names a compiled member already uses. - A body let through as store-backed without a binding stops the run (contract I1) instead of emitting a raw event access. - Anonymous functions in added-field initializers are no longer refused as instance members; names inside them are still checked one by one. --- .../HotReloadAddedEventApplyPublisher.cs | 25 + .../HotReloadAddedEventApplyPublisher.cs.meta | 11 + .../HotReloadAddedEventApplySubscriber.cs | 35 ++ ...HotReloadAddedEventApplySubscriber.cs.meta | 11 + .../HotReload/HotReloadAddedEventE2ETests.cs | 337 ++++++++++++ .../HotReloadAddedEventE2ETests.cs.meta | 11 + .../HotReload/HotReloadAddedEventPublisher.cs | 24 + .../HotReloadAddedEventSubscriber.cs | 2 + .../HotReload/HotReloadCrossFileE2ETests.cs | 10 +- ...HotReloadIntroducedTypeBodyEditE2ETests.cs | 16 +- .../HotReloadWorkerReasonTextTests.cs | 2 +- ...nsformWorkerAddedEventSubscriptionTests.cs | 83 ++- .../TransformWorkerAddedEventTests.cs | 512 ++++++++++++++++++ .../TransformWorkerAddedEventTests.cs.meta | 11 + .../TransformWorkerEventAccessorTests.cs | 12 +- .../TransformWorkerIntroducedTypeStubTests.cs | 12 +- ...adWorkerReasonText.AddedMemberTemplates.cs | 2 +- .../AccessorAccessRegistrar.cs | 6 + .../TransformWorker~/AccessorReadRegistrar.cs | 7 + .../TransformWorker~/AddedCallSiteGuard.cs | 1 + .../TransformWorker~/AddedEventLookup.cs | 13 + .../TransformWorker~/AddedEventStorePolicy.cs | 93 ++++ .../TransformWorker~/AddedFieldBinding.cs | 3 + .../TransformWorker~/AddedFieldBodyScan.cs | 60 +- .../TransformWorker~/AddedFieldClassifier.cs | 63 +++ .../TransformWorker~/AddedFieldShimRewrite.cs | 51 +- .../AddedFieldSkipEvaluator.cs | 7 + .../AddedMemberAccessLookup.cs | 17 +- .../AddedMemberReferenceClassifier.cs | 18 +- .../TransformWorker~/EventAccessorRules.cs | 152 ++++-- .../MethodTransformDecider.cs | 2 +- .../TransformWorker~/ShimBodyRewriter.cs | 3 +- .../TransformWorker~/TypeEmitPlanner.cs | 3 +- 33 files changed, 1464 insertions(+), 151 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadAddedEventApplySubscriber.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadAddedEventApplySubscriber.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs.meta create mode 100644 Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs create mode 100644 Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventStorePolicy.cs diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs new file mode 100644 index 000000000..6b1878dbc --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs @@ -0,0 +1,25 @@ +using System; +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Compiled publisher whose edited copy gains an event in an end-to-end apply, so a patched + /// body can raise an event the compiled class does not have. + /// + public sealed class HotReloadAddedEventApplyPublisher + { + public event Action Existing; + + [MethodImpl(MethodImplOptions.NoInlining)] + public void Raise(int value) + { + Existing?.Invoke(value); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static void RaiseStatic(int value) + { + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs.meta new file mode 100644 index 000000000..265b999bb --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 48303c15c20334ffc9e3f2719bb6a7e1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplySubscriber.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplySubscriber.cs new file mode 100644 index 000000000..aa5e1cf54 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplySubscriber.cs @@ -0,0 +1,35 @@ +using System.Runtime.CompilerServices; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// Compiled subscriber whose edited copy subscribes to an event the publisher's edited copy + /// adds, so a test can tell whether a raise reaches it. + /// + public sealed class HotReloadAddedEventApplySubscriber + { + private int _received; + + public int Received => _received; + + [MethodImpl(MethodImplOptions.NoInlining)] + public void Wire(HotReloadAddedEventApplyPublisher publisher) + { + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public void Unwire(HotReloadAddedEventApplyPublisher publisher) + { + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public void WireStatic() + { + } + + public void Accept(int value) + { + _received += value; + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplySubscriber.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplySubscriber.cs.meta new file mode 100644 index 000000000..5be0d65d8 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplySubscriber.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aed1a004cac474998a3ff4d0196a0f90 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs new file mode 100644 index 000000000..3b0e01bdb --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs @@ -0,0 +1,337 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// End-to-end EditMode coverage for a field-like event the edit adds to a compiled class: its + /// delegate lives in the added-field store, so patched bodies of the declaring type and of + /// other types in the same run can subscribe, unsubscribe, and raise it. + /// + public class HotReloadAddedEventE2ETests + { + private const string PublisherFileName = "HotReloadAddedEventApplyPublisher.cs"; + private const string SubscriberFileName = "HotReloadAddedEventApplySubscriber.cs"; + private const string ExistingEventAnchor = " public event Action Existing;"; + private const string RaiseBodyAnchor = " Existing?.Invoke(value);"; + private const string RaiseStaticAnchor = + " public static void RaiseStatic(int value)\n {\n }"; + private const string WireAnchor = + " public void Wire(HotReloadAddedEventApplyPublisher publisher)\n {\n }"; + private const string UnwireAnchor = + " public void Unwire(HotReloadAddedEventApplyPublisher publisher)\n {\n }"; + private const string WireStaticAnchor = " public void WireStatic()\n {\n }"; + private const string AddedEvent = " public event Action Changed;"; + private const string RaiseChanged = " Changed?.Invoke(value);"; + + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _scope = new HotReloadDomainTestScope(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + VibeLogger.ClearMemoryLogs(); + } + + /// + /// What: a patched method of the declaring type subscribes a lambda to the added event and + /// raises it, and the lambda runs. + /// + [Test] + public async Task Run_AddedInstanceEvent_RaisedFromPatchedMethod_InvokesSubscribers() + { + string publisher = EditPublisher( + AddedEvent, + " if (Changed == null)\n {\n" + + " Changed += forwarded => Existing?.Invoke(forwarded * 10);\n }\n\n" + + RaiseChanged); + + HotReloadOrchestratorResult result = await RunAsync(publisher, ReadFixture(SubscriberFileName)); + + AssertPatched(result, ".Raise("); + HotReloadAddedEventApplyPublisher target = new HotReloadAddedEventApplyPublisher(); + HotReloadAddedEventApplySubscriber subscriber = new HotReloadAddedEventApplySubscriber(); + target.Existing += subscriber.Accept; + target.Raise(3); + Assert.That(subscriber.Received, Is.EqualTo(30), FormatOutcomes(result)); + } + + /// + /// What: a patched method of another type in the same run subscribes a lambda to the added + /// event, and a raise from the declaring type reaches it. + /// + [Test] + public async Task Run_OtherTypeSubscribesLambda_ReceivesTheRaise() + { + HotReloadOrchestratorResult result = await RunAsync( + EditPublisher(AddedEvent, RaiseChanged), + EditSubscriber(WireAnchor, "publisher.Changed += value => Accept(value);")); + + AssertPatched(result, ".Raise("); + AssertPatched(result, ".Wire("); + Assert.That(WireAndRaise(4), Is.EqualTo(4), FormatOutcomes(result)); + } + + /// + /// What: unsubscribing the same compiled method group removes it, so a later raise no + /// longer reaches the subscriber. + /// + [Test] + public async Task Run_UnsubscribeWithTheSameDelegate_StopsDelivery() + { + string subscriber = EditSubscriber(WireAnchor, "publisher.Changed += Accept;"); + subscriber = ReplaceMember(subscriber, UnwireAnchor, "publisher.Changed -= Accept;"); + HotReloadOrchestratorResult result = await RunAsync(EditPublisher(AddedEvent, RaiseChanged), subscriber); + + AssertPatched(result, ".Unwire("); + HotReloadAddedEventApplyPublisher target = new HotReloadAddedEventApplyPublisher(); + HotReloadAddedEventApplySubscriber listener = new HotReloadAddedEventApplySubscriber(); + listener.Wire(target); + target.Raise(2); + listener.Unwire(target); + target.Raise(5); + Assert.That(listener.Received, Is.EqualTo(2), FormatOutcomes(result)); + } + + /// + /// What: an added static event with a generic delegate type is raised from a patched static + /// method and reaches a lambda another type subscribed. + /// + [Test] + public async Task Run_AddedStaticGenericEvent_RaiseAndSubscribe() + { + string publisher = ReplaceInSource( + ReadFixture(PublisherFileName), + ExistingEventAnchor, + ExistingEventAnchor + "\n\n public static event Action LinesCleared;"); + publisher = ReplaceInSource( + publisher, + RaiseStaticAnchor, + " public static void RaiseStatic(int value)\n {\n" + + " LinesCleared?.Invoke(new[] { value, value, value });\n }"); + string subscriber = ReplaceMember( + ReadFixture(SubscriberFileName), + WireStaticAnchor, + "HotReloadAddedEventApplyPublisher.LinesCleared += lines => Accept(lines.Length);"); + + HotReloadOrchestratorResult result = await RunAsync(publisher, subscriber); + + AssertPatched(result, ".RaiseStatic("); + AssertPatched(result, ".WireStatic("); + HotReloadAddedEventApplySubscriber listener = new HotReloadAddedEventApplySubscriber(); + listener.WireStatic(); + HotReloadAddedEventApplyPublisher.RaiseStatic(7); + Assert.That(listener.Received, Is.EqualTo(3), FormatOutcomes(result)); + } + + /// + /// What: the subscriber's file passed before the publisher's still binds, because whether + /// an added event lives in the store does not depend on which file was classified first. + /// + [Test] + public async Task Run_SubscriberFileFirst_StillBinds() + { + string publisherPath = FixturePath(PublisherFileName); + string subscriberPath = FixturePath(SubscriberFileName); + HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { subscriberPath, publisherPath }, + contentPathOverride: null, + CancellationToken.None, + BuildOverrides( + EditPublisher(AddedEvent, RaiseChanged), + EditSubscriber(WireAnchor, "publisher.Changed += Accept;"))); + + AssertPatched(result, ".Raise("); + AssertPatched(result, ".Wire("); + Assert.That(WireAndRaise(6), Is.EqualTo(6), FormatOutcomes(result)); + } + + /// + /// What: a later run that leaves the added event as it was and edits only the raising body + /// keeps the subscribers the first run stored, because the event is still absent from the + /// compiled class and keeps the same store key. + /// + [Test] + public async Task Run_ReapplyUnchanged_KeepsSubscribers() + { + string publisher = EditPublisher(AddedEvent, RaiseChanged); + string subscriber = EditSubscriber(WireAnchor, "publisher.Changed += Accept;"); + await RunAsync(publisher, subscriber); + HotReloadAddedEventApplyPublisher target = new HotReloadAddedEventApplyPublisher(); + HotReloadAddedEventApplySubscriber listener = new HotReloadAddedEventApplySubscriber(); + listener.Wire(target); + + HotReloadOrchestratorResult second = await RunAsync( + EditPublisher(AddedEvent, " Changed?.Invoke(value + 1);"), + subscriber); + + AssertPatched(second, ".Raise("); + target.Raise(8); + Assert.That(listener.Received, Is.EqualTo(9), FormatOutcomes(second)); + } + + /// + /// What: changing the added event's delegate type between runs drops the stored + /// subscribers, because the store resets a value of another type. + /// + [Test] + public async Task Run_ChangedDelegateType_DropsSubscribers() + { + await RunAsync( + EditPublisher(AddedEvent, RaiseChanged), + EditSubscriber(WireAnchor, "publisher.Changed += Accept;")); + HotReloadAddedEventApplyPublisher target = new HotReloadAddedEventApplyPublisher(); + HotReloadAddedEventApplySubscriber listener = new HotReloadAddedEventApplySubscriber(); + listener.Wire(target); + + HotReloadOrchestratorResult second = await RunAsync( + EditPublisher(" public event Action Changed;", RaiseChanged), + EditSubscriber(WireAnchor, "publisher.Changed += value => Accept((int)value);")); + + AssertPatched(second, ".Raise("); + target.Raise(9); + Assert.That(listener.Received, Is.EqualTo(0), FormatOutcomes(second)); + } + + /// + /// What: removing the added event from the source drops it from the run's added fields, + /// while its stored subscribers stay until a revert, as an added field's value does; adding + /// the event back reaches the subscriber the first run stored. + /// + [Test] + public async Task Run_RemovedAddedEvent_LeavesTheStoredValueLikeAField() + { + string publisher = EditPublisher(AddedEvent, RaiseChanged); + string subscriber = EditSubscriber(WireAnchor, "publisher.Changed += Accept;"); + HotReloadOrchestratorResult first = await RunAsync(publisher, subscriber); + Assert.That(first.AddedFields, Has.Some.EndsWith(".Changed"), FormatOutcomes(first)); + HotReloadAddedEventApplyPublisher target = new HotReloadAddedEventApplyPublisher(); + HotReloadAddedEventApplySubscriber listener = new HotReloadAddedEventApplySubscriber(); + listener.Wire(target); + + HotReloadOrchestratorResult second = await RunAsync( + ReadFixture(PublisherFileName), + ReadFixture(SubscriberFileName)); + Assert.That(second.AddedFields, Has.None.EndsWith(".Changed"), FormatOutcomes(second)); + + HotReloadOrchestratorResult third = await RunAsync(publisher, subscriber); + AssertPatched(third, ".Raise("); + target.Raise(4); + Assert.That(listener.Received, Is.EqualTo(4), FormatOutcomes(third)); + } + + private static int WireAndRaise(int value) + { + HotReloadAddedEventApplyPublisher target = new HotReloadAddedEventApplyPublisher(); + HotReloadAddedEventApplySubscriber listener = new HotReloadAddedEventApplySubscriber(); + listener.Wire(target); + target.Raise(value); + return listener.Received; + } + + private static string EditPublisher(string addedEvent, string raiseBody) + { + string publisher = ReplaceInSource( + ReadFixture(PublisherFileName), + ExistingEventAnchor, + ExistingEventAnchor + "\n\n" + addedEvent); + return ReplaceInSource(publisher, RaiseBodyAnchor, raiseBody); + } + + private static string EditSubscriber(string anchor, string body) + { + return ReplaceMember(ReadFixture(SubscriberFileName), anchor, body); + } + + // Replaces an empty-bodied member with the same signature and the given body. + private static string ReplaceMember(string source, string anchor, string body) + { + string signature = anchor.Substring(0, anchor.IndexOf("\n", StringComparison.Ordinal)); + return ReplaceInSource(source, anchor, signature + "\n {\n " + body + "\n }"); + } + + private static string ReplaceInSource(string source, string anchor, string replacement) + { + Assert.That(source, Does.Contain(anchor), "Precondition: anchor must exist: " + anchor); + return source.Replace(anchor, replacement, StringComparison.Ordinal); + } + + private static Task RunAsync(string editedPublisher, string editedSubscriber) + { + return HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { FixturePath(PublisherFileName), FixturePath(SubscriberFileName) }, + contentPathOverride: null, + CancellationToken.None, + BuildOverrides(editedPublisher, editedSubscriber)); + } + + private static Dictionary BuildOverrides(string editedPublisher, string editedSubscriber) + { + return new Dictionary + { + [FixturePath(PublisherFileName)] = HotReloadTestSourceWriter.WriteEditedSource( + "AddedEventE2E_" + PublisherFileName, + editedPublisher), + [FixturePath(SubscriberFileName)] = HotReloadTestSourceWriter.WriteEditedSource( + "AddedEventE2E_" + SubscriberFileName, + editedSubscriber) + }; + } + + private static void AssertPatched(HotReloadOrchestratorResult result, string methodPart) + { + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + if (outcome.Method != null && outcome.Method.Contains(methodPart)) + { + Assert.That(outcome.Kind, Is.EqualTo(HotReloadMethodOutcomeKind.Patched), FormatOutcomes(result)); + return; + } + } + + Assert.Fail("Missing outcome for " + methodPart + ".\n" + FormatOutcomes(result)); + } + + private static string ReadFixture(string fileName) + { + return File.ReadAllText(FixturePath(fileName)); + } + + private static string FixturePath(string fileName) + { + string path = Path.GetFullPath(Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", fileName)); + Assert.That(File.Exists(path), Is.True, "Fixture missing: " + path); + return path; + } + + private static string FormatOutcomes(HotReloadOrchestratorResult result) + { + List lines = new List(); + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + lines.Add(outcome.Kind + " " + outcome.Method + " @" + outcome.FilePath + " :: " + outcome.Reason); + } + + lines.AddRange(result.Warnings ?? new List()); + return string.Join("\n", lines); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs.meta new file mode 100644 index 000000000..105d78551 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 92e1099583b4f4a08abe58cd3d8b49b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventPublisher.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventPublisher.cs index 541836e20..3b50ef2ed 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedEventPublisher.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventPublisher.cs @@ -10,11 +10,29 @@ public sealed class HotReloadAddedEventPublisher { public event Action Existing; + // Compiled field an edited copy turns into an event of the same name. + public int Clash; + + public int Count => 0; + + public HotReloadAddedEventPublisher Self() + { + return this; + } + public void RaiseExisting(int value) { Existing?.Invoke(value); } + /// + /// Nested struct, because an event added to a struct cannot live in the added-field store. + /// + public struct Payload + { + public int Value; + } + /// /// Nested publisher, because a nested type is looked up compiled by a name that differs /// from its source spelling. @@ -29,4 +47,10 @@ public void RaiseInnerExisting(int value) } } } + + /// + /// A delegate type that code in another assembly cannot name, so an event of this type + /// cannot be reached from a shim. + /// + internal delegate void HotReloadAddedEventHiddenHandler(int value); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventSubscriber.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventSubscriber.cs index c2e77429f..399a28d63 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedEventSubscriber.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventSubscriber.cs @@ -9,6 +9,8 @@ public sealed class HotReloadAddedEventSubscriber { private int _received; + private HotReloadAddedEventPublisher _publisher = new HotReloadAddedEventPublisher(); + public int Received => _received; [MethodImpl(MethodImplOptions.NoInlining)] diff --git a/Assets/Tests/Editor/HotReload/HotReloadCrossFileE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadCrossFileE2ETests.cs index a24603a7d..7910a6e9b 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCrossFileE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCrossFileE2ETests.cs @@ -959,8 +959,10 @@ await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( /// /// What: an existing method of a file brought back to re-bind its active patches, whose - /// body subscribes to an event the host added, is Skipped rather than Failed once a later - /// reload no longer brings the host back, and its reason names the host file to pass. + /// body subscribes an added method group to an event the host added, is Skipped rather + /// than Failed once a later reload no longer brings the host back, and its reason names + /// the host file to pass. The host's raiser is kept Skipped (nameof of the event) so the + /// host has nothing active and comes back only for its retry. /// [Test] public async Task Run_ReappliedSiblingBodyNoLongerBinds_IsSkippedAndNamesTheMissingFile() @@ -976,14 +978,14 @@ public async Task Run_ReappliedSiblingBodyNoLongerBinds_IsSkippedAndNamesTheMiss [hostPath] = HotReloadTestSourceWriter.WriteEditedSource( "ReappliedUnboundHost.cs", InsertHostMember( - " public event System.Action Hit;\n\n public void RaiseHit()\n {\n Hit?.Invoke();\n }\n\n")), + " public event System.Action Hit;\n\n public void RaiseHit()\n {\n Hit?.Invoke();\n System.Console.WriteLine(nameof(Hit));\n }\n\n")), [callerPath] = HotReloadTestSourceWriter.WriteEditedSource("ReappliedUnboundCaller.cs", callerSource) }; HotReloadOrchestratorResult first = await RunWithOverridesAsync(new[] { hostPath, callerPath }, overrides); Assert.That( FindOutcome(first, HotReloadMethodOutcomeKind.Skipped, ".Call(").WorkerReason?.Code, - Is.EqualTo(HotReloadWorkerReasonCode.EventSubscriptionToAddedEvent), + Is.EqualTo(HotReloadWorkerReasonCode.AddedMethodMethodGroupReference), FormatOutcomes(first)); FindOutcome(first, HotReloadMethodOutcomeKind.Skipped, ".RaiseHit("); FindOutcome(first, HotReloadMethodOutcomeKind.Patched, ".Other("); diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeBodyEditE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeBodyEditE2ETests.cs index dad95acbd..9f74277a4 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeBodyEditE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeBodyEditE2ETests.cs @@ -138,12 +138,12 @@ await RunInIntroducedTypeDomainAsync(async readArtifact => /// /// Verifies that a body of an already introduced type that subscribes to an event the same - /// reload adds to a compiled type is skipped with the added-event reason. The introduced + /// reload adds to a compiled type is patched through the added-field store. The introduced /// type is served by the retained assembly, which never holds the compiled type, so the /// event has to be looked up where the compiled type is served. /// [Test] - public async Task Run_IntroducedTypeBodySubscribesToEventAddedOnCompiledType_SkipsNamingTheEvent() + public async Task Run_IntroducedTypeBodySubscribesToEventAddedOnCompiledType_IsPatched() { string hostPath = FixturePath("HotReloadCrossFileAddedMemberHost.cs"); string callerPath = FixturePath("HotReloadCrossFileAddedMemberCaller.cs"); @@ -165,14 +165,12 @@ await RunInIntroducedTypeDomainAsync(async readArtifact => Assert.That(compute, Is.Not.Null, "Missing Compute() row.\n" + DescribeOutcomes(second)); Assert.That( compute.Kind, - Is.EqualTo(HotReloadMethodOutcomeKind.Skipped), - DescribeOutcomes(second)); - Assert.That( - compute.Reason, - Does.Contain( - "Subscribes to the event '" + HostTypeFullName + "." + AddedHostEventName - + "', which this edit adds"), + Is.EqualTo(HotReloadMethodOutcomeKind.Patched), DescribeOutcomes(second)); + AssertComputedValue( + readArtifact(), + IntroducedSeed, + "The patched body subscribes and still returns the seed."); }); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs index b59cf321c..b9750f688 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs @@ -360,7 +360,7 @@ private static IEnumerable RenderCases() yield return Case( HotReloadWorkerReasonCode.AddedFieldDoubleEvalReceiver, NoArgs, - "Assignment to an added field would evaluate a receiver with possible side effects twice. " + "Assignment to an added field or event would evaluate a receiver with possible side effects twice. " + "Run 'uloop compile'."); yield return Case( HotReloadWorkerReasonCode.AddedFieldDeconstructionTarget, diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs index 5de9ec511..5086b4b9f 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs @@ -16,9 +16,9 @@ namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload { /// /// EditMode coverage for a body that subscribes to an event: a subscription to an event the - /// same edit adds is skipped with a reason that asks for a compile, because the shim is - /// compiled against the assembly that has no such event, while a subscription to an event the - /// compiled assembly already has is left alone. + /// same edit adds goes to the added-field store, and one to an event the compiled assembly + /// already has stays on the event's accessors. Either way the method is applied unless its + /// handler is a shape the shim cannot build. /// public class TransformWorkerAddedEventSubscriptionTests { @@ -36,14 +36,16 @@ public class TransformWorkerAddedEventSubscriptionTests private const string SubscriberMethodAnchor = " public int Received => _received;"; private const string WireBody = " publisher.Existing += Accept;"; private const string AddedEvent = "\n public event Action Changed;"; + private const string ChangedKey = PublisherTypeName + "::Changed"; private const string AddedInnerEvent = "\n public event Action InnerChanged;"; /// - /// What: an added method that subscribes a private method group to an event this edit adds - /// is skipped with the added-event reason, which names the event and offers no lambda. + /// What: an added method that subscribes a compiled private method group to an event this + /// edit adds is no longer refused for the event; it is refused for the private method + /// group, whose reason offers the lambda that does apply. /// [Test] - public async Task Run_AddedMethodSubscribesMethodGroupToAddedEvent_SkipsNamingTheEvent() + public async Task Run_AddedMethodSubscribesMethodGroupToAddedEvent_SkipsNamingTheMethodGroup() { TransformWorkerClientResult result = await RunAsync( WithAddedEvent(), @@ -54,23 +56,16 @@ public async Task Run_AddedMethodSubscribesMethodGroupToAddedEvent_SkipsNamingTh Assert.That(result.Success, Is.True, result.ErrorMessage); TransformWorkerSkippedDto skipped = FindSkipped(result, "WireChanged"); Assert.That(skipped, Is.Not.Null, "Missing skipped row.\n" + FormatSkipped(result)); - Assert.That(skipped.reason.code, Is.EqualTo(HotReloadWorkerReasonCode.EventSubscriptionToAddedEvent)); + Assert.That(skipped.reason.code, Is.Not.EqualTo(HotReloadWorkerReasonCode.EventSubscriptionToAddedEvent)); string reason = HotReloadWorkerReasonText.Render(skipped.reason); - Assert.That( - reason, - Is.EqualTo( - "Subscribes to the event '" + PublisherTypeName + ".Changed', which this edit adds; " - + "the compiled assembly has no such event yet, so the subscription cannot bind " - + "until 'uloop compile'.")); - Assert.That(reason, Does.Not.Contain("lambda")); + Assert.That(reason, Does.Contain("OnValue").And.Contain("lambda"), reason); } /// - /// What: subscribing a lambda to an event this edit adds is skipped the same way, so - /// following a lambda hint does not end in a failed shim compile. + /// What: an added method that subscribes a lambda to an event this edit adds is applied. /// [Test] - public async Task Run_AddedMethodSubscribesLambdaToAddedEvent_Skips() + public async Task Run_AddedMethodSubscribesLambdaToAddedEvent_IsApplied() { TransformWorkerClientResult result = await RunAsync( WithAddedEvent(), @@ -78,15 +73,15 @@ public async Task Run_AddedMethodSubscribesLambdaToAddedEvent_Skips() "public void WireChanged(HotReloadAddedEventPublisher publisher)\n {\n" + " publisher.Changed += value => OnValue(value);\n }")); - AssertSkippedForAddedEvent(result, "WireChanged"); + AssertAppliedThroughStore(result, "WireChanged", ChangedKey); } /// - /// What: an existing method whose edited body subscribes to an event this edit adds is - /// skipped instead of producing an entry whose shim cannot compile. + /// What: an existing method whose edited body subscribes a compiled public method group to + /// an event this edit adds is applied through the store. /// [Test] - public async Task Run_ExistingMethodSubscribesToAddedEvent_Skips() + public async Task Run_ExistingMethodSubscribesToAddedEvent_IsApplied() { string subscriber = ReadOnDisk(SubscriberFileName); Assert.That(subscriber, Does.Contain(WireBody), "Precondition: Wire body anchor must exist."); @@ -94,16 +89,15 @@ public async Task Run_ExistingMethodSubscribesToAddedEvent_Skips() WithAddedEvent(), subscriber.Replace(WireBody, " publisher.Changed += Accept;", StringComparison.Ordinal)); - AssertSkippedForAddedEvent(result, "Wire"); - Assert.That(FindEntry(result, "Wire"), Is.Null, "The subscription must not be emitted as an entry."); + AssertAppliedThroughStore(result, "Wire", ChangedKey); } /// - /// What: a method of the publisher itself that subscribes to the event this edit adds to it - /// is skipped the same way. + /// What: a method of the publisher itself that subscribes a compiled method group to the + /// event this edit adds to it is applied through the store. /// [Test] - public async Task Run_PublisherSubscribesToItsOwnAddedEvent_Skips() + public async Task Run_PublisherSubscribesToItsOwnAddedEvent_IsApplied() { string publisher = WithAddedEvent(); Assert.That(publisher, Does.Contain(PublisherMethodAnchor), "Precondition: method anchor must exist."); @@ -115,15 +109,16 @@ public async Task Run_PublisherSubscribesToItsOwnAddedEvent_Skips() StringComparison.Ordinal), ReadOnDisk(SubscriberFileName)); - AssertSkippedForAddedEvent(result, "SelfWire"); + AssertAppliedThroughStore(result, "SelfWire", ChangedKey, PublisherTypeName); } /// - /// What: a nested type's event this edit adds is recognized as added, because the compiled - /// nested type is looked up by its reflection name rather than its source spelling. + /// What: a nested type's event this edit adds is recognized as added and keyed by the + /// nested type's metadata name, because the compiled nested type is looked up by its + /// reflection name rather than its source spelling. /// [Test] - public async Task Run_AddedMethodSubscribesToAddedNestedEvent_SkipsNamingTheNestedEvent() + public async Task Run_AddedMethodSubscribesToAddedNestedEvent_UsesTheNestedKey() { string publisher = ReadOnDisk(PublisherFileName); Assert.That(publisher, Does.Contain(InnerEventAnchor), "Precondition: nested event anchor must exist."); @@ -133,10 +128,7 @@ public async Task Run_AddedMethodSubscribesToAddedNestedEvent_SkipsNamingTheNest "public void WireInner(HotReloadAddedEventPublisher.Inner inner)\n {\n" + " inner.InnerChanged += Accept;\n }")); - AssertSkippedForAddedEvent(result, "WireInner"); - Assert.That( - HotReloadWorkerReasonText.Render(FindSkipped(result, "WireInner").reason), - Does.Contain("'" + PublisherTypeName + ".Inner.InnerChanged'")); + AssertAppliedThroughStore(result, "WireInner", PublisherTypeName + "/Inner::InnerChanged"); } /// @@ -175,15 +167,19 @@ public async Task Run_AddedMethodSubscribesToEventOfAnotherAssembly_IsApplied() Assert.That(FindEntry(result, "WireLowMemory"), Is.Not.Null, "Missing entry.\n" + FormatSkipped(result)); } - private static void AssertSkippedForAddedEvent(TransformWorkerClientResult result, string methodName) + private static void AssertAppliedThroughStore( + TransformWorkerClientResult result, + string methodName, + string storeKey, + string typeMetadataName = SubscriberTypeMetadataName) { Assert.That(result.Success, Is.True, result.ErrorMessage); - TransformWorkerSkippedDto skipped = FindSkipped(result, methodName); - Assert.That(skipped, Is.Not.Null, "Missing skipped row for " + methodName + ".\n" + FormatSkipped(result)); + Assert.That(FindSkipped(result, methodName), Is.Null, "Unexpected skip.\n" + FormatSkipped(result)); Assert.That( - skipped.reason.code, - Is.EqualTo(HotReloadWorkerReasonCode.EventSubscriptionToAddedEvent), - HotReloadWorkerReasonText.Render(skipped.reason)); + FindEntry(result, methodName, typeMetadataName), + Is.Not.Null, + "Missing entry for " + methodName + ".\n" + FormatSkipped(result)); + Assert.That(result.Output.shimSource, Does.Contain("\"" + storeKey + "\"")); } private static string WithAddedEvent() @@ -210,11 +206,14 @@ private static string ReadOnDisk(string fileName) return File.ReadAllText(path); } - private static TransformWorkerEntryDto FindEntry(TransformWorkerClientResult result, string methodName) + private static TransformWorkerEntryDto FindEntry( + TransformWorkerClientResult result, + string methodName, + string typeMetadataName = SubscriberTypeMetadataName) { foreach (TransformWorkerEntryDto entry in result.Output.entries) { - if (entry.typeMetadataName == SubscriberTypeMetadataName && entry.methodName == methodName) + if (entry.typeMetadataName == typeMetadataName && entry.methodName == methodName) { return entry; } diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs new file mode 100644 index 000000000..646d61d13 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs @@ -0,0 +1,512 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEditor.Compilation; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// EditMode coverage for how the transform worker classifies and rewrites a field-like event + /// the edit adds to a compiled class: the uses that can live in the added-field store are + /// rewritten to it under the declaring type's key, and the shapes that cannot stay refused. + /// + public class TransformWorkerAddedEventTests + { + private const string TestAssemblyName = "UnityCLILoop.Tests.Editor.HotReload"; + private const string PublisherFileName = "HotReloadAddedEventPublisher.cs"; + private const string SubscriberFileName = "HotReloadAddedEventSubscriber.cs"; + private const string PublisherTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadAddedEventPublisher"; + private const string SubscriberTypeMetadataName = + "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload.HotReloadAddedEventSubscriber"; + private const string ChangedKey = PublisherTypeMetadataName + "::Changed"; + private const string PublisherEventAnchor = " public event Action Existing;"; + private const string RaiseBodyAnchor = " Existing?.Invoke(value);"; + private const string InnerRaiseBodyAnchor = " InnerExisting?.Invoke(value);"; + private const string CountAnchor = " public int Count => 0;"; + private const string PayloadAnchor = " public int Value;"; + private const string ClashAnchor = " public int Clash;"; + private const string SubscriberMethodAnchor = " public int Received => _received;"; + private const string WireBody = " publisher.Existing += Accept;"; + private const string AddedEvent = "\n public event Action Changed;"; + + /// + /// What: each way the declaring type reads or writes the added event is rewritten to the + /// store under the declaring type's key, and the method is emitted instead of skipped. + /// + [TestCase("Changed?.Invoke(value);")] + [TestCase("if (Changed != null)\n {\n Changed(value);\n }")] + [TestCase("Changed = null;")] + [TestCase("Action handler = Changed;\n handler?.Invoke(value);")] + [TestCase("this.Changed?.Invoke(value);")] + public async Task Rewrite_EachReadAndWriteForm_UsesTheStore(string raiseBody) + { + TransformWorkerClientResult result = await RunAsync( + WithRaiseBody(WithAddedEvent(AddedEvent), raiseBody), + ReadOnDisk(SubscriberFileName)); + + AssertEmittedThroughStore(result, PublisherTypeMetadataName, "RaiseExisting", ChangedKey); + } + + /// + /// What: an event added to a struct is skipped with the reason an added struct field gets, + /// because a boxed struct has no identity the store could key a value by. + /// + [Test] + public async Task Classify_EventOnStruct_IsSkippedLikeAStructField() + { + string publisher = ReplaceInSource( + ReadOnDisk(PublisherFileName), + PayloadAnchor, + PayloadAnchor + "\n\n public event Action PayloadChanged;"); + TransformWorkerClientResult result = await RunAsync( + publisher, + WithSubscriberMethod( + "public void WirePayload(HotReloadAddedEventPublisher.Payload payload)\n {\n" + + " payload.PayloadChanged += Accept;\n }")); + + AssertSkippedWith(result, "WirePayload", HotReloadWorkerReasonCode.AddedFieldStructHost); + } + + /// + /// What: an added event whose delegate type code outside the assembly cannot name stays + /// skipped for that reason, whether it is raised or subscribed to. + /// + [TestCase(true)] + [TestCase(false)] + public async Task Classify_EventWithNonVisibleDelegate_StaysSkipped(bool raise) + { + string publisher = WithAddedEvent("\n internal event HotReloadAddedEventHiddenHandler Hidden;"); + string subscriber = ReadOnDisk(SubscriberFileName); + if (raise) + { + publisher = WithRaiseBody(publisher, "Hidden?.Invoke(value);"); + } + else + { + subscriber = WithSubscriberMethod( + "public void WireHidden(HotReloadAddedEventPublisher publisher)\n {\n" + + " publisher.Hidden += Accept;\n }"); + } + + TransformWorkerClientResult result = await RunAsync(publisher, subscriber); + + AssertSkippedWith( + result, + raise ? "RaiseExisting" : "WireHidden", + HotReloadWorkerReasonCode.EventDelegateTypeNotVisible); + } + + /// + /// What: an event whose name the compiled class already uses for a field is not put in the + /// store, so subscribing and raising keep today's refusals. + /// + [TestCase(true)] + [TestCase(false)] + public async Task Classify_EventNameClashesWithCompiledField_StaysRefused(bool raise) + { + string publisher = ReplaceInSource( + ReadOnDisk(PublisherFileName), + ClashAnchor, + " public event Action Clash;"); + string subscriber = ReadOnDisk(SubscriberFileName); + if (raise) + { + publisher = WithRaiseBody(publisher, "Clash?.Invoke(value);"); + } + else + { + subscriber = WithSubscriberMethod( + "public void WireClash(HotReloadAddedEventPublisher publisher)\n {\n" + + " publisher.Clash += Accept;\n }"); + } + + TransformWorkerClientResult result = await RunAsync(publisher, subscriber); + + AssertSkippedWith( + result, + raise ? "RaiseExisting" : "WireClash", + raise + ? HotReloadWorkerReasonCode.EventAddedInThisEdit + : HotReloadWorkerReasonCode.EventSubscriptionToAddedEvent); + } + + /// + /// What: a compiled event re-declared static is not an added event, so raising it keeps + /// the reason that the compiled backing field does not match. + /// + [Test] + public async Task Classify_EventWithChangedStaticness_KeepsTodaysReasons() + { + string publisher = ReplaceInSource( + ReadOnDisk(PublisherFileName), + PublisherEventAnchor, + " public static event Action Existing;"); + TransformWorkerClientResult result = await RunAsync( + WithRaiseBody(publisher, "Existing?.Invoke(value + 1);"), + ReadOnDisk(SubscriberFileName)); + + AssertSkippedWith(result, "RaiseExisting", HotReloadWorkerReasonCode.EventAddedInThisEdit); + } + + /// + /// What: an initializer the store can run is emitted with the event, and one it cannot run + /// skips the using body with the added-field initializer reason instead of failing. + /// + [TestCase("delegate { }", true)] + [TestCase("new Action(RaiseExisting)", false)] + public async Task Classify_EventInitializer_FollowsTheFieldInitializerRules(string initializer, bool emittable) + { + string publisher = WithRaiseBody( + WithAddedEvent("\n public event Action Changed = " + initializer + ";"), + "Changed?.Invoke(value);"); + TransformWorkerClientResult result = await RunAsync(publisher, ReadOnDisk(SubscriberFileName)); + + if (emittable) + { + AssertEmittedThroughStore(result, PublisherTypeMetadataName, "RaiseExisting", ChangedKey); + return; + } + + AssertSkippedWith( + result, + "RaiseExisting", + HotReloadWorkerReasonCode.AddedFieldInitializerNotLiteralOrExternalStatic); + } + + /// + /// What: each declarator of a multi-declarator event gets its own store key. + /// + [Test] + public async Task Classify_MultipleDeclarators_EachGetsAKey() + { + TransformWorkerClientResult result = await RunAsync( + WithRaiseBody( + WithAddedEvent("\n public event Action First, Second;"), + "First?.Invoke(value);\n Second?.Invoke(value);"), + ReadOnDisk(SubscriberFileName)); + + AssertEmittedThroughStore(result, PublisherTypeMetadataName, "RaiseExisting", PublisherTypeMetadataName + "::First"); + Assert.That(result.Output.shimSource, Does.Contain("\"" + PublisherTypeMetadataName + "::Second\"")); + } + + /// + /// What: subscribing to an added event through a null-conditional receiver is skipped, + /// because the store call has no receiver name to put there. + /// + [Test] + public async Task Classify_ConditionalReceiverSubscription_IsSkipped() + { + TransformWorkerClientResult result = await RunAsync( + WithAddedEvent(AddedEvent), + ReadOnDisk(SubscriberFileName).Replace(WireBody, " publisher?.Changed += Accept;", StringComparison.Ordinal)); + + AssertSkippedWith(result, "Wire", HotReloadWorkerReasonCode.EventConditionalReceiver); + } + + /// + /// What: '??=' on an added event is skipped with the added-field '??=' reason before the + /// rewrite is reached. + /// + [Test] + public async Task Classify_NullCoalescingAssignment_IsSkippedBeforeRewrite() + { + TransformWorkerClientResult result = await RunAsync( + WithRaiseBody(WithAddedEvent(AddedEvent), "Changed ??= RaiseExisting;"), + ReadOnDisk(SubscriberFileName)); + + AssertSkippedWith(result, "RaiseExisting", HotReloadWorkerReasonCode.AddedFieldCoalesceAssignment); + } + + /// + /// What: a parenthesized event on the left of '+=' is a subscription like the bare one. + /// + [Test] + public async Task Rewrite_ParenthesizedSubscription_IsTreatedAsSubscription() + { + TransformWorkerClientResult result = await RunAsync( + WithRaiseBody(WithAddedEvent(AddedEvent), "(Changed) += RaiseExisting;"), + ReadOnDisk(SubscriberFileName)); + + AssertEmittedThroughStore(result, PublisherTypeMetadataName, "RaiseExisting", ChangedKey); + Assert.That(result.Output.shimSource, Does.Contain("Delegate.Combine")); + } + + /// + /// What: a nested type's body that raises and subscribes to its outer type's added event + /// uses the outer type's key, not the nested type it is emitted from. + /// + [Test] + public async Task Rewrite_NestedTypeUsesOuterAddedEvent_UsesTheOuterKey() + { + string publisher = ReplaceInSource( + WithAddedEvent(AddedEvent), + InnerRaiseBodyAnchor, + " HotReloadAddedEventPublisher outer = new HotReloadAddedEventPublisher();\n" + + " outer.Changed += InnerExisting;\n" + + " outer.Changed?.Invoke(value);"); + TransformWorkerClientResult result = await RunAsync(publisher, ReadOnDisk(SubscriberFileName)); + + AssertEmittedThroughStore(result, PublisherTypeMetadataName + "/Inner", "RaiseInnerExisting", ChangedKey); + } + + /// + /// What: raising and subscribing inside a lambda and a local function use the store too. + /// + [Test] + public async Task Rewrite_UseInsideLambda_UsesTheStore() + { + TransformWorkerClientResult result = await RunAsync( + WithRaiseBody( + WithAddedEvent(AddedEvent), + "Action raise = () => Changed?.Invoke(value);\n raise();\n" + + " void Wire()\n {\n Changed += RaiseExisting;\n }\n\n" + + " Wire();"), + ReadOnDisk(SubscriberFileName)); + + AssertEmittedThroughStore(result, PublisherTypeMetadataName, "RaiseExisting", ChangedKey); + } + + /// + /// What: a compiled property's getter and an added property's getter that raise the added + /// event are emitted through the store, since the getter paths ask the same question. + /// + [TestCase(true)] + [TestCase(false)] + public async Task Rewrite_RaiseFromPropertyGetter_UsesTheStore(bool compiledProperty) + { + string property = compiledProperty + ? " public int Count\n {\n get\n {\n" + + " Changed?.Invoke(1);\n return 1;\n }\n }" + : CountAnchor + "\n\n public int AddedCount\n {\n get\n {\n" + + " Changed?.Invoke(2);\n return 2;\n }\n }"; + TransformWorkerClientResult result = await RunAsync( + ReplaceInSource(WithAddedEvent(AddedEvent), CountAnchor, property), + ReadOnDisk(SubscriberFileName)); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + string getterName = compiledProperty ? "get_Count" : "get_AddedCount"; + Assert.That(FindSkipped(result, getterName), Is.Null, "Unexpected skip.\n" + FormatSkipped(result)); + Assert.That(result.Output.shimSource, Does.Contain("\"" + ChangedKey + "\""), FormatSkipped(result)); + } + + /// + /// What: subscribing through a receiver that may have side effects is skipped, because the + /// store write reads and writes the receiver twice. + /// + [Test] + public async Task Classify_SubscriptionThroughAnInvocationReceiver_IsSkippedForDoubleEvaluation() + { + TransformWorkerClientResult result = await RunAsync( + WithAddedEvent(AddedEvent), + ReadOnDisk(SubscriberFileName).Replace(WireBody, " publisher.Self().Changed += Accept;", StringComparison.Ordinal)); + + AssertSkippedWith(result, "Wire", HotReloadWorkerReasonCode.AddedFieldDoubleEvalReceiver); + } + + /// + /// What: subscribing through a field or a local is applied, since reading either twice has + /// no side effect; this is the shape most subscriptions take. + /// + [TestCase("_publisher.Changed += Accept;")] + [TestCase("HotReloadAddedEventPublisher local = publisher;\n local.Changed += Accept;")] + public async Task Rewrite_SubscriptionThroughAFieldOrLocal_IsApplied(string body) + { + TransformWorkerClientResult result = await RunAsync( + WithAddedEvent(AddedEvent), + ReadOnDisk(SubscriberFileName).Replace(WireBody, " " + body, StringComparison.Ordinal)); + + AssertEmittedThroughStore(result, SubscriberTypeMetadataName, "Wire", ChangedKey); + } + + private static void AssertEmittedThroughStore( + TransformWorkerClientResult result, + string typeMetadataName, + string methodName, + string storeKey) + { + Assert.That(result.Success, Is.True, result.ErrorMessage); + Assert.That(FindSkipped(result, methodName), Is.Null, "Unexpected skip.\n" + FormatSkipped(result)); + Assert.That( + FindEntry(result, typeMetadataName, methodName), + Is.Not.Null, + "Missing entry.\n" + FormatSkipped(result)); + Assert.That(result.Output.shimSource, Does.Contain("\"" + storeKey + "\"")); + Assert.That(result.Output.shimSource, Does.Contain("HotReloadAddedFieldStore")); + } + + private static void AssertSkippedWith( + TransformWorkerClientResult result, + string methodName, + HotReloadWorkerReasonCode code) + { + Assert.That(result.Success, Is.True, result.ErrorMessage); + TransformWorkerSkippedDto skipped = FindSkipped(result, methodName); + Assert.That(skipped, Is.Not.Null, "Missing skipped row for " + methodName + ".\n" + FormatSkipped(result)); + Assert.That(skipped.reason.code, Is.EqualTo(code), HotReloadWorkerReasonText.Render(skipped.reason)); + } + + private static string WithAddedEvent(string addedEvent) + { + return ReplaceInSource(ReadOnDisk(PublisherFileName), PublisherEventAnchor, PublisherEventAnchor + addedEvent); + } + + private static string WithRaiseBody(string publisher, string body) + { + return ReplaceInSource(publisher, RaiseBodyAnchor, " " + body); + } + + private static string WithSubscriberMethod(string method) + { + return ReplaceInSource( + ReadOnDisk(SubscriberFileName), + SubscriberMethodAnchor, + SubscriberMethodAnchor + "\n\n " + method); + } + + private static string ReplaceInSource(string source, string anchor, string replacement) + { + Assert.That(source, Does.Contain(anchor), "Precondition: anchor must exist: " + anchor); + return source.Replace(anchor, replacement, StringComparison.Ordinal); + } + + private static string ReadOnDisk(string fileName) + { + string path = Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", fileName); + Assert.That(File.Exists(path), Is.True, "Fixture missing: " + path); + return File.ReadAllText(path); + } + + private static TransformWorkerEntryDto FindEntry( + TransformWorkerClientResult result, + string typeMetadataName, + string methodName) + { + foreach (TransformWorkerEntryDto entry in result.Output.entries) + { + if (entry.typeMetadataName == typeMetadataName && entry.methodName == methodName) + { + return entry; + } + } + + return null; + } + + private static TransformWorkerSkippedDto FindSkipped(TransformWorkerClientResult result, string methodName) + { + foreach (TransformWorkerSkippedDto skipped in result.Output.skipped) + { + if (skipped.method != null && skipped.method.Contains("." + methodName + "(")) + { + return skipped; + } + } + + return null; + } + + private static string FormatSkipped(TransformWorkerClientResult result) + { + List lines = new List(); + foreach (TransformWorkerSkippedDto skipped in result.Output.skipped) + { + lines.Add(skipped.method + " :: " + HotReloadWorkerReasonText.Render(skipped.reason)); + } + + return string.Join("\n", lines); + } + + // Both files are written edited under the test sources directory and sent under their + // project-relative paths, with the source on disk as the snapshot the worker diffs against. + private static async Task RunAsync(string editedPublisher, string editedSubscriber) + { + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string targetDllPath = Path.Combine(projectRoot, "Library", "ScriptAssemblies", TestAssemblyName + ".dll"); + Assert.That(File.Exists(targetDllPath), Is.True, "Test assembly dll missing: " + targetDllPath); + UnityEditor.Compilation.Assembly compilationAssembly = FindCompilationAssembly(); + + TransformWorkerInputDto input = new TransformWorkerInputDto + { + sources = new[] + { + CreateSource(PublisherFileName, editedPublisher), + CreateSource(SubscriberFileName, editedSubscriber) + }, + defines = compilationAssembly.defines ?? Array.Empty(), + referencePaths = BuildAbsoluteReferencePaths(compilationAssembly.allReferences, targetDllPath), + targetTypesAssemblyPath = targetDllPath, + assemblySourcePaths = BuildAbsoluteAssemblySourcePaths(projectRoot, compilationAssembly.sourceFiles), + excludedMethodKeys = Array.Empty(), + excludedAddedMethodKeys = Array.Empty() + }; + + return await HotReloadCompositionRoot.Services.TransformWorkerClient.RunAsync(input, CancellationToken.None); + } + + private static TransformWorkerSourceDto CreateSource(string fileName, string editedSource) + { + return new TransformWorkerSourceDto + { + sourcePath = HotReloadTestSourceWriter.WriteEditedSource("AddedEvent_" + fileName, editedSource), + projectRelativePath = "Assets/Tests/Editor/HotReload/" + fileName, + snapshotSource = ReadOnDisk(fileName) + }; + } + + private static UnityEditor.Compilation.Assembly FindCompilationAssembly() + { + foreach (UnityEditor.Compilation.Assembly assembly in CompilationPipeline.GetAssemblies()) + { + if (assembly.name == TestAssemblyName) + { + return assembly; + } + } + + Assert.Fail("CompilationPipeline assembly not found."); + return null; + } + + private static string[] BuildAbsoluteReferencePaths(string[] allReferences, string targetDllPath) + { + List paths = new List(); + foreach (string reference in allReferences ?? Array.Empty()) + { + if (!string.IsNullOrEmpty(reference) && File.Exists(reference)) + { + paths.Add(Path.GetFullPath(reference)); + } + } + + string fullTarget = Path.GetFullPath(targetDllPath); + if (!paths.Contains(fullTarget)) + { + paths.Add(fullTarget); + } + + return paths.ToArray(); + } + + private static string[] BuildAbsoluteAssemblySourcePaths(string projectRoot, string[] sourceFiles) + { + List paths = new List(); + foreach (string sourceFile in sourceFiles ?? Array.Empty()) + { + string relative = sourceFile.Replace('\\', '/').Replace('/', Path.DirectorySeparatorChar); + paths.Add(Path.GetFullPath(Path.Combine(projectRoot, relative))); + } + + return paths.ToArray(); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs.meta b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs.meta new file mode 100644 index 000000000..b30cc679a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4f4f4717de3954b3c984df8b1f354ab2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerEventAccessorTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerEventAccessorTests.cs index a021022d1..4df819a36 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerEventAccessorTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerEventAccessorTests.cs @@ -302,10 +302,10 @@ public async Task Skip_EventWithNonVisibleDelegateType_ReportsDelegateTypeReason /// /// What: an event declared in this edit has no backing field in the compiled assembly, so - /// the raiser is skipped with a reason that names compiling as the way forward. + /// the raiser reads it from the added-field store instead of a backing-field accessor. /// [Test] - public async Task Skip_EventAddedInThisEdit_ReportsMissingCompiledBackingField() + public async Task Rewrite_EventAddedInThisEdit_RaisesThroughTheAddedFieldStore() { string onDisk = File.ReadAllText(ResolveHostPath()); string edited = onDisk.Replace( @@ -323,10 +323,10 @@ public async Task Skip_EventAddedInThisEdit_ReportsMissingCompiledBackingField() HostProjectRelativePath, snapshotSource: onDisk); Assert.That(result.Success, Is.True, result.ErrorMessage); - AssertHasSkip( - result, - nameof(HotReloadEventAccessorHost.RaiseScored), - "the compiled assembly has no backing field yet"); + Assert.That(FindEntry(result, nameof(HotReloadEventAccessorHost.RaiseScored)), Is.Not.Null); + Assert.That(result.Output.shimSource, Does.Contain("HotReloadAddedFieldStore")); + Assert.That(result.Output.shimSource, Does.Contain("::AddedScored\"")); + Assert.That(result.Output.shimSource, Does.Not.Contain("AddedScored?.Invoke")); } /// diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeStubTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeStubTests.cs index 47ba95baa..644f23a52 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeStubTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeStubTests.cs @@ -195,11 +195,11 @@ public async Task PrepareIntroducedTypes_AddedFieldAndProperty_AreStubbed() } /// - /// A body that uses an added event is not stubbed, even when it also calls an added method: - /// the transform never patches such a body, so a stub would stay in place. + /// A body that only subscribes to an added event is stubbed like one that uses an added + /// field: the transform patches it through the added-field store. /// [Test] - public async Task PrepareIntroducedTypes_BodyUsingAnAddedEvent_IsNotStubbed() + public async Task PrepareIntroducedTypes_BodyUsingAnAddedEvent_IsStubbed() { TransformWorkerIntroducedTypeDto introducedType = await PrepareSingleTypeAsync( "AddedEvent", @@ -212,13 +212,13 @@ public async Task PrepareIntroducedTypes_BodyUsingAnAddedEvent_IsNotStubbed() + " {\n" + " HotReloadCrossFileAddedMemberHost host = new HotReloadCrossFileAddedMemberHost();\n" + " host.AddedEvent += () => { };\n" - + " return host.AddedValue();\n" + + " return 1;\n" + " }\n" + " }\n" + "}\n"); - Assert.That(introducedType.stubbedMethodKeys, Is.Empty); - Assert.That(introducedType.source, Does.Contain("host.AddedEvent += () => { };")); + Assert.That(introducedType.stubbedMethodKeys, Is.EqualTo(new[] { "Example.Stubs.Caller::Subscribes()" })); + Assert.That(introducedType.source, Does.Not.Contain("host.AddedEvent += () => { };")); } /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs index 58d8f4c66..47864ddae 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs @@ -149,7 +149,7 @@ private static void AddAddedMemberTemplates( templates.Add( HotReloadWorkerReasonCode.AddedFieldDoubleEvalReceiver, Plain( - "Assignment to an added field would evaluate a receiver with possible side effects twice.", + "Assignment to an added field or event would evaluate a receiver with possible side effects twice.", 0).EndingWith(CompileCallToAction)); templates.Add( HotReloadWorkerReasonCode.AddedFieldDeconstructionTarget, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorAccessRegistrar.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorAccessRegistrar.cs index 0e2aa6429..3ada5871b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorAccessRegistrar.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorAccessRegistrar.cs @@ -331,6 +331,12 @@ internal static bool TryRegisterAssignment( if (leftSymbol is IEventSymbol eventSymbol) { + // An added event the store keeps is written through the store, never a backing field. + if (addedMemberAccess != null && addedMemberAccess.IsStoreBackedEvent(eventSymbol)) + { + return false; + } + if (EventAccessorRules.IsSubscriptionAssignment(assignment)) { // += / -= stay on the publicized add/remove accessors, which keep the diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs index 635693204..577f62b89 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs @@ -37,6 +37,13 @@ internal static bool TryRegisterPropertyOrFieldRead( if (symbol is IEventSymbol eventSymbol) { + // An added event the store keeps has no backing field to reach; its reads become + // store calls any assembly can compile. + if (addedMemberAccess != null && addedMemberAccess.IsStoreBackedEvent(eventSymbol)) + { + return false; + } + plan.GetOrAddEventBackingField(eventSymbol); return true; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedCallSiteGuard.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedCallSiteGuard.cs index 0f5306473..e49e22c06 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedCallSiteGuard.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedCallSiteGuard.cs @@ -77,6 +77,7 @@ internal static void SkipBodiesThatCannotUseAddedMethods( continue; } + AddedEventStorePolicy.RequireBindings(bodyNode, semanticModel, typeState.AddedEvents, addedFieldCatalog); remaining.Add(queued); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventLookup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventLookup.cs index ce4c1dd28..7881b50c8 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventLookup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventLookup.cs @@ -46,6 +46,19 @@ internal bool IsAddedInThisEdit(IEventSymbol eventSymbol) return true; } + /// Whether an event's uses are rewritten to the added-field store. + internal bool IsStoreBacked(IEventSymbol eventSymbol) + { + if (eventSymbol.DeclaringSyntaxReferences.IsEmpty) + { + return false; + } + + return AddedEventStorePolicy.IsStoreBacked( + eventSymbol, + FindCompiledDeclaringType(eventSymbol.ContainingType.OriginalDefinition)); + } + // Why not the subscribing type's compiled counterpart and its assembly: when a retained // artifact serves the subscribing type, that assembly is the artifact, which never holds a // type the patch target compiled. The declaring type is looked up where it is served instead: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventStorePolicy.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventStorePolicy.cs new file mode 100644 index 000000000..26ca78417 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventStorePolicy.cs @@ -0,0 +1,93 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +/// +/// Decides whether an event this edit adds keeps its delegate in the added-field store, so its +/// uses are rewritten to the store instead of being refused. +/// +/// +/// Why the symbol and the compiled declaring type only, never a catalog: the catalogs fill one +/// type at a time, and a body of another type is decided before the declaring type is classified +/// when its file comes first, so a catalog answer would change with the order of the files. +/// +internal static class AddedEventStorePolicy +{ + internal static bool IsStoreBacked(IEventSymbol eventSymbol, INamedTypeSymbol compiledDeclaringType) + { + // An event read from metadata belongs to an assembly the edit does not compile, and a + // declaring type the Editor does not hold yet is decided by the introduced-type paths. + if (eventSymbol.DeclaringSyntaxReferences.IsEmpty || compiledDeclaringType == null) + { + return false; + } + + if (!IsFieldLike(eventSymbol) || !HasStoreHost(eventSymbol.ContainingType)) + { + return false; + } + + if (!AccessibilityRules.IsExternallyVisibleType(eventSymbol.Type) + || !AccessibilityRules.IsExternallyVisibleType(eventSymbol.ContainingType)) + { + return false; + } + + // Why any member of the name and not only another event: a compiled field or method of + // that name is what compiled callers still bind to, so the store would hide the change. + return compiledDeclaringType.GetMembers(eventSymbol.Name).IsEmpty; + } + + /// + /// Stops the run when a body about to be emitted uses a store-backed event that has no store + /// binding: the body was let through on the promise of the store rewrite, and without the + /// binding the raw event access would reach the shim and bind to a member that does not exist. + /// + internal static void RequireBindings( + SyntaxNode bodyNode, + SemanticModel semanticModel, + AddedEventLookup addedEvents, + AddedFieldCatalog addedFieldCatalog) + { + foreach (SyntaxNode node in bodyNode.DescendantNodesAndSelf()) + { + if (node is not SimpleNameSyntax + || semanticModel.GetSymbolInfo(node).Symbol is not IEventSymbol eventSymbol + || !addedEvents.IsStoreBacked(eventSymbol)) + { + continue; + } + + if (addedFieldCatalog.FindOrNull(AddedFieldBodyScan.FormatAddedStoreKeyOrNull(eventSymbol)) == null) + { + throw new System.InvalidOperationException( + "Added event '" + eventSymbol.ToDisplayString() + + "' is store-backed but its declaring type registered no store binding."); + } + } + } + + private static bool IsFieldLike(IEventSymbol eventSymbol) + { + if (eventSymbol.IsAbstract || eventSymbol.IsExtern) + { + return false; + } + + foreach (SyntaxReference reference in eventSymbol.DeclaringSyntaxReferences) + { + if (reference.GetSyntax() is EventDeclarationSyntax) + { + return false; + } + } + + return true; + } + + // Why a struct is let through: its event gets a binding the classifier marks unavailable, so + // a use is skipped with the reason an added struct field gets rather than an event reason. + private static bool HasStoreHost(INamedTypeSymbol containingType) + { + return containingType.TypeKind == TypeKind.Class || containingType.TypeKind == TypeKind.Struct; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldBinding.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldBinding.cs index 239db016c..e903f4c44 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldBinding.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldBinding.cs @@ -36,6 +36,9 @@ internal sealed class AddedFieldBinding public bool IsConst { get; set; } + // A field-like event kept in the store: '+=' and '-=' combine delegates instead of adding. + public bool IsEvent { get; set; } + // Why only recorded, never warned about here: whether the field ever becomes active is // decided by the Editor's apply, so the Editor is what names it. public bool HasSerializationAttribute { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldBodyScan.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldBodyScan.cs index e7bede011..ad5bb076a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldBodyScan.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldBodyScan.cs @@ -29,13 +29,14 @@ internal static WorkerReason BodyReferencesUnavailableAddedField( continue; } - IFieldSymbol field = TryGetFieldSymbolOrCandidate(semanticModel, node); - if (field == null) + string storeKey = FormatAddedStoreKeyOrNull(TryGetFieldSymbolOrCandidate(semanticModel, node)) + ?? FormatAddedStoreKeyOrNull(semanticModel.GetSymbolInfo(node).Symbol as IEventSymbol); + if (storeKey == null) { continue; } - AddedFieldBinding binding = addedFieldCatalog.FindOrNull(FormatAddedFieldKeyFromSymbol(field)); + AddedFieldBinding binding = addedFieldCatalog.FindOrNull(storeKey); if (binding != null && binding.UnavailableReason != null) { return binding.UnavailableReason; @@ -322,19 +323,14 @@ internal static bool IsIncrementOrDecrement(SyntaxKind kind) || kind == SyntaxKind.PostDecrementExpression; } + // Fields and field-like events alike: an added event the store keeps is written and read + // through the same store calls, so it is refused for the same shapes. internal static bool IsStoreAddedField( SemanticModel semanticModel, ExpressionSyntax expression, AddedFieldCatalog addedFieldCatalog) { - IFieldSymbol field = TryGetFieldSymbol(semanticModel, expression); - if (field == null) - { - return false; - } - - AddedFieldBinding binding = addedFieldCatalog.FindOrNull(FormatAddedFieldKeyFromSymbol(field)); - return binding != null && binding.IsStoreRewriteable; + return FindStoreBinding(semanticModel, expression, addedFieldCatalog) != null; } internal static bool IsStoreAddedInstanceField( @@ -342,14 +338,25 @@ internal static bool IsStoreAddedInstanceField( ExpressionSyntax expression, AddedFieldCatalog addedFieldCatalog) { - IFieldSymbol field = TryGetFieldSymbol(semanticModel, expression); - if (field == null || field.IsStatic) + AddedFieldBinding binding = FindStoreBinding(semanticModel, expression, addedFieldCatalog); + return binding != null && !binding.IsStatic; + } + + private static AddedFieldBinding FindStoreBinding( + SemanticModel semanticModel, + ExpressionSyntax expression, + AddedFieldCatalog addedFieldCatalog) + { + if (expression == null) { - return false; + return null; } - AddedFieldBinding binding = addedFieldCatalog.FindOrNull(FormatAddedFieldKeyFromSymbol(field)); - return binding != null && binding.IsStoreRewriteable; + // Why unparenthesized: '(E) += h' writes E, and the symbol of the parentheses is not E's. + ExpressionSyntax target = AssignmentTargetRules.Unparenthesized(expression); + AddedFieldBinding binding = addedFieldCatalog.FindOrNull( + FormatAddedStoreKeyOrNull(semanticModel.GetSymbolInfo(target).Symbol)); + return binding != null && binding.IsStoreRewriteable ? binding : null; } internal static bool IsStoreAddedValueTypeField( @@ -406,6 +413,27 @@ internal static IFieldSymbol TryGetFieldSymbolOrCandidate( return null; } + /// + /// The store key of an added field or field-like event, or null for any other symbol. Both + /// are keyed by the declaring type's metadata name, whichever type the use is emitted from. + /// + internal static string FormatAddedStoreKeyOrNull(ISymbol symbol) + { + if (symbol is IFieldSymbol fieldSymbol) + { + return FormatAddedFieldKeyFromSymbol(fieldSymbol); + } + + if (symbol is IEventSymbol eventSymbol && eventSymbol.ContainingType != null) + { + return AddedFieldClassifier.FormatAddedFieldStoreKey( + CecilTypeNames.ToMetadataName(eventSymbol.ContainingType.OriginalDefinition), + eventSymbol.Name); + } + + return null; + } + internal static string FormatAddedFieldKeyFromSymbol(IFieldSymbol fieldSymbol) { if (fieldSymbol.ContainingType == null) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldClassifier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldClassifier.cs index 801786d94..10073c64b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldClassifier.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldClassifier.cs @@ -47,6 +47,69 @@ internal static void ClassifyAddedFields( fieldMatch); } } + + ClassifyAddedEvents(typeState, semanticModel, home, addedFieldCatalog); + } + + /// + /// What: registers a store binding for each field-like event the store keeps, under the key an + /// added field of that name would get, so every use of it is rewritten like an added field. + /// + private static void ClassifyAddedEvents( + TypeEmitState typeState, + SemanticModel semanticModel, + WorkerTypeHome home, + AddedFieldCatalog addedFieldCatalog) + { + foreach (EventFieldDeclarationSyntax eventDeclaration in typeState.TypeDeclaration.Members + .OfType()) + { + foreach (VariableDeclaratorSyntax variable in eventDeclaration.Declaration.Variables) + { + IEventSymbol eventSymbol = semanticModel.GetDeclaredSymbol(variable) as IEventSymbol; + if (eventSymbol == null || !typeState.AddedEvents.IsStoreBacked(eventSymbol)) + { + continue; + } + + ClassifyOneAddedEvent(typeState, semanticModel, home, variable, eventSymbol, addedFieldCatalog); + } + } + } + + // Why a binding even when the store cannot hold the event: the use sites were already let + // through as store-backed, and the binding's reason is what the guard stage skips them with. + private static void ClassifyOneAddedEvent( + TypeEmitState typeState, + SemanticModel semanticModel, + WorkerTypeHome home, + VariableDeclaratorSyntax variable, + IEventSymbol eventSymbol, + AddedFieldCatalog addedFieldCatalog) + { + string syntaxKey = WorkerSyntaxIndex.BuildSyntaxFieldKey(typeState.TypeMetadataNameFromSyntax, eventSymbol.Name); + addedFieldCatalog.AddAddedSyntaxKey(syntaxKey); + AddedFieldBinding binding = new AddedFieldBinding + { + SourceProjectRelativePath = typeState.SourceUnit.Input.ProjectRelativePath, + FieldKey = AddedFieldBodyScan.FormatAddedStoreKeyOrNull(eventSymbol), + SyntaxKey = syntaxKey, + FieldName = eventSymbol.Name, + FieldType = eventSymbol.Type, + IsStatic = eventSymbol.IsStatic, + IsEvent = true, + Initializer = variable.Initializer != null ? variable.Initializer.Value : null + }; + AddedFieldStoreAvailability availability = EvaluateStoreAvailability( + typeState.TypeSymbol, + semanticModel, + home, + eventSymbol.Type, + binding.Initializer, + typeState.SourceUnit, + out ITypeSymbol unresolvedStoreType); + binding.UnavailableReason = DescribeStoreAvailability(availability, unresolvedStoreType, eventSymbol.Name); + addedFieldCatalog.Register(binding); } internal static void ClassifyOneAddedField( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldShimRewrite.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldShimRewrite.cs index 76e7d3439..13be8448e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldShimRewrite.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldShimRewrite.cs @@ -24,15 +24,11 @@ internal AddedFieldShimRewrite(ShimBodyRewriter rewriter) _rewriter = rewriter; } + // Fields and the field-like events the store keeps share the bindings and the store calls. internal AddedFieldBinding FindStoreBinding(ISymbol symbol) { - if (symbol is not IFieldSymbol fieldSymbol) - { - return null; - } - AddedFieldBinding binding = _rewriter._addedFieldCatalog.FindOrNull( - AddedFieldBodyScan.FormatAddedFieldKeyFromSymbol(fieldSymbol)); + AddedFieldBodyScan.FormatAddedStoreKeyOrNull(symbol)); if (binding == null || !binding.IsStoreRewriteable) { return null; @@ -43,13 +39,7 @@ internal AddedFieldBinding FindStoreBinding(ISymbol symbol) internal AddedFieldBinding FindAnyAddedBinding(ISymbol symbol) { - if (symbol is not IFieldSymbol fieldSymbol) - { - return null; - } - - return _rewriter._addedFieldCatalog.FindOrNull( - AddedFieldBodyScan.FormatAddedFieldKeyFromSymbol(fieldSymbol)); + return _rewriter._addedFieldCatalog.FindOrNull(AddedFieldBodyScan.FormatAddedStoreKeyOrNull(symbol)); } internal SyntaxNode TryRewriteAddedFieldRead( @@ -104,8 +94,17 @@ internal SyntaxNode RewriteAddedFieldAssignment( return CreateAddedFieldSet(binding, receiver, visitedRight).WithTriviaFrom(node); } - SyntaxKind binaryKind = ShimBodyRewriter.GetCompoundAssignmentBinaryKind(node.Kind()); ExpressionSyntax getCall = CreateAddedFieldGetOrInit(binding, receiver); + if (binding.IsEvent) + { + return CreateAddedFieldSet( + binding, + receiver, + CombineDelegates(node.Kind(), getCall, visitedRight, binding.FieldType)) + .WithTriviaFrom(node); + } + + SyntaxKind binaryKind = ShimBodyRewriter.GetCompoundAssignmentBinaryKind(node.Kind()); ExpressionSyntax combined = CombineCompoundOperands(binaryKind, getCall, visitedRight); return CreateAddedFieldSet( binding, @@ -137,6 +136,30 @@ internal SyntaxNode RewriteAddedFieldIncrement( .WithTriviaFrom(triviaSource); } + // Why Delegate.Combine and not '+': a lambda or a method group has no type of its own, and C# + // only converts it to the event's delegate type on '+=' to an event, never on a binary '+'. + // The handler is cast to that type so either converts, and the result is cast back because + // Combine and Remove return Delegate. + private static ExpressionSyntax CombineDelegates( + SyntaxKind assignmentKind, + ExpressionSyntax getCall, + ExpressionSyntax handler, + ITypeSymbol delegateType) + { + string methodName = assignmentKind == SyntaxKind.SubtractAssignmentExpression ? "Remove" : "Combine"; + ExpressionSyntax combineAccess = SyntaxFactory.ParseExpression("global::System.Delegate." + methodName); + InvocationExpressionSyntax combined = SyntaxFactory.InvocationExpression( + combineAccess, + SyntaxFactory.ArgumentList( + SyntaxFactory.SeparatedList( + new[] + { + SyntaxFactory.Argument(getCall), + SyntaxFactory.Argument(CastToAssignedType(handler, delegateType)) + }))); + return CastToAssignedType(combined, delegateType); + } + internal static bool IsDecrementNode(SyntaxNode node) { if (node is PrefixUnaryExpressionSyntax prefix) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldSkipEvaluator.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldSkipEvaluator.cs index e6944a041..352744393 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldSkipEvaluator.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedFieldSkipEvaluator.cs @@ -305,6 +305,13 @@ internal static bool HasDisallowedInitializerSymbol( return false; } + // Why an anonymous function is let through: it is not a member of the host but a value + // the static lambda creates, and every name inside its body is checked on its own node. + if (symbol is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction }) + { + return false; + } + if (!symbol.IsStatic) { return true; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedMemberAccessLookup.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedMemberAccessLookup.cs index 8e30a9989..f21d081bf 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedMemberAccessLookup.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedMemberAccessLookup.cs @@ -11,11 +11,13 @@ internal sealed class AddedMemberAccessLookup private readonly INamedTypeSymbol _typeSymbol; private readonly INamedTypeSymbol _compiledType; private readonly AddedPropertyCatalog _addedPropertyCatalog; + private readonly AddedEventLookup _addedEvents; public AddedMemberAccessLookup( INamedTypeSymbol typeSymbol, INamedTypeSymbol compiledType, - AddedPropertyCatalog addedPropertyCatalog) + AddedPropertyCatalog addedPropertyCatalog, + AddedEventLookup addedEvents) { if (typeSymbol == null) { @@ -27,7 +29,13 @@ public AddedMemberAccessLookup( throw new ArgumentNullException(nameof(addedPropertyCatalog)); } + if (addedEvents == null) + { + throw new ArgumentNullException(nameof(addedEvents)); + } + _typeSymbol = typeSymbol; + _addedEvents = addedEvents; _compiledType = compiledType; _addedPropertyCatalog = addedPropertyCatalog; } @@ -60,4 +68,11 @@ public bool IsAddedProperty(IPropertySymbol propertySymbol) return _addedPropertyCatalog.FindBySymbolOrNull(propertySymbol) != null; } + + // Why any declaring type, unlike the property question: the event answer comes from the + // compiled declaring type alone, which every type can ask regardless of classification order. + public bool IsStoreBackedEvent(IEventSymbol eventSymbol) + { + return eventSymbol != null && _addedEvents.IsStoreBacked(eventSymbol); + } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedMemberReferenceClassifier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedMemberReferenceClassifier.cs index 081e69a38..abf1bf789 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedMemberReferenceClassifier.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedMemberReferenceClassifier.cs @@ -36,9 +36,10 @@ internal AddedMemberReferenceClassifier( } /// - /// Event when any name in the bodies is an added event, otherwise whether any names an added - /// method, field or property. An added event wins because a body that uses one is never - /// transformed, so it has to keep failing the way it fails without a stub. + /// Event when any name in the bodies is an added event the store cannot keep, otherwise + /// whether any names an added method, field, property, or store-kept event. Such an event + /// wins because a body that uses one is never transformed, so it has to keep failing the way + /// it fails without a stub. /// internal AddedMemberUse Classify(IReadOnlyList bodyNodes, SemanticModel semanticModel) { @@ -135,8 +136,15 @@ private static AddedMemberUse ClassifyAgainstExistingType(ISymbol definition, IN ? AddedMemberUse.None : AddedMemberUse.MethodsFieldsOrProperties; case IEventSymbol addedEvent: - return HoldsMemberOfKind(existingType, addedEvent.Name, SymbolKind.Event) - ? AddedMemberUse.None + if (HoldsMemberOfKind(existingType, addedEvent.Name, SymbolKind.Event)) + { + return AddedMemberUse.None; + } + + // An event the store keeps is patched like an added field, so the body is + // stubbed as one; any other added event keeps failing the way it does unstubbed. + return AddedEventStorePolicy.IsStoreBacked(addedEvent, existingType) + ? AddedMemberUse.MethodsFieldsOrProperties : AddedMemberUse.Event; default: return AddedMemberUse.None; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/EventAccessorRules.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/EventAccessorRules.cs index bec16db32..60ee03e51 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/EventAccessorRules.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/EventAccessorRules.cs @@ -23,9 +23,9 @@ internal static class EventAccessorRules { /// - /// What: the skip reason for a body's event uses, or null when every use is either a - /// subscription (+= / -=) to an event the compiled assembly already has, or rewritable - /// through the backing field. + /// What: the skip reason for a body's event uses, or null when every use is a subscription + /// (+= / -=) to an event the compiled assembly already has, rewritable through the backing + /// field, or a use of an added event the added-field store keeps. /// internal static WorkerReason EvaluateEventUseSkipReason( SyntaxNode bodyNode, @@ -35,38 +35,9 @@ internal static WorkerReason EvaluateEventUseSkipReason( { foreach (EventUse use in EnumerateEventUses(bodyNode, semanticModel)) { - if (use.IsSubscription) - { - if (addedEvents.IsAddedInThisEdit(use.EventSymbol)) - { - return WorkerReason.Of( - HotReloadWorkerReasonCode.EventSubscriptionToAddedEvent, - use.EventSymbol.ContainingType.ToDisplayString() + "." + use.EventSymbol.Name); - } - - continue; - } - - if (NameofRules.IsInsideNameofArgument(use.Node)) - { - return WorkerReason.Of(HotReloadWorkerReasonCode.EventNameof); - } - - // 'a?.E' binds the event on a receiver the shim has no name for, so the accessor - // call cannot be built and the raw event access would reach the shim source. - if (use.Node is MemberBindingExpressionSyntax) - { - return WorkerReason.Of(HotReloadWorkerReasonCode.EventConditionalReceiver); - } - - // The rewrite turns the event read into a cast of an accessor call, and C# cannot - // pass that by reference, so the shim would fail to compile instead of skipping. - if (IsPassedByRef(use.Node)) - { - return WorkerReason.Of(HotReloadWorkerReasonCode.EventPassedByRef); - } - - WorkerReason reason = EvaluateEventSkipReason(use.EventSymbol, compiledType); + WorkerReason reason = addedEvents.IsStoreBacked(use.EventSymbol) + ? EvaluateStoreBackedUseSkipReason(use) + : EvaluateAccessorUseSkipReason(use, compiledType, addedEvents); if (reason != null) { return reason; @@ -78,13 +49,19 @@ internal static WorkerReason EvaluateEventUseSkipReason( /// /// What: whether the body needs the event rewrite, which forces delegation even when nothing - /// else in the body is inaccessible (a transplanted shim would not compile). + /// else in the body is inaccessible (a transplanted shim would not compile). An added event + /// the store keeps does not: its uses become store calls any assembly can compile. /// - internal static bool BodyRequiresEventAccessors(SyntaxNode bodyNode, SemanticModel semanticModel) + internal static bool BodyRequiresEventAccessors( + SyntaxNode bodyNode, + SemanticModel semanticModel, + AddedEventLookup addedEvents) { foreach (EventUse use in EnumerateEventUses(bodyNode, semanticModel)) { - if (!use.IsSubscription && !NameofRules.IsInsideNameofArgument(use.Node)) + if (!use.IsSubscription + && !NameofRules.IsInsideNameofArgument(use.Node) + && !addedEvents.IsStoreBacked(use.EventSymbol)) { return true; } @@ -93,6 +70,77 @@ internal static bool BodyRequiresEventAccessors(SyntaxNode bodyNode, SemanticMod return false; } + // The store rewrite covers subscribing, raising, reading, and assigning; what is left are the + // shapes it has no receiver or no variable for. + private static WorkerReason EvaluateStoreBackedUseSkipReason(EventUse use) + { + // 'a?.E' has no receiver name the store call could take, for a subscription as for a read. + if (use.Node is MemberBindingExpressionSyntax) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.EventConditionalReceiver); + } + + if (use.IsSubscription) + { + return null; + } + + if (NameofRules.IsInsideNameofArgument(use.Node)) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.EventNameof); + } + + // A store read is a call result, which C# cannot pass by reference. + if (IsPassedByRef(use.Node)) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.EventPassedByRef); + } + + return null; + } + + private static WorkerReason EvaluateAccessorUseSkipReason( + EventUse use, + INamedTypeSymbol compiledType, + AddedEventLookup addedEvents) + { + if (use.IsSubscription) + { + if (!addedEvents.IsAddedInThisEdit(use.EventSymbol)) + { + return null; + } + + // Why the visibility reason first: it is the one a reader can act on without a + // compile, while the added-event reason only says the event is new. + return DescribeInvisibleEvent(use.EventSymbol) + ?? WorkerReason.Of( + HotReloadWorkerReasonCode.EventSubscriptionToAddedEvent, + use.EventSymbol.ContainingType.ToDisplayString() + "." + use.EventSymbol.Name); + } + + if (NameofRules.IsInsideNameofArgument(use.Node)) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.EventNameof); + } + + // 'a?.E' binds the event on a receiver the shim has no name for, so the accessor + // call cannot be built and the raw event access would reach the shim source. + if (use.Node is MemberBindingExpressionSyntax) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.EventConditionalReceiver); + } + + // The rewrite turns the event read into a cast of an accessor call, and C# cannot + // pass that by reference, so the shim would fail to compile instead of skipping. + if (IsPassedByRef(use.Node)) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.EventPassedByRef); + } + + return EvaluateEventSkipReason(use.EventSymbol, compiledType); + } + /// /// What: whether an assignment writes the event's backing field (E = handler) rather than /// subscribing to it. += / -= keep the publicized add/remove accessors so the compiler's @@ -163,10 +211,10 @@ private static WorkerReason EvaluateEventSkipReason(IEventSymbol eventSymbol, IN return WorkerReason.Of(HotReloadWorkerReasonCode.EventCustomAccessor); } - if (!AccessibilityRules.IsExternallyVisibleType(eventSymbol.Type) - || !AccessibilityRules.IsExternallyVisibleType(eventSymbol.ContainingType)) + WorkerReason invisible = DescribeInvisibleEvent(eventSymbol); + if (invisible != null) { - return WorkerReason.Of(HotReloadWorkerReasonCode.EventDelegateTypeNotVisible); + return invisible; } if (!CompiledBackingFieldExists(eventSymbol, compiledType)) @@ -177,6 +225,17 @@ private static WorkerReason EvaluateEventSkipReason(IEventSymbol eventSymbol, IN return null; } + private static WorkerReason DescribeInvisibleEvent(IEventSymbol eventSymbol) + { + if (!AccessibilityRules.IsExternallyVisibleType(eventSymbol.Type) + || !AccessibilityRules.IsExternallyVisibleType(eventSymbol.ContainingType)) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.EventDelegateTypeNotVisible); + } + + return null; + } + private static bool HasCustomAccessors(IEventSymbol eventSymbol) { foreach (SyntaxReference reference in eventSymbol.DeclaringSyntaxReferences) @@ -272,9 +331,16 @@ private static IEnumerable EnumerateEventUses(SyntaxNode bodyNode, Sem effective = parentBinding; } - bool isSubscription = effective.Parent is AssignmentExpressionSyntax assignment + // Why parentheses are looked past: '(E) += h' subscribes as 'E += h' does. + SyntaxNode assignedSide = effective; + while (assignedSide.Parent is ParenthesizedExpressionSyntax parenthesized) + { + assignedSide = parenthesized; + } + + bool isSubscription = assignedSide.Parent is AssignmentExpressionSyntax assignment && IsSubscriptionAssignment(assignment) - && assignment.Left == effective; + && assignment.Left == assignedSide; yield return new EventUse(effective, eventSymbol, isSubscription); } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs index 148c336dc..9e7cc4fd7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs @@ -67,7 +67,7 @@ internal static MethodTransformDecision DecideMethodTransform( && InaccessibleAccessScanner.SubtreeHasInaccessibleMemberAccess(semanticModel, new[] { bodyNode }); // Why delegation is forced: a transplanted shim body still has to compile as C#, and C# // rejects raising or reading an event outside its declaring type whatever its visibility. - bool eventAccessorsRequired = EventAccessorRules.BodyRequiresEventAccessors(bodyNode, semanticModel); + bool eventAccessorsRequired = EventAccessorRules.BodyRequiresEventAccessors(bodyNode, semanticModel, addedEvents); if (!closureInaccessible && !asyncIteratorInaccessible && !eventAccessorsRequired) { diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimBodyRewriter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimBodyRewriter.cs index e0dd1057f..164f6a22b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimBodyRewriter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimBodyRewriter.cs @@ -297,7 +297,8 @@ public override SyntaxNode VisitAssignmentExpression(AssignmentExpressionSyntax return base.VisitAssignmentExpression(node); } - AddedFieldBinding assignedField = AddedFields.FindStoreBinding(_semanticModel.GetSymbolInfo(node.Left).Symbol); + AddedFieldBinding assignedField = AddedFields.FindStoreBinding( + _semanticModel.GetSymbolInfo(AssignmentTargetRules.Unparenthesized(node.Left)).Symbol); if (assignedField != null) { return AddedFields.RewriteAddedFieldAssignment(node, assignedField); diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TypeEmitPlanner.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TypeEmitPlanner.cs index 7f9ed73df..a479c521b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TypeEmitPlanner.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/TypeEmitPlanner.cs @@ -84,7 +84,8 @@ internal static List QueueAllTypeEmitStates( typeState.AddedMemberAccess = new AddedMemberAccessLookup( typeSymbol, typeState.CompiledType, - addedPropertyCatalog); + addedPropertyCatalog, + typeState.AddedEvents); // Existing property setters/init and all indexer accessors with bodies stay Skipped. // Added properties were classified above and must not receive duplicate skip rows. From 7cebb061c3569b340c384e77d7c0304152f4a408 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 20:39:25 +0900 Subject: [PATCH 04/26] feat(hot-reload): Warn when a reload re-declares an added field or event with another type The added-field store replaces a value the new declared type cannot hold, and an added event re-declared with another delegate type loses its subscribers, yet the response said nothing. The run now compares each added declaration's full type name (generic arguments included) with the one a previous reload committed and names the changed members in a warning. --- .../HotReload/HotReloadAddedEventE2ETests.cs | 15 ++- ...oadAddedFieldDeclaredTypeChangeE2ETests.cs | 121 ++++++++++++++++++ ...dedFieldDeclaredTypeChangeE2ETests.cs.meta | 11 ++ .../HotReload/HotReloadFileEntryApplier.cs | 66 ++++++---- .../Patching/HotReloadAddedFieldLedger.cs | 36 ++++++ .../Patching/HotReloadFileGeneration.cs | 12 ++ .../HotReload/Shared/HotReloadConstants.cs | 10 ++ 7 files changed, 246 insertions(+), 25 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs index 3b0e01bdb..01098cd2c 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs @@ -34,6 +34,10 @@ public class HotReloadAddedEventE2ETests private const string AddedEvent = " public event Action Changed;"; private const string RaiseChanged = " Changed?.Invoke(value);"; + // The sentence only the declared-type warning carries, so another warning naming the + // event cannot satisfy the pin. + private const string DeclaredTypeChangedToken = "with a different type"; + private HotReloadDomainTestScope _scope; [SetUp] @@ -186,14 +190,16 @@ public async Task Run_ReapplyUnchanged_KeepsSubscribers() AssertPatched(second, ".Raise("); target.Raise(8); Assert.That(listener.Received, Is.EqualTo(9), FormatOutcomes(second)); + Assert.That(second.Warnings ?? new List(), Has.None.Contains(DeclaredTypeChangedToken), FormatOutcomes(second)); } /// /// What: changing the added event's delegate type between runs drops the stored - /// subscribers, because the store resets a value of another type. + /// subscribers, because the store resets a value of another type, and the run warns that + /// it did, naming the event. A change of a generic argument alone counts as a change. /// [Test] - public async Task Run_ChangedDelegateType_DropsSubscribers() + public async Task Run_ChangedDelegateType_DropsSubscribersWithAWarning() { await RunAsync( EditPublisher(AddedEvent, RaiseChanged), @@ -209,6 +215,11 @@ await RunAsync( AssertPatched(second, ".Raise("); target.Raise(9); Assert.That(listener.Received, Is.EqualTo(0), FormatOutcomes(second)); + Assert.That( + second.Warnings ?? new List(), + Has.Some.Contains(DeclaredTypeChangedToken) + .And.Some.Contains(typeof(HotReloadAddedEventApplyPublisher).FullName + ".Changed"), + FormatOutcomes(second)); } /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs new file mode 100644 index 000000000..034e18638 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// End-to-end EditMode coverage for the warning a run gives when it re-declares an added field + /// a previous reload added with another type: the store replaces a value the new type cannot + /// hold, and nothing else in the response says the value is gone. + /// + public class HotReloadAddedFieldDeclaredTypeChangeE2ETests + { + private const string FixtureFileName = "HotReloadAddedFieldApplyFixture.cs"; + private const string ReadAddedOriginal = + " public int ReadAdded()\n {\n return 0;\n }"; + private const string DeclaredTypeChangedToken = "with a different type"; + + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _scope = new HotReloadDomainTestScope(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + VibeLogger.ClearMemoryLogs(); + } + + /// + /// What: re-declaring an added field with another type warns and names the field, and the + /// patched reader sees the new type's initial value rather than the old one. + /// + [Test] + public async Task Run_AddedFieldDeclaredTypeChanged_WarnsNamingTheField() + { + await RunAsync(WithAddedMember("private int _addedCount = 3;", "return _addedCount;")); + Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(3)); + + HotReloadOrchestratorResult second = await RunAsync( + WithAddedMember("private string _addedCount = \"ab\";", "return _addedCount.Length;")); + + Assert.That( + second.Warnings ?? new List(), + Has.Some.Contains(DeclaredTypeChangedToken) + .And.Some.Contains(typeof(HotReloadAddedFieldApplyFixture).FullName + "._addedCount"), + FormatOutcomes(second)); + } + + /// + /// What: a later run that keeps the added field's type and edits only the reader does not + /// warn, so the warning is not given for every re-declaration. + /// + [Test] + public async Task Run_AddedFieldDeclaredTypeKept_DoesNotWarn() + { + await RunAsync(WithAddedMember("private int _addedCount = 3;", "return _addedCount;")); + + HotReloadOrchestratorResult second = await RunAsync( + WithAddedMember("private int _addedCount = 3;", "return _addedCount + 1;")); + + Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(4), FormatOutcomes(second)); + Assert.That( + second.Warnings ?? new List(), + Has.None.Contains(DeclaredTypeChangedToken), + FormatOutcomes(second)); + } + + private static string WithAddedMember(string addedMember, string readAddedBody) + { + string onDisk = File.ReadAllText(FixturePath()); + Assert.That(onDisk, Does.Contain(ReadAddedOriginal), "Precondition: ReadAdded anchor must exist."); + return onDisk.Replace( + ReadAddedOriginal, + " " + addedMember + "\n\n" + + " public int ReadAdded()\n {\n " + readAddedBody + "\n }"); + } + + private static Task RunAsync(string edited) + { + return HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { FixturePath() }, + HotReloadTestSourceWriter.WriteEditedSource("AddedFieldDeclaredTypeChangeE2E.cs", edited), + CancellationToken.None); + } + + private static string FixturePath() + { + string path = Path.GetFullPath( + Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", FixtureFileName)); + Assert.That(File.Exists(path), Is.True, "Fixture missing: " + path); + return path; + } + + private static string FormatOutcomes(HotReloadOrchestratorResult result) + { + List lines = new List(); + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + lines.Add(outcome.Kind + " " + outcome.Method + " @" + outcome.FilePath + " :: " + outcome.Reason); + } + + lines.AddRange(result.Warnings ?? new List()); + return string.Join("\n", lines); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs.meta new file mode 100644 index 000000000..09a046b52 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 99b244ce6dcdf48e7b120fce31ed27f3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadFileEntryApplier.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadFileEntryApplier.cs index 7f02b3d0e..84a47f919 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadFileEntryApplier.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadFileEntryApplier.cs @@ -42,11 +42,12 @@ internal HotReloadFileProcessResult ApplyResolvedFileAndBuildResult( Debug.Assert(resolution != null && resolution.AllResolved, "resolution must be resolved."); // Why before the generation starts: BeginGeneration drops the added-field ledger, - // and with it the initializers the previous reload committed. - List initializerChangedFields = CollectInitializerChangedAddedFields( + // and with it the initializers and declarations the previous reload committed. + List staleAddedFieldWarnings = CollectStaleAddedFieldWarnings( file.ProjectRelativePath, file.AddedFieldNames, - file.AddedFieldInitializers); + file.AddedFieldInitializers, + file.AddedFieldDeclarations); _domain.BeginGeneration( file.ProjectRelativePath, compileResult.AssemblyBytes, @@ -59,7 +60,7 @@ internal HotReloadFileProcessResult ApplyResolvedFileAndBuildResult( file.AddedFieldNames, file.AddedFieldInitializers, file.AddedFieldDeclarations); - AppendAddedFieldInitializerChangedWarning(file.Sinks.Warnings, initializerChangedFields); + file.Sinks.Warnings.AddRange(staleAddedFieldWarnings); List inlineRiskMethodLabels = new List(); List unforwardedUnityMessageLabels = new List(); int patchedCount = ApplyResolvedEntries( @@ -119,17 +120,18 @@ internal void ClearFileGeneration(HotReloadApplyContext context, HotReloadGroupF file.AddedFieldInitializers ?? file.FileOutput.addedFieldInitializers; TransformWorkerAddedFieldDeclarationDto[] addedFieldDeclarations = file.AddedFieldDeclarations ?? file.FileOutput.addedFieldDeclarations; - List initializerChangedFields = CollectInitializerChangedAddedFields( + List staleAddedFieldWarnings = CollectStaleAddedFieldWarnings( file.ProjectRelativePath, addedFieldNames, - addedFieldInitializers); + addedFieldInitializers, + addedFieldDeclarations); _domain.BeginAddedMemberOnlyGeneration(file.ProjectRelativePath); CommitAddedFieldsForFile( file.ProjectRelativePath, addedFieldNames, addedFieldInitializers, addedFieldDeclarations); - AppendAddedFieldInitializerChangedWarning(file.Sinks.Warnings, initializerChangedFields); + file.Sinks.Warnings.AddRange(staleAddedFieldWarnings); // Why recorded: a file that only declares an added member has no entry of its own, // yet a sibling file's applied body uses that field, so the run must report it. file.ClearedAddedFieldNames = addedFieldNames; @@ -167,37 +169,55 @@ private void CommitAddedFieldsForFile( HotReloadAddedFieldDeclarationConversion.ListSerializedFields(addedFieldDeclarations)); } - // The fields a previous reload already added and this run declares with a different - // initializer. Read from the generation the run is about to replace, so the caller has to - // collect before it starts the new one. - private List CollectInitializerChangedAddedFields( + // The warnings about fields a previous reload already added and this run declares with a + // different initializer or a different type. Read from the generation the run is about to + // replace, so the caller has to collect before it starts the new one. + private List CollectStaleAddedFieldWarnings( string projectRelativePath, string[] addedFieldNames, - string[] addedFieldInitializers) + string[] addedFieldInitializers, + TransformWorkerAddedFieldDeclarationDto[] addedFieldDeclarations) { - List changedFieldNames = new List(); - _domain.FindGeneration(projectRelativePath)?.CollectAddedFieldsWithChangedInitializer( + List warnings = new List(); + HotReloadFileGeneration generation = _domain.FindGeneration(projectRelativePath); + if (generation == null) + { + return warnings; + } + + List initializerChangedFields = new List(); + generation.CollectAddedFieldsWithChangedInitializer( addedFieldNames, addedFieldInitializers, - changedFieldNames); - return changedFieldNames; + initializerChangedFields); + AppendNamedFieldsWarning( + warnings, + HotReloadConstants.AddedFieldInitializerChangedWarningFormat, + initializerChangedFields); + List typeChangedFields = new List(); + generation.CollectAddedFieldsWithChangedDeclaredType( + HotReloadAddedFieldDeclarationConversion.FromWorkerRows(addedFieldDeclarations), + typeChangedFields); + AppendNamedFieldsWarning( + warnings, + HotReloadConstants.AddedFieldDeclaredTypeChangedWarningFormat, + typeChangedFields); + return warnings; } // Why one line for the whole file: the reader's next step is the same for every field // named, and a line per field would bury the rest of the run's warnings. - private static void AppendAddedFieldInitializerChangedWarning( + private static void AppendNamedFieldsWarning( List warnings, - List changedFieldNames) + string format, + List fieldNames) { - if (changedFieldNames.Count == 0) + if (fieldNames.Count == 0) { return; } - warnings.Add( - string.Format( - HotReloadConstants.AddedFieldInitializerChangedWarningFormat, - string.Join(", ", changedFieldNames))); + warnings.Add(string.Format(format, string.Join(", ", fieldNames))); } internal HotReloadFileProcessResult BuildUnappliedResult(HotReloadGroupFile file) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadAddedFieldLedger.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadAddedFieldLedger.cs index 0d7a2ea8d..43a270fad 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadAddedFieldLedger.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadAddedFieldLedger.cs @@ -140,6 +140,42 @@ internal void CollectFieldsWithChangedInitializer( /// The row describing one added field of , which may be spelled /// either way a nested type is spelled. /// + /// + /// Adds to each added field whose committed + /// declaration names another type than does. + /// + /// + /// Why the assembly-qualified name: it spells every generic argument, so Action<int> + /// and Action<long> differ, which a simple name would not show. + /// + internal void CollectFieldsWithChangedDeclaredType( + IReadOnlyList addedFieldDeclarations, + List changedFullNames) + { + Debug.Assert(changedFullNames != null, "changedFullNames must not be null."); + if (addedFieldDeclarations == null) + { + return; + } + + foreach (HotReloadAddedFieldDeclaration declaration in addedFieldDeclarations) + { + if (declaration == null + || string.IsNullOrEmpty(declaration.DeclaredTypeAssemblyQualifiedName) + || !TryGetDeclaration(declaration.DeclaringTypeName, declaration.FieldName, out HotReloadAddedFieldDeclaration committed) + || string.IsNullOrEmpty(committed.DeclaredTypeAssemblyQualifiedName) + || string.Equals( + committed.DeclaredTypeAssemblyQualifiedName, + declaration.DeclaredTypeAssemblyQualifiedName, + StringComparison.Ordinal)) + { + continue; + } + + changedFullNames.Add(NormalizeTypeKey(declaration.DeclaringTypeName) + "." + declaration.FieldName); + } + } + internal bool TryGetDeclaration( string typeName, string fieldName, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadFileGeneration.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadFileGeneration.cs index 5092c06c5..a5b16b3ab 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadFileGeneration.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadFileGeneration.cs @@ -232,6 +232,18 @@ internal void CollectAddedFieldsWithChangedInitializer( changedFullNames); } + /// + /// Adds to each added field this generation already + /// holds under another declared type. Has to run before the generation starts, as the + /// initializer comparison does. + /// + internal void CollectAddedFieldsWithChangedDeclaredType( + IReadOnlyList addedFieldDeclarations, + List changedFullNames) + { + _addedFields.CollectFieldsWithChangedDeclaredType(addedFieldDeclarations, changedFullNames); + } + /// /// The row describing one added field of , which may be spelled /// either way a nested type is spelled. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index de4ce7785..5f8f7d1a2 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -330,6 +330,16 @@ internal static class HotReloadConstants + "read yet; assign the value inside a patched method (for a reference type, " + "'if (field == null) field = ...;'), rename the field, or run 'uloop compile'."; + // Why a warning: the store keeps a value under the field's key whatever its type, and a + // read with a type that cannot hold it starts over from the initializer, so a value (for + // an added event, its subscribers) is silently gone. The run is the only place that sees + // both declarations. + public const string AddedFieldDeclaredTypeChangedWarningFormat = + "A previous reload already added these fields with a different type, so a value stored " + + "under the old type is replaced by the initializer (or the default) wherever the new " + + "type cannot hold it; an added event loses its subscribers: {0}. Assign or subscribe " + + "again inside a patched method, or run 'uloop compile'."; + public const string MissingUsingCompileHint = "This can mean a missing using or global using (hot reload collects global usings from the edited file's assembly)."; From 88634328a0879b780cb8e3ded498a329c9ce0ad8 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 20:43:53 +0900 Subject: [PATCH 05/26] fix(hot-reload): Stop the introduced-type hint from listing subscriptions to an added event as unreachable A new type's method can now subscribe to a field-like event added to a compiled class, so the hint no longer names that as a place such a call cannot be made. --- .../HotReloadIntroducedTypeAddedMemberHintE2ETests.cs | 3 +-- .../HotReloadIntroducedTypeCompileFailureOutcomesTests.cs | 4 ++-- .../HotReloadIntroducedTypeCompileFailureOutcomes.cs | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs index 40c03f44c..866862392 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs @@ -41,8 +41,7 @@ public class HotReloadIntroducedTypeAddedMemberHintE2ETests : HotReloadIntroduce // The part of the hint that says a constructor is one of the places no patch can reach. private const string UnpatchableBodiesHintCore = - "Constructors, initializers, setters, indexers, operators, event accessors and " - + "subscriptions to an added event cannot."; + "Constructors, initializers, setters, indexers, operators and event accessors cannot."; private static readonly string CompiledTypeAddedMember = " public int " + CompiledTypeAddedMethodName + "()\n" diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs index 36ff4e809..10faac1b4 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs @@ -233,8 +233,8 @@ public void Build_DiagnosticNamesAnActiveAddedMember_AppendsTheAddedMemberHint() + "only from its methods and get-only properties, and only when the file that " + "declares the addition belongs to the same assembly and is part of this " + "reload: passed, or unchanged since it was last applied. Constructors, " - + "initializers, setters, indexers, operators, event accessors and " - + "subscriptions to an added event cannot. Pass that file too or move the call " + + "initializers, setters, indexers, operators and event accessors cannot. Pass that " + + "file too or move the call " + "into a method, or run 'uloop compile' to make the added members compiled, " + "then rerun.")); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs b/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs index 05990c3e5..25a03a0e1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs @@ -31,8 +31,8 @@ internal static class HotReloadIntroducedTypeCompileFailureOutcomes + "reload or an earlier one. A new type can call such an addition only from its methods " + "and get-only properties, and only when the file that declares the addition belongs " + "to the same assembly and is part of this reload: passed, or unchanged since it was " - + "last applied. Constructors, initializers, setters, indexers, operators, event " - + "accessors and subscriptions to an added event cannot. Pass that file too or move the " + + "last applied. Constructors, initializers, setters, indexers, operators and event " + + "accessors cannot. Pass that file too or move the " + "call into a method, or run 'uloop compile' to make the added members compiled, then " + "rerun."; From cbe6c9225d97f5530c311b420abdf54b03ba8e53 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 20:43:53 +0900 Subject: [PATCH 06/26] docs(hot-reload): Describe field-like events added to a compiled class The skill now says which added events apply through the added-field store, which shapes stay Skipped, that subscriptions must be made on the main thread, how earlier subscribers and a changed delegate type behave, and that a method-group handler of an added or compiled private method needs a lambda. --- .agents/skills/uloop-hot-reload/SKILL.md | 3 +- .../references/introduced-types.md | 3 +- .../uloop-hot-reload/references/output.md | 2 +- .../references/scope-and-limits.md | 34 ++++++++++++++----- .claude/skills/uloop-hot-reload/SKILL.md | 3 +- .../references/introduced-types.md | 3 +- .../uloop-hot-reload/references/output.md | 2 +- .../references/scope-and-limits.md | 34 ++++++++++++++----- .../FirstPartyTools/HotReload/Skill/SKILL.md | 3 +- .../Skill/references/introduced-types.md | 3 +- .../HotReload/Skill/references/output.md | 2 +- .../Skill/references/scope-and-limits.md | 34 ++++++++++++++----- docs/hot-reload.md | 4 +-- 13 files changed, 92 insertions(+), 38 deletions(-) diff --git a/.agents/skills/uloop-hot-reload/SKILL.md b/.agents/skills/uloop-hot-reload/SKILL.md index eae792aa0..9fb664c86 100644 --- a/.agents/skills/uloop-hot-reload/SKILL.md +++ b/.agents/skills/uloop-hot-reload/SKILL.md @@ -63,7 +63,8 @@ changed are patched (`UnchangedTotal` counts the rest). ## Scope in Brief - Patched: ordinary method bodies and property getters with a body. -- Added members: new methods, fields, and supported properties apply as `Added` rows, +- Added members: new methods, fields, field-like events of a class, and supported properties + apply as `Added` rows, visible to edited code of the same reload within the same assembly (pass the declaring file and its callers together), and gone on any compile or domain reload. - New types: a top-level class, struct, enum, or interface declared in an edited diff --git a/.agents/skills/uloop-hot-reload/references/introduced-types.md b/.agents/skills/uloop-hot-reload/references/introduced-types.md index adbc05734..3552cae85 100644 --- a/.agents/skills/uloop-hot-reload/references/introduced-types.md +++ b/.agents/skills/uloop-hot-reload/references/introduced-types.md @@ -152,8 +152,7 @@ include the file keep the type `AlreadyActive` and patch the body again. The fil addition has to be in the reload: passed, or unchanged since it was last applied, which the reload pulls back in on its own. -Constructors, initializers, setters, indexers, operators, event accessors and subscriptions to an -added event cannot be patched, so a call from them still fails the artifact compile (CS1061 or +Constructors, initializers, setters, indexers, operators and event accessors cannot be patched, so a call from them still fails the artifact compile (CS1061 or CS0117) with a hint saying where such a call works. So does a call to an addition in another assembly, or in a file that changed since it was last applied and is not passed. diff --git a/.agents/skills/uloop-hot-reload/references/output.md b/.agents/skills/uloop-hot-reload/references/output.md index b91e2d5b8..87faf9720 100644 --- a/.agents/skills/uloop-hot-reload/references/output.md +++ b/.agents/skills/uloop-hot-reload/references/output.md @@ -8,7 +8,7 @@ Returns JSON with: - `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 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 }` - `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. - `PatchedTotal` (number): Methods patched in this run -- `AddedFields` (array): source-level names ("Type.field") of fields this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. +- `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `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. diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 1f970a1a7..0ce09c73a 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -114,7 +114,8 @@ scope and is reported as `Skipped`, same as edits to them. With a verified baseline, event declarations are compared per accessor, so only the edited add or remove appears as a `Skipped` row. A newly added explicit event, or an edit before the first compile snapshot, still reports both accessors. -Adding a nested type, an event, or an indexer to a compiled type is still out of scope. +Adding a nested type or an indexer to a compiled type is still out of scope; an added +field-like event applies within the limits described below. A new top-level `class`, `struct`, `enum`, or `interface` is introduced instead, within the limits in [introduced-types.md](introduced-types.md); a `record` is refused there. A member added to a compiled enum is out of scope too: it is not folded like an added @@ -156,7 +157,8 @@ pattern that matches the property, `nameof`, `ref`/`out`/`in`, and conditional a on the property itself. A compound assignment or increment keeps hot reloading when it is rewritten as a plain assignment statement (`X = X + 1;`). -Types, events, and indexers are not reported per member — no `Skipped` row names them; +Types, indexers, and added events the store cannot hold (a struct host, a delegate type not +visible outside the assembly) are not reported per member — no `Skipped` row names them; at most they surface as outside-body drift in `Warnings`. Treat their silence as "not applied" and land them with `uloop compile`. @@ -354,12 +356,28 @@ Harmony accessor, which puts that method on the delegation path. Four shapes hav backing field to reach and stay `Skipped` (see the table below): an event with custom `add`/`remove` accessors, an `abstract`/`extern`/interface event, an event whose delegate type is not visible outside the assembly, and an event added in this edit -(including one that had custom accessors when the assembly was last compiled). +that the added-field store cannot hold (see below). Raising through a conditional receiver (`other?.E?.Invoke(x)`) and `nameof(E)` also stay `Skipped`. -Subscribing to an event added in this edit, in any file of the run, is `Skipped` -too, whether the handler is a method group or a lambda: the compiled assembly has no -such event for the subscription to bind to until `uloop compile`. + +A field-like event added in this edit to a compiled class keeps its delegate in the +added-field store, like an added field, so edited bodies in any file of the same reload +can subscribe, unsubscribe, raise, read, and assign it (instance or static). It is listed in +`AddedFields`, shares the added-field lifetime, and compiled code that is not patched +cannot see it. These stay `Skipped`: a struct host (the struct-field reason), a delegate +type not visible outside the assembly, custom `add`/`remove` accessors, a name the +compiled class already uses for another member, `E ??= h`, `nameof(E)`, `a?.E += h`, passing it by `ref`, an initializer an added +field could not have, and `Get().E += h` (the receiver would be evaluated twice). +A handler that is a method group of an added method or of a compiled private method is +`Skipped` too; subscribe a lambda that calls it instead (`E += x => OnValue(x);`). +Subscriptions live on the store's delegate, not on Unity objects: `+=` is not atomic, so +subscribe and raise on the main thread only. A handler subscribed by an earlier reload +keeps running the body it was subscribed with until it is removed and subscribed again +(for a subscription made in `OnEnable`, toggle `enabled` as described under "Added Unity +messages"). Changing the event's delegate type in a later reload drops its subscribers, +and that reload names it in `Warnings`. Deleting the event from the source removes it +from `AddedFields`, but its subscribers stay in the store until `--revert-all`, so adding +it back delivers to them again. A `Skipped` row never undoes what an earlier reload applied to the same method: that patch keeps running, so the method matches neither the compiled assembly nor the @@ -381,10 +399,10 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | An added member's body cannot be fully bound in the hot-reload compilation | Hot reload cannot verify a member it cannot bind. A common cause: another file of the same reload, passed or pulled back in because it holds active patches, declares a compiled type from source, while a compiled API the body calls still names the compiled copy (for example, a lambda handed to a compiled `Register(Action)`). The reason then names both types and the file declaring the compiled API. When the called member belongs to a type an earlier reload introduced and its signature was bound to the compiled copy, the reason names the introduced type and that compiled type instead. Either way the `Skipped` row names the step for that run (pass the file declaring the compiled API, leave the file declaring the type out, undo its edit and leave it out, or `uloop compile`), chosen from whether each file was passed, carried in, or already holds patches; a row about an added property's body points to the row of its accessor instead. A file passed this way is brought back by every later reload of the assembly while it stays unchanged, including the reload that re-applies after `--revert-all` or Play entry, until the next successful compile. When the skip deactivated added members an earlier reload applied, the next reload of the assembly retries their unchanged file once, so passing only the declaring file applies them again | | Edited setter, init, or indexer accessor of a *compiled* property | Accessor patching covers getters only; `uloop compile` applies these edits. Accessors of a property added in this edit are emitted instead | | Constructor (instance or static), operator, conversion operator, or explicit event accessor (add/remove) | Skipped; `uloop compile` applies these edits | -| Method raises or reads a field-like event that has no reachable backing field | Custom `add`/`remove` accessors, an `abstract`/`extern`/interface event, a delegate type that is not visible outside the assembly, or an event added in this edit leave nothing for the shim's Harmony accessor to bind | +| Method raises or reads a field-like event that has no reachable backing field | Custom `add`/`remove` accessors, an `abstract`/`extern`/interface event, a delegate type that is not visible outside the assembly, or an added event the added-field store cannot hold leave nothing for the shim's Harmony accessor to bind | | Method raises or reads a field-like event through a conditional receiver (`other?.E`) | The shim has no name for the conditional receiver to pass to the accessor call | | Method names a field-like event inside `nameof` | The shim is a different type and cannot keep the bare event name | -| Method subscribes (`+=`/`-=`) to an event added in this edit | The shim binds the subscription against the compiled assembly, which has no such event yet | +| Method subscribes (`+=`/`-=`) to an event added in this edit that the added-field store cannot hold | The shim binds the subscription against the compiled assembly, which has no such event yet; an added field-like event of a class is subscribed through the store instead | ## Failed — flips `Success` to `false` diff --git a/.claude/skills/uloop-hot-reload/SKILL.md b/.claude/skills/uloop-hot-reload/SKILL.md index eae792aa0..9fb664c86 100644 --- a/.claude/skills/uloop-hot-reload/SKILL.md +++ b/.claude/skills/uloop-hot-reload/SKILL.md @@ -63,7 +63,8 @@ changed are patched (`UnchangedTotal` counts the rest). ## Scope in Brief - Patched: ordinary method bodies and property getters with a body. -- Added members: new methods, fields, and supported properties apply as `Added` rows, +- Added members: new methods, fields, field-like events of a class, and supported properties + apply as `Added` rows, visible to edited code of the same reload within the same assembly (pass the declaring file and its callers together), and gone on any compile or domain reload. - New types: a top-level class, struct, enum, or interface declared in an edited diff --git a/.claude/skills/uloop-hot-reload/references/introduced-types.md b/.claude/skills/uloop-hot-reload/references/introduced-types.md index adbc05734..3552cae85 100644 --- a/.claude/skills/uloop-hot-reload/references/introduced-types.md +++ b/.claude/skills/uloop-hot-reload/references/introduced-types.md @@ -152,8 +152,7 @@ include the file keep the type `AlreadyActive` and patch the body again. The fil addition has to be in the reload: passed, or unchanged since it was last applied, which the reload pulls back in on its own. -Constructors, initializers, setters, indexers, operators, event accessors and subscriptions to an -added event cannot be patched, so a call from them still fails the artifact compile (CS1061 or +Constructors, initializers, setters, indexers, operators and event accessors cannot be patched, so a call from them still fails the artifact compile (CS1061 or CS0117) with a hint saying where such a call works. So does a call to an addition in another assembly, or in a file that changed since it was last applied and is not passed. diff --git a/.claude/skills/uloop-hot-reload/references/output.md b/.claude/skills/uloop-hot-reload/references/output.md index b91e2d5b8..87faf9720 100644 --- a/.claude/skills/uloop-hot-reload/references/output.md +++ b/.claude/skills/uloop-hot-reload/references/output.md @@ -8,7 +8,7 @@ Returns JSON with: - `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 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 }` - `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. - `PatchedTotal` (number): Methods patched in this run -- `AddedFields` (array): source-level names ("Type.field") of fields this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. +- `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `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. diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 1f970a1a7..0ce09c73a 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -114,7 +114,8 @@ scope and is reported as `Skipped`, same as edits to them. With a verified baseline, event declarations are compared per accessor, so only the edited add or remove appears as a `Skipped` row. A newly added explicit event, or an edit before the first compile snapshot, still reports both accessors. -Adding a nested type, an event, or an indexer to a compiled type is still out of scope. +Adding a nested type or an indexer to a compiled type is still out of scope; an added +field-like event applies within the limits described below. A new top-level `class`, `struct`, `enum`, or `interface` is introduced instead, within the limits in [introduced-types.md](introduced-types.md); a `record` is refused there. A member added to a compiled enum is out of scope too: it is not folded like an added @@ -156,7 +157,8 @@ pattern that matches the property, `nameof`, `ref`/`out`/`in`, and conditional a on the property itself. A compound assignment or increment keeps hot reloading when it is rewritten as a plain assignment statement (`X = X + 1;`). -Types, events, and indexers are not reported per member — no `Skipped` row names them; +Types, indexers, and added events the store cannot hold (a struct host, a delegate type not +visible outside the assembly) are not reported per member — no `Skipped` row names them; at most they surface as outside-body drift in `Warnings`. Treat their silence as "not applied" and land them with `uloop compile`. @@ -354,12 +356,28 @@ Harmony accessor, which puts that method on the delegation path. Four shapes hav backing field to reach and stay `Skipped` (see the table below): an event with custom `add`/`remove` accessors, an `abstract`/`extern`/interface event, an event whose delegate type is not visible outside the assembly, and an event added in this edit -(including one that had custom accessors when the assembly was last compiled). +that the added-field store cannot hold (see below). Raising through a conditional receiver (`other?.E?.Invoke(x)`) and `nameof(E)` also stay `Skipped`. -Subscribing to an event added in this edit, in any file of the run, is `Skipped` -too, whether the handler is a method group or a lambda: the compiled assembly has no -such event for the subscription to bind to until `uloop compile`. + +A field-like event added in this edit to a compiled class keeps its delegate in the +added-field store, like an added field, so edited bodies in any file of the same reload +can subscribe, unsubscribe, raise, read, and assign it (instance or static). It is listed in +`AddedFields`, shares the added-field lifetime, and compiled code that is not patched +cannot see it. These stay `Skipped`: a struct host (the struct-field reason), a delegate +type not visible outside the assembly, custom `add`/`remove` accessors, a name the +compiled class already uses for another member, `E ??= h`, `nameof(E)`, `a?.E += h`, passing it by `ref`, an initializer an added +field could not have, and `Get().E += h` (the receiver would be evaluated twice). +A handler that is a method group of an added method or of a compiled private method is +`Skipped` too; subscribe a lambda that calls it instead (`E += x => OnValue(x);`). +Subscriptions live on the store's delegate, not on Unity objects: `+=` is not atomic, so +subscribe and raise on the main thread only. A handler subscribed by an earlier reload +keeps running the body it was subscribed with until it is removed and subscribed again +(for a subscription made in `OnEnable`, toggle `enabled` as described under "Added Unity +messages"). Changing the event's delegate type in a later reload drops its subscribers, +and that reload names it in `Warnings`. Deleting the event from the source removes it +from `AddedFields`, but its subscribers stay in the store until `--revert-all`, so adding +it back delivers to them again. A `Skipped` row never undoes what an earlier reload applied to the same method: that patch keeps running, so the method matches neither the compiled assembly nor the @@ -381,10 +399,10 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | An added member's body cannot be fully bound in the hot-reload compilation | Hot reload cannot verify a member it cannot bind. A common cause: another file of the same reload, passed or pulled back in because it holds active patches, declares a compiled type from source, while a compiled API the body calls still names the compiled copy (for example, a lambda handed to a compiled `Register(Action)`). The reason then names both types and the file declaring the compiled API. When the called member belongs to a type an earlier reload introduced and its signature was bound to the compiled copy, the reason names the introduced type and that compiled type instead. Either way the `Skipped` row names the step for that run (pass the file declaring the compiled API, leave the file declaring the type out, undo its edit and leave it out, or `uloop compile`), chosen from whether each file was passed, carried in, or already holds patches; a row about an added property's body points to the row of its accessor instead. A file passed this way is brought back by every later reload of the assembly while it stays unchanged, including the reload that re-applies after `--revert-all` or Play entry, until the next successful compile. When the skip deactivated added members an earlier reload applied, the next reload of the assembly retries their unchanged file once, so passing only the declaring file applies them again | | Edited setter, init, or indexer accessor of a *compiled* property | Accessor patching covers getters only; `uloop compile` applies these edits. Accessors of a property added in this edit are emitted instead | | Constructor (instance or static), operator, conversion operator, or explicit event accessor (add/remove) | Skipped; `uloop compile` applies these edits | -| Method raises or reads a field-like event that has no reachable backing field | Custom `add`/`remove` accessors, an `abstract`/`extern`/interface event, a delegate type that is not visible outside the assembly, or an event added in this edit leave nothing for the shim's Harmony accessor to bind | +| Method raises or reads a field-like event that has no reachable backing field | Custom `add`/`remove` accessors, an `abstract`/`extern`/interface event, a delegate type that is not visible outside the assembly, or an added event the added-field store cannot hold leave nothing for the shim's Harmony accessor to bind | | Method raises or reads a field-like event through a conditional receiver (`other?.E`) | The shim has no name for the conditional receiver to pass to the accessor call | | Method names a field-like event inside `nameof` | The shim is a different type and cannot keep the bare event name | -| Method subscribes (`+=`/`-=`) to an event added in this edit | The shim binds the subscription against the compiled assembly, which has no such event yet | +| Method subscribes (`+=`/`-=`) to an event added in this edit that the added-field store cannot hold | The shim binds the subscription against the compiled assembly, which has no such event yet; an added field-like event of a class is subscribed through the store instead | ## Failed — flips `Success` to `false` diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md index eae792aa0..9fb664c86 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md @@ -63,7 +63,8 @@ changed are patched (`UnchangedTotal` counts the rest). ## Scope in Brief - Patched: ordinary method bodies and property getters with a body. -- Added members: new methods, fields, and supported properties apply as `Added` rows, +- Added members: new methods, fields, field-like events of a class, and supported properties + apply as `Added` rows, visible to edited code of the same reload within the same assembly (pass the declaring file and its callers together), and gone on any compile or domain reload. - New types: a top-level class, struct, enum, or interface declared in an edited diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md index adbc05734..3552cae85 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md @@ -152,8 +152,7 @@ include the file keep the type `AlreadyActive` and patch the body again. The fil addition has to be in the reload: passed, or unchanged since it was last applied, which the reload pulls back in on its own. -Constructors, initializers, setters, indexers, operators, event accessors and subscriptions to an -added event cannot be patched, so a call from them still fails the artifact compile (CS1061 or +Constructors, initializers, setters, indexers, operators and event accessors cannot be patched, so a call from them still fails the artifact compile (CS1061 or CS0117) with a hint saying where such a call works. So does a call to an addition in another assembly, or in a file that changed since it was last applied and is not passed. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md index b91e2d5b8..87faf9720 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md @@ -8,7 +8,7 @@ Returns JSON with: - `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 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 }` - `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. - `PatchedTotal` (number): Methods patched in this run -- `AddedFields` (array): source-level names ("Type.field") of fields this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. +- `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `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. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 1f970a1a7..0ce09c73a 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -114,7 +114,8 @@ scope and is reported as `Skipped`, same as edits to them. With a verified baseline, event declarations are compared per accessor, so only the edited add or remove appears as a `Skipped` row. A newly added explicit event, or an edit before the first compile snapshot, still reports both accessors. -Adding a nested type, an event, or an indexer to a compiled type is still out of scope. +Adding a nested type or an indexer to a compiled type is still out of scope; an added +field-like event applies within the limits described below. A new top-level `class`, `struct`, `enum`, or `interface` is introduced instead, within the limits in [introduced-types.md](introduced-types.md); a `record` is refused there. A member added to a compiled enum is out of scope too: it is not folded like an added @@ -156,7 +157,8 @@ pattern that matches the property, `nameof`, `ref`/`out`/`in`, and conditional a on the property itself. A compound assignment or increment keeps hot reloading when it is rewritten as a plain assignment statement (`X = X + 1;`). -Types, events, and indexers are not reported per member — no `Skipped` row names them; +Types, indexers, and added events the store cannot hold (a struct host, a delegate type not +visible outside the assembly) are not reported per member — no `Skipped` row names them; at most they surface as outside-body drift in `Warnings`. Treat their silence as "not applied" and land them with `uloop compile`. @@ -354,12 +356,28 @@ Harmony accessor, which puts that method on the delegation path. Four shapes hav backing field to reach and stay `Skipped` (see the table below): an event with custom `add`/`remove` accessors, an `abstract`/`extern`/interface event, an event whose delegate type is not visible outside the assembly, and an event added in this edit -(including one that had custom accessors when the assembly was last compiled). +that the added-field store cannot hold (see below). Raising through a conditional receiver (`other?.E?.Invoke(x)`) and `nameof(E)` also stay `Skipped`. -Subscribing to an event added in this edit, in any file of the run, is `Skipped` -too, whether the handler is a method group or a lambda: the compiled assembly has no -such event for the subscription to bind to until `uloop compile`. + +A field-like event added in this edit to a compiled class keeps its delegate in the +added-field store, like an added field, so edited bodies in any file of the same reload +can subscribe, unsubscribe, raise, read, and assign it (instance or static). It is listed in +`AddedFields`, shares the added-field lifetime, and compiled code that is not patched +cannot see it. These stay `Skipped`: a struct host (the struct-field reason), a delegate +type not visible outside the assembly, custom `add`/`remove` accessors, a name the +compiled class already uses for another member, `E ??= h`, `nameof(E)`, `a?.E += h`, passing it by `ref`, an initializer an added +field could not have, and `Get().E += h` (the receiver would be evaluated twice). +A handler that is a method group of an added method or of a compiled private method is +`Skipped` too; subscribe a lambda that calls it instead (`E += x => OnValue(x);`). +Subscriptions live on the store's delegate, not on Unity objects: `+=` is not atomic, so +subscribe and raise on the main thread only. A handler subscribed by an earlier reload +keeps running the body it was subscribed with until it is removed and subscribed again +(for a subscription made in `OnEnable`, toggle `enabled` as described under "Added Unity +messages"). Changing the event's delegate type in a later reload drops its subscribers, +and that reload names it in `Warnings`. Deleting the event from the source removes it +from `AddedFields`, but its subscribers stay in the store until `--revert-all`, so adding +it back delivers to them again. A `Skipped` row never undoes what an earlier reload applied to the same method: that patch keeps running, so the method matches neither the compiled assembly nor the @@ -381,10 +399,10 @@ source on disk. When a run skips a method it had patched before, `Warnings` name | An added member's body cannot be fully bound in the hot-reload compilation | Hot reload cannot verify a member it cannot bind. A common cause: another file of the same reload, passed or pulled back in because it holds active patches, declares a compiled type from source, while a compiled API the body calls still names the compiled copy (for example, a lambda handed to a compiled `Register(Action)`). The reason then names both types and the file declaring the compiled API. When the called member belongs to a type an earlier reload introduced and its signature was bound to the compiled copy, the reason names the introduced type and that compiled type instead. Either way the `Skipped` row names the step for that run (pass the file declaring the compiled API, leave the file declaring the type out, undo its edit and leave it out, or `uloop compile`), chosen from whether each file was passed, carried in, or already holds patches; a row about an added property's body points to the row of its accessor instead. A file passed this way is brought back by every later reload of the assembly while it stays unchanged, including the reload that re-applies after `--revert-all` or Play entry, until the next successful compile. When the skip deactivated added members an earlier reload applied, the next reload of the assembly retries their unchanged file once, so passing only the declaring file applies them again | | Edited setter, init, or indexer accessor of a *compiled* property | Accessor patching covers getters only; `uloop compile` applies these edits. Accessors of a property added in this edit are emitted instead | | Constructor (instance or static), operator, conversion operator, or explicit event accessor (add/remove) | Skipped; `uloop compile` applies these edits | -| Method raises or reads a field-like event that has no reachable backing field | Custom `add`/`remove` accessors, an `abstract`/`extern`/interface event, a delegate type that is not visible outside the assembly, or an event added in this edit leave nothing for the shim's Harmony accessor to bind | +| Method raises or reads a field-like event that has no reachable backing field | Custom `add`/`remove` accessors, an `abstract`/`extern`/interface event, a delegate type that is not visible outside the assembly, or an added event the added-field store cannot hold leave nothing for the shim's Harmony accessor to bind | | Method raises or reads a field-like event through a conditional receiver (`other?.E`) | The shim has no name for the conditional receiver to pass to the accessor call | | Method names a field-like event inside `nameof` | The shim is a different type and cannot keep the bare event name | -| Method subscribes (`+=`/`-=`) to an event added in this edit | The shim binds the subscription against the compiled assembly, which has no such event yet | +| Method subscribes (`+=`/`-=`) to an event added in this edit that the added-field store cannot hold | The shim binds the subscription against the compiled assembly, which has no such event yet; an added field-like event of a class is subscribed through the store instead | ## Failed — flips `Success` to `false` diff --git a/docs/hot-reload.md b/docs/hot-reload.md index e918c6f58..59efb672d 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -414,8 +414,8 @@ Wire details: Added fields, methods, and properties themselves apply, and are visible to the bodies edited in any file of the same assembly passed to the same reload — including on a type an earlier reload introduced, where they are applied on the artifact that already carries it. An - added auto-property is backed by the added-field store, so its value shares that lifetime. - Added events, indexers, and the property shapes listed in the skill's scope reference + added auto-property is backed by the added-field store, so its value shares that lifetime, + and so is a field-like event added to a compiled class (ADR 0011). Other added events, indexers, and the property shapes listed in the skill's scope reference (set-only, virtual, explicit interface, `init`, struct host) still require `uloop compile`. Unchanged files of that assembly that already hold active patches are re-applied so they bind to the newest shim. Shim compile errors caused by references to members that are still missing are reported with that hint, and changed `const` values From 9ca37986cb8af5a735485b9b6fe056004d206f5f Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 20:53:57 +0900 Subject: [PATCH 07/26] refactor(hot-reload): Extract the field and event branches of the accessor read registration The store-backed event check pushed the method past the repository's cyclomatic complexity limit. --- .../TransformWorker~/AccessorReadRegistrar.cs | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs index 577f62b89..850335c0e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs @@ -16,18 +16,7 @@ internal static bool TryRegisterPropertyOrFieldRead( rejectReason = null; if (symbol is IFieldSymbol fieldSymbol) { - if (!AccessibilityRules.IsInaccessibleFromExternalAssembly(fieldSymbol)) - { - return false; - } - - if (fieldSymbol.IsConst) - { - return true; - } - - plan.GetOrAddField(fieldSymbol); - return true; + return TryRegisterFieldRead(fieldSymbol, plan); } if (symbol is IPropertySymbol propertySymbol) @@ -37,15 +26,7 @@ internal static bool TryRegisterPropertyOrFieldRead( if (symbol is IEventSymbol eventSymbol) { - // An added event the store keeps has no backing field to reach; its reads become - // store calls any assembly can compile. - if (addedMemberAccess != null && addedMemberAccess.IsStoreBackedEvent(eventSymbol)) - { - return false; - } - - plan.GetOrAddEventBackingField(eventSymbol); - return true; + return TryRegisterEventRead(eventSymbol, plan, addedMemberAccess); } if (symbol is IMethodSymbol methodSymbol @@ -76,6 +57,38 @@ internal static bool TryRegisterPropertyOrFieldRead( return false; } + private static bool TryRegisterFieldRead(IFieldSymbol fieldSymbol, AccessorPlan plan) + { + if (!AccessibilityRules.IsInaccessibleFromExternalAssembly(fieldSymbol)) + { + return false; + } + + if (fieldSymbol.IsConst) + { + return true; + } + + plan.GetOrAddField(fieldSymbol); + return true; + } + + private static bool TryRegisterEventRead( + IEventSymbol eventSymbol, + AccessorPlan plan, + AddedMemberAccessLookup addedMemberAccess) + { + // An added event the store keeps has no backing field to reach; its reads become + // store calls any assembly can compile. + if (addedMemberAccess != null && addedMemberAccess.IsStoreBackedEvent(eventSymbol)) + { + return false; + } + + plan.GetOrAddEventBackingField(eventSymbol); + return true; + } + private static bool TryRegisterInaccessiblePropertyRead( IPropertySymbol propertySymbol, AccessorPlan plan, From d3a7ee008df62763fac2424d6d09deba811eb553 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 21:15:30 +0900 Subject: [PATCH 08/26] docs(hot-reload): Move the declaration lookup's summary back onto the method it describes --- .../HotReload/Patching/HotReloadAddedFieldLedger.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadAddedFieldLedger.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadAddedFieldLedger.cs index 43a270fad..8ab245a67 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadAddedFieldLedger.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Patching/HotReloadAddedFieldLedger.cs @@ -136,10 +136,6 @@ internal void CollectFieldsWithChangedInitializer( } } - /// - /// The row describing one added field of , which may be spelled - /// either way a nested type is spelled. - /// /// /// Adds to each added field whose committed /// declaration names another type than does. @@ -176,6 +172,10 @@ internal void CollectFieldsWithChangedDeclaredType( } } + /// + /// The row describing one added field of , which may be spelled + /// either way a nested type is spelled. + /// internal bool TryGetDeclaration( string typeName, string fieldName, From e2f2e3dc76290a14944aa63a72021d02503d55a7 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 21:15:31 +0900 Subject: [PATCH 09/26] test(hot-reload): Pin parenthesized subscriptions to a compiled event as patched Looking past parentheses when deciding whether an event use is a subscription also changed the path a compiled event takes; both the other-type and the declaring-type shape now have an end-to-end test. --- .../HotReload/HotReloadAddedEventE2ETests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs index 01098cd2c..e3a6378b2 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs @@ -115,6 +115,45 @@ public async Task Run_UnsubscribeWithTheSameDelegate_StopsDelivery() Assert.That(listener.Received, Is.EqualTo(2), FormatOutcomes(result)); } + /// + /// What: another type subscribing to a compiled event through parentheses, + /// '(publisher.Existing) += h', is patched and the compiled raise reaches the handler, + /// so looking past the parentheses does not break the compiled-event subscription path. + /// + [Test] + public async Task Run_OtherTypeSubscribesToCompiledEventThroughParentheses_IsPatched() + { + HotReloadOrchestratorResult result = await RunAsync( + ReadFixture(PublisherFileName), + EditSubscriber(WireAnchor, "(publisher.Existing) += Accept;")); + + AssertPatched(result, ".Wire("); + Assert.That(WireAndRaise(4), Is.EqualTo(4), FormatOutcomes(result)); + } + + /// + /// What: the declaring type subscribing to its compiled event through parentheses, + /// '(Existing) += h', is patched and the raise reaches both that handler and a handler + /// compiled code subscribed. + /// + [Test] + public async Task Run_DeclaringTypeSubscribesToCompiledEventThroughParentheses_IsPatched() + { + string publisher = ReplaceInSource( + ReadFixture(PublisherFileName), + RaiseBodyAnchor, + " (Existing) += forwarded => RaiseStatic(forwarded);\n" + RaiseBodyAnchor); + + HotReloadOrchestratorResult result = await RunAsync(publisher, ReadFixture(SubscriberFileName)); + + AssertPatched(result, ".Raise("); + HotReloadAddedEventApplyPublisher target = new HotReloadAddedEventApplyPublisher(); + HotReloadAddedEventApplySubscriber subscriber = new HotReloadAddedEventApplySubscriber(); + target.Existing += subscriber.Accept; + target.Raise(3); + Assert.That(subscriber.Received, Is.EqualTo(3), FormatOutcomes(result)); + } + /// /// What: an added static event with a generic delegate type is raised from a patched static /// method and reaches a lambda another type subscribed. From 3c2bbd5d05553f861ce239cc218484b6a8214970 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 21:38:50 +0900 Subject: [PATCH 10/26] fix(hot-reload): Keep naming subscriptions to added events the store cannot hold in the introduced-type hint An added event with custom accessors or a delegate type not visible outside the assembly is still not stubbed, so a new type subscribing to it still fails the artifact compile; the hint now says so instead of suggesting steps that cannot help. --- .../references/introduced-types.md | 4 +- .../references/introduced-types.md | 4 +- ...adIntroducedTypeAddedMemberHintE2ETests.cs | 59 ++++++++++++++++++- ...troducedTypeCompileFailureOutcomesTests.cs | 6 +- ...oadIntroducedTypeCompileFailureOutcomes.cs | 4 +- .../Skill/references/introduced-types.md | 4 +- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/.agents/skills/uloop-hot-reload/references/introduced-types.md b/.agents/skills/uloop-hot-reload/references/introduced-types.md index 3552cae85..442499ac2 100644 --- a/.agents/skills/uloop-hot-reload/references/introduced-types.md +++ b/.agents/skills/uloop-hot-reload/references/introduced-types.md @@ -154,7 +154,9 @@ reload pulls back in on its own. Constructors, initializers, setters, indexers, operators and event accessors cannot be patched, so a call from them still fails the artifact compile (CS1061 or CS0117) with a hint saying where such a call works. So does a call to an addition in another -assembly, or in a file that changed since it was last applied and is not passed. +assembly, or in a file that changed since it was last applied and is not passed, and so does a +subscription to an added event the added-field store cannot hold (custom `add`/`remove` +accessors, or a delegate type not visible outside the assembly). - When this reload does not patch a stubbed body (a generic method, or a method of a struct, is `Skipped`), no type of that artifact is introduced: the stubbed type's row reads `Not diff --git a/.claude/skills/uloop-hot-reload/references/introduced-types.md b/.claude/skills/uloop-hot-reload/references/introduced-types.md index 3552cae85..442499ac2 100644 --- a/.claude/skills/uloop-hot-reload/references/introduced-types.md +++ b/.claude/skills/uloop-hot-reload/references/introduced-types.md @@ -154,7 +154,9 @@ reload pulls back in on its own. Constructors, initializers, setters, indexers, operators and event accessors cannot be patched, so a call from them still fails the artifact compile (CS1061 or CS0117) with a hint saying where such a call works. So does a call to an addition in another -assembly, or in a file that changed since it was last applied and is not passed. +assembly, or in a file that changed since it was last applied and is not passed, and so does a +subscription to an added event the added-field store cannot hold (custom `add`/`remove` +accessors, or a delegate type not visible outside the assembly). - When this reload does not patch a stubbed body (a generic method, or a method of a struct, is `Skipped`), no type of that artifact is introduced: the stubbed type's row reads `Not diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs index 866862392..363ae30c2 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs @@ -41,7 +41,14 @@ public class HotReloadIntroducedTypeAddedMemberHintE2ETests : HotReloadIntroduce // The part of the hint that says a constructor is one of the places no patch can reach. private const string UnpatchableBodiesHintCore = - "Constructors, initializers, setters, indexers, operators and event accessors cannot."; + "Constructors, initializers, setters, indexers, operators and event accessors cannot"; + + // The part of the hint that says an added event outside the added-field store cannot be + // subscribed to, which is the only step a reader of that failure can take. + private const string StorelessEventHintCore = + "nor can a subscription to an added event the added-field store cannot hold"; + + private const string StorelessEventName = "AddedWithAccessors"; private static readonly string CompiledTypeAddedMember = " public int " + CompiledTypeAddedMethodName + "()\n" @@ -76,6 +83,40 @@ await RunInIntroducedTypeDomainAsync(async _ => }); } + /// + /// What: a new type whose method subscribes to an event the same reload adds to a compiled + /// type with custom accessors fails to compile, because that event stays out of the + /// added-field store, and the hint names that subscription as one that cannot be made. + /// + [Test] + public async Task Run_NewTypeSubscribesToAnAddedEventOutsideTheStore_FailsWithTheEventHint() + { + string hostPath = FixturePath("HotReloadCrossFileAddedMemberHost.cs"); + string host = File.ReadAllText(hostPath); + Assert.That(host, Does.Contain(HostValueAnchor), "Precondition: host value anchor must exist."); + host = host.Replace( + HostValueAnchor, + " public event System.Action " + StorelessEventName + "\n" + + " {\n add { }\n remove { }\n }\n\n" + HostValueAnchor, + StringComparison.Ordinal); + + await RunInIntroducedTypeDomainAsync(async _ => + { + HotReloadOrchestratorResult result = await RunAsync( + new Dictionary + { + [hostPath] = host, + [UserOwnerPath] = BuildStatementUserSource( + "new HotReloadCrossFileAddedMemberHost()." + StorelessEventName + " += value => { };") + }, + "StorelessEventSameReload"); + + string reason = FindIntroducedTypeFailureReason(result, "CS1061"); + Assert.That(reason, Does.Contain(HintCore), DescribeOutcomes(result)); + Assert.That(reason, Does.Contain(StorelessEventHintCore), DescribeOutcomes(result)); + }); + } + /// /// What: a new type naming an enum member the same reload adds to a compiled enum fails /// to compile (CS0117), and the run still carries the added-enum-member warning with its @@ -218,6 +259,22 @@ private static string BuildUserSource(string expression) + "}\n"; } + private static string BuildStatementUserSource(string statement) + { + return + "namespace " + Namespace + "\n" + + "{\n" + + " public sealed class " + UserSimpleName + "\n" + + " {\n" + + " public int Run()\n" + + " {\n" + + " " + statement + "\n" + + " return 0;\n" + + " }\n" + + " }\n" + + "}\n"; + } + private static string BuildConstructorUserSource(string expression) { return diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs index 10faac1b4..b78bdacaf 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs @@ -233,8 +233,10 @@ public void Build_DiagnosticNamesAnActiveAddedMember_AppendsTheAddedMemberHint() + "only from its methods and get-only properties, and only when the file that " + "declares the addition belongs to the same assembly and is part of this " + "reload: passed, or unchanged since it was last applied. Constructors, " - + "initializers, setters, indexers, operators and event accessors cannot. Pass that " - + "file too or move the call " + + "initializers, setters, indexers, operators and event accessors cannot, nor can a " + + "subscription to an added event the added-field store cannot hold (custom " + + "add/remove accessors, or a delegate type not visible outside the assembly). " + + "Pass that file too or move the call " + "into a method, or run 'uloop compile' to make the added members compiled, " + "then rerun.")); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs b/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs index 25a03a0e1..e28fae38e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs @@ -32,7 +32,9 @@ internal static class HotReloadIntroducedTypeCompileFailureOutcomes + "and get-only properties, and only when the file that declares the addition belongs " + "to the same assembly and is part of this reload: passed, or unchanged since it was " + "last applied. Constructors, initializers, setters, indexers, operators and event " - + "accessors cannot. Pass that file too or move the " + + "accessors cannot, nor can a subscription to an added event the added-field store " + + "cannot hold (custom add/remove accessors, or a delegate type not visible outside the " + + "assembly). Pass that file too or move the " + "call into a method, or run 'uloop compile' to make the added members compiled, then " + "rerun."; diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md index 3552cae85..442499ac2 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md @@ -154,7 +154,9 @@ reload pulls back in on its own. Constructors, initializers, setters, indexers, operators and event accessors cannot be patched, so a call from them still fails the artifact compile (CS1061 or CS0117) with a hint saying where such a call works. So does a call to an addition in another -assembly, or in a file that changed since it was last applied and is not passed. +assembly, or in a file that changed since it was last applied and is not passed, and so does a +subscription to an added event the added-field store cannot hold (custom `add`/`remove` +accessors, or a delegate type not visible outside the assembly). - When this reload does not patch a stubbed body (a generic method, or a method of a struct, is `Skipped`), no type of that artifact is introduced: the stubbed type's row reads `Not From 159c765b98207eec884453801c40d5f122d8fe62 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 21:38:50 +0900 Subject: [PATCH 11/26] test(hot-reload): Require the declared-type warning itself to name the changed member The initializer-changed warning names the same field, so the old constraint was met by two different warnings; the field test also checks the reader sees the new type's initial value. --- .../Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs | 5 +++-- .../HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs index e3a6378b2..2bc69ead7 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs @@ -254,10 +254,11 @@ await RunAsync( AssertPatched(second, ".Raise("); target.Raise(9); Assert.That(listener.Received, Is.EqualTo(0), FormatOutcomes(second)); + string changedName = typeof(HotReloadAddedEventApplyPublisher).FullName + ".Changed"; Assert.That( second.Warnings ?? new List(), - Has.Some.Contains(DeclaredTypeChangedToken) - .And.Some.Contains(typeof(HotReloadAddedEventApplyPublisher).FullName + ".Changed"), + Has.Some.Matches( + warning => warning.Contains(DeclaredTypeChangedToken) && warning.Contains(changedName)), FormatOutcomes(second)); } diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs index 034e18638..835c2b090 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs @@ -54,11 +54,15 @@ public async Task Run_AddedFieldDeclaredTypeChanged_WarnsNamingTheField() HotReloadOrchestratorResult second = await RunAsync( WithAddedMember("private string _addedCount = \"ab\";", "return _addedCount.Length;")); + // Why one element must carry both: the initializer also changes, so the + // initializer-changed warning names the same field on its own. + string changedName = typeof(HotReloadAddedFieldApplyFixture).FullName + "._addedCount"; Assert.That( second.Warnings ?? new List(), - Has.Some.Contains(DeclaredTypeChangedToken) - .And.Some.Contains(typeof(HotReloadAddedFieldApplyFixture).FullName + "._addedCount"), + Has.Some.Matches( + warning => warning.Contains(DeclaredTypeChangedToken) && warning.Contains(changedName)), FormatOutcomes(second)); + Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(2), FormatOutcomes(second)); } /// From 0d310b6bbf497ecd7425317ebeb1e38d12afd727 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 21:38:51 +0900 Subject: [PATCH 12/26] test(hot-reload): Pin lambda initializers of added fields and events A lambda that calls a private static of the host is still refused, so the body of an anonymous function stays checked, and an added field whose lambda names only its parameter applies end to end. --- ...loadAddedFieldLambdaInitializerE2ETests.cs | 84 +++++++++++++++++++ ...ddedFieldLambdaInitializerE2ETests.cs.meta | 11 +++ .../TransformWorkerAddedEventTests.cs | 19 +++-- 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 Assets/Tests/Editor/HotReload/HotReloadAddedFieldLambdaInitializerE2ETests.cs create mode 100644 Assets/Tests/Editor/HotReload/HotReloadAddedFieldLambdaInitializerE2ETests.cs.meta diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedFieldLambdaInitializerE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldLambdaInitializerE2ETests.cs new file mode 100644 index 000000000..8a8699c6a --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldLambdaInitializerE2ETests.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload +{ + /// + /// End-to-end EditMode coverage for an added field whose initializer is a lambda, which the + /// store runs on first access like any other initializer it can emit. + /// + public class HotReloadAddedFieldLambdaInitializerE2ETests + { + private const string FixtureFileName = "HotReloadAddedFieldApplyFixture.cs"; + private const string ReadAddedOriginal = + " public int ReadAdded()\n {\n return 0;\n }"; + + private HotReloadDomainTestScope _scope; + + [SetUp] + public void SetUp() + { + _scope = new HotReloadDomainTestScope(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + } + + [TearDown] + public void TearDown() + { + _scope.Dispose(); + HotReloadAutoRefreshHold.SyncToActiveChanges(); + VibeLogger.ClearMemoryLogs(); + } + + /// + /// What: an added field initialized with a lambda that names only its own parameter + /// applies, and the patched reader calls the stored delegate. + /// + [Test] + public async Task Run_AddedFieldWithLambdaInitializer_PatchedReaderCallsIt() + { + string onDisk = File.ReadAllText(FixturePath()); + Assert.That(onDisk, Does.Contain(ReadAddedOriginal), "Precondition: ReadAdded anchor must exist."); + string edited = onDisk.Replace( + ReadAddedOriginal, + " private System.Func _addedStep = v => v + 1;\n\n" + + " public int ReadAdded()\n {\n return _addedStep(4);\n }"); + + HotReloadOrchestratorResult result = await HotReloadCompositionRoot.Services.Orchestrator.RunAsync( + new[] { FixturePath() }, + HotReloadTestSourceWriter.WriteEditedSource("AddedFieldLambdaInitializerE2E.cs", edited), + CancellationToken.None); + + Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(5), FormatOutcomes(result)); + } + + private static string FixturePath() + { + string path = Path.GetFullPath( + Path.Combine(Application.dataPath, "Tests", "Editor", "HotReload", FixtureFileName)); + Assert.That(File.Exists(path), Is.True, "Fixture missing: " + path); + return path; + } + + private static string FormatOutcomes(HotReloadOrchestratorResult result) + { + List lines = new List(); + foreach (HotReloadMethodOutcome outcome in result.Methods) + { + lines.Add(outcome.Kind + " " + outcome.Method + " @" + outcome.FilePath + " :: " + outcome.Reason); + } + + lines.AddRange(result.Warnings ?? new List()); + return string.Join("\n", lines); + } + } +} diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedFieldLambdaInitializerE2ETests.cs.meta b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldLambdaInitializerE2ETests.cs.meta new file mode 100644 index 000000000..3b2af1c74 --- /dev/null +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldLambdaInitializerE2ETests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7bf056d008f314bbea95e9d047e8f1af +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs index 646d61d13..e870868da 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs @@ -160,14 +160,23 @@ public async Task Classify_EventWithChangedStaticness_KeepsTodaysReasons() /// /// What: an initializer the store can run is emitted with the event, and one it cannot run - /// skips the using body with the added-field initializer reason instead of failing. + /// skips the using body with the added-field initializer reason instead of failing. The + /// lambda that calls a private static of the host pins that a lambda's body is still + /// checked name by name, not let through because it is an anonymous function. /// - [TestCase("delegate { }", true)] - [TestCase("new Action(RaiseExisting)", false)] - public async Task Classify_EventInitializer_FollowsTheFieldInitializerRules(string initializer, bool emittable) + [TestCase("delegate { }", "", true)] + [TestCase("new Action(RaiseExisting)", "", false)] + [TestCase( + "v => HostPrivateStatic(v)", + "\n private static void HostPrivateStatic(int value)\n {\n }\n", + false)] + public async Task Classify_EventInitializer_FollowsTheFieldInitializerRules( + string initializer, + string extraMember, + bool emittable) { string publisher = WithRaiseBody( - WithAddedEvent("\n public event Action Changed = " + initializer + ";"), + WithAddedEvent(extraMember + "\n public event Action Changed = " + initializer + ";"), "Changed?.Invoke(value);"); TransformWorkerClientResult result = await RunAsync(publisher, ReadOnDisk(SubscriberFileName)); From 729f4665db47b4f907aa87b77f33a209b56d7a3c Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 21:52:41 +0900 Subject: [PATCH 13/26] fix(hot-reload): Keep refusing events added to a generic class The store key names the open definition, so every closed instantiation of a generic host would have shared one subscriber list. The worker tests also pin that an added getter planned through accessor delegates raises the event through the store without planning a backing field. --- .../HotReload/HotReloadAddedEventPublisher.cs | 15 +++++++ .../TransformWorkerAddedEventTests.cs | 39 +++++++++++++++++++ .../TransformWorker~/AddedEventStorePolicy.cs | 20 +++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventPublisher.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventPublisher.cs index 3b50ef2ed..484a7bf47 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedEventPublisher.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventPublisher.cs @@ -20,6 +20,12 @@ public HotReloadAddedEventPublisher Self() return this; } + // Private, so a body calling it goes through accessor delegates. + private int Secret() + { + return 2; + } + public void RaiseExisting(int value) { Existing?.Invoke(value); @@ -48,6 +54,15 @@ public void RaiseInnerExisting(int value) } } + /// + /// Generic publisher, because the added-field store keys an event by the open definition, so + /// every closed instantiation would share one slot. + /// + public sealed class HotReloadAddedEventGenericHost + { + public static int Marker; + } + /// /// A delegate type that code in another assembly cannot name, so an event of this type /// cannot be reached from a shim. diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs index e870868da..7705a2872 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventTests.cs @@ -35,6 +35,7 @@ public class TransformWorkerAddedEventTests private const string CountAnchor = " public int Count => 0;"; private const string PayloadAnchor = " public int Value;"; private const string ClashAnchor = " public int Clash;"; + private const string GenericMarkerAnchor = " public static int Marker;"; private const string SubscriberMethodAnchor = " public int Received => _received;"; private const string WireBody = " publisher.Existing += Accept;"; private const string AddedEvent = "\n public event Action Changed;"; @@ -57,6 +58,44 @@ public async Task Rewrite_EachReadAndWriteForm_UsesTheStore(string raiseBody) AssertEmittedThroughStore(result, PublisherTypeMetadataName, "RaiseExisting", ChangedKey); } + /// + /// What: an added property's getter that calls a private member, so its body is planned + /// through accessor delegates, raises the added event through the store and the plan + /// holds no backing-field accessor for the event, which the compiled class does not have. + /// + [Test] + public async Task Rewrite_AddedGetterWithPrivateAccess_PlansNoBackingFieldForTheEvent() + { + string publisher = WithAddedEvent( + AddedEvent + "\n\n public int Fire\n {\n get\n {\n" + + " Changed?.Invoke(Secret());\n return 1;\n }\n }"); + TransformWorkerClientResult result = await RunAsync(publisher, ReadOnDisk(SubscriberFileName)); + + AssertEmittedThroughStore(result, PublisherTypeMetadataName, "get_Fire", ChangedKey); + Assert.That(result.Output.shimSource, Does.Not.Contain("__EV_Changed")); + } + + /// + /// What: a static event added to a generic class keeps today's refusal when another type + /// subscribes through a closed instantiation, because one store slot would serve every + /// instantiation the CLR keeps apart. + /// + [Test] + public async Task Classify_EventOnGenericHost_StaysRefused() + { + string publisher = ReplaceInSource( + ReadOnDisk(PublisherFileName), + GenericMarkerAnchor, + GenericMarkerAnchor + "\n\n public static event Action GenericChanged;"); + TransformWorkerClientResult result = await RunAsync( + publisher, + WithSubscriberMethod( + "public void WireGeneric()\n {\n" + + " HotReloadAddedEventGenericHost.GenericChanged += Accept;\n }")); + + AssertSkippedWith(result, "WireGeneric", HotReloadWorkerReasonCode.EventSubscriptionToAddedEvent); + } + /// /// What: an event added to a struct is skipped with the reason an added struct field gets, /// because a boxed struct has no identity the store could key a value by. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventStorePolicy.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventStorePolicy.cs index 26ca78417..5c51a5ad0 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventStorePolicy.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedEventStorePolicy.cs @@ -21,7 +21,9 @@ internal static bool IsStoreBacked(IEventSymbol eventSymbol, INamedTypeSymbol co return false; } - if (!IsFieldLike(eventSymbol) || !HasStoreHost(eventSymbol.ContainingType)) + if (!IsFieldLike(eventSymbol) + || !HasStoreHost(eventSymbol.ContainingType) + || HasTypeArgumentsInHostChain(eventSymbol.ContainingType)) { return false; } @@ -84,6 +86,22 @@ private static bool IsFieldLike(IEventSymbol eventSymbol) return true; } + // Why the outer types too: the store key names the open definition, so Host.E and + // Host.E, or Outer.Inner.E and Outer.Inner.E, would share one slot + // where the CLR gives each closed instantiation its own event. + private static bool HasTypeArgumentsInHostChain(INamedTypeSymbol containingType) + { + for (INamedTypeSymbol current = containingType; current != null; current = current.ContainingType) + { + if (current.TypeParameters.Length > 0) + { + return true; + } + } + + return false; + } + // Why a struct is let through: its event gets a binding the classifier marks unavailable, so // a use is skipped with the reason an added struct field gets rather than an event reason. private static bool HasStoreHost(INamedTypeSymbol containingType) From 581d2d9c742cb19c37e7b5d881d12d580a53d845 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 21:52:41 +0900 Subject: [PATCH 14/26] test(hot-reload): Pin getters with private access raising an added event end to end Both an added property's getter and an edited compiled getter take the accessor-delegate path without the added-member lookup, and both must still deliver the raise. --- .../HotReloadAddedEventApplyPublisher.cs | 14 +++++ .../HotReload/HotReloadAddedEventE2ETests.cs | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs index 6b1878dbc..081cda58e 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs @@ -21,5 +21,19 @@ public void Raise(int value) public static void RaiseStatic(int value) { } + + // A compiled getter an edit can make raise an added event. + public int Probe + { + [MethodImpl(MethodImplOptions.NoInlining)] + get { return 0; } + } + + // Private, so a body calling it is rewritten through accessor delegates, the path that + // must not plan a backing field for an added event. + private int Secret() + { + return 2; + } } } diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs index 2bc69ead7..86174917c 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs @@ -115,6 +115,52 @@ public async Task Run_UnsubscribeWithTheSameDelegate_StopsDelivery() Assert.That(listener.Received, Is.EqualTo(2), FormatOutcomes(result)); } + /// + /// What: an added property's getter that calls a private member, and so goes through + /// accessor delegates, raises the added event through the store and the raise arrives, + /// instead of the run planning a backing field the compiled class does not have. + /// + [Test] + public async Task Run_AddedPropertyGetterWithPrivateAccessRaisesAddedEvent_ReachesSubscriber() + { + string publisher = EditPublisher( + AddedEvent + "\n\n public int Fire\n {\n" + + " get\n {\n Changed?.Invoke(Secret());\n" + + " return 1;\n }\n }", + " if (Changed == null)\n {\n" + + " Changed += forwarded => Existing?.Invoke(forwarded);\n }\n\n" + + " _ = Fire;"); + + HotReloadOrchestratorResult result = await RunAsync(publisher, ReadFixture(SubscriberFileName)); + + AssertPatched(result, ".Raise("); + Assert.That(RaiseThroughExisting(), Is.EqualTo(2), FormatOutcomes(result)); + } + + /// + /// What: a compiled property's getter edited to call a private member and raise the added + /// event is patched, and the raise reaches a subscriber. + /// + [Test] + public async Task Run_CompiledGetterWithPrivateAccessRaisesAddedEvent_ReachesSubscriber() + { + string publisher = EditPublisher( + AddedEvent, + " if (Changed == null)\n {\n" + + " Changed += forwarded => Existing?.Invoke(forwarded);\n }\n\n" + + " _ = Probe;"); + publisher = ReplaceInSource( + publisher, + " get { return 0; }", + " get\n {\n Changed?.Invoke(Secret());\n" + + " return 1;\n }"); + + HotReloadOrchestratorResult result = await RunAsync(publisher, ReadFixture(SubscriberFileName)); + + AssertPatched(result, ".Raise("); + Assert.That(RaiseThroughExisting(), Is.EqualTo(2), FormatOutcomes(result)); + } + /// /// What: another type subscribing to a compiled event through parentheses, /// '(publisher.Existing) += h', is patched and the compiled raise reaches the handler, @@ -289,6 +335,15 @@ public async Task Run_RemovedAddedEvent_LeavesTheStoredValueLikeAField() Assert.That(listener.Received, Is.EqualTo(4), FormatOutcomes(third)); } + private static int RaiseThroughExisting() + { + HotReloadAddedEventApplyPublisher target = new HotReloadAddedEventApplyPublisher(); + HotReloadAddedEventApplySubscriber listener = new HotReloadAddedEventApplySubscriber(); + target.Existing += listener.Accept; + target.Raise(0); + return listener.Received; + } + private static int WireAndRaise(int value) { HotReloadAddedEventApplyPublisher target = new HotReloadAddedEventApplyPublisher(); From 195817e154a49ff720240ac512c0d30c3bd8ebc5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 21:56:11 +0900 Subject: [PATCH 15/26] test(hot-reload): Make the added-event and declared-type tests observe the outcome The declared-type test reads the instance whose value was stored before the change, the introduced-type test raises the added event and checks the handler received it, and the stub test checks the body was replaced by the stub. --- ...loadAddedFieldDeclaredTypeChangeE2ETests.cs | 7 +++++-- .../HotReloadIntroducedTypeBodyEditE2ETests.cs | 18 +++++++++++++----- .../TransformWorkerIntroducedTypeStubTests.cs | 1 + 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs index 835c2b090..e7f518604 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedFieldDeclaredTypeChangeE2ETests.cs @@ -49,7 +49,8 @@ public void TearDown() public async Task Run_AddedFieldDeclaredTypeChanged_WarnsNamingTheField() { await RunAsync(WithAddedMember("private int _addedCount = 3;", "return _addedCount;")); - Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(3)); + HotReloadAddedFieldApplyFixture fixture = new HotReloadAddedFieldApplyFixture(); + Assert.That(fixture.ReadAdded(), Is.EqualTo(3)); HotReloadOrchestratorResult second = await RunAsync( WithAddedMember("private string _addedCount = \"ab\";", "return _addedCount.Length;")); @@ -62,7 +63,9 @@ public async Task Run_AddedFieldDeclaredTypeChanged_WarnsNamingTheField() Has.Some.Matches( warning => warning.Contains(DeclaredTypeChangedToken) && warning.Contains(changedName)), FormatOutcomes(second)); - Assert.That(new HotReloadAddedFieldApplyFixture().ReadAdded(), Is.EqualTo(2), FormatOutcomes(second)); + // Why the instance read before the change: its stored int is what the new type + // cannot hold, so reading 2 there shows the value was initialized again. + Assert.That(fixture.ReadAdded(), Is.EqualTo(2), FormatOutcomes(second)); } /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeBodyEditE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeBodyEditE2ETests.cs index 9f74277a4..5e8e7309d 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeBodyEditE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeBodyEditE2ETests.cs @@ -31,6 +31,10 @@ public class HotReloadIntroducedTypeBodyEditE2ETests : HotReloadIntroducedTypeE2 private const string IntroducedTypeMetadataName = "io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload." + IntroducedTypeSimpleName; private const int IntroducedSeed = 5; + + // Added to the seed when the added event is raised, so the handler's value is told apart + // from a body that returns the seed without the raise arriving. + private const int AddedEventRaiseOffset = 2; private const int EditedComputedValue = IntroducedSeed * 2; private const string SeedExpression = "_seed"; private const string EditedExpression = "_seed * 2"; @@ -169,8 +173,8 @@ await RunInIntroducedTypeDomainAsync(async readArtifact => DescribeOutcomes(second)); AssertComputedValue( readArtifact(), - IntroducedSeed, - "The patched body subscribes and still returns the seed."); + IntroducedSeed + AddedEventRaiseOffset, + "The patched body's handler must receive the value the added event is raised with."); }); } @@ -823,14 +827,18 @@ private static Dictionary CreateAddedEventSubscriptionEdits(stri Assert.That(host, Does.Contain(HostStoredFieldAnchor), "Precondition: host field anchor must exist."); string hostWithEvent = host.Replace( HostStoredFieldAnchor, - HostStoredFieldAnchor + "\n\n public event System.Action " + AddedHostEventName + ";", + HostStoredFieldAnchor + "\n\n public event System.Action " + AddedHostEventName + ";\n\n" + + " public void RaiseAddedEvent(int value)\n {\n" + + " " + AddedHostEventName + "?.Invoke(value);\n }", StringComparison.Ordinal); string subscribingExpression = "new System.Func(() =>\n" + " {\n" + " HotReloadCrossFileAddedMemberHost host = new HotReloadCrossFileAddedMemberHost();\n" - + " host." + AddedHostEventName + " += value => { };\n" - + " return _seed;\n" + + " int received = 0;\n" + + " host." + AddedHostEventName + " += value => received = value;\n" + + " host.RaiseAddedEvent(_seed + " + AddedEventRaiseOffset + ");\n" + + " return received;\n" + " })()"; return new Dictionary { diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeStubTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeStubTests.cs index 644f23a52..e96dfaf46 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeStubTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerIntroducedTypeStubTests.cs @@ -219,6 +219,7 @@ public async Task PrepareIntroducedTypes_BodyUsingAnAddedEvent_IsStubbed() Assert.That(introducedType.stubbedMethodKeys, Is.EqualTo(new[] { "Example.Stubs.Caller::Subscribes()" })); Assert.That(introducedType.source, Does.Not.Contain("host.AddedEvent += () => { };")); + Assert.That(introducedType.source, Does.Contain("{ " + StubThrow + "\"'Example.Stubs.Caller.Subscribes' calls members")); } /// From db3c1797374ff5173a867311d39d3fc22814e425 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 1 Oct 2026 21:56:11 +0900 Subject: [PATCH 16/26] fix(hot-reload): Say a delegate type change drops subscribers only when the old list cannot be read The store reads a value as the new type when it can, so a variance-compatible change keeps the subscribers. --- .../FirstPartyTools/HotReload/Shared/HotReloadConstants.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 5f8f7d1a2..675fd9c6c 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -337,7 +337,8 @@ internal static class HotReloadConstants public const string AddedFieldDeclaredTypeChangedWarningFormat = "A previous reload already added these fields with a different type, so a value stored " + "under the old type is replaced by the initializer (or the default) wherever the new " - + "type cannot hold it; an added event loses its subscribers: {0}. Assign or subscribe " + + "type cannot hold it; an added event loses its subscribers unless the new delegate type " + + "can still be read from the old one: {0}. Assign or subscribe " + "again inside a patched method, or run 'uloop compile'."; public const string MissingUsingCompileHint = From d5862634631ddfc886a9a27523e8fc13bb03b1ae Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 2 Oct 2026 09:57:31 +0900 Subject: [PATCH 17/26] docs(hot-reload): Correct the lifetime and scope of added events Subscriptions stay in the store until --revert-all, a compile, or a domain reload, so a static event's outlive Play Mode with Domain Reload off. The docs also limit added events to non-generic classes visible outside the assembly, note that a private or added handler needs a lambda, and say that --status rows and AddedFieldTotal include added events. --- .agents/skills/uloop-hot-reload/SKILL.md | 4 ++-- .../uloop-hot-reload/references/output.md | 4 ++-- .../references/scope-and-limits.md | 19 +++++++++++-------- .claude/skills/uloop-hot-reload/SKILL.md | 4 ++-- .../uloop-hot-reload/references/output.md | 4 ++-- .../references/scope-and-limits.md | 19 +++++++++++-------- .../FirstPartyTools/HotReload/Skill/SKILL.md | 4 ++-- .../HotReload/Skill/references/output.md | 4 ++-- .../Skill/references/scope-and-limits.md | 19 +++++++++++-------- ...-where-ordinary-code-sees-no-difference.md | 15 +++++++++------ docs/hot-reload.md | 2 +- 11 files changed, 55 insertions(+), 43 deletions(-) diff --git a/.agents/skills/uloop-hot-reload/SKILL.md b/.agents/skills/uloop-hot-reload/SKILL.md index 9fb664c86..01dd4c0a9 100644 --- a/.agents/skills/uloop-hot-reload/SKILL.md +++ b/.agents/skills/uloop-hot-reload/SKILL.md @@ -63,8 +63,8 @@ changed are patched (`UnchangedTotal` counts the rest). ## Scope in Brief - Patched: ordinary method bodies and property getters with a body. -- Added members: new methods, fields, field-like events of a class, and supported properties - apply as `Added` rows, +- Added members: new methods, fields, and supported properties apply as `Added` rows + (supported field-like events of a class are listed in `AddedFields`), visible to edited code of the same reload within the same assembly (pass the declaring file and its callers together), and gone on any compile or domain reload. - New types: a top-level class, struct, enum, or interface declared in an edited diff --git a/.agents/skills/uloop-hot-reload/references/output.md b/.agents/skills/uloop-hot-reload/references/output.md index 87faf9720..d1f1514ca 100644 --- a/.agents/skills/uloop-hot-reload/references/output.md +++ b/.agents/skills/uloop-hot-reload/references/output.md @@ -5,7 +5,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 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. `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. - `PatchedTotal` (number): Methods patched in this run - `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. @@ -13,7 +13,7 @@ Returns JSON with: - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `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. -- `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` +- `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) - `UnrestoredWiredValues` (array, `--status` only): `{ Host, Field, Reason }` rows for wired values that scene reload could not give back; `Field` is `Type.field` (nested types joined with `+`) and `Host` names the rebuilt object's place. Omitted when empty. Each `Reason` ends with what brings the value back. A value that comes back later in the same session drops its row. Before listing rows, `--status` and apply check each wired value's host again: a host back at its place has its value read and restored, and its row drops; a value that still fails keeps its row with the reason that holds now; a host still missing keeps its row as it was. The same failures appear in `Warnings` as one `Wired added-field value(s) did not come back after the scene reload: ...` line, on `--status` every time and on apply runs only the first time each failure is seen. On an apply run in unpaused Play Mode, that line also brings the warning to pause before wiring diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index 0ce09c73a..c8288d6ed 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -360,12 +360,12 @@ that the added-field store cannot hold (see below). Raising through a conditional receiver (`other?.E?.Invoke(x)`) and `nameof(E)` also stay `Skipped`. -A field-like event added in this edit to a compiled class keeps its delegate in the -added-field store, like an added field, so edited bodies in any file of the same reload +A field-like event added in this edit to a compiled, non-generic class that is visible +outside the assembly keeps its delegate in the added-field store, like an added field, so edited bodies in any file of the same reload can subscribe, unsubscribe, raise, read, and assign it (instance or static). It is listed in `AddedFields`, shares the added-field lifetime, and compiled code that is not patched -cannot see it. These stay `Skipped`: a struct host (the struct-field reason), a delegate -type not visible outside the assembly, custom `add`/`remove` accessors, a name the +cannot see it. These stay `Skipped`: a struct host (the struct-field reason), a generic +host or one nested in a generic type, a delegate type not visible outside the assembly, custom `add`/`remove` accessors, a name the compiled class already uses for another member, `E ??= h`, `nameof(E)`, `a?.E += h`, passing it by `ref`, an initializer an added field could not have, and `Get().E += h` (the receiver would be evaluated twice). A handler that is a method group of an added method or of a compiled private method is @@ -374,10 +374,13 @@ Subscriptions live on the store's delegate, not on Unity objects: `+=` is not at subscribe and raise on the main thread only. A handler subscribed by an earlier reload keeps running the body it was subscribed with until it is removed and subscribed again (for a subscription made in `OnEnable`, toggle `enabled` as described under "Added Unity -messages"). Changing the event's delegate type in a later reload drops its subscribers, -and that reload names it in `Warnings`. Deleting the event from the source removes it -from `AddedFields`, but its subscribers stay in the store until `--revert-all`, so adding -it back delivers to them again. +messages"). Changing the event's delegate type in a later reload drops its subscribers unless the +new type can still read the old list (a variance-compatible change), and that reload names +it in `Warnings`. The store keeps subscriptions until `--revert-all`, a compile, or a domain +reload: an instance event's subscriptions go with the object, while a static event's outlive +Play Mode when Domain Reload is off, as a compiled static event's do. Deleting the event from +the source removes it from `AddedFields`, but its subscribers stay in the store until +`--revert-all`, so adding it back delivers to them again. A `Skipped` row never undoes what an earlier reload applied to the same method: that patch keeps running, so the method matches neither the compiled assembly nor the diff --git a/.claude/skills/uloop-hot-reload/SKILL.md b/.claude/skills/uloop-hot-reload/SKILL.md index 9fb664c86..01dd4c0a9 100644 --- a/.claude/skills/uloop-hot-reload/SKILL.md +++ b/.claude/skills/uloop-hot-reload/SKILL.md @@ -63,8 +63,8 @@ changed are patched (`UnchangedTotal` counts the rest). ## Scope in Brief - Patched: ordinary method bodies and property getters with a body. -- Added members: new methods, fields, field-like events of a class, and supported properties - apply as `Added` rows, +- Added members: new methods, fields, and supported properties apply as `Added` rows + (supported field-like events of a class are listed in `AddedFields`), visible to edited code of the same reload within the same assembly (pass the declaring file and its callers together), and gone on any compile or domain reload. - New types: a top-level class, struct, enum, or interface declared in an edited diff --git a/.claude/skills/uloop-hot-reload/references/output.md b/.claude/skills/uloop-hot-reload/references/output.md index 87faf9720..d1f1514ca 100644 --- a/.claude/skills/uloop-hot-reload/references/output.md +++ b/.claude/skills/uloop-hot-reload/references/output.md @@ -5,7 +5,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 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. `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. - `PatchedTotal` (number): Methods patched in this run - `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. @@ -13,7 +13,7 @@ Returns JSON with: - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `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. -- `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` +- `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) - `UnrestoredWiredValues` (array, `--status` only): `{ Host, Field, Reason }` rows for wired values that scene reload could not give back; `Field` is `Type.field` (nested types joined with `+`) and `Host` names the rebuilt object's place. Omitted when empty. Each `Reason` ends with what brings the value back. A value that comes back later in the same session drops its row. Before listing rows, `--status` and apply check each wired value's host again: a host back at its place has its value read and restored, and its row drops; a value that still fails keeps its row with the reason that holds now; a host still missing keeps its row as it was. The same failures appear in `Warnings` as one `Wired added-field value(s) did not come back after the scene reload: ...` line, on `--status` every time and on apply runs only the first time each failure is seen. On an apply run in unpaused Play Mode, that line also brings the warning to pause before wiring diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index 0ce09c73a..c8288d6ed 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -360,12 +360,12 @@ that the added-field store cannot hold (see below). Raising through a conditional receiver (`other?.E?.Invoke(x)`) and `nameof(E)` also stay `Skipped`. -A field-like event added in this edit to a compiled class keeps its delegate in the -added-field store, like an added field, so edited bodies in any file of the same reload +A field-like event added in this edit to a compiled, non-generic class that is visible +outside the assembly keeps its delegate in the added-field store, like an added field, so edited bodies in any file of the same reload can subscribe, unsubscribe, raise, read, and assign it (instance or static). It is listed in `AddedFields`, shares the added-field lifetime, and compiled code that is not patched -cannot see it. These stay `Skipped`: a struct host (the struct-field reason), a delegate -type not visible outside the assembly, custom `add`/`remove` accessors, a name the +cannot see it. These stay `Skipped`: a struct host (the struct-field reason), a generic +host or one nested in a generic type, a delegate type not visible outside the assembly, custom `add`/`remove` accessors, a name the compiled class already uses for another member, `E ??= h`, `nameof(E)`, `a?.E += h`, passing it by `ref`, an initializer an added field could not have, and `Get().E += h` (the receiver would be evaluated twice). A handler that is a method group of an added method or of a compiled private method is @@ -374,10 +374,13 @@ Subscriptions live on the store's delegate, not on Unity objects: `+=` is not at subscribe and raise on the main thread only. A handler subscribed by an earlier reload keeps running the body it was subscribed with until it is removed and subscribed again (for a subscription made in `OnEnable`, toggle `enabled` as described under "Added Unity -messages"). Changing the event's delegate type in a later reload drops its subscribers, -and that reload names it in `Warnings`. Deleting the event from the source removes it -from `AddedFields`, but its subscribers stay in the store until `--revert-all`, so adding -it back delivers to them again. +messages"). Changing the event's delegate type in a later reload drops its subscribers unless the +new type can still read the old list (a variance-compatible change), and that reload names +it in `Warnings`. The store keeps subscriptions until `--revert-all`, a compile, or a domain +reload: an instance event's subscriptions go with the object, while a static event's outlive +Play Mode when Domain Reload is off, as a compiled static event's do. Deleting the event from +the source removes it from `AddedFields`, but its subscribers stay in the store until +`--revert-all`, so adding it back delivers to them again. A `Skipped` row never undoes what an earlier reload applied to the same method: that patch keeps running, so the method matches neither the compiled assembly nor the diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md index 9fb664c86..01dd4c0a9 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md @@ -63,8 +63,8 @@ changed are patched (`UnchangedTotal` counts the rest). ## Scope in Brief - Patched: ordinary method bodies and property getters with a body. -- Added members: new methods, fields, field-like events of a class, and supported properties - apply as `Added` rows, +- Added members: new methods, fields, and supported properties apply as `Added` rows + (supported field-like events of a class are listed in `AddedFields`), visible to edited code of the same reload within the same assembly (pass the declaring file and its callers together), and gone on any compile or domain reload. - New types: a top-level class, struct, enum, or interface declared in an edited diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md index 87faf9720..d1f1514ca 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md @@ -5,7 +5,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 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. `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. - `PatchedTotal` (number): Methods patched in this run - `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. @@ -13,7 +13,7 @@ Returns JSON with: - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `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. -- `AddedFieldTotal` (number): Live added-field ledger rows after this run or on `--status`. Those rows appear as `Kind` `AddedField` on `--status` only; they are not counted in `ActivePatchTotal` +- `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) - `UnrestoredWiredValues` (array, `--status` only): `{ Host, Field, Reason }` rows for wired values that scene reload could not give back; `Field` is `Type.field` (nested types joined with `+`) and `Host` names the rebuilt object's place. Omitted when empty. Each `Reason` ends with what brings the value back. A value that comes back later in the same session drops its row. Before listing rows, `--status` and apply check each wired value's host again: a host back at its place has its value read and restored, and its row drops; a value that still fails keeps its row with the reason that holds now; a host still missing keeps its row as it was. The same failures appear in `Warnings` as one `Wired added-field value(s) did not come back after the scene reload: ...` line, on `--status` every time and on apply runs only the first time each failure is seen. On an apply run in unpaused Play Mode, that line also brings the warning to pause before wiring diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index 0ce09c73a..c8288d6ed 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -360,12 +360,12 @@ that the added-field store cannot hold (see below). Raising through a conditional receiver (`other?.E?.Invoke(x)`) and `nameof(E)` also stay `Skipped`. -A field-like event added in this edit to a compiled class keeps its delegate in the -added-field store, like an added field, so edited bodies in any file of the same reload +A field-like event added in this edit to a compiled, non-generic class that is visible +outside the assembly keeps its delegate in the added-field store, like an added field, so edited bodies in any file of the same reload can subscribe, unsubscribe, raise, read, and assign it (instance or static). It is listed in `AddedFields`, shares the added-field lifetime, and compiled code that is not patched -cannot see it. These stay `Skipped`: a struct host (the struct-field reason), a delegate -type not visible outside the assembly, custom `add`/`remove` accessors, a name the +cannot see it. These stay `Skipped`: a struct host (the struct-field reason), a generic +host or one nested in a generic type, a delegate type not visible outside the assembly, custom `add`/`remove` accessors, a name the compiled class already uses for another member, `E ??= h`, `nameof(E)`, `a?.E += h`, passing it by `ref`, an initializer an added field could not have, and `Get().E += h` (the receiver would be evaluated twice). A handler that is a method group of an added method or of a compiled private method is @@ -374,10 +374,13 @@ Subscriptions live on the store's delegate, not on Unity objects: `+=` is not at subscribe and raise on the main thread only. A handler subscribed by an earlier reload keeps running the body it was subscribed with until it is removed and subscribed again (for a subscription made in `OnEnable`, toggle `enabled` as described under "Added Unity -messages"). Changing the event's delegate type in a later reload drops its subscribers, -and that reload names it in `Warnings`. Deleting the event from the source removes it -from `AddedFields`, but its subscribers stay in the store until `--revert-all`, so adding -it back delivers to them again. +messages"). Changing the event's delegate type in a later reload drops its subscribers unless the +new type can still read the old list (a variance-compatible change), and that reload names +it in `Warnings`. The store keeps subscriptions until `--revert-all`, a compile, or a domain +reload: an instance event's subscriptions go with the object, while a static event's outlive +Play Mode when Domain Reload is off, as a compiled static event's do. Deleting the event from +the source removes it from `AddedFields`, but its subscribers stay in the store until +`--revert-all`, so adding it back delivers to them again. A `Skipped` row never undoes what an earlier reload applied to the same method: that patch keeps running, so the method matches neither the compiled assembly nor the diff --git a/docs/adr/0011-hot-reload-added-members-only-where-ordinary-code-sees-no-difference.md b/docs/adr/0011-hot-reload-added-members-only-where-ordinary-code-sees-no-difference.md index 9edb514d3..d892b3996 100644 --- a/docs/adr/0011-hot-reload-added-members-only-where-ordinary-code-sees-no-difference.md +++ b/docs/adr/0011-hot-reload-added-members-only-where-ordinary-code-sees-no-difference.md @@ -24,10 +24,12 @@ Applying this question to the requests from the 2026-10-01 usability round: added-field store under the same key form as an added field. Subscribing, unsubscribing, raising, comparing with null, and assigning work as they do after a compile, from the declaring type and from other types in the same run. What differs: reflection does not see - the event, `+=` is not atomic (the store is main-thread only, as for added fields), and - subscriptions are dropped when Play Mode ends or `--revert-all` runs. Event declarations - with accessors, events on structs, events whose delegate type is not visible, and names that - clash with a compiled member stay refused. + the event, `+=` is not atomic (the store is main-thread only, as for added fields), and the + store keeps subscriptions until `--revert-all`, a compile, or a domain reload. An instance + event's subscriptions go with the object; a static event's outlive Play Mode when Domain + Reload is off, as a compiled static event's do. Event declarations with accessors, events on + structs or generic classes, events whose declaring or delegate type is not visible outside the + assembly, and names that clash with a compiled member stay refused. - **Not taken — forwarding an added `Awake`, `OnEnable`, `OnDisable`, or `OnDestroy`.** Unity calls these only on methods that exist when the object is created. Forwarding them through a hidden component runs them after the compiled lifecycle methods, ignores Script Execution @@ -88,8 +90,9 @@ added event reuses it, so the event change adds no new runtime mechanism. ## Consequences - An added field-like event on a compiled class can be raised and subscribed to without leaving - Play Mode. Subscribing with a lambda or a compiled method works; subscribing with a method - group that names an added method stays refused. + Play Mode. Subscribing with a lambda or an accessible compiled method works; a private + handler, or one that is an added method, is subscribed through a lambda, since a method group + naming either stays refused. - An added `Awake`, `OnEnable`, `OnDisable`, or `OnDestroy` still needs a compile, as before. Code added to an existing compiled lifecycle method is patched as any other body edit; Unity does not call it again for objects that already ran it. diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 59efb672d..a9f4962a6 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -415,7 +415,7 @@ Wire details: edited in any file of the same assembly passed to the same reload — including on a type an earlier reload introduced, where they are applied on the artifact that already carries it. An added auto-property is backed by the added-field store, so its value shares that lifetime, - and so is a field-like event added to a compiled class (ADR 0011). Other added events, indexers, and the property shapes listed in the skill's scope reference + and so is a field-like event added to a compiled, non-generic class whose declaring and delegate types are visible outside the assembly (ADR 0011; not on a struct, not with custom accessors). Other added events, indexers, and the property shapes listed in the skill's scope reference (set-only, virtual, explicit interface, `init`, struct host) still require `uloop compile`. Unchanged files of that assembly that already hold active patches are re-applied so they bind to the newest shim. Shim compile errors caused by references to members that are still missing are reported with that hint, and changed `const` values From caf6887c50cfef73ca28425f6bb5aca72e50003d Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 2 Oct 2026 10:10:23 +0900 Subject: [PATCH 18/26] fix(hot-reload): Point subscriptions to added events outside the store at a compile only Passing the declaring file or moving the call into a method does not help such a subscription, since the worker refuses it in a method too; the hint now names 'uloop compile' as its only step. --- .../references/introduced-types.md | 7 ++++--- .../references/introduced-types.md | 7 ++++--- ...tReloadIntroducedTypeAddedMemberHintE2ETests.cs | 8 +++++--- ...oadIntroducedTypeCompileFailureOutcomesTests.cs | 14 +++++++------- ...otReloadIntroducedTypeCompileFailureOutcomes.cs | 10 +++++----- .../HotReload/Skill/references/introduced-types.md | 7 ++++--- 6 files changed, 29 insertions(+), 24 deletions(-) diff --git a/.agents/skills/uloop-hot-reload/references/introduced-types.md b/.agents/skills/uloop-hot-reload/references/introduced-types.md index 442499ac2..ef375a3b2 100644 --- a/.agents/skills/uloop-hot-reload/references/introduced-types.md +++ b/.agents/skills/uloop-hot-reload/references/introduced-types.md @@ -154,9 +154,10 @@ reload pulls back in on its own. Constructors, initializers, setters, indexers, operators and event accessors cannot be patched, so a call from them still fails the artifact compile (CS1061 or CS0117) with a hint saying where such a call works. So does a call to an addition in another -assembly, or in a file that changed since it was last applied and is not passed, and so does a -subscription to an added event the added-field store cannot hold (custom `add`/`remove` -accessors, or a delegate type not visible outside the assembly). +assembly, or in a file that changed since it was last applied and is not passed. A subscription +to an added event the added-field store cannot hold (custom `add`/`remove` accessors, or a +delegate type not visible outside the assembly) fails the same way wherever it is written, even +in a method; only `uloop compile` makes it bind. - When this reload does not patch a stubbed body (a generic method, or a method of a struct, is `Skipped`), no type of that artifact is introduced: the stubbed type's row reads `Not diff --git a/.claude/skills/uloop-hot-reload/references/introduced-types.md b/.claude/skills/uloop-hot-reload/references/introduced-types.md index 442499ac2..ef375a3b2 100644 --- a/.claude/skills/uloop-hot-reload/references/introduced-types.md +++ b/.claude/skills/uloop-hot-reload/references/introduced-types.md @@ -154,9 +154,10 @@ reload pulls back in on its own. Constructors, initializers, setters, indexers, operators and event accessors cannot be patched, so a call from them still fails the artifact compile (CS1061 or CS0117) with a hint saying where such a call works. So does a call to an addition in another -assembly, or in a file that changed since it was last applied and is not passed, and so does a -subscription to an added event the added-field store cannot hold (custom `add`/`remove` -accessors, or a delegate type not visible outside the assembly). +assembly, or in a file that changed since it was last applied and is not passed. A subscription +to an added event the added-field store cannot hold (custom `add`/`remove` accessors, or a +delegate type not visible outside the assembly) fails the same way wherever it is written, even +in a method; only `uloop compile` makes it bind. - When this reload does not patch a stubbed body (a generic method, or a method of a struct, is `Skipped`), no type of that artifact is introduced: the stubbed type's row reads `Not diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs index 363ae30c2..8503a1e2a 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeAddedMemberHintE2ETests.cs @@ -43,10 +43,12 @@ public class HotReloadIntroducedTypeAddedMemberHintE2ETests : HotReloadIntroduce private const string UnpatchableBodiesHintCore = "Constructors, initializers, setters, indexers, operators and event accessors cannot"; - // The part of the hint that says an added event outside the added-field store cannot be - // subscribed to, which is the only step a reader of that failure can take. + // The part of the hint that sends a subscription to an added event outside the + // added-field store to a compile, the only step that makes that subscription bind. private const string StorelessEventHintCore = - "nor can a subscription to an added event the added-field store cannot hold"; + "A subscription to an added event the added-field store cannot hold (custom add/remove " + + "accessors, or a delegate type not visible outside the assembly) needs 'uloop compile' " + + "wherever it is written."; private const string StorelessEventName = "AddedWithAccessors"; diff --git a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs index b78bdacaf..355a30e6b 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadIntroducedTypeCompileFailureOutcomesTests.cs @@ -233,12 +233,12 @@ public void Build_DiagnosticNamesAnActiveAddedMember_AppendsTheAddedMemberHint() + "only from its methods and get-only properties, and only when the file that " + "declares the addition belongs to the same assembly and is part of this " + "reload: passed, or unchanged since it was last applied. Constructors, " - + "initializers, setters, indexers, operators and event accessors cannot, nor can a " - + "subscription to an added event the added-field store cannot hold (custom " - + "add/remove accessors, or a delegate type not visible outside the assembly). " - + "Pass that file too or move the call " + + "initializers, setters, indexers, operators and event accessors cannot. Pass that " + + "file too or move the call " + "into a method, or run 'uloop compile' to make the added members compiled, " - + "then rerun.")); + + "then rerun. A subscription to an added event the added-field store cannot hold " + + "(custom add/remove accessors, or a delegate type not visible outside the " + + "assembly) needs 'uloop compile' wherever it is written.")); } /// @@ -385,7 +385,7 @@ public void Build_OwnerPathHoldsAnApostrophe_StillAppendsTheAddedMemberHint() new HotReloadIntroducedTypeAddedMemberNames(new[] { "Clear" }, Array.Empty())); Assert.That(rows, Has.Count.EqualTo(1)); - Assert.That(rows[0].Reason, Does.EndWith("then rerun.")); + Assert.That(rows[0].Reason, Does.EndWith("needs 'uloop compile' wherever it is written.")); } /// @@ -413,7 +413,7 @@ public void Build_StaticMemberDiagnosticNamesAnActiveAddedMember_AppendsTheAdded new HotReloadIntroducedTypeAddedMemberNames(new[] { "Reset" }, Array.Empty())); Assert.That(rows, Has.Count.EqualTo(1)); - Assert.That(rows[0].Reason, Does.EndWith("then rerun.")); + Assert.That(rows[0].Reason, Does.EndWith("needs 'uloop compile' wherever it is written.")); } /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs b/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs index e28fae38e..f80292530 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/IntroducedType/HotReloadIntroducedTypeCompileFailureOutcomes.cs @@ -32,11 +32,11 @@ internal static class HotReloadIntroducedTypeCompileFailureOutcomes + "and get-only properties, and only when the file that declares the addition belongs " + "to the same assembly and is part of this reload: passed, or unchanged since it was " + "last applied. Constructors, initializers, setters, indexers, operators and event " - + "accessors cannot, nor can a subscription to an added event the added-field store " - + "cannot hold (custom add/remove accessors, or a delegate type not visible outside the " - + "assembly). Pass that file too or move the " - + "call into a method, or run 'uloop compile' to make the added members compiled, then " - + "rerun."; + + "accessors cannot. Pass that file too or move the call into a method, or run " + + "'uloop compile' to make the added members compiled, then rerun. A subscription to an " + + "added event the added-field store cannot hold (custom add/remove accessors, or a " + + "delegate type not visible outside the assembly) needs 'uloop compile' wherever it is " + + "written."; // Why it points at the warning: the enum-member warning of the same run already carries // the cast that avoids the member, and repeating the value here would need the enum too. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md index 442499ac2..ef375a3b2 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/introduced-types.md @@ -154,9 +154,10 @@ reload pulls back in on its own. Constructors, initializers, setters, indexers, operators and event accessors cannot be patched, so a call from them still fails the artifact compile (CS1061 or CS0117) with a hint saying where such a call works. So does a call to an addition in another -assembly, or in a file that changed since it was last applied and is not passed, and so does a -subscription to an added event the added-field store cannot hold (custom `add`/`remove` -accessors, or a delegate type not visible outside the assembly). +assembly, or in a file that changed since it was last applied and is not passed. A subscription +to an added event the added-field store cannot hold (custom `add`/`remove` accessors, or a +delegate type not visible outside the assembly) fails the same way wherever it is written, even +in a method; only `uloop compile` makes it bind. - When this reload does not patch a stubbed body (a generic method, or a method of a struct, is `Skipped`), no type of that artifact is introduced: the stubbed type's row reads `Not From 0506c509f04e38d66bca31d60239b9625608186c Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 2 Oct 2026 10:10:23 +0900 Subject: [PATCH 19/26] test(hot-reload): Observe the handler a parenthesized subscription adds The handler wrote nothing before, so the test passed even if the subscription was dropped. --- .../Editor/HotReload/HotReloadAddedEventApplyPublisher.cs | 3 +++ Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs index 081cda58e..b6d40fed0 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventApplyPublisher.cs @@ -11,6 +11,9 @@ public sealed class HotReloadAddedEventApplyPublisher { public event Action Existing; + // Written only by a handler an edited body subscribes, so a test can tell that handler ran. + public static int LastForwarded; + [MethodImpl(MethodImplOptions.NoInlining)] public void Raise(int value) { diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs index 86174917c..7988f5b30 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventE2ETests.cs @@ -188,7 +188,8 @@ public async Task Run_DeclaringTypeSubscribesToCompiledEventThroughParentheses_I string publisher = ReplaceInSource( ReadFixture(PublisherFileName), RaiseBodyAnchor, - " (Existing) += forwarded => RaiseStatic(forwarded);\n" + RaiseBodyAnchor); + " (Existing) += forwarded => LastForwarded = forwarded * 10;\n" + RaiseBodyAnchor); + HotReloadAddedEventApplyPublisher.LastForwarded = 0; HotReloadOrchestratorResult result = await RunAsync(publisher, ReadFixture(SubscriberFileName)); @@ -197,6 +198,7 @@ public async Task Run_DeclaringTypeSubscribesToCompiledEventThroughParentheses_I HotReloadAddedEventApplySubscriber subscriber = new HotReloadAddedEventApplySubscriber(); target.Existing += subscriber.Accept; target.Raise(3); + Assert.That(HotReloadAddedEventApplyPublisher.LastForwarded, Is.EqualTo(30), FormatOutcomes(result)); Assert.That(subscriber.Received, Is.EqualTo(3), FormatOutcomes(result)); } From 547a339849a0173acb3b2533dd6045cade1b3138 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 2 Oct 2026 11:00:36 +0900 Subject: [PATCH 20/26] fix(hot-reload): Name the step that applies when a method group handler is refused A method group of an added method now gets a reason for where it is used: on the right of '+=' it is told to subscribe a lambda (kept in an added field to remove later), on the right of '-=' to remove a kept delegate instead of a lambda, and elsewhere to wrap it in a lambda. A compiled private handler on the right of '+=' in a body that needs accessor delegates is told to move the code that needs private access into an added method, since wrapping the handler would leave a compiled '-=' unable to remove it. --- .../HotReload/HotReloadCrossFileE2ETests.cs | 2 +- .../HotReloadWorkerReasonTextTests.cs | 30 +++++++- ...nsformWorkerAddedEventSubscriptionTests.cs | 68 +++++++++++++++++++ .../Shared/HotReloadWorkerReasonCode.cs | 3 + ...eloadWorkerReasonText.AccessorTemplates.cs | 13 ++++ ...adWorkerReasonText.AddedMemberTemplates.cs | 29 +++++++- .../AccessorAccessRegistrar.cs | 2 +- .../TransformWorker~/AccessorReadRegistrar.cs | 28 +++++--- .../TransformWorker~/AddedCallSiteGuard.cs | 64 +++++++++++++---- .../TransformWorker~/EventAccessorRules.cs | 18 +++-- 10 files changed, 223 insertions(+), 34 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadCrossFileE2ETests.cs b/Assets/Tests/Editor/HotReload/HotReloadCrossFileE2ETests.cs index 7910a6e9b..811f7ebd0 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadCrossFileE2ETests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadCrossFileE2ETests.cs @@ -985,7 +985,7 @@ public async Task Run_ReappliedSiblingBodyNoLongerBinds_IsSkippedAndNamesTheMiss HotReloadOrchestratorResult first = await RunWithOverridesAsync(new[] { hostPath, callerPath }, overrides); Assert.That( FindOutcome(first, HotReloadMethodOutcomeKind.Skipped, ".Call(").WorkerReason?.Code, - Is.EqualTo(HotReloadWorkerReasonCode.AddedMethodMethodGroupReference), + Is.EqualTo(HotReloadWorkerReasonCode.AddedMethodMethodGroupSubscription), FormatOutcomes(first)); FindOutcome(first, HotReloadMethodOutcomeKind.Skipped, ".RaiseHit("); FindOutcome(first, HotReloadMethodOutcomeKind.Patched, ".Other("); diff --git a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs index b9750f688..e4657cef3 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadWorkerReasonTextTests.cs @@ -254,9 +254,25 @@ private static IEnumerable RenderCases() + "Run 'uloop compile'."); yield return Case( HotReloadWorkerReasonCode.AddedMethodMethodGroupReference, - NoArgs, - "Methods that capture an added method as a method group or delegate are skipped; " - + "the shim signature does not match. Run 'uloop compile'."); + new[] { "Helper", " (such as 'a => Helper(a)')" }, + "Methods that capture the added method 'Helper' as a method group or delegate are " + + "skipped; the shim signature does not match. A call is rewritten, so wrapping the " + + "method group in a lambda that calls it (such as 'a => Helper(a)') keeps hot " + + "reloading; otherwise run 'uloop compile'."); + yield return Case( + HotReloadWorkerReasonCode.AddedMethodMethodGroupSubscription, + new[] { "Helper", " (such as 'a => Helper(a)')" }, + "Subscribing the added method 'Helper' as a method group is skipped; the shim " + + "signature does not match. Subscribe a lambda that calls it instead (such as " + + "'a => Helper(a)'); to remove it later, keep that lambda in an added field and use " + + "'-=' with the same field. Otherwise run 'uloop compile'."); + yield return Case( + HotReloadWorkerReasonCode.AddedMethodMethodGroupUnsubscription, + new[] { "Helper" }, + "Removing the added method 'Helper' as a method group with '-=' is skipped; the shim " + + "signature does not match, and a lambda there would remove a different delegate. " + + "Subscribe through a delegate kept in an added field and '-=' that same field, or " + + "run 'uloop compile'."); yield return Case( HotReloadWorkerReasonCode.AddedMethodConditionalAccess, NoArgs, @@ -688,6 +704,14 @@ private static IEnumerable RenderCases() "inaccessible method group 'Helper' (non-invocation) has no accessor rewrite shape. " + "A call is rewritten, so wrapping the method group in a lambda that calls it " + "keeps hot reloading."); + yield return Case( + HotReloadWorkerReasonCode.AccessorMethodGroupSubscribeNoShape, + new[] { "Helper", " (such as 'a => Helper(a)')" }, + "inaccessible method group 'Helper' on the right of '+=' has no accessor rewrite " + + "shape. If compiled code removes it with '-= Helper', leave this line as it is and " + + "move the code here that needs private access (such as a lambda you added) into a " + + "method this reload adds, called from here; otherwise, wrapping the method group in " + + "a lambda that calls it (such as 'a => Helper(a)') keeps hot reloading."); yield return Case( HotReloadWorkerReasonCode.AccessorMethodGroupUnsubscribeNoShape, new[] { "Helper" }, diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs index 5086b4b9f..9cfc44282 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs @@ -61,6 +61,74 @@ public async Task Run_AddedMethodSubscribesMethodGroupToAddedEvent_SkipsNamingTh Assert.That(reason, Does.Contain("OnValue").And.Contain("lambda"), reason); } + /// + /// What: a method group of an added method is skipped with the reason that fits where it is + /// used: a '+=' handler is told to subscribe a lambda, a '-=' handler is told to remove a + /// kept delegate instead of a lambda, and any other use is told to wrap it in a lambda. + /// + [TestCase( + "publisher.Existing += AddedHandler;", + nameof(HotReloadWorkerReasonCode.AddedMethodMethodGroupSubscription), + "a => AddedHandler(a)")] + [TestCase( + "publisher.Existing -= AddedHandler;", + nameof(HotReloadWorkerReasonCode.AddedMethodMethodGroupUnsubscription), + "added field")] + [TestCase( + "System.Action handler = AddedHandler;\n handler(1);", + nameof(HotReloadWorkerReasonCode.AddedMethodMethodGroupReference), + "a => AddedHandler(a)")] + public async Task Skip_AddedMethodGroup_ReasonFitsWhereItIsUsed( + string body, + string expectedCode, + string expectedAdvice) + { + TransformWorkerClientResult result = await RunAsync( + ReadOnDisk(PublisherFileName), + WithSubscriberMethod( + "public void AddedHandler(int value)\n {\n Accept(value);\n }\n\n" + + " public void WireAdded(HotReloadAddedEventPublisher publisher)\n {\n" + + " " + body + "\n }")); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + TransformWorkerSkippedDto skipped = FindSkipped(result, "WireAdded"); + Assert.That(skipped, Is.Not.Null, "Missing skipped row.\n" + FormatSkipped(result)); + string reason = HotReloadWorkerReasonText.Render(skipped.reason); + Assert.That(skipped.reason.code.ToString(), Is.EqualTo(expectedCode), reason); + Assert.That(reason, Does.Contain(expectedAdvice), reason); + } + + /// + /// What: an edited compiled method that keeps a compiled private method group on the right + /// of '+=' and adds a lambda calling a private member is skipped with advice to move the + /// added code into an added method, not to wrap the existing handler in a lambda that a + /// compiled '-=' could no longer remove. + /// + [Test] + public async Task Skip_PrivateHandlerKeptBesideAddedLambda_AdvisesAnAddedMethod() + { + string subscriber = ReadOnDisk(SubscriberFileName); + Assert.That(subscriber, Does.Contain(WireBody), "Precondition: Wire body must exist."); + subscriber = subscriber.Replace( + WireBody, + " publisher.Existing += OnValue;\n" + + " publisher.Existing += value => OnValue(value + 1);", + StringComparison.Ordinal); + + TransformWorkerClientResult result = await RunAsync(ReadOnDisk(PublisherFileName), subscriber); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + TransformWorkerSkippedDto skipped = FindSkipped(result, "Wire"); + Assert.That(skipped, Is.Not.Null, "Missing skipped row.\n" + FormatSkipped(result)); + string reason = HotReloadWorkerReasonText.Render(skipped.reason); + Assert.That(skipped.reason.detail, Is.Not.Null, reason); + Assert.That( + skipped.reason.detail.code, + Is.EqualTo(HotReloadWorkerReasonCode.AccessorMethodGroupSubscribeNoShape), + reason); + Assert.That(reason, Does.Contain("method this reload adds"), reason); + } + /// /// What: an added method that subscribes a lambda to an event this edit adds is applied. /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs index 12781a322..a955d4d36 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonCode.cs @@ -27,6 +27,8 @@ internal enum HotReloadWorkerReasonCode AddedMethodVirtualOrAbstract, AddedMethodGeneric, AddedMethodMethodGroupReference, + AddedMethodMethodGroupSubscription, + AddedMethodMethodGroupUnsubscription, AddedMethodConditionalAccess, AddedMethodUnavailableAddedCall, AddedMethodTypeNotIntroduced, @@ -113,6 +115,7 @@ internal enum HotReloadWorkerReasonCode AccessorNamedArgumentNotRewritten, AccessorOptionalOrParamsArgumentNotRewritten, AccessorMethodGroupNoShape, + AccessorMethodGroupSubscribeNoShape, AccessorMethodGroupUnsubscribeNoShape, IntroducedTypeSymbolUnresolved, IntroducedTypeGeneric, diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AccessorTemplates.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AccessorTemplates.cs index 3197ed6b4..6d111d338 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AccessorTemplates.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AccessorTemplates.cs @@ -217,6 +217,19 @@ private static void AddAccessorTemplates( + "A call is rewritten, so wrapping the method group in a lambda that calls " + "it{1} keeps hot reloading.", 2)); + // Why the added method comes first: a compiled '-= {0}' elsewhere removes only the + // method group, so wrapping this handler in a lambda would leave it subscribed for good. + // Moving the code that needs private access out of this body leaves the body nothing + // to rewrite, so this line applies unchanged. + templates.Add( + HotReloadWorkerReasonCode.AccessorMethodGroupSubscribeNoShape, + Plain( + "inaccessible method group '{0}' on the right of '+=' has no accessor rewrite " + + "shape. If compiled code removes it with '-= {0}', leave this line as it is and " + + "move the code here that needs private access (such as a lambda you added) into a " + + "method this reload adds, called from here; otherwise, wrapping the method group in " + + "a lambda that calls it{1} keeps hot reloading.", + 2)); // Why no lambda is offered: a lambda on the right of '-=' is a new delegate, so the // rewrite would compile and silently leave the original handler subscribed. templates.Add( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs index 47864ddae..ea225cfe6 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadWorkerReasonText.AddedMemberTemplates.cs @@ -20,12 +20,35 @@ private static void AddAddedMemberTemplates( Plain( "Added generic methods are skipped; hot reload cannot emit a typed shim for them.", 0).EndingWith(CompileCallToAction)); + // Why each method-group reason names a step before the compile: a call to an added + // method is rewritten, so a lambda applies without leaving Play Mode, and the compile + // the reason used to name alone sent readers out of Play Mode for an edit that applies. templates.Add( HotReloadWorkerReasonCode.AddedMethodMethodGroupReference, Plain( - "Methods that capture an added method as a method group or delegate are skipped; " - + "the shim signature does not match.", - 0).EndingWith(CompileCallToAction)); + "Methods that capture the added method '{0}' as a method group or delegate are " + + "skipped; the shim signature does not match. A call is rewritten, so wrapping the " + + "method group in a lambda that calls it{1} keeps hot reloading; otherwise run " + + "'uloop compile'.", + 2)); + templates.Add( + HotReloadWorkerReasonCode.AddedMethodMethodGroupSubscription, + Plain( + "Subscribing the added method '{0}' as a method group is skipped; the shim " + + "signature does not match. Subscribe a lambda that calls it instead{1}; to remove " + + "it later, keep that lambda in an added field and use '-=' with the same field. " + + "Otherwise run 'uloop compile'.", + 2)); + // Why no lambda is offered: a lambda on the right of '-=' is a new delegate, so it would + // compile and leave the handler subscribed. + templates.Add( + HotReloadWorkerReasonCode.AddedMethodMethodGroupUnsubscription, + Plain( + "Removing the added method '{0}' as a method group with '-=' is skipped; the shim " + + "signature does not match, and a lambda there would remove a different delegate. " + + "Subscribe through a delegate kept in an added field and '-=' that same field, or " + + "run 'uloop compile'.", + 1)); templates.Add( HotReloadWorkerReasonCode.AddedMethodConditionalAccess, Plain( diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorAccessRegistrar.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorAccessRegistrar.cs index 3ada5871b..b4219e468 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorAccessRegistrar.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorAccessRegistrar.cs @@ -278,7 +278,7 @@ private static bool TryRegisterUseOutsideAssignment( symbol, plan, addedMemberAccess, - EventAccessorRules.IsUnsubscribeOperand(site), + EventAccessorRules.FindHandlerAssignmentKind(site), out rejectReason); } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs index 850335c0e..0a1f25e5b 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AccessorReadRegistrar.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using io.github.hatayama.UnityCliLoop.FirstPartyTools; // Registers a read of a property or a field on the accessor plan, or rejects the shape the plan @@ -10,7 +11,7 @@ internal static bool TryRegisterPropertyOrFieldRead( ISymbol symbol, AccessorPlan plan, AddedMemberAccessLookup addedMemberAccess, - bool unsubscribeOperand, + SyntaxKind handlerAssignmentKind, out WorkerReason rejectReason) { rejectReason = null; @@ -32,14 +33,7 @@ internal static bool TryRegisterPropertyOrFieldRead( if (symbol is IMethodSymbol methodSymbol && AccessibilityRules.IsInaccessibleFromExternalAssembly(methodSymbol)) { - // Why no lambda example on the right of '-=': a lambda there is a new delegate that - // was never subscribed, so the rewrite would compile and leave the handler attached. - rejectReason = unsubscribeOperand - ? WorkerReason.Of(HotReloadWorkerReasonCode.AccessorMethodGroupUnsubscribeNoShape, methodSymbol.Name) - : WorkerReason.Of( - HotReloadWorkerReasonCode.AccessorMethodGroupNoShape, - methodSymbol.Name, - MethodGroupLambdaExample.BuildSuffix(methodSymbol)); + rejectReason = DescribeMethodGroupReject(methodSymbol, handlerAssignmentKind); return false; } @@ -57,6 +51,22 @@ internal static bool TryRegisterPropertyOrFieldRead( return false; } + // Why the reason follows the assignment: a lambda on the right of '-=' is a new delegate that + // was never subscribed, so offering one there would compile and leave the handler attached, + // and on the right of '+=' a lambda could no longer be removed by a compiled '-='. + private static WorkerReason DescribeMethodGroupReject(IMethodSymbol methodSymbol, SyntaxKind handlerAssignmentKind) + { + if (handlerAssignmentKind == SyntaxKind.SubtractAssignmentExpression) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.AccessorMethodGroupUnsubscribeNoShape, methodSymbol.Name); + } + + HotReloadWorkerReasonCode code = handlerAssignmentKind == SyntaxKind.AddAssignmentExpression + ? HotReloadWorkerReasonCode.AccessorMethodGroupSubscribeNoShape + : HotReloadWorkerReasonCode.AccessorMethodGroupNoShape; + return WorkerReason.Of(code, methodSymbol.Name, MethodGroupLambdaExample.BuildSuffix(methodSymbol)); + } + private static bool TryRegisterFieldRead(IFieldSymbol fieldSymbol, AccessorPlan plan) { if (!AccessibilityRules.IsInaccessibleFromExternalAssembly(fieldSymbol)) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedCallSiteGuard.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedCallSiteGuard.cs index e49e22c06..5a6dc51a7 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedCallSiteGuard.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/AddedCallSiteGuard.cs @@ -138,9 +138,11 @@ internal static (WorkerReason Reason, string CalledAddedMethodKey) EvaluateAdded } } - if (BodyReferencesAddedMethodGroup(bodyNode, semanticModel, addedMethodCatalog)) + (ExpressionSyntax methodGroup, IMethodSymbol groupMethod) = + FindAddedMethodGroup(bodyNode, semanticModel, addedMethodCatalog); + if (methodGroup != null) { - return (WorkerReason.Of(HotReloadWorkerReasonCode.AddedMethodMethodGroupReference), null); + return (DescribeAddedMethodGroup(methodGroup, groupMethod), null); } WorkerReason propertyReason = AddedPropertyBodyScan.EvaluateAddedPropertySkipReason( @@ -156,7 +158,28 @@ internal static (WorkerReason Reason, string CalledAddedMethodKey) EvaluateAdded return (AddedFieldSkipEvaluator.EvaluateAddedFieldSkipReason(bodyNode, semanticModel, addedFieldCatalog), null); } - internal static bool BodyReferencesAddedMethodGroup( + // Why the reason follows where the group is used: a '+=' handler is fixed by subscribing a + // lambda, while a lambda on the right of '-=' removes a different delegate, so each use is + // told the step that applies to it. + private static WorkerReason DescribeAddedMethodGroup(ExpressionSyntax methodGroup, IMethodSymbol groupMethod) + { + SyntaxKind handlerAssignmentKind = EventAccessorRules.FindHandlerAssignmentKind(methodGroup); + if (handlerAssignmentKind == SyntaxKind.SubtractAssignmentExpression) + { + return WorkerReason.Of(HotReloadWorkerReasonCode.AddedMethodMethodGroupUnsubscription, groupMethod.Name); + } + + HotReloadWorkerReasonCode code = handlerAssignmentKind == SyntaxKind.AddAssignmentExpression + ? HotReloadWorkerReasonCode.AddedMethodMethodGroupSubscription + : HotReloadWorkerReasonCode.AddedMethodMethodGroupReference; + return WorkerReason.Of(code, groupMethod.Name, MethodGroupLambdaExample.BuildSuffix(groupMethod)); + } + + /// + /// The first use of an added method as a method group in the body (not a call and not inside + /// nameof), with the method it names, or (null, null) when there is none. + /// + internal static (ExpressionSyntax MethodGroup, IMethodSymbol Method) FindAddedMethodGroup( SyntaxNode bodyNode, SemanticModel semanticModel, AddedMethodCatalog addedMethodCatalog) @@ -168,11 +191,15 @@ internal static bool BodyReferencesAddedMethodGroup( continue; } - ISymbol symbol = semanticModel.GetSymbolInfo(name).Symbol; - if (symbol is IMethodSymbol methodSymbol - && addedMethodCatalog.IsClassifiedAdded(WorkerMethodKeys.BuildMethodKeyFromSymbol(methodSymbol))) + IMethodSymbol methodSymbol = FindAddedMethod(name, semanticModel, addedMethodCatalog); + if (methodSymbol != null) { - return true; + // Why the member access is returned for 'this.M': the '+=' or '-=' it is the + // handler of sees the whole access, not its name. + ExpressionSyntax site = name.Parent is MemberAccessExpressionSyntax access && access.Name == name + ? access + : name; + return (site, methodSymbol); } } @@ -185,15 +212,28 @@ internal static bool BodyReferencesAddedMethodGroup( continue; } - ISymbol symbol = semanticModel.GetSymbolInfo(access).Symbol; - if (symbol is IMethodSymbol methodSymbol - && addedMethodCatalog.IsClassifiedAdded(WorkerMethodKeys.BuildMethodKeyFromSymbol(methodSymbol))) + IMethodSymbol methodSymbol = FindAddedMethod(access, semanticModel, addedMethodCatalog); + if (methodSymbol != null) { - return true; + return (access, methodSymbol); } } - return false; + return (null, null); + } + + private static IMethodSymbol FindAddedMethod( + ExpressionSyntax expression, + SemanticModel semanticModel, + AddedMethodCatalog addedMethodCatalog) + { + if (semanticModel.GetSymbolInfo(expression).Symbol is IMethodSymbol methodSymbol + && addedMethodCatalog.IsClassifiedAdded(WorkerMethodKeys.BuildMethodKeyFromSymbol(methodSymbol))) + { + return methodSymbol; + } + + return null; } internal static bool IsInvocationCalleeName(IdentifierNameSyntax name) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/EventAccessorRules.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/EventAccessorRules.cs index 60ee03e51..9bcecfe99 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/EventAccessorRules.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/EventAccessorRules.cs @@ -157,8 +157,11 @@ internal static bool IsSubscriptionAssignment(AssignmentExpressionSyntax assignm || assignment.IsKind(SyntaxKind.SubtractAssignmentExpression); } - /// Whether the expression is the handler removed by a '-=' assignment. - internal static bool IsUnsubscribeOperand(ExpressionSyntax operand) + /// + /// The kind of the '+=' or '-=' assignment whose handler the expression is, or + /// SyntaxKind.None when the expression is not such a handler. + /// + internal static SyntaxKind FindHandlerAssignmentKind(ExpressionSyntax operand) { // Why parentheses and one cast are looked past: '-= (Handler)' and '-= (Action)Handler' // still remove the delegate the method group converts to. @@ -177,9 +180,14 @@ internal static bool IsUnsubscribeOperand(ExpressionSyntax operand) } } - return unwrapped.Parent is AssignmentExpressionSyntax assignment - && assignment.Right == unwrapped - && assignment.IsKind(SyntaxKind.SubtractAssignmentExpression); + if (unwrapped.Parent is not AssignmentExpressionSyntax assignment + || assignment.Right != unwrapped + || !IsSubscriptionAssignment(assignment)) + { + return SyntaxKind.None; + } + + return assignment.Kind(); } private static bool IsPassedByRef(SyntaxNode eventUseNode) From b05ea42848857523ecc0a4035db1e8eb1b5d3d6a Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 2 Oct 2026 11:00:48 +0900 Subject: [PATCH 21/26] fix(hot-reload): Tell how to re-run an edited OnEnable or OnDisable on live objects The lifecycle notes and the never-invoked --status reason said only new objects or a compile run the patched body. Toggling the component's enabled makes Unity call OnDisable and OnEnable again, so the notes for those two, for methods they call, and the --status reason now say so; Awake and Start keep the old wording. --- .../HotReload/HotReloadAddedMemberHost.cs | 18 ++++++++ .../HotReloadOneShotCallerNoteBuilderTests.cs | 44 +++++++++++++++++++ .../Editor/HotReload/HotReloadToolTests.cs | 2 +- .../HotReload/TransformWorkerClientTests.cs | 34 ++++++++++++++ .../HotReloadOneShotCallerNoteBuilder.cs | 11 ++++- .../HotReload/Shared/HotReloadConstants.cs | 2 +- .../TransformWorker~/LifecycleNotes.cs | 15 +++++++ .../TransformWorker~/ShimMethodEmitter.cs | 2 +- 8 files changed, 124 insertions(+), 4 deletions(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedMemberHost.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedMemberHost.cs index 361248f30..e52ea153d 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedMemberHost.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedMemberHost.cs @@ -390,6 +390,24 @@ private void Awake() } } + /// + /// Compiled OnEnable / OnDisable host for the direct note that offers toggling enabled. + /// + public class HotReloadLifecycleOnEnableFixture : MonoBehaviour + { + private int _count; + + private void OnEnable() + { + _count++; + } + + private void OnDisable() + { + _count--; + } + } + /// /// Compiled alias-shadow host so the local-vs-global using-alias test is not a new type. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadOneShotCallerNoteBuilderTests.cs b/Assets/Tests/Editor/HotReload/HotReloadOneShotCallerNoteBuilderTests.cs index 688226ed8..7610f6cc9 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadOneShotCallerNoteBuilderTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadOneShotCallerNoteBuilderTests.cs @@ -790,6 +790,50 @@ public void WithLifecycleNote_PatchedOutcome_PreservesOutcomeFields() Assert.That(updated.FilePath, Is.EqualTo("Assets/Test.cs")); Assert.That(updated.LifecycleNote, Is.EqualTo("note")); } + + /// + /// What: an indirect note with OnEnable among its callers says how to run the patched body + /// on live objects by toggling enabled, and that Awake and Start do not run again that way. + /// + [Test] + public void Build_OnEnableAmongCallers_AddsTheEnabledToggle() + { + IReadOnlyList callers = + new List + { + new OneShotCallerClassification("OnEnable", true), + new OneShotCallerClassification("Awake", true) + }; + + string note = HotReloadOneShotCallerNoteBuilder.Build("Bind", callers); + + Assert.That( + note, + Does.EndWith( + " To run it on live objects from OnEnable or OnDisable, set the component's " + + "`enabled` to false and back to true (for example with `uloop execute-dynamic-code`); " + + "Awake and Start do not run again that way.")); + } + + /// + /// What: an indirect note whose callers are only Awake or Start does not offer the enabled + /// toggle, because toggling never runs those again. + /// + [Test] + public void Build_OnlyAwakeAndStartCallers_DoesNotOfferTheEnabledToggle() + { + IReadOnlyList callers = + new List + { + new OneShotCallerClassification("Awake", true), + new OneShotCallerClassification("Start", true) + }; + + string note = HotReloadOneShotCallerNoteBuilder.Build("Bind", callers); + + Assert.That(note, Does.Not.Contain("`enabled`")); + } + /// /// What: one Awake caller produces the caller-aware lifecycle note. /// diff --git a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs index 60aa71d5e..682cec1c4 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadToolTests.cs @@ -266,7 +266,7 @@ public async Task ExecuteAsync_Status_NeverInvokedActiveRow_SetsNeverInvokedReas Assert.That( activeRow.Reason, Is.EqualTo( - "Not invoked since this patch was applied. Calls that already finished before the patch (for example one-time initialization) do not re-run automatically; the patched body takes effect the next time this method is called. If this method only runs during initialization, trigger that path again — re-create the object that runs it, or run 'uloop compile' and enter Play Mode again.")); + "Not invoked since this patch was applied. Calls that already finished before the patch (for example one-time initialization) do not re-run automatically; the patched body takes effect the next time this method is called. If this method only runs during initialization, trigger that path again — re-create the object that runs it, toggle the component's `enabled` to false and back to true when it runs from OnEnable or OnDisable (for example with `uloop execute-dynamic-code`), or run 'uloop compile' and enter Play Mode again.")); Assert.That( activeRow.Reason, Is.EqualTo(HotReloadConstants.ActivePatchNeverInvokedReason)); diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs index 763062d7e..8c3cec535 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerClientTests.cs @@ -1928,6 +1928,40 @@ public async Task Run_WithAwakeMethod_EmitsDirectLifecycleNote() Assert.That( awakeEntry.lifecycleNote, Does.Contain("A method hot reload added or patched that calls it runs the patched body.")); + Assert.That(awakeEntry.lifecycleNote, Does.Not.Contain(ReEnableNoteFragment)); + } + + // The sentence only an OnEnable / OnDisable note carries: toggling enabled re-runs those two + // on live objects, and Awake or Start never run again that way. + private const string ReEnableNoteFragment = "set the component's `enabled` to false and back to true"; + + /// + /// What: patching OnEnable or OnDisable emits a direct note that tells how to run the patched + /// body on live objects by toggling enabled, instead of saying only new objects run it. + /// + [TestCase("OnEnable", "_count++;", "_count += 2;")] + [TestCase("OnDisable", "_count--;", "_count -= 2;")] + public async Task Run_WithOnEnableOrOnDisable_EmitsDirectNoteThatTogglesEnabled( + string methodName, + string originalStatement, + string editedStatement) + { + string source = + "using UnityEngine;\n" + + "namespace io.github.hatayama.UnityCliLoop.Tests.Editor.HotReload\n{\n" + + "public class HotReloadLifecycleOnEnableFixture : MonoBehaviour\n" + + "{\n" + + " private int _count;\n\n" + + " private void OnEnable()\n {\n _count++;\n }\n\n" + + " private void OnDisable()\n {\n _count--;\n }\n" + + "}\n}\n"; + source = source.Replace(originalStatement, editedStatement); + + TransformWorkerEntryDto entry = + await RunWorkerAndFindEntryAsync(source, "LifecycleOnEnable.cs", methodName); + Assert.That(entry.lifecycleNote, Does.Contain(methodName + " is a one-shot lifecycle method")); + Assert.That(entry.lifecycleNote, Does.Contain(ReEnableNoteFragment)); + Assert.That(entry.lifecycleNote, Does.Not.Contain("only for newly created objects")); } private const string ExpectedUnsupportedMemberKindSkipReason = diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOneShotCallerNoteBuilder.cs b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOneShotCallerNoteBuilder.cs index 262242c43..c7cf6c12d 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOneShotCallerNoteBuilder.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/HotReloadOneShotCallerNoteBuilder.cs @@ -31,6 +31,13 @@ internal static class HotReloadOneShotCallerNoteBuilder + "newly created objects, or run `uloop compile` and re-enter Play Mode. Callers hot reload " + "added or patched are not counted; if one of them calls it, the patched body already runs."; + // Why only when OnEnable or OnDisable is a caller: toggling enabled makes Unity call those + // two again on a live object, while Awake and Start never run again that way. + public const string ReEnableSuffix = + " To run it on live objects from OnEnable or OnDisable, set the component's `enabled` to " + + "false and back to true (for example with `uloop execute-dynamic-code`); Awake and Start " + + "do not run again that way."; + /// /// Returns a note only when every compiled caller is a one-shot lifecycle message. /// @@ -58,11 +65,13 @@ public static string Build( } lifecycleNames.Sort(StringComparer.Ordinal); - return string.Format( + string note = string.Format( CultureInfo.InvariantCulture, IndirectFormat, targetMethodName, string.Join(", ", lifecycleNames)); + bool reEnableRunsIt = lifecycleNames.Contains("OnEnable") || lifecycleNames.Contains("OnDisable"); + return reEnableRunsIt ? note + ReEnableSuffix : note; } } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs index 675fd9c6c..d2b4c313e 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Shared/HotReloadConstants.cs @@ -552,7 +552,7 @@ public static bool IsPublicizableProjectAssemblyFileName(string fileNameWithoutE // The row must also say how to trigger the next call, because for // initialization-only methods that is the non-obvious step. public const string ActivePatchNeverInvokedReason = - "Not invoked since this patch was applied. Calls that already finished before the patch (for example one-time initialization) do not re-run automatically; the patched body takes effect the next time this method is called. If this method only runs during initialization, trigger that path again — re-create the object that runs it, or run 'uloop compile' and enter Play Mode again."; + "Not invoked since this patch was applied. Calls that already finished before the patch (for example one-time initialization) do not re-run automatically; the patched body takes effect the next time this method is called. If this method only runs during initialization, trigger that path again — re-create the object that runs it, toggle the component's `enabled` to false and back to true when it runs from OnEnable or OnDisable (for example with `uloop execute-dynamic-code`), or run 'uloop compile' and enter Play Mode again."; // Format: replacement display name for an Active row whose compiled signature was // replaced in a later edit. Supersedes ActivePatchNeverInvokedReason when both apply. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/LifecycleNotes.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/LifecycleNotes.cs index 3340f5612..02ebb1916 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/LifecycleNotes.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/LifecycleNotes.cs @@ -30,4 +30,19 @@ internal static class LifecycleNotes "{0} is a one-shot lifecycle method; objects that already ran it will not run the " + "patched body. It takes effect only for newly created objects. A method hot reload added " + "or patched that calls it runs the patched body."; + + // Why OnEnable and OnDisable get their own note: toggling a component's enabled makes Unity + // call both again on a live object, so "only for newly created objects" sent readers to a + // compile and out of Play Mode for an edit they could run in place. + public const string ReEnableDirectFormat = + "{0} is a one-shot lifecycle method; objects that already ran it will not run the " + + "patched body until Unity calls it again. To run it on live objects now, set the " + + "component's `enabled` to false and back to true (for example with " + + "`uloop execute-dynamic-code`): Unity calls OnDisable and then OnEnable. A method hot " + + "reload added or patched that calls it runs the patched body."; + + public static string SelectDirectFormat(string methodName) + { + return methodName == "OnEnable" || methodName == "OnDisable" ? ReEnableDirectFormat : DirectFormat; + } } diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs index dc71b5f35..831b30240 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/ShimMethodEmitter.cs @@ -178,7 +178,7 @@ internal static string ComputeLifecycleNote( return null; } - return string.Format(LifecycleNotes.DirectFormat, methodName); + return string.Format(LifecycleNotes.SelectDirectFormat(methodName), methodName); } internal static bool IsOneShotLifecycleMethodName(string methodName) From 3dadbbc326aa712d9a2aaee7cf40b2e7c1cb4412 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 2 Oct 2026 11:01:12 +0900 Subject: [PATCH 22/26] docs(hot-reload): Show the enabled toggle and the added-method workaround in the skill The skill's workflow now gives the enabled toggle for an edited OnEnable or OnDisable, and the event section says to keep a compiled '+= Handler' line and put a new lambda subscription in an added method. --- .agents/skills/uloop-hot-reload/SKILL.md | 3 ++- .../skills/uloop-hot-reload/references/scope-and-limits.md | 5 ++++- .claude/skills/uloop-hot-reload/SKILL.md | 3 ++- .../skills/uloop-hot-reload/references/scope-and-limits.md | 5 ++++- Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md | 3 ++- .../HotReload/Skill/references/scope-and-limits.md | 5 ++++- 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.agents/skills/uloop-hot-reload/SKILL.md b/.agents/skills/uloop-hot-reload/SKILL.md index 01dd4c0a9..3ad72a469 100644 --- a/.agents/skills/uloop-hot-reload/SKILL.md +++ b/.agents/skills/uloop-hot-reload/SKILL.md @@ -89,7 +89,8 @@ While hot-reload changes are active, `AutoRefreshHeld` is true so returning focu recompile; `uloop compile` releases the hold, and `--revert-all` only when no introduced type remains. One-shot methods (`Awake`, `Start`, init helpers) patch but show no effect on the call that -already ran; the response marks them with `LifecycleNote`. +already ran; the response marks them with `LifecycleNote`. For an edited compiled `OnEnable` / +`OnDisable`, set `enabled = false` with `execute-dynamic-code`, reload, then set it back to `true`. To tune a value while playing, expose a static property getter instead of a `const`; its body is patched on compiled and introduced types alike. diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index c8288d6ed..dedbf11a1 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -369,7 +369,10 @@ host or one nested in a generic type, a delegate type not visible outside the as compiled class already uses for another member, `E ??= h`, `nameof(E)`, `a?.E += h`, passing it by `ref`, an initializer an added field could not have, and `Get().E += h` (the receiver would be evaluated twice). A handler that is a method group of an added method or of a compiled private method is -`Skipped` too; subscribe a lambda that calls it instead (`E += x => OnValue(x);`). +`Skipped` too; subscribe a lambda that calls it instead (`E += x => OnValue(x);`). When a +compiled method already holds `E += OnValue;` and compiled code removes it with `-= OnValue`, +do not wrap that line in a lambda (the `-=` would stop removing it): leave it as it is and +put the new lambda subscription in a method this reload adds, called from the edited method. Subscriptions live on the store's delegate, not on Unity objects: `+=` is not atomic, so subscribe and raise on the main thread only. A handler subscribed by an earlier reload keeps running the body it was subscribed with until it is removed and subscribed again diff --git a/.claude/skills/uloop-hot-reload/SKILL.md b/.claude/skills/uloop-hot-reload/SKILL.md index 01dd4c0a9..3ad72a469 100644 --- a/.claude/skills/uloop-hot-reload/SKILL.md +++ b/.claude/skills/uloop-hot-reload/SKILL.md @@ -89,7 +89,8 @@ While hot-reload changes are active, `AutoRefreshHeld` is true so returning focu recompile; `uloop compile` releases the hold, and `--revert-all` only when no introduced type remains. One-shot methods (`Awake`, `Start`, init helpers) patch but show no effect on the call that -already ran; the response marks them with `LifecycleNote`. +already ran; the response marks them with `LifecycleNote`. For an edited compiled `OnEnable` / +`OnDisable`, set `enabled = false` with `execute-dynamic-code`, reload, then set it back to `true`. To tune a value while playing, expose a static property getter instead of a `const`; its body is patched on compiled and introduced types alike. diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index c8288d6ed..dedbf11a1 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -369,7 +369,10 @@ host or one nested in a generic type, a delegate type not visible outside the as compiled class already uses for another member, `E ??= h`, `nameof(E)`, `a?.E += h`, passing it by `ref`, an initializer an added field could not have, and `Get().E += h` (the receiver would be evaluated twice). A handler that is a method group of an added method or of a compiled private method is -`Skipped` too; subscribe a lambda that calls it instead (`E += x => OnValue(x);`). +`Skipped` too; subscribe a lambda that calls it instead (`E += x => OnValue(x);`). When a +compiled method already holds `E += OnValue;` and compiled code removes it with `-= OnValue`, +do not wrap that line in a lambda (the `-=` would stop removing it): leave it as it is and +put the new lambda subscription in a method this reload adds, called from the edited method. Subscriptions live on the store's delegate, not on Unity objects: `+=` is not atomic, so subscribe and raise on the main thread only. A handler subscribed by an earlier reload keeps running the body it was subscribed with until it is removed and subscribed again diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md index 01dd4c0a9..3ad72a469 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md @@ -89,7 +89,8 @@ While hot-reload changes are active, `AutoRefreshHeld` is true so returning focu recompile; `uloop compile` releases the hold, and `--revert-all` only when no introduced type remains. One-shot methods (`Awake`, `Start`, init helpers) patch but show no effect on the call that -already ran; the response marks them with `LifecycleNote`. +already ran; the response marks them with `LifecycleNote`. For an edited compiled `OnEnable` / +`OnDisable`, set `enabled = false` with `execute-dynamic-code`, reload, then set it back to `true`. To tune a value while playing, expose a static property getter instead of a `const`; its body is patched on compiled and introduced types alike. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index c8288d6ed..dedbf11a1 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -369,7 +369,10 @@ host or one nested in a generic type, a delegate type not visible outside the as compiled class already uses for another member, `E ??= h`, `nameof(E)`, `a?.E += h`, passing it by `ref`, an initializer an added field could not have, and `Get().E += h` (the receiver would be evaluated twice). A handler that is a method group of an added method or of a compiled private method is -`Skipped` too; subscribe a lambda that calls it instead (`E += x => OnValue(x);`). +`Skipped` too; subscribe a lambda that calls it instead (`E += x => OnValue(x);`). When a +compiled method already holds `E += OnValue;` and compiled code removes it with `-= OnValue`, +do not wrap that line in a lambda (the `-=` would stop removing it): leave it as it is and +put the new lambda subscription in a method this reload adds, called from the edited method. Subscriptions live on the store's delegate, not on Unity objects: `+=` is not atomic, so subscribe and raise on the main thread only. A handler subscribed by an earlier reload keeps running the body it was subscribed with until it is removed and subscribed again From 7bb4899785b688fd6d87feeea0a7cf722550bd97 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 2 Oct 2026 11:06:50 +0900 Subject: [PATCH 23/26] fix(hot-reload): Keep the lambda advice for a private '+=' handler in an async or iterator body Such a body is rewritten whole, so leaving the '+= Handler' line and moving other code out would not make it apply. --- .../HotReloadAddedEventSubscriber.cs | 6 ++++ ...nsformWorkerAddedEventSubscriptionTests.cs | 29 +++++++++++++++++++ .../MethodTransformDecider.cs | 15 +++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/HotReload/HotReloadAddedEventSubscriber.cs b/Assets/Tests/Editor/HotReload/HotReloadAddedEventSubscriber.cs index 399a28d63..e76bf8190 100644 --- a/Assets/Tests/Editor/HotReload/HotReloadAddedEventSubscriber.cs +++ b/Assets/Tests/Editor/HotReload/HotReloadAddedEventSubscriber.cs @@ -19,6 +19,12 @@ public void Wire(HotReloadAddedEventPublisher publisher) publisher.Existing += Accept; } + // An iterator, so an edit that touches a private member rewrites the whole body. + public System.Collections.IEnumerator WireLater(HotReloadAddedEventPublisher publisher) + { + yield return null; + } + public void Accept(int value) { _received += value; diff --git a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs index 9cfc44282..bebdf0a89 100644 --- a/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs +++ b/Assets/Tests/Editor/HotReload/TransformWorkerAddedEventSubscriptionTests.cs @@ -129,6 +129,35 @@ public async Task Skip_PrivateHandlerKeptBesideAddedLambda_AdvisesAnAddedMethod( Assert.That(reason, Does.Contain("method this reload adds"), reason); } + /// + /// What: in an iterator, whose whole body is rewritten when it touches a private member, a + /// compiled private method group on the right of '+=' keeps the lambda advice: leaving the + /// line and moving other code out would not make the body apply. + /// + [Test] + public async Task Skip_PrivateHandlerInIterator_KeepsTheLambdaAdvice() + { + string subscriber = ReadOnDisk(SubscriberFileName); + const string iteratorBody = " yield return null;\n }\n\n public void Accept"; + Assert.That(subscriber, Does.Contain(iteratorBody), "Precondition: WireLater body must exist."); + subscriber = subscriber.Replace( + iteratorBody, + " publisher.Existing += OnValue;\n" + iteratorBody, + StringComparison.Ordinal); + + TransformWorkerClientResult result = await RunAsync(ReadOnDisk(PublisherFileName), subscriber); + + Assert.That(result.Success, Is.True, result.ErrorMessage); + TransformWorkerSkippedDto skipped = FindSkipped(result, "WireLater"); + Assert.That(skipped, Is.Not.Null, "Missing skipped row.\n" + FormatSkipped(result)); + string reason = HotReloadWorkerReasonText.Render(skipped.reason); + Assert.That(skipped.reason.detail, Is.Not.Null, reason); + Assert.That( + skipped.reason.detail.code, + Is.EqualTo(HotReloadWorkerReasonCode.AccessorMethodGroupNoShape), + reason); + } + /// /// What: an added method that subscribes a lambda to an event this edit adds is applied. /// diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs index 9e7cc4fd7..97d726f93 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs @@ -90,7 +90,7 @@ internal static MethodTransformDecision DecideMethodTransform( return MethodTransformDecision.Skip( WorkerReason.Composite( rescuableSkipCode ?? HotReloadWorkerReasonCode.EventAccessorRewriteUnavailable, - accessorRejectReason)); + asyncIteratorInaccessible ? AdviseForWholeBody(accessorRejectReason) : accessorRejectReason)); } // Safety net: detection said "needs accessors" but eligibility found nothing to rewrite @@ -104,6 +104,19 @@ internal static MethodTransformDecision DecideMethodTransform( } // Null when the body needs accessors only for its event uses: there is no skip to rescue. + // Why an async or iterator body drops the '+=' advice: its whole state machine is rewritten, + // so leaving the '+= Handler' line and moving other code out still leaves a private access the + // rewrite has no shape for; only wrapping the handler in a lambda lets it apply. + private static WorkerReason AdviseForWholeBody(WorkerReason accessorRejectReason) + { + if (accessorRejectReason?.Code != HotReloadWorkerReasonCode.AccessorMethodGroupSubscribeNoShape) + { + return accessorRejectReason; + } + + return WorkerReason.Of(HotReloadWorkerReasonCode.AccessorMethodGroupNoShape, accessorRejectReason.Args); + } + private static HotReloadWorkerReasonCode? BuildAccessorRescueReason( bool closureInaccessible, bool asyncIteratorInaccessible) From 417c60211947b8101bebeb56b7310552fe0dd6ae Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 2 Oct 2026 11:06:51 +0900 Subject: [PATCH 24/26] docs(hot-reload): Say AddedFields is empty on --status and --revert-all The live list of added fields and events is the AddedField rows and AddedFieldTotal. --- .agents/skills/uloop-hot-reload/references/output.md | 2 +- .claude/skills/uloop-hot-reload/references/output.md | 2 +- .../Editor/FirstPartyTools/HotReload/Skill/references/output.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/uloop-hot-reload/references/output.md b/.agents/skills/uloop-hot-reload/references/output.md index d1f1514ca..b40dd86a8 100644 --- a/.agents/skills/uloop-hot-reload/references/output.md +++ b/.agents/skills/uloop-hot-reload/references/output.md @@ -8,7 +8,7 @@ Returns JSON with: - `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 }` - `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. - `PatchedTotal` (number): Methods patched in this run -- `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. +- `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. It is always empty on `--status` and `--revert-all` runs; the live list is the `Methods` rows with `Kind` `AddedField`, counted by `AddedFieldTotal`. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `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. diff --git a/.claude/skills/uloop-hot-reload/references/output.md b/.claude/skills/uloop-hot-reload/references/output.md index d1f1514ca..b40dd86a8 100644 --- a/.claude/skills/uloop-hot-reload/references/output.md +++ b/.claude/skills/uloop-hot-reload/references/output.md @@ -8,7 +8,7 @@ Returns JSON with: - `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 }` - `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. - `PatchedTotal` (number): Methods patched in this run -- `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. +- `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. It is always empty on `--status` and `--revert-all` runs; the live list is the `Methods` rows with `Kind` `AddedField`, counted by `AddedFieldTotal`. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `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. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md index d1f1514ca..b40dd86a8 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/output.md @@ -8,7 +8,7 @@ Returns JSON with: - `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 }` - `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. - `PatchedTotal` (number): Methods patched in this run -- `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. +- `AddedFields` (array): source-level names ("Type.field") of fields and field-like events this reload added; their values live outside the compiled type until 'uloop compile'. It is always empty on `--status` and `--revert-all` runs; the live list is the `Methods` rows with `Kind` `AddedField`, counted by `AddedFieldTotal`. Every run that adds fields also carries one warning stating that the values live outside the compiled assembly and last only until the next 'uloop compile' or domain reload; the warning names exactly the fields listed in AddedFields. An active added field declared with `[SerializeField]`, `[SerializeReference]`, or `[FormerlySerializedAs]` is also named, as `Namespace.Type.field` (nested types joined with `.`), in one `Added field(s) with a serialization attribute will not appear in the Inspector or serialize until 'uloop compile': ...` warning that points at [added-field-wiring.md](added-field-wiring.md). Only the run that first leaves the field active names it; a file that is Skipped or Failed names none of its fields, and a field is named again only after it stopped being active or after `--revert-all`. Pause-point `CapturedVariables` never includes these fields; `enable-pause-point` warns when the resolved type has any. - `AddedConsts` (array): source-level names ("Type.const") of consts this reload added. They are folded into edited bodies as literals, so they are not listed in AddedFields and do not emit the added-field lifetime warning. - `UnchangedTotal` (number): Methods left untouched because their bodies match the source baseline from the last compile; `0` when no baseline was available - `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. From 0e31b6add867fda4976e3b926766b9d732e549af Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 2 Oct 2026 11:16:20 +0900 Subject: [PATCH 25/26] docs(hot-reload): Separate re-running an edited OnEnable from an edited OnDisable Toggling enabled around a reload runs the old OnDisable and the patched OnEnable, so an edited OnDisable runs only when the component is disabled after the reload. --- .agents/skills/uloop-hot-reload/SKILL.md | 6 ++++-- .../skills/uloop-hot-reload/references/scope-and-limits.md | 6 ++++-- .claude/skills/uloop-hot-reload/SKILL.md | 6 ++++-- .../skills/uloop-hot-reload/references/scope-and-limits.md | 6 ++++-- .../src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md | 6 ++++-- .../HotReload/Skill/references/scope-and-limits.md | 6 ++++-- 6 files changed, 24 insertions(+), 12 deletions(-) diff --git a/.agents/skills/uloop-hot-reload/SKILL.md b/.agents/skills/uloop-hot-reload/SKILL.md index 3ad72a469..865e43479 100644 --- a/.agents/skills/uloop-hot-reload/SKILL.md +++ b/.agents/skills/uloop-hot-reload/SKILL.md @@ -89,8 +89,10 @@ While hot-reload changes are active, `AutoRefreshHeld` is true so returning focu recompile; `uloop compile` releases the hold, and `--revert-all` only when no introduced type remains. One-shot methods (`Awake`, `Start`, init helpers) patch but show no effect on the call that -already ran; the response marks them with `LifecycleNote`. For an edited compiled `OnEnable` / -`OnDisable`, set `enabled = false` with `execute-dynamic-code`, reload, then set it back to `true`. +already ran; the response marks them with `LifecycleNote`. To re-run an edited compiled +`OnEnable`, set `enabled = false` with `execute-dynamic-code`, reload, then set it back to `true` +(Unity calls the old `OnDisable`, then the patched `OnEnable`). An edited `OnDisable` runs the +next time the component is disabled; to run it now, toggle `enabled` after the reload. To tune a value while playing, expose a static property getter instead of a `const`; its body is patched on compiled and introduced types alike. diff --git a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md index dedbf11a1..a24b53166 100644 --- a/.agents/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.agents/skills/uloop-hot-reload/references/scope-and-limits.md @@ -264,8 +264,10 @@ To re-run an edited compiled `OnEnable` on live objects — for example after ad subscription to it — toggle the component around the reload instead of compiling: set `enabled = false` with `execute-dynamic-code`, run `hot-reload`, then set `enabled = true`. Unity calls the old `OnDisable` (so the old subscription is removed) and then the patched -`OnEnable`; coroutines keep running because only the component is toggled. This works only -for an `OnEnable` / `OnDisable` the compiled class already has; an added one is not called. +`OnEnable`; coroutines keep running because only the component is toggled. An edited +`OnDisable` runs the next time the component is disabled; to run it now, toggle `enabled` +after the reload. This works only for an `OnEnable` / `OnDisable` the compiled class already +has; an added one is not called. The proxies exist only for the running session: nothing is attached outside Play Mode, and a compile or a domain reload drops them along with every other patch. Execution order relative diff --git a/.claude/skills/uloop-hot-reload/SKILL.md b/.claude/skills/uloop-hot-reload/SKILL.md index 3ad72a469..865e43479 100644 --- a/.claude/skills/uloop-hot-reload/SKILL.md +++ b/.claude/skills/uloop-hot-reload/SKILL.md @@ -89,8 +89,10 @@ While hot-reload changes are active, `AutoRefreshHeld` is true so returning focu recompile; `uloop compile` releases the hold, and `--revert-all` only when no introduced type remains. One-shot methods (`Awake`, `Start`, init helpers) patch but show no effect on the call that -already ran; the response marks them with `LifecycleNote`. For an edited compiled `OnEnable` / -`OnDisable`, set `enabled = false` with `execute-dynamic-code`, reload, then set it back to `true`. +already ran; the response marks them with `LifecycleNote`. To re-run an edited compiled +`OnEnable`, set `enabled = false` with `execute-dynamic-code`, reload, then set it back to `true` +(Unity calls the old `OnDisable`, then the patched `OnEnable`). An edited `OnDisable` runs the +next time the component is disabled; to run it now, toggle `enabled` after the reload. To tune a value while playing, expose a static property getter instead of a `const`; its body is patched on compiled and introduced types alike. diff --git a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md index dedbf11a1..a24b53166 100644 --- a/.claude/skills/uloop-hot-reload/references/scope-and-limits.md +++ b/.claude/skills/uloop-hot-reload/references/scope-and-limits.md @@ -264,8 +264,10 @@ To re-run an edited compiled `OnEnable` on live objects — for example after ad subscription to it — toggle the component around the reload instead of compiling: set `enabled = false` with `execute-dynamic-code`, run `hot-reload`, then set `enabled = true`. Unity calls the old `OnDisable` (so the old subscription is removed) and then the patched -`OnEnable`; coroutines keep running because only the component is toggled. This works only -for an `OnEnable` / `OnDisable` the compiled class already has; an added one is not called. +`OnEnable`; coroutines keep running because only the component is toggled. An edited +`OnDisable` runs the next time the component is disabled; to run it now, toggle `enabled` +after the reload. This works only for an `OnEnable` / `OnDisable` the compiled class already +has; an added one is not called. The proxies exist only for the running session: nothing is attached outside Play Mode, and a compile or a domain reload drops them along with every other patch. Execution order relative diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md index 3ad72a469..865e43479 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/SKILL.md @@ -89,8 +89,10 @@ While hot-reload changes are active, `AutoRefreshHeld` is true so returning focu recompile; `uloop compile` releases the hold, and `--revert-all` only when no introduced type remains. One-shot methods (`Awake`, `Start`, init helpers) patch but show no effect on the call that -already ran; the response marks them with `LifecycleNote`. For an edited compiled `OnEnable` / -`OnDisable`, set `enabled = false` with `execute-dynamic-code`, reload, then set it back to `true`. +already ran; the response marks them with `LifecycleNote`. To re-run an edited compiled +`OnEnable`, set `enabled = false` with `execute-dynamic-code`, reload, then set it back to `true` +(Unity calls the old `OnDisable`, then the patched `OnEnable`). An edited `OnDisable` runs the +next time the component is disabled; to run it now, toggle `enabled` after the reload. To tune a value while playing, expose a static property getter instead of a `const`; its body is patched on compiled and introduced types alike. diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md index dedbf11a1..a24b53166 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md +++ b/Packages/src/Editor/FirstPartyTools/HotReload/Skill/references/scope-and-limits.md @@ -264,8 +264,10 @@ To re-run an edited compiled `OnEnable` on live objects — for example after ad subscription to it — toggle the component around the reload instead of compiling: set `enabled = false` with `execute-dynamic-code`, run `hot-reload`, then set `enabled = true`. Unity calls the old `OnDisable` (so the old subscription is removed) and then the patched -`OnEnable`; coroutines keep running because only the component is toggled. This works only -for an `OnEnable` / `OnDisable` the compiled class already has; an added one is not called. +`OnEnable`; coroutines keep running because only the component is toggled. An edited +`OnDisable` runs the next time the component is disabled; to run it now, toggle `enabled` +after the reload. This works only for an `OnEnable` / `OnDisable` the compiled class already +has; an added one is not called. The proxies exist only for the running session: nothing is attached outside Play Mode, and a compile or a domain reload drops them along with every other patch. Execution order relative From 57bc2cda25d1f788f3090de1aa3bee767376fb04 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 2 Oct 2026 11:16:21 +0900 Subject: [PATCH 26/26] refactor(hot-reload): Move the rescue-reason comment back onto its method --- .../HotReload/TransformWorker~/MethodTransformDecider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs index 97d726f93..0bc9642a2 100644 --- a/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs +++ b/Packages/src/Editor/FirstPartyTools/HotReload/TransformWorker~/MethodTransformDecider.cs @@ -103,7 +103,6 @@ internal static MethodTransformDecision DecideMethodTransform( return MethodTransformDecision.Delegation(); } - // Null when the body needs accessors only for its event uses: there is no skip to rescue. // Why an async or iterator body drops the '+=' advice: its whole state machine is rewritten, // so leaving the '+= Handler' line and moving other code out still leaves a private access the // rewrite has no shape for; only wrapping the handler in a lambda lets it apply. @@ -117,6 +116,7 @@ private static WorkerReason AdviseForWholeBody(WorkerReason accessorRejectReason return WorkerReason.Of(HotReloadWorkerReasonCode.AccessorMethodGroupNoShape, accessorRejectReason.Args); } + // Null when the body needs accessors only for its event uses: there is no skip to rescue. private static HotReloadWorkerReasonCode? BuildAccessorRescueReason( bool closureInaccessible, bool asyncIteratorInaccessible)