test(upserver): 归档存档迁移与玩法闭环回归工具

This commit is contained in:
Liubing\LB
2026-09-28 18:16:14 +08:00
parent c4ae02def9
commit a6f5e107f8
232 changed files with 12903 additions and 0 deletions

View File

@@ -0,0 +1,341 @@
using System;using System.Linq;using System.Collections.Generic;using System.Threading;using System.Threading.Tasks;
public partial class GetUiSubject {
public async Task GetUI()
{
if (this == null) return;
var container = GContext.container;
var loginLifetime = container.Resolve<IUserService>().LoginLifetime;
HomePanel homePanel = null;
if (!fishingData.isMapBuff && !IsHideHomeMap)
{
var go = await UIManager.Instance.RestartShowUI(UITypes.HomePanel);
if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return;
homePanel = go.GetComponent<HomePanel>();
}
//实例
container.Resolve<IDeferredRewardStashService>();
//这里确认补单
await container.Resolve<PlayerShopData>().ReleaseReplenishment();
if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return;
await Awaiters.SecondsRealtime(0.5f);
if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return;
container.Resolve<FishingEventData>().ShowRankTierChange();
if (fishingData.isMapBuff)
{
fishingData.isMapBuff = false;
}
else
{
fishingBehavior?.RodBehaviour.gameObject.SetActive(true);
fishingBehavior.cameraAnimator.speed = 1;
GContext.Publish(new ShowHandEvent() { isShow = true });
SetMapShow(!IsHideHomeMap);
if (IsHideHomeMap)
{
IsHideHomeMap = false;
}
else if (homePanel != null)
{
homePanel.OnStart();
}
}
}
public async Task NewEnterMap(PlayableDirector camera_timeline)
{
if (this == null) return;
var container = GContext.container;
var loginLifetime = container.Resolve<IUserService>().LoginLifetime;
camera_timeline.gameObject.SetActive(true);
fishingBehavior.cameraAnimator.enabled = false;
fishingBehavior.cameraAnimator.transform.localEulerAngles = Vector3.zero;
fishingBehavior.cameraAnimator.transform.localPosition = Vector3.zero;
await Awaiters.Seconds((float)camera_timeline.duration);
if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return;
camera_timeline.gameObject.SetActive(false);
fishingData.mainCamera.transform.localEulerAngles = Vector3.zero;
fishingData.mainCamera.transform.localPosition = Vector3.zero;
fishingBehavior.cameraAnimator.enabled = true;
GContext.Publish(new EventBGMSound(_curMapData.BGM));
fishingBehavior.cameraAnimator.Play("EnterMap", -1, 0);
await OnCameraSpeedEvent();
if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return;
var go = await UIManager.Instance.RestartShowUI(UITypes.HomePanel);
if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return;
HomePanel homePanel = go.GetComponent<HomePanel>();
homePanel.OnStart();
}
public async Task OnCameraSpeedEvent()
{
if (this == null) return;
var container = GContext.container;
var loginLifetime = container.Resolve<IUserService>().LoginLifetime;
fishingBehavior.cameraAnimator.speed = 1;
await Awaiters.Seconds(fishingData.fishingBehaviorConf.rodEnterMapDelay);
if (this == null || loginLifetime.IsCancellationRequested || !ReferenceEquals(container, GContext.container)) return;
fishingBehavior?.RodBehaviour.gameObject.SetActive(true);
GContext.Publish(new ShowHandEvent() { isShow = true });
}
}
public partial class ManagerSubject {
public async Task Init(System.Action<float> progress)
{
if (residentClip != null || disposables != null) Release();
var version = ++initializationVersion;
var container = GContext.container;
_tables = container.Resolve<cfg.Tables>();
var opMap = new Dictionary<string, AsyncOperationHandle<AudioClip>>();
CompositeDisposable pendingSubscriptions = null;
bool transferred = false;
try
{
await soundService.InitAsync();
if (version != initializationVersion || !ReferenceEquals(container, GContext.container)) return;
foreach (var addr in residentFish)
{
if (!opMap.ContainsKey(addr)) opMap.Add(addr, Addressables.LoadAssetAsync<AudioClip>(addr));
}
var tasks = opMap.Values.Select(_ => _.Task).ToArray();
var total = (float)tasks.Length;
while (true)
{
var completed = tasks.Count(_ => _.IsCompleted);
if (completed == total)
{
progress.Invoke(1f);
break;
}
progress.Invoke(completed / total);
await Awaiters.NextFrame;
if (version != initializationVersion || !ReferenceEquals(container, GContext.container)) return;
}
if (version != initializationVersion || !ReferenceEquals(container, GContext.container)) return;
var loadedClips = opMap.ToDictionary(_ => _.Key, _ => _.Value.Result);
pendingSubscriptions = new CompositeDisposable();
GContext.OnEvent<EventUISound>().Subscribe(PlaySound).AddTo(pendingSubscriptions);
GContext.OnEvent<EventRewardSound>().Subscribe(PlayRewardSound).AddTo(pendingSubscriptions);
GContext.OnEvent<EventFishingSound>().Subscribe(PlayFishingSound).AddTo(pendingSubscriptions);
GContext.OnEvent<EventBGMSound>().Subscribe(PlayMusic).AddTo(pendingSubscriptions);
GContext.OnEvent<FadeOutEvent>().Subscribe(FadeOutEvent).AddTo(pendingSubscriptions);
GContext.OnEvent<AssetReferenceAudioClip>().Subscribe(PlaySoundByReference).AddTo(pendingSubscriptions);
GContext.OnEvent<AssetReferenceAudioVoiceClip>().Subscribe(PlayVoiceSoundByReference).AddTo(pendingSubscriptions);
GContext.OnEvent<ChangeRodDataEvent>().Subscribe(OnChangeRodData).AddTo(pendingSubscriptions);
GContext.OnEvent<SoundMuteEvent>().Subscribe(OnSoundMute).AddTo(pendingSubscriptions);
GContext.OnEvent<OnSoundStateEvent>().Subscribe(OnSoundStateEvent).AddTo(pendingSubscriptions);
soundService.BGMVol = container.Resolve<ISettingService>().Music ? 80 : 0;
soundService.SFXVol = container.Resolve<ISettingService>().Sound ? 80 : 0;
soundService.VoiceVol = container.Resolve<ISettingService>().Sound ? 80 : 0;
residentClip = loadedClips;
disposables = pendingSubscriptions;
transferred = true;
}
finally
{
if (!transferred)
{
pendingSubscriptions?.Dispose();
if (version == initializationVersion) Release();
foreach (var handle in opMap.Values)
if (handle.IsValid()) Addressables.Release(handle);
}
}
}
public void Release()
{
initializationVersion++;
if (disposables != null)
{
disposables.Dispose();
disposables = null;
}
if (residentClip != null)
{
foreach (var item in residentClip)
{
if (item.Value != null)
{
Addressables.Release(item.Value);
}
}
residentClip.Clear();
}
if (rewardSoundDic != null)
{
rewardSoundDic.Clear();
}
if (soundService != null)
{
foreach (var s in _activeFishingLoopSounds.ToList())
{
if (s != null)
{
s.audioSource.Stop();
soundService.ReturnSound(s);
}
}
}
_activeFishingLoopSounds.Clear();
if (soundService != null)
{
soundService.Release();
}
if (BGM_Clip != null)
{
Addressables.Release(BGM_Clip);
BGM_Clip = null;
}
if (BGM_Sound != null && BGM_Sound.gameObject != null)
{
GameObject.Destroy(BGM_Sound.gameObject);
BGM_Sound = null;
}
BGMName = null;
}
}
public partial class ServiceSubject {
public async Task InitAsync(string mixerAddr = "AudioMixer", string soundPrefabAddr = "sound",
string bgmGroupId = "BGM", string fishingGroupId = "Fishing", string uiGroupId = "UI", string voiceGroupId = "Voice")
{
if (mixerHandle.IsValid() || soundPrefabHandle.IsValid() || lowpassEvtDisposable != null) Release();
var version = ++initializationVersion;
SoundFadeTaskDic = new Dictionary<Sound, CancellationTokenSource>();
AsyncOperationHandle<AudioMixer> pendingMixer = default;
AsyncOperationHandle<GameObject> pendingPrefab = default;
IDisposable pendingSubscription = null;
bool transferred = false;
try
{
pendingMixer = Addressables.LoadAssetAsync<AudioMixer>(mixerAddr);
var loadedMixer = await pendingMixer.Task;
if (version != initializationVersion) return;
mixer = loadedMixer;
Assert.IsNotNull(mixer);
mixer.GetFloat(Bgm_Vol_Param_Name, out bgmVol);
mixer.GetFloat(Fishing_Vol_Param_Name, out fishingVol);
bgmGroup = FindMixerGroup(bgmGroupId);
fishingGroup = FindMixerGroup(fishingGroupId);
uiGroup = FindMixerGroup(uiGroupId);
voiceGroup = FindMixerGroup(voiceGroupId);
normal = mixer.FindSnapshot("Snapshot");
Assert.IsNotNull(bgmGroup);
Assert.IsNotNull(fishingGroup);
Assert.IsNotNull(uiGroup);
pendingPrefab = Addressables.LoadAssetAsync<GameObject>(soundPrefabAddr);
var soundPrefab = await pendingPrefab.Task;
if (version != initializationVersion) return;
Assert.IsNotNull(soundPrefab);
var sound = soundPrefab.GetComponent<Sound>();
Assert.IsNotNull(sound);
poolService.CreatePool<Sound>(sound, 10, 50);
pendingSubscription = GContext.OnEvent<LowpassEffectEvt>().Subscribe(OnLowpassEffectEvt);
mixerHandle = pendingMixer;
soundPrefabHandle = pendingPrefab;
lowpassEvtDisposable = pendingSubscription;
transferred = true;
}
finally
{
if (!transferred)
{
pendingSubscription?.Dispose();
if (version == initializationVersion) Release();
if (pendingPrefab.IsValid()) Addressables.Release(pendingPrefab);
if (pendingMixer.IsValid()) Addressables.Release(pendingMixer);
}
}
}
public void Release()
{
initializationVersion++;
if (SoundFadeTaskDic != null)
{
foreach (var source in SoundFadeTaskDic.Values)
{
source.Cancel();
}
SoundFadeTaskDic.Clear();
SoundFadeTaskDic = null;
}
poolService?.DestroyPool<Sound>();
if (mixerHandle.IsValid()) Addressables.Release(mixerHandle);
mixerHandle = default;
if (soundPrefabHandle.IsValid()) Addressables.Release(soundPrefabHandle);
soundPrefabHandle = default;
lowpassEvtDisposable?.Dispose();
lowpassEvtDisposable = null;
}
}
public partial class StageSubject : StageBase {
public async Task RunPrefix()
{
if (stopping || this == null) return;
var loginLifetime = UserService.LoginLifetime;
LoadingScreen.SetProgress(0.54f);
await Awaiters.NextFrame;
if (stopping || this == null || loginLifetime.IsCancellationRequested) return;
// wait authorize , 5 sec max
var checkAuthorTimes = 0;
while (checkAuthorTimes < 10 && !UserService.IsAuthorized)
{
await Task.Delay(500);
if (stopping || this == null || loginLifetime.IsCancellationRequested) return;
checkAuthorTimes++;
}
if (!UserService.IsAuthorized)
{
Debug.LogWarning("[FishingStage::startAsync] Api Svr not author");
}
if (GContext.container.Resolve<IConfig>().Get("DEBUG", "0") == "1")
{
var console = await UnityEngine.AddressableAssets.Addressables.InstantiateAsync("IngameDebugConsole").Task;
if (stopping || this == null || loginLifetime.IsCancellationRequested)
{
if (console != null) UnityEngine.AddressableAssets.Addressables.ReleaseInstance(console);
return;
}
}
await GContext.container.Resolve<SoundMgr>().Init((progress) =>
{
if (!stopping && this != null && !loginLifetime.IsCancellationRequested) LoadingScreen.SetProgress(0.55f + progress * 0.2f);
});
if (stopping || this == null || loginLifetime.IsCancellationRequested) return;
Applied=true; }
public override async Task Stop()
{
stopping = true;
GContext.container.Unregister<IStageData>();
GContext.container.Unregister<SurveyService>();
GContext.container.Resolve<IDeferredRewardStashService>()?.DisposeSubscriptions();
GContext.container.Unregister<IDeferredRewardStashService>();
disposable?.Dispose();
disposable = null;
_autoRestart?.Dispose();
_autoRestart = null;
await base.Stop();
}
protected override void OnDestroy() { stopping = true; }
public async Task RunCatch(){ var loginLifetime=UserService.LoginLifetime;
try { await base.StartAsync(); }
catch (System.Exception ex) when (stopping || this == null || loginLifetime.IsCancellationRequested)
{
Debug.LogWarning($"[FishingStage] Startup stopped, cancelled or stage destroyed: {ex.GetType().Name}: {ex.Message}");
return;
}
if (stopping || this == null || loginLifetime.IsCancellationRequested) return;
CatchCompleted=true; }
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json;
var mode=args.Contains("--normal")?"normal":args.Contains("--regression")?"regression":args.Contains("--stage")?"stage":"extended";
var results=new List<object>();var failures=0;
bool NoLeaks()=>Addressables.All.All(o=>!o.Valid&&o.Releases==1);
async Task Case(string name,Func<Task<bool>> body){Probe.Reset();bool ok=false;string error=null;try{ok=await body();}catch(Exception ex){error=ex.ToString();}if(!ok)failures++;var observation=new{Probe.RankCalls,Probe.HomeCalls,Probe.CameraWrites,Probe.UiCalls,Probe.Published,Probe.PoolCreates,Probe.Subscriptions,Probe.VolumeAfterRelease,refs=Addressables.All.Select(o=>new{o.Key,o.Valid,o.Releases}).ToArray(),error};results.Add(new{name,passed=ok,observation});Console.WriteLine($"{(ok?"PASS":"FAIL")} {name}: {JsonSerializer.Serialize(observation)}");}
void Invalidate(GetUiSubject s,string how){if(how=="destroy")s.Destroyed=true;else if(how=="cancel")GContext.container.Resolve<IUserService>().Lifetime.Cancel();else GContext.container=new();}
void ClipsReady(){foreach(var op in Addressables.All.OfType<Operation<AudioClip>>().Where(o=>o.Valid&&!o.Completion.Task.IsCompleted))op.Completion.SetResult(new());}
async Task ServiceReady(ServiceSubject s){var task=s.InitAsync();Addressables.Complete("AudioMixer",new AudioMixer());Addressables.Complete("sound",new GameObject());await task;}
async Task<bool> Fails(Task t){try{await t;return false;}catch{return true;}}
if(mode=="normal"){
await Case("GetUI normal",async()=>{var s=new GetUiSubject();UIManager.Instance.Ready.SetResult(new());var t=s.GetUI();Probe.Delay.SetResult(true);await t;return Probe.RankCalls==1&&Probe.HomeCalls==1&&Probe.CameraWrites==1;});
await Case("SoundMgr normal Init/Release",async()=>{var s=new ManagerSubject();var t=s.Init(_=>{});ClipsReady();Probe.Frame.SetResult(true);await t;var count=Probe.Subscriptions;s.Release();return count==10&&Probe.Subscriptions==0&&Probe.VolumeAfterRelease==0&&NoLeaks();});
foreach(var name in new[]{"mixer","prefab"})await Case("SoundService normal "+name,async()=>{var s=new ServiceSubject();await ServiceReady(s);var count=Probe.PoolCreates;s.Release();s.Release();return count==1&&Probe.Subscriptions==0&&NoLeaks();});
}else if(mode=="regression"){
await Case("GetUI exits during delay",async()=>{var s=new GetUiSubject();UIManager.Instance.Ready.SetResult(new());var t=s.GetUI();s.Destroyed=true;GContext.container=new();Probe.Delay.SetResult(true);await t;return Probe.RankCalls==0&&Probe.HomeCalls==0&&Probe.CameraWrites==0;});
await Case("SoundMgr pending Init then Release",async()=>{var s=new ManagerSubject();var t=s.Init(_=>{});s.Release();GContext.container=new();ClipsReady();Probe.Frame.SetResult(true);await t;return Probe.Subscriptions==0&&Probe.VolumeAfterRelease==0&&(s.residentClip==null||s.residentClip.Count==0)&&NoLeaks();});
foreach(var boundary in new[]{"mixer","prefab"})await Case("SoundService Release during "+boundary,async()=>{var s=new ServiceSubject();var t=s.InitAsync();if(boundary=="prefab")Addressables.Complete("AudioMixer",new AudioMixer());s.Release();if(boundary=="mixer")Addressables.Complete("AudioMixer",new AudioMixer());else Addressables.Complete("sound",new GameObject());await t;return Probe.PoolCreates==0&&Probe.Subscriptions==0&&NoLeaks();});
}else if(mode=="stage"){
await Case("Stage normal prefix",async()=>{var s=new StageSubject();var t=s.RunPrefix();Probe.Frame.SetResult(true);GContext.container.Resolve<SoundMgr>().Ready.SetResult(true);await t;return s.Applied;});
await Case("Stage stopped before Start, alive uncancelled",async()=>{var s=new StageSubject();await s.Stop();await s.RunPrefix();return !s.Applied&&!s.Destroyed&&!s.UserService.LoginLifetime.IsCancellationRequested;});
await Case("Stage Stop first statement blocks pending NextFrame",async()=>{var s=new StageSubject{StopReady=new()};var t=s.RunPrefix();var stop=s.Stop();Probe.Frame.SetResult(true);await t;var blocked=!s.Applied&&!s.Destroyed&&!s.UserService.LoginLifetime.IsCancellationRequested&&!stop.IsCompleted;s.StopReady.SetResult(true);await stop;return blocked;});
await Case("Stage Stop during SoundMgr wait and stale progress",async()=>{var s=new StageSubject();var t=s.RunPrefix();Probe.Frame.SetResult(true);var sound=GContext.container.Resolve<SoundMgr>();await s.Stop();var writes=LoadingScreen.Writes;sound.Progress(.5f);sound.Ready.SetResult(true);await t;await s.Stop();return !s.Applied&&!s.Destroyed&&!s.UserService.LoginLifetime.IsCancellationRequested&&LoadingScreen.Writes==writes;});
await Case("Stage OnDestroy invalidates pending initialization",async()=>{var s=new StageSubject();var t=s.RunPrefix();s.DestroyStage();Probe.Frame.SetResult(true);await t;return !s.Applied;});
foreach(var stopped in new[]{false,true})await Case("Stage base startup failure, stopped="+stopped,async()=>{var s=new StageSubject();var t=s.RunCatch();if(stopped)await s.Stop();var warnings=Debug.Warnings;s.StartReady.SetException(new Exception("base startup failure"));var failed=await Fails(t);return stopped?!failed&&!s.CatchCompleted&&Debug.Warnings==warnings+1:failed&&!s.CatchCompleted&&Debug.Warnings==warnings;});
foreach(var stopped in new[]{false,true})await Case("Stage base startup completion, stopped="+stopped,async()=>{var s=new StageSubject();var t=s.RunCatch();if(stopped)await s.Stop();s.StartReady.SetResult(true);await t;return s.CatchCompleted==!stopped;});
}else{
foreach(var boundary in new[]{"ui","replenishment","delay"})foreach(var how in new[]{"destroy","cancel","container"})await Case("GetUI "+boundary+"/"+how,async()=>{var s=new GetUiSubject();var shop=GContext.container.Resolve<PlayerShopData>();shop.Pending=new();if(boundary!="ui")UIManager.Instance.Ready.SetResult(new());if(boundary=="delay")shop.Pending.SetResult(true);var t=s.GetUI();Invalidate(s,how);if(boundary=="ui")UIManager.Instance.Ready.SetResult(new());else if(boundary=="replenishment")shop.Pending.SetResult(true);else Probe.Delay.SetResult(true);await t;return Probe.RankCalls==0&&Probe.HomeCalls==0&&Probe.CameraWrites==0;});
foreach(var boundary in new[]{"timeline","rod","ui"})foreach(var how in new[]{"destroy","cancel","container"})await Case("NewEnterMap "+boundary+"/"+how,async()=>{var s=new GetUiSubject();var t=s.NewEnterMap(new());if(boundary!="timeline")Probe.Seconds[0].SetResult(true);if(boundary=="ui")Probe.Seconds[1].SetResult(true);var camera=Probe.CameraWrites;var published=Probe.Published;var active=s.fishingBehavior.RodBehaviour.gameObject.ActiveWrites;Invalidate(s,how);if(boundary=="timeline")Probe.Seconds[0].SetResult(true);else if(boundary=="rod")Probe.Seconds[1].SetResult(true);else UIManager.Instance.Ready.SetResult(new());await t;return Probe.HomeCalls==0&&Probe.CameraWrites==camera&&Probe.Published==published&&s.fishingBehavior.RodBehaviour.gameObject.ActiveWrites==active;});
foreach(var how in new[]{"destroy","cancel","container"})await Case("OnCameraSpeedEvent "+how,async()=>{var s=new GetUiSubject();var t=s.OnCameraSpeedEvent();Invalidate(s,how);Probe.Seconds[0].SetResult(true);await t;return Probe.Published==0&&s.fishingBehavior.RodBehaviour.gameObject.ActiveWrites==0;});
await Case("NewEnterMap normal",async()=>{var s=new GetUiSubject();var t=s.NewEnterMap(new());Probe.Seconds[0].SetResult(true);Probe.Seconds[1].SetResult(true);UIManager.Instance.Ready.SetResult(new());await t;return Probe.HomeCalls==1&&Probe.Published==2&&s.fishingBehavior.RodBehaviour.gameObject.ActiveWrites==1;});
await Case("GetUI entry already destroyed",async()=>{var s=new GetUiSubject{Destroyed=true};await s.GetUI();return Probe.UiCalls==0;});
foreach(var how in new[]{"release","container"})await Case("SoundMgr bottom service wait/"+how,async()=>{var s=new ManagerSubject();s.soundService.Pending=new();var t=s.Init(_=>{});if(how=="release")s.Release();else GContext.container=new();s.soundService.Pending.SetResult(true);await t;return Probe.Subscriptions==0&&Addressables.All.Count==0&&s.soundService.Released;});
await Case("SoundMgr frame/container",async()=>{var s=new ManagerSubject();var t=s.Init(_=>{});GContext.container=new();Probe.Frame.SetResult(true);await t;return Probe.Subscriptions==0&&NoLeaks();});
foreach(var failure in new[]{"clip","subscription","volume"})await Case("SoundMgr failure rollback/"+failure,async()=>{var s=new ManagerSubject();var t=s.Init(_=>{});if(failure=="clip")Addressables.Get<AudioClip>("clip-a").Completion.SetException(new Exception("clip failure"));else if(failure=="subscription")Probe.ThrowSubscriptionAt=3;else Probe.ThrowVolume=true;ClipsReady();Probe.Frame.SetResult(true);var failed=await Fails(t);return failed&&Probe.Subscriptions==0&&s.soundService.Released&&NoLeaks();});
await Case("SoundMgr duplicate address + repeated Release",async()=>{var s=new ManagerSubject{residentFish=new[]{"one","one","two"}};var t=s.Init(_=>{});ClipsReady();Probe.Frame.SetResult(true);await t;var n=Addressables.All.Count;s.Release();s.Release();return n==2&&Probe.Subscriptions==0&&NoLeaks();});
await Case("SoundMgr repeat successful Init",async()=>{var s=new ManagerSubject();var a=s.Init(_=>{});ClipsReady();Probe.Frame.SetResult(true);await a;Probe.Frame=new();var b=s.Init(_=>{});ClipsReady();Probe.Frame.SetResult(true);await b;var subs=Probe.Subscriptions;s.Release();return subs==10&&Probe.Subscriptions==0&&NoLeaks();});
await Case("SoundMgr superseded audio wait",async()=>{var s=new ManagerSubject();var a=s.Init(_=>{});var old=Addressables.All.ToArray();var b=s.Init(_=>{});ClipsReady();Probe.Frame.SetResult(true);await Task.WhenAll(a,b);var oldReleased=old.All(x=>!x.Valid&&x.Releases==1);var subs=Probe.Subscriptions;var current=Addressables.All.Except(old).All(x=>x.Valid&&x.Releases==0);s.Release();return oldReleased&&current&&subs==10&&Probe.Subscriptions==0&&NoLeaks();});
foreach(var boundary in new[]{"mixer","prefab"})await Case("SoundService failure/"+boundary,async()=>{var s=new ServiceSubject();var t=s.InitAsync();if(boundary=="mixer")Addressables.Get<AudioMixer>("AudioMixer").Completion.SetException(new Exception("mixer fail"));else{Addressables.Complete("AudioMixer",new AudioMixer());Addressables.Get<GameObject>("sound").Completion.SetException(new Exception("prefab fail"));}var failed=await Fails(t);return failed&&Probe.Subscriptions==0&&s.poolService.Active==0&&NoLeaks();});
foreach(var failure in new[]{"pool","subscription"})await Case("SoundService failure rollback/"+failure,async()=>{var s=new ServiceSubject();var t=s.InitAsync();Addressables.Complete("AudioMixer",new AudioMixer());if(failure=="pool")Probe.ThrowPool=true;else Probe.ThrowSubscriptionAt=1;Addressables.Complete("sound",new GameObject());var failed=await Fails(t);return failed&&Probe.Subscriptions==0&&s.poolService.Active==0&&NoLeaks();});
await Case("SoundService repeated successful Init/Release",async()=>{var s=new ServiceSubject();await ServiceReady(s);await ServiceReady(s);var sub=Probe.Subscriptions;s.Release();s.Release();return sub==1&&Probe.Subscriptions==0&&s.poolService.Active==0&&NoLeaks();});
foreach(var boundary in new[]{"mixer","prefab"})foreach(var oldFails in new[]{false,true})await Case("SoundService old "+boundary+" completes after new/fault="+oldFails,async()=>{var s=new ServiceSubject();var a=s.InitAsync();var oldMixer=Addressables.Get<AudioMixer>("AudioMixer");Operation<GameObject> oldPrefab=null;if(boundary=="prefab"){oldMixer.Completion.SetResult(new());oldPrefab=Addressables.Get<GameObject>("sound");}await ServiceReady(s);var fresh=Addressables.All.Where(o=>!ReferenceEquals(o,oldMixer)&&!ReferenceEquals(o,oldPrefab)).ToArray();if(boundary=="mixer"){if(oldFails)oldMixer.Completion.SetException(new Exception("old fail"));else oldMixer.Completion.SetResult(new());}else{if(oldFails)oldPrefab.Completion.SetException(new Exception("old fail"));else oldPrefab.Completion.SetResult(new());}if(oldFails){if(!await Fails(a))return false;}else await a;var isolated=fresh.All(o=>o.Valid&&o.Releases==0)&&Probe.Subscriptions==1&&s.poolService.Active==1;s.Release();return isolated&&Probe.Subscriptions==0&&NoLeaks();});
}
File.WriteAllText(Path.Combine(AppContext.BaseDirectory,"../../../"+$"fixed-{mode}-result.json"),JsonSerializer.Serialize(new{mode,failures,results},new JsonSerializerOptions{WriteIndented=true}));return failures==0?0:1;

View File

@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile></PropertyGroup><ItemGroup><Compile Remove="baseline/**/*.cs" /></ItemGroup></Project>

View File

@@ -0,0 +1,85 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
// Dependency substitutes only. Production method bodies are in Generated.cs.
public static class Probe {
public static int RankCalls,HomeCalls,CameraWrites,VolumeAfterRelease,PoolCreates,Subscriptions,ReleasedHandles,UiCalls,Published,SubscriptionsAttempt,ThrowSubscriptionAt;
public static bool ThrowVolume,ThrowPool;
public static List<TaskCompletionSource<bool>> Seconds=new();
public static TaskCompletionSource<bool> Frame,Delay;
public static void Reset(){RankCalls=HomeCalls=CameraWrites=VolumeAfterRelease=PoolCreates=Subscriptions=ReleasedHandles=UiCalls=Published=SubscriptionsAttempt=ThrowSubscriptionAt=0;ThrowVolume=ThrowPool=false;Seconds.Clear();Frame=new();Delay=new();Addressables.Pending.Clear();Addressables.All.Clear();GContext.container=new();UIManager.Instance=new();}
}
public static class Awaiters {public static Task NextFrame=>Probe.Frame.Task;public static Task SecondsRealtime(float _) => Probe.Delay.Task;public static Task Seconds(float _){var t=new TaskCompletionSource<bool>();Probe.Seconds.Add(t);return t.Task;}}
public static class GContext {public static Container container;public static void Publish<T>(T _){Probe.Published++;} public static EventStream<T> OnEvent<T>()=>new();}
public class Container {public Dictionary<Type,object> Values=new();public void Unregister<T>(){Values.Remove(typeof(T));} public T Resolve<T>(){if(Values.TryGetValue(typeof(T),out var v))return (T)v; var a=Activator.CreateInstance<T>();Values[typeof(T)]=a;return a;}}
public class EventStream<T>{public IDisposable Subscribe(Action<T> action){Probe.SubscriptionsAttempt++;if(Probe.ThrowSubscriptionAt>0&&Probe.SubscriptionsAttempt==Probe.ThrowSubscriptionAt)throw new Exception("subscription failure");Probe.Subscriptions++;return new Subscription();}}
public class Subscription:IDisposable{bool disposed;public void Dispose(){if(disposed)return;disposed=true;Probe.Subscriptions--;}}
public class CompositeDisposable:IDisposable{public List<IDisposable> Items=new();public void Dispose(){foreach(var x in Items)x.Dispose();Items.Clear();}}
public static class RxExtensions{public static void AddTo(this IDisposable d,CompositeDisposable c)=>c.Items.Add(d);}
public class GameObject {public bool Destroyed;public Transform transform=new();public int ActiveWrites;public void SetActive(bool _){ActiveWrites++;}public T GetComponent<T>() where T:new()=>new();public static void Destroy(GameObject g){if(g!=null)g.Destroyed=true;}}
public class HomePanel {public void OnStart()=>Probe.HomeCalls++;}
public class UIManager {public static UIManager Instance;public TaskCompletionSource<GameObject> Ready=new();public Task<GameObject> RestartShowUI(object _) {Probe.UiCalls++;return Ready.Task;}}
public static class UITypes {public static object HomePanel=new();}
public class IDeferredRewardStashService{public void DisposeSubscriptions(){}}
public class PlayerShopData{public TaskCompletionSource<bool> Pending;public Task ReleaseReplenishment()=>Pending?.Task??Task.CompletedTask;}
public class FishingEventData{public void ShowRankTierChange()=>Probe.RankCalls++;}
public class FishingData{public bool isMapBuff;public GameObject mainCamera=new();public BehaviorConf fishingBehaviorConf=new();}
public class BehaviorConf{public float rodEnterMapDelay=.1f;}
public class Behavior {public Rod RodBehaviour=new();public Animator cameraAnimator=new();}
public class Rod {public GameObject gameObject=new();}
public class Animator {public bool enabled;public Transform transform=new();public int speed{set{Probe.CameraWrites++;}}public void Play(string _,int __,int ___){Probe.CameraWrites++;}}
public class Transform{public Vector3 localEulerAngles,localPosition;}
public struct Vector3{public static Vector3 zero=>new();}
public class PlayableDirector{public GameObject gameObject=new();public double duration=1;}
public class MapData{public string BGM="bgm";}
public class IUserService{public bool IsAuthorized=true;public CancellationTokenSource Lifetime=new();public CancellationToken LoginLifetime=>Lifetime.Token;}
public class ShowHandEvent{public bool isShow;}
public partial class GetUiSubject {public bool Destroyed;public FishingData fishingData=new();public bool IsHideHomeMap;public Behavior fishingBehavior=new();public MapData _curMapData=new();public void SetMapShow(bool _){} public static bool operator ==(GetUiSubject a,GetUiSubject b)=>ReferenceEquals(a,b)||(ReferenceEquals(a,null)&&!ReferenceEquals(b,null)&&b.Destroyed)||(ReferenceEquals(b,null)&&!ReferenceEquals(a,null)&&a.Destroyed);public static bool operator !=(GetUiSubject a,GetUiSubject b)=>!(a==b);public override bool Equals(object obj)=>ReferenceEquals(this,obj);public override int GetHashCode()=>base.GetHashCode();}
namespace cfg{public class Tables{}}
public class ISettingService{public bool Music=true,Sound=true;}
public class AudioClip{}
public class AudioMixer{public void GetFloat(string _,out float v){v=0;}public AudioMixerSnapshot FindSnapshot(string _)=>new();}
public class AudioMixerSnapshot{}
public class AudioMixerGroup{}
public class Sound {public GameObject gameObject=new();public AudioSource audioSource=new();}
public class AudioSource{public void Stop(){}}
public class FakeSoundService {
public bool Released;public int Releases;public TaskCompletionSource<bool> Pending;public Task InitAsync(){Released=false;return Pending?.Task??Task.CompletedTask;}public void Release(){Released=true;Releases++;}public void ReturnSound(Sound _){}
void Volume(){if(Released)Probe.VolumeAfterRelease++;if(Probe.ThrowVolume)throw new Exception("volume failure");}
public int BGMVol{set{Volume();}}public int SFXVol{set{Volume();}}public int VoiceVol{set{Volume();}}
}
public class EventUISound{} public class EventRewardSound{} public class EventFishingSound{} public class EventBGMSound{public EventBGMSound(string _) {}} public class FadeOutEvent{}
public class AssetReferenceAudioClip{} public class AssetReferenceAudioVoiceClip{} public class ChangeRodDataEvent{} public class SoundMuteEvent{} public class OnSoundStateEvent{} public class LowpassEffectEvt{}
public partial class ManagerSubject {
private int initializationVersion;
public FakeSoundService soundService=new();public cfg.Tables _tables;public string[] residentFish={"clip-a","clip-b"};
public Dictionary<string,AudioClip> residentClip;public CompositeDisposable disposables;public Dictionary<string,Sound> rewardSoundDic=new();public HashSet<Sound> _activeFishingLoopSounds=new();public AudioClip BGM_Clip;public Sound BGM_Sound;public string BGMName;
void PlaySound(EventUISound _){}void PlayRewardSound(EventRewardSound _){}void PlayFishingSound(EventFishingSound _){}void PlayMusic(EventBGMSound _){}void FadeOutEvent(FadeOutEvent _){}void PlaySoundByReference(AssetReferenceAudioClip _){}void PlayVoiceSoundByReference(AssetReferenceAudioVoiceClip _){}void OnChangeRodData(ChangeRodDataEvent _){}void OnSoundMute(SoundMuteEvent _){}void OnSoundStateEvent(OnSoundStateEvent _){}
}
public interface IOperation{bool Valid{get;set;}int Releases{get;set;}object Asset{get;}string Key{get;set;}}
public class Operation<T>:IOperation {public TaskCompletionSource<T> Completion=new(); public bool Valid{get;set;}=true; public int Releases{get;set;}public object Asset=>Completion.Task.IsCompletedSuccessfully?Completion.Task.Result:null;public string Key{get;set;}}
public struct AsyncOperationHandle<T>{public Operation<T> Op;public Task<T> Task=>Op.Completion.Task;public T Result{get{if(!IsValid())throw new Exception("read released handle");return Op.Completion.Task.GetAwaiter().GetResult();}}public bool IsValid()=>Op!=null&&Op.Valid;}
public static class Addressables {
public static List<IOperation> All=new();public static Dictionary<string,object> Pending=new();public static AsyncOperationHandle<T> LoadAssetAsync<T>(string key){var op=new Operation<T>{Key=key};Pending[key]=op;All.Add(op);return new(){Op=op};}
public static Operation<T> Get<T>(string key)=>(Operation<T>)Pending[key];public static void Complete<T>(string key,T value)=>Get<T>(key).Completion.SetResult(value);
public static void Release<T>(AsyncOperationHandle<T> h){if(!h.IsValid())throw new Exception("invalid/double handle release");h.Op.Valid=false;h.Op.Releases++;Probe.ReleasedHandles++;}
public static void Release(object asset) {if(asset==null)return;var op=All.FirstOrDefault(o=>o.Valid&&ReferenceEquals(o.Asset,asset));if(op==null)throw new Exception("unowned/double object release");op.Valid=false;op.Releases++;Probe.ReleasedHandles++;}
}
public static class Assert{public static void IsNotNull(object value){if(value==null)throw new Exception("Assert null");}}
public class Pool{public int Active;public void CreatePool<T>(T _,int a,int b){Active++;Probe.PoolCreates++;if(Probe.ThrowPool)throw new Exception("pool failure");}public void DestroyPool<T>(){Active=0;}}
public partial class ServiceSubject {
private int initializationVersion;
public Dictionary<Sound,CancellationTokenSource> SoundFadeTaskDic;public AsyncOperationHandle<AudioMixer> mixerHandle;public AsyncOperationHandle<GameObject> soundPrefabHandle;
public AudioMixer mixer;public float bgmVol,fishingVol;public AudioMixerGroup bgmGroup,fishingGroup,uiGroup,voiceGroup;public AudioMixerSnapshot normal;public IDisposable lowpassEvtDisposable;public Pool poolService=new();
const string Bgm_Vol_Param_Name="bgmVol",Fishing_Vol_Param_Name="fishingVol";
AudioMixerGroup FindMixerGroup(string _) => new();void OnLowpassEffectEvt(LowpassEffectEvt _){}
}
public class IStageData{}public class SurveyService{}public class IConfig{public string Get(string key,string fallback)=>fallback;}
public static class LoadingScreen{public static int Writes;public static void SetProgress(float _) => Writes++;}
public static class Debug{public static int Warnings;public static void LogWarning(string _) => Warnings++;}
public class SoundMgr{public Action<float> Progress;public TaskCompletionSource<bool> Ready=new();public Task Init(Action<float> progress){Progress=progress;return Ready.Task;}}
namespace UnityEngine.AddressableAssets{public static class Addressables{public static AsyncOperationHandle<GameObject> InstantiateAsync(string _){var op=new Operation<GameObject>();op.Completion.SetResult(new());return new(){Op=op};}public static void ReleaseInstance(GameObject _) {}}}
public class StageBase{public TaskCompletionSource<bool> StartReady=new();public TaskCompletionSource<bool> StopReady;public virtual Task StartAsync()=>StartReady.Task;public virtual Task Stop()=>StopReady?.Task??Task.CompletedTask;protected virtual void OnDestroy(){}}
public partial class StageSubject{private bool stopping;public bool Destroyed,Applied,CatchCompleted;public IUserService UserService=new();private CompositeDisposable disposable=new();private IDisposable _autoRestart;public void DestroyStage(){Destroyed=true;OnDestroy();}public static bool operator ==(StageSubject a,StageSubject b)=>ReferenceEquals(a,b)||(ReferenceEquals(a,null)&&!ReferenceEquals(b,null)&&b.Destroyed)||(ReferenceEquals(b,null)&&!ReferenceEquals(a,null)&&a.Destroyed);public static bool operator !=(StageSubject a,StageSubject b)=>!(a==b);public override bool Equals(object o)=>ReferenceEquals(this,o);public override int GetHashCode()=>base.GetHashCode();}

View File

@@ -0,0 +1,199 @@
using System;using System.Linq;using System.Collections.Generic;using System.Threading;using System.Threading.Tasks;
public partial class GetUiSubject {
public async Task GetUI()
{
HomePanel homePanel = null;
if (!fishingData.isMapBuff && !IsHideHomeMap)
{
var go = await UIManager.Instance.RestartShowUI(UITypes.HomePanel);
homePanel = go.GetComponent<HomePanel>();
}
//实例
GContext.container.Resolve<IDeferredRewardStashService>();
//这里确认补单
await GContext.container.Resolve<PlayerShopData>().ReleaseReplenishment();
await Awaiters.SecondsRealtime(0.5f);
GContext.container.Resolve<FishingEventData>().ShowRankTierChange();
if (fishingData.isMapBuff)
{
fishingData.isMapBuff = false;
}
else
{
fishingBehavior?.RodBehaviour.gameObject.SetActive(true);
fishingBehavior.cameraAnimator.speed = 1;
GContext.Publish(new ShowHandEvent() { isShow = true });
SetMapShow(!IsHideHomeMap);
if (IsHideHomeMap)
{
IsHideHomeMap = false;
}
else if (homePanel != null)
{
homePanel.OnStart();
}
}
}
}
public partial class ManagerSubject {
public async Task Init(System.Action<float> progress)
{
//playerData = GContext.container.Resolve<PlayerData>();
_tables = GContext.container.Resolve<cfg.Tables>();
//var service = (SoundService)soundService;
await soundService.InitAsync();
var opMap = new Dictionary<string, AsyncOperationHandle<AudioClip>>();
foreach (var addr in residentFish)
{
opMap[addr] = Addressables.LoadAssetAsync<AudioClip>(addr);
}
var tasks = opMap.Values.Select(_ => _.Task);
var total = (float)tasks.Count();
while (true)
{
var completed = tasks.Count(_ => _.IsCompleted);
if (completed == total)
{
progress.Invoke(1f);
break;
}
else
{
progress.Invoke(completed / total);
}
await Awaiters.NextFrame;
}
residentClip = opMap.ToDictionary(_ => _.Key, _ => _.Value.Result);
disposables = new CompositeDisposable();
GContext.OnEvent<EventUISound>().Subscribe(PlaySound).AddTo(disposables);
GContext.OnEvent<EventRewardSound>().Subscribe(PlayRewardSound).AddTo(disposables);
GContext.OnEvent<EventFishingSound>().Subscribe(PlayFishingSound).AddTo(disposables);
GContext.OnEvent<EventBGMSound>().Subscribe(PlayMusic).AddTo(disposables);
GContext.OnEvent<FadeOutEvent>().Subscribe(FadeOutEvent).AddTo(disposables);
GContext.OnEvent<AssetReferenceAudioClip>().Subscribe(PlaySoundByReference).AddTo(disposables);
GContext.OnEvent<AssetReferenceAudioVoiceClip>().Subscribe(PlayVoiceSoundByReference).AddTo(disposables);
GContext.OnEvent<ChangeRodDataEvent>().Subscribe(OnChangeRodData).AddTo(disposables);
GContext.OnEvent<SoundMuteEvent>().Subscribe(OnSoundMute).AddTo(disposables);
GContext.OnEvent<OnSoundStateEvent>().Subscribe(OnSoundStateEvent).AddTo(disposables);
soundService.BGMVol = GContext.container.Resolve<ISettingService>().Music ? 80 : 0;
soundService.SFXVol = GContext.container.Resolve<ISettingService>().Sound ? 80 : 0;
soundService.VoiceVol = GContext.container.Resolve<ISettingService>().Sound ? 80 : 0;
}
public void Release()
{
if (disposables != null)
{
disposables.Dispose();
disposables = null;
}
if (residentClip != null)
{
foreach (var item in residentClip)
{
if (item.Value != null)
{
Addressables.Release(item.Value);
}
}
residentClip.Clear();
}
if (rewardSoundDic != null)
{
rewardSoundDic.Clear();
}
if (soundService != null)
{
foreach (var s in _activeFishingLoopSounds.ToList())
{
if (s != null)
{
s.audioSource.Stop();
soundService.ReturnSound(s);
}
}
}
_activeFishingLoopSounds.Clear();
if (soundService != null)
{
soundService.Release();
}
if (BGM_Clip != null)
{
Addressables.Release(BGM_Clip);
BGM_Clip = null;
}
if (BGM_Sound != null && BGM_Sound.gameObject != null)
{
GameObject.Destroy(BGM_Sound.gameObject);
BGM_Sound = null;
}
BGMName = null;
}
}
public partial class ServiceSubject {
public async Task InitAsync(string mixerAddr = "AudioMixer", string soundPrefabAddr = "sound",
string bgmGroupId = "BGM", string fishingGroupId = "Fishing", string uiGroupId = "UI", string voiceGroupId = "Voice")
{
SoundFadeTaskDic = new Dictionary<Sound, CancellationTokenSource>();
mixerHandle = Addressables.LoadAssetAsync<AudioMixer>(mixerAddr);
mixer = await mixerHandle.Task;
Assert.IsNotNull(mixer);
mixer.GetFloat(Bgm_Vol_Param_Name, out bgmVol);
mixer.GetFloat(Fishing_Vol_Param_Name, out fishingVol);
bgmGroup = FindMixerGroup(bgmGroupId);
fishingGroup = FindMixerGroup(fishingGroupId);
uiGroup = FindMixerGroup(uiGroupId);
voiceGroup = FindMixerGroup(voiceGroupId);
normal = mixer.FindSnapshot("Snapshot");// ("normal");
//lowpass = mixer.FindSnapshot("lowpass");
Assert.IsNotNull(bgmGroup);
Assert.IsNotNull(fishingGroup);
Assert.IsNotNull(uiGroup);
soundPrefabHandle = Addressables.LoadAssetAsync<GameObject>(soundPrefabAddr);
var soundPrefab = await soundPrefabHandle.Task;
Assert.IsNotNull(soundPrefab);
var sound = soundPrefab.GetComponent<Sound>();
Assert.IsNotNull(sound);
lowpassEvtDisposable = GContext.OnEvent<LowpassEffectEvt>().Subscribe(OnLowpassEffectEvt);
poolService.CreatePool<Sound>(sound, 10, 50);
}
public void Release()
{
if (SoundFadeTaskDic != null)
{
foreach (var source in SoundFadeTaskDic.Values)
{
source.Cancel();
}
SoundFadeTaskDic.Clear();
SoundFadeTaskDic = null;
}
poolService?.DestroyPool<Sound>();
if (mixerHandle.IsValid()) Addressables.Release(mixerHandle);
mixerHandle = default;
if (soundPrefabHandle.IsValid()) Addressables.Release(soundPrefabHandle);
soundPrefabHandle = default;
lowpassEvtDisposable?.Dispose();
lowpassEvtDisposable = null;
}
}

View File

@@ -0,0 +1,34 @@
from pathlib import Path
import hashlib,json
root=Path('Client/Assets/Scripts'); out=Path('CodexOutput/upserver/ReviewLifecycleTests')
def method(path, signature):
text=(root/path).read_text(encoding='utf-8-sig'); start=text.index(signature); opening=text.index('{',start); depth=1; end=opening+1
while depth:
depth+=(text[end]=='{')-(text[end]=='}'); end+=1
return text[start:end]
parts=[('GetUiSubject','Stages/FishingAct.cs',['async void GetUI()','async void NewEnterMap(PlayableDirector camera_timeline)','async Task OnCameraSpeedEvent()']),('ManagerSubject','Core/SoundMgr.cs',['public async Task Init(System.Action<float> progress)','public void Release()']),('ServiceSubject','Sound/SoundService.cs',['public async Task InitAsync(string mixerAddr','public void Release()'])]
generated='using System;using System.Linq;using System.Collections.Generic;using System.Threading;using System.Threading.Tasks;\n'
evidence=[]
for name,path,signatures in parts:
generated+=f'public partial class {name} {{\n'
for signature in signatures:
# SoundService also has an interface Release; select class suffix.
if name=='ServiceSubject' and signature=='public void Release()':
text=(root/path).read_text(encoding='utf-8-sig'); text=text[text.index('public class SoundService'):]
start=text.index(signature); opening=text.index('{',start); depth=1; end=opening+1
while depth: depth+=(text[end]=='{')-(text[end]=='}'); end+=1
raw=text[start:end]
else: raw=method(path,signature)
extracted=raw.replace('async void GetUI()', 'public async Task GetUI()').replace('async void NewEnterMap(', 'public async Task NewEnterMap(').replace('async Task OnCameraSpeedEvent()', 'public async Task OnCameraSpeedEvent()')
generated+=extracted+'\n'
evidence.append({'source':str(root/path),'signature':signature,'source_sha256':hashlib.sha256((root/path).read_bytes()).hexdigest(),'method_sha256':hashlib.sha256(raw.encode()).hexdigest(),'transformation':'async void -> public async Task only' if name=='GetUiSubject' else 'none'})
generated+='}\n'
stage_path='Stages/FishingStage.cs'; stage_text=(root/stage_path).read_text(encoding='utf-8-sig')
prefix=stage_text[stage_text.index('public override async Task StartAsync()'):stage_text.index('//GContext.Publish()')].replace('public override async Task StartAsync()', 'public async Task RunPrefix()')
stop=method(stage_path,'public override async Task Stop()')
destroy=method(stage_path,'protected override void OnDestroy()')
catch_start=stage_text.index('try { await base.StartAsync(); }'); catch_end=stage_text.index('//静默加载后续场景',catch_start); guard=stage_text[catch_start:catch_end]
generated+='public partial class StageSubject : StageBase {\n'+prefix+'Applied=true; }\n'+stop+'\n'+destroy+'\npublic async Task RunCatch(){ var loginLifetime=UserService.LoginLifetime;\n'+guard+'CatchCompleted=true; }\n}\n'
evidence.append({'source':str(root/stage_path),'source_sha256':hashlib.sha256((root/stage_path).read_bytes()).hexdigest(),'transformation':'StartAsync prefix ends before //GContext.Publish(); Stop and OnDestroy complete; actual base.StartAsync catch segment through next-scene comment; only wrapper names/assertion markers added'})
(out/'Generated.cs').write_text(generated,encoding='utf-8')
(out/'extraction.json').write_text(json.dumps(evidence,ensure_ascii=False,indent=2),encoding='utf-8')