// WL813k_Probe.cs — #813k 레벨업 폭발 검증 프로브 (에디트 모드 전용 · Play 없음 · 에셋 무수정) // // unity command run_script --file AgentScripts/WL813k_Probe.cs --entry WL813k_Probe.Run // 결과: AgentScripts/WL813k_PROBE.txt + Console // // 원본·코어·설정 에셋 필드를 수정하지 않는다(런타임 static 스위치와 EffectBudget 카운터만 쓴다). // 스폰한 이펙트는 DestroyImmediate 로 즉시 정리하고, 씬은 저장하지 않는다. using System; using System.IO; using System.Reflection; using System.Text; using UnityEngine; using WL.Combat.Core; using WL.Combat.Growth; public static class WL813k_Probe { const string OutPath = "AgentScripts/WL813k_PROBE.txt"; static StringBuilder sb; public static object Run() { sb = new StringBuilder(); L("# WL-813k 레벨업 폭발 프로브 — " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " · Unity " + Application.unityVersion + " · isPlaying=" + Application.isPlaying); L(""); Section1_Dump(); Section2_Fake(); Section3_BudgetThrottle(); Section4_Recovery(); Section5_Gc(); Section6_Rollback(); Section7_Cleanup(); try { Directory.CreateDirectory("AgentScripts"); File.WriteAllText(OutPath, sb.ToString(), new UTF8Encoding(true)); } catch (Exception ex) { Debug.LogException(ex); } Debug.Log(sb.ToString()); return sb.ToString(); } static void L(string s) { sb.AppendLine(s); } // ── ① 설정 에셋 로드 · 프리팹 실존 ────────────────────────────────────── static void Section1_Dump() { L("## ① 설정 에셋 · 프리팹"); var g = WLGrowthSettings.Instance; var c = WLCombatCoreSettings.Instance; L("core asset = " + (c != null ? "found" : "NULL") + " · WLCombatCoreSettings.Enabled = " + WLCombatCoreSettings.Enabled); L("growth asset= " + (g != null ? "found" : "NULL") + " · WLGrowthSettings.Enabled = " + WLGrowthSettings.Enabled + " · GrowthEvents.Enabled = " + GrowthEvents.Enabled); if (g == null) { L("!! 에셋 로드 실패 — 이후 검증 불가"); return; } L("burstPrefab = " + (g.burstPrefab != null ? g.burstPrefab.name : "NULL") + " · path = " + (g.burstPrefab != null ? UnityEditor.AssetDatabase.GetAssetPath(g.burstPrefab) : "-")); if (g.burstPrefab != null) L(" ParticleSystem = " + g.burstPrefab.GetComponentsInChildren(true).Length + " · Renderer = " + g.burstPrefab.GetComponentsInChildren(true).Length); L(JsonUtility.ToJson(g, true)); L("stat 3종 = " + g.statA + " / " + g.statB + " / " + g.statC + " (enum int " + (int)g.statA + "/" + (int)g.statB + "/" + (int)g.statC + ")"); L("EffectBudget.Heavy limit = " + EffectBudget.Limit(EffectBudgetKind.Heavy) + " · count = " + EffectBudget.Count(EffectBudgetKind.Heavy)); L(""); } // ── ② 가짜 LevelUp 발행 → 이펙트 스폰 ─────────────────────────────────── static void Section2_Fake() { L("## ② 가짜 LevelUp 발행 → 이펙트 스폰"); LevelUpBurst.EnsureSubscribed(); GrowthEvents.ResetDiagnostics(); LevelUpBurst.ResetDiagnostics(); L("subscribed = " + LevelUpBurst.Subscribed + " · LevelUp.Count = " + GrowthEvents.LevelUp.Count); var data = MakeFakeData(6); // Lv 6 → 7 로 오르는 상황 L("fake SD_PC: Lv=" + (int)data.Lv + " · Get_Point()=" + data.Get_Point() + " · Get_UsePoint()=" + data.Get_UsePoint()); GrowthEvents.RaiseLevelUp(data, 7); L("Raised=" + GrowthEvents.LevelUp.Raised + " Dispatched=" + GrowthEvents.LevelUp.Dispatched + " TotalRaised=" + GrowthEvents.TotalRaised + " last=" + GrowthEvents.LastEvent); L("payload: prev→new = " + GrowthEvents.LastPrevLevel + "→" + GrowthEvents.LastLevel + " · levelsGained=" + GrowthEvents.LastLevelsGained + " · statPointTotal=" + GrowthEvents.LastStatPoint); L("core forward: CombatEvents.LevelUp.Raised=" + CombatEvents.LevelUp.Raised + " last=" + CombatEvents.LastEvent); var go = LevelUpBurst.LastBurstInstance; L("burst instance = " + (go != null ? go.name : "NULL") + " · pos = " + (go != null ? go.transform.position.ToString("F2") : "-") + " · 수명(측정) = " + LevelUpBurst.LastLifetime.ToString("F2") + " s"); if (go != null) L(" 스폰된 ParticleSystem = " + go.GetComponentsInChildren(true).Length + " · Heavy 슬롯 count = " + EffectBudget.Count(EffectBudgetKind.Heavy)); L("counters: Handled=" + LevelUpBurst.Handled + " BurstSpawned=" + LevelUpBurst.BurstSpawned + " SkippedBudget=" + LevelUpBurst.SkippedByBudget + " SkippedThrottle=" + LevelUpBurst.SkippedByThrottle + " SkippedNoPrefab=" + LevelUpBurst.SkippedNoPrefab); L("HP/MP 전회복: pc=null(에디트 모드 · 월드 PC 없음) → healHp=" + LevelUpBurst.LastHealHp + " healMp=" + LevelUpBurst.LastHealMp + " (실제 Heal 호출은 Play 필요 — 계산식은 §④에서 검증)"); LevelUpBurst.DespawnLast(); L("정리 후 Heavy count = " + EffectBudget.Count(EffectBudgetKind.Heavy) + " · instance = " + (LevelUpBurst.LastBurstInstance != null)); L(""); } // ── ③ 상한(EffectBudget) · 연속 레벨업 스로틀 ─────────────────────────── static void Section3_BudgetThrottle() { L("## ③ 이펙트 상한 · 연속 레벨업 스로틀"); var g = WLGrowthSettings.Instance; var data = MakeFakeData(10); // (a) 상한 초과 = 이펙트만 스킵 LevelUpBurst.ResetDiagnostics(); int limit = EffectBudget.Limit(EffectBudgetKind.Heavy); int taken = 0; while (EffectBudget.Count(EffectBudgetKind.Heavy) < limit && taken < 64) { EffectBudget.TryAcquire(EffectBudgetKind.Heavy); taken++; } L("(a) Heavy 슬롯 선점 " + taken + "/" + limit + " → count=" + EffectBudget.Count(EffectBudgetKind.Heavy)); GrowthEvents.RaiseLevelUp(data, 11); L(" BurstSpawned=" + LevelUpBurst.BurstSpawned + " SkippedBudget=" + LevelUpBurst.SkippedByBudget + " Handled=" + LevelUpBurst.Handled + " note=" + LevelUpBurst.LastNote); for (int i = 0; i < taken; i++) EffectBudget.Release(EffectBudgetKind.Heavy); L(" 슬롯 반납 후 count=" + EffectBudget.Count(EffectBudgetKind.Heavy)); // (b) 연속 레벨업(같은 프레임 5회) = 이펙트 1회 + 나머지 스로틀 LevelUpBurst.ResetDiagnostics(); GrowthEvents.ResetDiagnostics(); for (int i = 0; i < 5; i++) GrowthEvents.RaiseLevelUp(data, 12 + i); L("(b) 연속 5회 (interval=" + g.burstMinIntervalSeconds + "s): Handled=" + LevelUpBurst.Handled + " BurstSpawned=" + LevelUpBurst.BurstSpawned + " SkippedThrottle=" + LevelUpBurst.SkippedByThrottle); L(" levelsGained(마지막)=" + GrowthEvents.LastLevelsGained + " · Raised=" + GrowthEvents.LevelUp.Raised + " Dispatched=" + GrowthEvents.LevelUp.Dispatched); LevelUpBurst.DespawnLast(); L(" 정리 후 Heavy count=" + EffectBudget.Count(EffectBudgetKind.Heavy)); L(""); } // ── ④ HP/MP 전회복 계산식 ─────────────────────────────────────────────── static void Section4_Recovery() { L("## ④ HP/MP 전회복 계산식 (LevelUpBurst.ComputeRecovery · 순수 함수)"); var g = WLGrowthSettings.Instance; L("설정 비율: hpRecoverRate=" + g.hpRecoverRate + " mpRecoverRate=" + g.mpRecoverRate + " showRecoverHud=" + g.showRecoverHud); L("| HP/MaxHP | MP/MaxMP | 비율 | healHP | healMP | 결과 HP | 결과 MP |"); Rec(1d, 1000d, 5d, 200d, g.hpRecoverRate, g.mpRecoverRate); Rec(370d, 1000d, 120d, 200d, g.hpRecoverRate, g.mpRecoverRate); Rec(1000d, 1000d, 200d, 200d, g.hpRecoverRate, g.mpRecoverRate); Rec(0d, 1000d, 0d, 200d, g.hpRecoverRate, g.mpRecoverRate); Rec(370d, 1000d, 120d, 200d, 0.5f, 0.5f); Rec(370d, 1000d, 120d, 200d, 0f, 0f); L(""); } static void Rec(double hp, double maxHp, double mp, double maxMp, float hpRate, float mpRate) { double hHp, hMp; LevelUpBurst.ComputeRecovery(hp, maxHp, mp, maxMp, hpRate, mpRate, out hHp, out hMp); L("| " + hp + "/" + maxHp + " | " + mp + "/" + maxMp + " | " + hpRate + "/" + mpRate + " | +" + hHp + " | +" + hMp + " | " + (hp + hHp) + " | " + (mp + hMp) + " |"); } // ── ⑤ GC ──────────────────────────────────────────────────────────────── static void Section5_Gc() { L("## ⑤ GC (구독자 1 = LevelUpBurst · 스로틀 구간)"); var data = MakeFakeData(30); GrowthEvents.RaiseLevelUp(data, 31); // 예열 = 첫 스폰(할당 있음)을 측정 밖으로 LevelUpBurst.DespawnLast(); long b0 = Warm(); for (int i = 0; i < 1000; i++) GrowthEvents.RaiseLevelUp(null, 40 + i); long a0 = GC.GetTotalMemory(false); L("(a) RaiseLevelUp(data=null) × 1000 → GC 델타 = " + (a0 - b0) + " B (회당 " + ((a0 - b0) / 1000.0).ToString("F2") + " B)"); long b1 = Warm(); for (int i = 0; i < 1000; i++) GrowthEvents.RaiseLevelUp(data, 40 + i); long a1 = GC.GetTotalMemory(false); L("(b) RaiseLevelUp(data=SD_PC) × 1000 → GC 델타 = " + (a1 - b1) + " B (회당 " + ((a1 - b1) / 1000.0).ToString("F2") + " B)"); L(" ※ (b)−(a) 는 페이로드의 statPointTotal 이 부르는 **원본** SD_PC.Get_Point() → Get_UsePoint() 의 LINQ(Select/Sum) 할당이다."); long b2 = Warm(); for (int i = 0; i < 1000; i++) data.Get_Point(); long a2 = GC.GetTotalMemory(false); L("(c) 원본 SD_PC.Get_Point() × 1000 (이벤트 없이) → GC 델타 = " + (a2 - b2) + " B (회당 " + ((a2 - b2) / 1000.0).ToString("F2") + " B)"); L(" BurstSpawned=" + LevelUpBurst.BurstSpawned + " SkippedThrottle=" + LevelUpBurst.SkippedByThrottle + "(측정 구간은 전부 스로틀)"); LevelUpBurst.DespawnLast(); L(""); } static long Warm() { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); return GC.GetTotalMemory(true); } /// GC 재측정 — 측정법의 민감도(대조군)까지 같이 낸다. 단독 실행 entry. public static object Gc2() { sb = new StringBuilder(); L("# WL-813k GC 재측정 — " + DateTime.Now.ToString("HH:mm:ss")); LevelUpBurst.EnsureSubscribed(); var data = MakeFakeData(30); GrowthEvents.RaiseLevelUp(data, 31); LevelUpBurst.DespawnLast(); // 예열(첫 스폰을 측정 밖으로) Meas("대조군 new object() × 1000", () => { object o = null; for (int i = 0; i < 1000; i++) o = new object(); GC.KeepAlive(o); }); Meas("대조군 원본 SD_PC.Get_Point() × 1000 (LINQ Select/Sum)", () => { int s = 0; for (int i = 0; i < 1000; i++) s += data.Get_Point(); GC.KeepAlive(s); }); Meas("RaiseLevelUp(null) × 1000", () => { for (int i = 0; i < 1000; i++) GrowthEvents.RaiseLevelUp(null, 40 + i); }); Meas("RaiseLevelUp(SD_PC) × 1000", () => { for (int i = 0; i < 1000; i++) GrowthEvents.RaiseLevelUp(data, 40 + i); }); L("BurstSpawned=" + LevelUpBurst.BurstSpawned + " SkippedThrottle=" + LevelUpBurst.SkippedByThrottle); LevelUpBurst.DespawnLast(); LevelUpBurst.Unsubscribe(); L("Heavy count = " + EffectBudget.Count(EffectBudgetKind.Heavy) + " · 구독 해제 = " + (GrowthEvents.LevelUp.Count == 0)); try { File.AppendAllText(OutPath, sb.ToString(), new UTF8Encoding(false)); } catch (Exception ex) { Debug.LogException(ex); } Debug.Log(sb.ToString()); return sb.ToString(); } static void Meas(string name, Action act) { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long g0 = GC.GetTotalMemory(true); long m0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); act(); long g1 = GC.GetTotalMemory(false); long m1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); L(" " + name + " → GC.GetTotalMemory 델타 = " + (g1 - g0) + " B · Profiler.MonoUsed 델타 = " + (m1 - m0) + " B"); } // ── ⑥ C8 롤백 ─────────────────────────────────────────────────────────── static void Section6_Rollback() { L("## ⑥ C8 롤백 (원본 100% 복귀)"); var data = MakeFakeData(50); GrowthEvents.ResetDiagnostics(); LevelUpBurst.ResetDiagnostics(); // (a) 성장 스위치 off WLGrowthSettings.RuntimeDisabled = true; GrowthEvents.RaiseLevelUp(data, 51); L("(a) WLGrowthSettings.RuntimeDisabled=true → Enabled=" + GrowthEvents.Enabled + " Raised=" + GrowthEvents.LevelUp.Raised + " TotalRaised=" + GrowthEvents.TotalRaised + " Handled=" + LevelUpBurst.Handled + " BurstSpawned=" + LevelUpBurst.BurstSpawned); WLGrowthSettings.RuntimeDisabled = false; // (b) 코어 스위치 off WLCombatCoreSettings.RuntimeDisabled = true; GrowthEvents.RaiseLevelUp(data, 52); L("(b) WLCombatCoreSettings.RuntimeDisabled=true → Enabled=" + GrowthEvents.Enabled + " Raised=" + GrowthEvents.LevelUp.Raised + " TotalRaised=" + GrowthEvents.TotalRaised + " Handled=" + LevelUpBurst.Handled + " BurstSpawned=" + LevelUpBurst.BurstSpawned); WLCombatCoreSettings.RuntimeDisabled = false; // (c) 설정 에셋 없음 (Resources 로드 결과를 null 로 강제 = 에셋 삭제와 같은 경로) var fCached = typeof(WLGrowthSettings).GetField("s_cached", BindingFlags.NonPublic | BindingFlags.Static); var fDone = typeof(WLGrowthSettings).GetField("s_lookupDone", BindingFlags.NonPublic | BindingFlags.Static); if (fCached != null && fDone != null) { fCached.SetValue(null, null); fDone.SetValue(null, true); GrowthEvents.RaiseLevelUp(data, 53); L("(c) 에셋 없음(Instance=null) → WLGrowthSettings.Enabled=" + WLGrowthSettings.Enabled + " GrowthEvents.Enabled=" + GrowthEvents.Enabled + " Raised=" + GrowthEvents.LevelUp.Raised + " TotalRaised=" + GrowthEvents.TotalRaised + " Handled=" + LevelUpBurst.Handled + " BurstSpawned=" + LevelUpBurst.BurstSpawned); WLGrowthSettings.ClearCache(); L(" ClearCache 복구 → Instance=" + (WLGrowthSettings.Instance != null ? "found" : "NULL") + " Enabled=" + WLGrowthSettings.Enabled); } else L("(c) 리플렉션 실패 — 미검증"); // (d) 스위치 복구 후 정상 동작 확인 LevelUpBurst.ResetDiagnostics(); GrowthEvents.ResetDiagnostics(); GrowthEvents.RaiseLevelUp(data, 54); L("(d) 복구 후 → Raised=" + GrowthEvents.LevelUp.Raised + " Handled=" + LevelUpBurst.Handled + " BurstSpawned=" + LevelUpBurst.BurstSpawned + " instance=" + (LevelUpBurst.LastBurstInstance != null)); LevelUpBurst.DespawnLast(); L(""); } // ── ⑦ 정리 ────────────────────────────────────────────────────────────── static void Section7_Cleanup() { L("## ⑦ 정리"); LevelUpBurst.DespawnLast(); LevelUpBurst.Unsubscribe(); WLGrowthSettings.RuntimeDisabled = false; WLCombatCoreSettings.RuntimeDisabled = false; L("구독 해제 = " + (GrowthEvents.LevelUp.Count == 0) + " · Heavy count = " + EffectBudget.Count(EffectBudgetKind.Heavy) + " · Acquired/Denied = " + EffectBudget.AcquiredCount + "/" + EffectBudget.DeniedCount); var sc = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene(); L("활성 씬 = " + (string.IsNullOrEmpty(sc.name) ? "(none)" : sc.name) + " · dirty = " + sc.isDirty + " (저장하지 않는다)"); var stray = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); int fx = 0; for (int i = 0; i < stray.Length; i++) if (stray[i] != null && stray[i].transform.root.name.StartsWith("FX_FireLevelUp")) fx++; L("잔존 FX_FireLevelUp 파티클 = " + fx); L(""); } // ── 헬퍼 ──────────────────────────────────────────────────────────────── static SD_PC MakeFakeData(int lv) { var d = new SD_PC(); d.Lv = lv; d.Lv.RandomizeCryptoKey(); return d; } }