// WL811i_Probe.cs — #811i 스킬 스펙터클 검증 프로브 (읽기 전용 · 에셋 무수정 · 가짜 이벤트 발행) // // unity command run_script --file AgentScripts/WL811i_Probe.cs --entry WL811i_Probe.Dump // 설정 · 16행 매핑 · 프리팹 실존(AssetDatabase) · 파티클/렌더러 예산 표 (Edit/Play) // unity command run_script --file AgentScripts/WL811i_Probe.cs --entry WL811i_Probe.EditProbe // Edit 모드: 로드 → 가짜 SkillCast/SkillFired/HitboxHit 16행 → 3단 스폰 · 절두체 밖 스킵 · Heavy 2 · Medium · C8 · GC 100 // unity command run_script --file AgentScripts/WL811i_Probe.cs --entry WL811i_Probe.PlayProbe // Title Play: 위 + 수명 실측(차지 1 s · 본체 자동 · Heavy 반납 시각) · 착탄 발동당 상한 · GC 100 · 폴백 착탄 // unity command run_script --file AgentScripts/WL811i_Probe.cs --entry WL811i_Probe.Status // 카운터 // 결과: Screenshots_WL/WL811i/<이름>_.txt + Console using System; using System.Collections; using System.Collections.Generic; using System.Reflection; using System.Text; using UnityEngine; using UnityEngine.AI; using WL.Combat.Core; using WL.Combat.Reaction; public static class WL811i_Probe { const string OutDir = "Screenshots_WL/WL811i"; const BindingFlags F = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; // ───────────────────────────────────────────── Dump (Edit/Play) public static object Dump() { var sb = new StringBuilder(); var st = WLSkillSpectacleSettings.Instance; sb.AppendLine("# WL811i Dump " + DateTime.Now.ToString("HH:mm:ss") + " playing=" + Application.isPlaying + " core=" + WLCombatCoreSettings.Enabled + " spectacle=" + WLSkillSpectacleSettings.Enabled + " subscribed=" + SkillSpectacle.Subscribed); if (st == null) { sb.AppendLine("asset NULL"); Save("dump", sb); return sb.ToString(); } var core = WLCombatCoreSettings.Instance; sb.AppendLine("core heavyEffectMax=" + (core != null ? core.heavyEffectMax.ToString() : "?") + " viewMargin=" + (core != null ? core.viewMargin.ToString() : "?")); sb.AppendLine("subs: AttackStarted=" + CombatEvents.AttackStarted.Count + " SkillCast=" + CombatEvents.SkillCast.Count + " SkillFired=" + CombatEvents.SkillFired.Count + " HitboxHit=" + CombatEvents.HitboxHit.Count + " HitConfirmed=" + CombatEvents.HitConfirmed.Count); sb.AppendLine("## 값"); sb.AppendLine("enabled=" + st.enabled + " requireMainPC=" + st.requireMainPC + " requireInView=" + st.requireInView + " preloadOnFirstAttack=" + st.preloadOnFirstAttack + " warm=" + st.warmInstanceOnLoad + " chargeFlash=" + st.chargeFlashSeconds + " chargeMax=" + st.chargeMaxLifetime + " heavyUsesBudget=" + st.heavyUsesEffectBudget + " mediumMax=" + st.mediumMax + " impactMaxPerFire=" + st.impactMaxPerFire + " impactMinInterval=" + st.impactMinIntervalSeconds + " autoLifetimeMax=" + st.autoLifetimeMax + " loopingLifetime=" + st.loopingLifetime + " poolMaxPerPrefab=" + st.poolMaxPerPrefab + " trackCapacity=" + st.trackCapacity + " tint=" + st.tintEnabled + "×" + st.tintStrength); sb.AppendLine("## 클래스 원소"); for (int i = 0; i < st.classElements.Length; i++) { var c = st.classElements[i]; sb.AppendLine(c.classId + " " + c.label + " " + c.element + " tint=" + c.tint.ToString("F2")); } sb.AppendLine("## 매핑 16행 (실존 = AssetDatabase.LoadAssetAtPath)"); sb.AppendLine("| 클래스 | 슬롯 | SkillID | 이름 | charge | body | impact | budget | 실존 |"); int missing = 0, total = 0; var uniq = new List(); for (int i = 0; i < st.rows.Length; i++) { var r = st.rows[i]; string ex = ""; foreach (var v in new[] { r.charge, r.body, r.impact }) { if (string.IsNullOrEmpty(v.prefab)) { ex += "-"; continue; } total++; bool ok = Exists(v.prefab); if (!ok) missing++; ex += ok ? "Y" : "N"; if (!uniq.Contains(v.prefab)) uniq.Add(v.prefab); } sb.AppendLine("| " + r.classId + " | " + r.slot + " | " + r.skillId + " | " + r.label + " | " + V(r.charge) + " | " + V(r.body) + " | " + V(r.impact) + " | " + r.budgetClass + " | " + ex + " |"); } sb.AppendLine("실존: 사용 " + total + " 건 · 고유 " + uniq.Count + " · 누락 " + missing + " → " + (missing == 0 ? "100 %" : "FAIL")); sb.AppendLine("## 프리팹 예산 표 (프리팹 에셋 계층 실측 · 파티클 수 = maxParticles 합 · 예상 = Σ min(max, rate×life+burst) 「추정」 · SetPass ≈ 고유 머티리얼 수 「추정」)"); sb.AppendLine(string.Format("{0,-36} {1,3} {2,5} {3,7} {4,5} {5,4} {6,4} {7,5} {8,5} {9}", "prefab", "ps", "maxP", "estP", "dur", "loop", "rend", "mats", "tint", "shaders / TurnOff")); var totalEst = 0f; var totalMax = 0; foreach (var name in uniq) { var go = Load(name); if (go == null) { sb.AppendLine(string.Format("{0,-36} MISSING", name)); continue; } var pss = go.GetComponentsInChildren(true); int maxP = 0, loops = 0; float dur = 0f, est = 0f; foreach (var ps in pss) { var m = ps.main; maxP += m.maxParticles; if (m.loop) loops++; float life = m.startLifetime.constantMax; float d = m.duration + life + m.startDelay.constantMax; if (d > dur) dur = d; var em = ps.emission; float burst = 0f; for (int b = 0; b < em.burstCount; b++) burst += em.GetBurst(b).maxCount; float rate = em.rateOverTimeMultiplier * (m.loop ? life : Mathf.Min(m.duration, life) + 0f); est += Mathf.Min(m.maxParticles, rate + burst); } var rends = go.GetComponentsInChildren(true); var mats = new List(); var shaders = new List(); int tintable = 0; foreach (var r in rends) { var mat = r.sharedMaterial; if (mat == null) continue; if (!mats.Contains(mat)) mats.Add(mat); if (mat.shader != null) { string sn = mat.shader.name; bool has = false; foreach (var p in st.tintProperties) if (mat.shader.FindPropertyIndex(p) >= 0) { has = true; break; } if (has) tintable++; if (!shaders.Contains(sn + (has ? "" : "(no-tint)"))) shaders.Add(sn + (has ? "" : "(no-tint)")); } } var off = go.GetComponent(); string offs = off != null ? ("TurnOff OffTime=" + off.OffTime + (off.NoUseParticleTime ? " noPS" : "")) : "no-TurnOff"; totalEst += est; totalMax += maxP; sb.AppendLine(string.Format("{0,-36} {1,3} {2,5} {3,7:F0} {4,5:F2} {5,4} {6,4} {7,5} {8,5} {9} · {10}", name, pss.Length, maxP, est, dur, loops, rends.Length, mats.Count, tintable, string.Join(",", shaders.ToArray()), offs)); } sb.AppendLine("합계(33종 1회씩) maxParticles=" + totalMax + " 예상 파티클=" + totalEst.ToString("F0") + " 「추정」"); Save("dump", sb); return sb.ToString(); } static string V(SpectacleVfx v) { if (string.IsNullOrEmpty(v.prefab)) return "—"; return v.prefab + "(" + (v.scale > 0f ? v.scale : 1f) + "×" + v.anchor + (v.forwardOffset != 0f ? " +" + v.forwardOffset + "m" : "") + (v.useCasterRotation ? " rot" : "") + (v.tint ? " tint" : "") + (v.lifetime > 0f ? " " + v.lifetime + "s" : "") + ")"; } static string PathOf(string name) { return WLSkillSpectacleSettings.EffectPathPrefix + name + WLSkillSpectacleSettings.EffectPathSuffix; } static bool Exists(string name) { return Load(name) != null; } static GameObject Load(string name) { return UnityEditor.AssetDatabase.LoadAssetAtPath(PathOf(name)); } // 에디터 전용 프로브 // ───────────────────────────────────────────── Status public static object Status() { var sb = new StringBuilder(); sb.AppendLine("# WL811i Status " + DateTime.Now.ToString("HH:mm:ss") + " " + Counters()); Save("status", sb); return sb.ToString(); } static string Counters() { return "cast=" + SkillSpectacle.CastSeen + " fired=" + SkillSpectacle.FiredSeen + " hit=" + SkillSpectacle.HitSeen + " | spawn charge/body/impact=" + SkillSpectacle.ChargeSpawned + "/" + SkillSpectacle.BodySpawned + "/" + SkillSpectacle.ImpactSpawned + " fallback=" + SkillSpectacle.ImpactFallback + " | skip disabled/noRow/notMain/outOfView/heavy/medium/impactCap/interval/noDmg/notLoaded/noPrefab/poolFull/trackFull=" + SkillSpectacle.SkippedDisabled + "/" + SkillSpectacle.SkippedNoRow + "/" + SkillSpectacle.SkippedNotMainPC + "/" + SkillSpectacle.SkippedOutOfView + "/" + SkillSpectacle.SkippedHeavyBudget + "/" + SkillSpectacle.SkippedMediumBudget + "/" + SkillSpectacle.SkippedImpactCap + "/" + SkillSpectacle.SkippedImpactInterval + "/" + SkillSpectacle.SkippedNoDamage + "/" + SkillSpectacle.SkippedNotLoaded + "/" + SkillSpectacle.SkippedNoPrefab + "/" + SkillSpectacle.SkippedPoolFull + "/" + SkillSpectacle.SkippedTrackFull + " | load req/done/fail=" + SkillSpectacle.LoadRequested + "/" + SkillSpectacle.LoadCompleted + "/" + SkillSpectacle.LoadFailed + " inst=" + SkillSpectacle.Instantiated + " expired=" + SkillSpectacle.Expired + " heavy acq/rel/held=" + SkillSpectacle.HeavyAcquired + "/" + SkillSpectacle.HeavyReleased + "/" + SkillSpectacle.HeavyHeldRows + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + "/" + EffectBudget.Limit(EffectBudgetKind.Heavy) + " active=" + SkillSpectacle.ActiveCount + " medium=" + SkillSpectacle.MediumActive + " tint=" + SkillSpectacle.TintApplied + " preload=" + SkillSpectacle.PreloadCalls + " loading=" + SkillSpectacle.LoadingCount + " entries=" + SkillSpectacle.EntryCount + " runner=" + SkillSpectacleRunner.Exists + " last=" + SkillSpectacle.LastInfo; } // ───────────────────────────────────────────── 공용 static void Save(string name, StringBuilder sb) { try { System.IO.Directory.CreateDirectory(OutDir); string path = System.IO.Path.Combine(OutDir, name + "_" + DateTime.Now.ToString("HHmmss") + ".txt"); System.IO.File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false)); Debug.Log("[WL811i] 저장 " + path); } catch (Exception e) { Debug.LogWarning("[WL811i] 저장 실패 " + e.Message); } Debug.Log(sb.ToString()); } /// 가짜 PC: m_Role=PC(public) · m_ID(protected → 리플렉션) · Play 면 Awake 가 돌고(arr_Renderer·tf_Top) Edit 면 돌지 않는다(앵커 Bottom/Mid 만 씀). static PCActor MakePC(string name, Vector3 pos, int classId, Quaternion rot) { var go = new GameObject(name); go.SetActive(false); go.transform.position = pos; go.transform.rotation = rot; go.AddComponent().isKinematic = true; go.AddComponent(); go.AddComponent(); var agent = go.AddComponent(); agent.enabled = false; var pc = go.AddComponent(); pc.enabled = false; pc.m_Role = eRole.PC; var f = typeof(Actor).GetField("m_ID", F); if (f != null) f.SetValue(pc, classId); go.SetActive(true); if (Application.isPlaying) InitCC(pc); return pc; } static MobActor MakeMob(string name, Vector3 pos) { var go = new GameObject(name); go.SetActive(false); go.transform.position = pos; go.AddComponent().isKinematic = true; go.AddComponent(); go.AddComponent(); var agent = go.AddComponent(); agent.enabled = false; var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); cube.name = "Body"; cube.transform.SetParent(go.transform, false); if (Application.isPlaying) UnityEngine.Object.Destroy(cube.GetComponent()); else UnityEngine.Object.DestroyImmediate(cube.GetComponent()); var mob = go.AddComponent(); mob.enabled = false; mob.m_Role = eRole.Mob; go.SetActive(true); if (Application.isPlaying) InitCC(mob); return mob; } static void InitCC(Actor a) { var m = typeof(Actor).GetMethod("Init_CC", F); if (m != null) { try { m.Invoke(a, null); } catch (Exception e) { Debug.LogWarning("[WL811i] Init_CC " + e.Message); } } } static SkillListTableData Skill(int id, int classId, float castTime, eSkillTarget target) { return new SkillListTableData { n_SkillID = id, n_SkillClassID = classId, f_CastingTime = castTime, e_SkillTarget = target, e_SkillType = eSkillType.Active }; } static UseMagicInfo Magic(SkillListTableData d, int slot) { return new UseMagicInfo { m_SkillTableData = d, m_uiIndex = slot - 1 }; } static ProjectileData Pd(Actor shooter, SkillListTableData d) { return new ProjectileData { shooter = shooter, m_SkillListTableData = d }; } static Camera InstallCamera(Vector3 pos, Vector3 lookAt) { var go = new GameObject("__wl811i_cam"); go.tag = "MainCamera"; var cam = go.AddComponent(); go.transform.position = pos; go.transform.LookAt(lookAt); return cam; } static void Kill(GameObject go) { if (go == null) return; if (Application.isPlaying) UnityEngine.Object.Destroy(go); else UnityEngine.Object.DestroyImmediate(go); } struct RowDef { public int id, cls, slot; public float cast; public eSkillTarget target; public SpectacleBudgetClass budget; } static RowDef[] Rows(WLSkillSpectacleSettings st) { var list = new RowDef[st.rows.Length]; for (int i = 0; i < st.rows.Length; i++) { var r = st.rows[i]; list[i] = new RowDef { id = r.skillId, cls = r.classId, slot = r.slot, cast = r.slot == 4 ? 1f : 0f, target = eSkillTarget.Enemy, budget = r.budgetClass }; } return list; } /// 로드 완료까지 폴링(Edit 모드는 Tick 을 직접 · Addressables AssetDatabase 모드는 보통 즉시 완료). static int PumpLoads(int maxIter) { int it = 0; while (SkillSpectacle.LoadingCount > 0 && it < maxIter) { SkillSpectacle.Tick(Time.time); it++; System.Threading.Thread.Sleep(5); } if (SkillSpectacle.LoadingCount > 0 && !Application.isPlaying) it += 1000 * SkillSpectacle.CompleteLoadsNow(); // Edit 모드: 프레임 펌프가 없어 동기 완료 return it; } static string EntryRow(string name) { bool loaded, failed, looping; int inst, ps, maxP, rend, tintable; float life; if (!SkillSpectacle.TryGetEntryInfo(name, out loaded, out failed, out inst, out ps, out maxP, out life, out looping, out rend, out tintable)) return name + ": (no entry)"; return name + ": loaded=" + loaded + " failed=" + failed + " inst=" + inst + " ps=" + ps + " maxP=" + maxP + " life=" + life.ToString("F2") + " loop=" + looping + " rend=" + rend + " tintable=" + tintable; } // ───────────────────────────────────────────── Edit 모드 프로브 public static object EditProbe() { var sb = new StringBuilder(); var st = WLSkillSpectacleSettings.Instance; sb.AppendLine("# WL811i EditProbe " + DateTime.Now.ToString("HH:mm:ss") + " playing=" + Application.isPlaying + " core=" + WLCombatCoreSettings.Enabled + " spectacle=" + WLSkillSpectacleSettings.Enabled); if (st == null || !WLCombatCoreSettings.Enabled) { sb.AppendLine("asset/core 없음 → 중단"); Save("edit", sb); return sb.ToString(); } SkillSpectacle.EnsureSubscribed(); SkillSpectacle.ResetDiagnostics(); var trash = new List(); try { // ① 로드: 16행 전부 SkillSpectacle.PreloadAll(); int it = PumpLoads(20); sb.AppendLine("① PreloadAll → req=" + SkillSpectacle.LoadRequested + " done=" + SkillSpectacle.LoadCompleted + " fail=" + SkillSpectacle.LoadFailed + " loading=" + SkillSpectacle.LoadingCount + " iter=" + it + " inst(warm)=" + SkillSpectacle.Instantiated + " entries=" + SkillSpectacle.EntryCount); var names = new List(); foreach (var r in st.rows) foreach (var v in new[] { r.charge, r.body, r.impact }) if (!string.IsNullOrEmpty(v.prefab) && !names.Contains(v.prefab)) names.Add(v.prefab); foreach (var n in names) sb.AppendLine(" " + EntryRow(n)); if (SkillSpectacle.LoadingCount > 0) sb.AppendLine(" ⚠ 로드 미완료 — Edit 모드 Addressables 비동기 → Play 프로브에서 재확인"); // ② 카메라(절두체) + 가짜 PC 4클래스 + 몹 var cam = InstallCamera(new Vector3(0f, 6f, -6f), Vector3.zero); trash.Add(cam.gameObject); var pcs = new Dictionary(); foreach (var cid in new[] { 10101, 10102, 10501, 10502 }) { var pc = MakePC("__wl811i_pc" + cid, Vector3.zero, cid, Quaternion.identity); pcs[cid] = pc; trash.Add(pc.gameObject); } var mobIn = MakeMob("__wl811i_mobIn", new Vector3(1.5f, 0f, 0f)); trash.Add(mobIn.gameObject); var mobOut = MakeMob("__wl811i_mobOut", new Vector3(0f, 0f, -40f)); trash.Add(mobOut.gameObject); // 카메라 뒤 = 절두체 밖 sb.AppendLine("② cam main=" + (Camera.main != null ? Camera.main.name : "NULL") + " inView(0,0,0)=" + EffectBudget.IsInView(Vector3.zero) + " inView(mobOut)=" + EffectBudget.IsInView(mobOut.Get_position()) + " pc10101.IsMainPC=" + pcs[10101].IsMainPC() + " id=" + pcs[10101].Get_ID()); // ③ 16행 × (SkillCast → SkillFired → HitboxHit×2) · 매 행 뒤 만료 sb.AppendLine("③ 16행 가짜 발행 (row: charge/body/impact 스폰 여부 · 스킵 사유)"); var rows = Rows(st); int c0, b0, i0; for (int i = 0; i < rows.Length; i++) { var d = Skill(rows[i].id, rows[i].cls == 0 ? 0 : rows[i].cls, rows[i].cast, rows[i].target); var pc = pcs[rows[i].cls]; c0 = SkillSpectacle.ChargeSpawned; b0 = SkillSpectacle.BodySpawned; i0 = SkillSpectacle.ImpactSpawned; CombatEvents.RaiseSkillCast(pc, Magic(d, rows[i].slot)); string sc = SkillSpectacle.LastInfo; CombatEvents.RaiseSkillFired(pc, 0, Magic(d, rows[i].slot)); string sf = SkillSpectacle.LastInfo; CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false); string si = SkillSpectacle.LastInfo; CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false); // 같은 시각 → 간격 스킵 int heavyNow = EffectBudget.Count(EffectBudgetKind.Heavy); sb.AppendLine(" " + st.rows[i].classId + "/" + st.rows[i].slot + " " + st.rows[i].skillId + " " + st.rows[i].label + ": charge=" + (SkillSpectacle.ChargeSpawned - c0) + "(" + sc + ") body=" + (SkillSpectacle.BodySpawned - b0) + "(" + sf + ") impact=" + (SkillSpectacle.ImpactSpawned - i0) + "(" + si + ") active=" + SkillSpectacle.ActiveCount + " heavyHeld=" + SkillSpectacle.RowHeavyHeld(i) + " budgetHeavy=" + heavyNow + " medium=" + SkillSpectacle.MediumActive + " interval-skip=" + SkillSpectacle.SkippedImpactInterval); SkillSpectacle.Tick(Time.time + 100f); // 만료 } sb.AppendLine(" 만료 후 active=" + SkillSpectacle.ActiveCount + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + " heavy acq/rel=" + SkillSpectacle.HeavyAcquired + "/" + SkillSpectacle.HeavyReleased + " medium=" + SkillSpectacle.MediumActive); // ④ 절두체 밖: 카메라 뒤 몹에 착탄 → 스킵 { var d = Skill(703101, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101]; CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1)); int v0 = SkillSpectacle.SkippedOutOfView, i1 = SkillSpectacle.ImpactSpawned; CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobOut, false); sb.AppendLine("④ 절두체 밖 착탄 → outOfView +" + (SkillSpectacle.SkippedOutOfView - v0) + " impact +" + (SkillSpectacle.ImpactSpawned - i1) + " (기대 1 / 0)"); pc.transform.position = new Vector3(0f, 0f, -40f); int v1 = SkillSpectacle.SkippedOutOfView, c1 = SkillSpectacle.ChargeSpawned; CombatEvents.RaiseSkillCast(pc, Magic(Skill(703102, 10101, 0f, eSkillTarget.Enemy), 2)); sb.AppendLine(" 절두체 밖 시전(차지) → outOfView +" + (SkillSpectacle.SkippedOutOfView - v1) + " charge +" + (SkillSpectacle.ChargeSpawned - c1) + " (기대 1 / 0)"); pc.transform.position = Vector3.zero; SkillSpectacle.Tick(Time.time + 100f); } // ⑤ Heavy 2: 슬롯 4 3개 동시 → 2 스폰 + 1 heavy 스킵 · 만료 후 반납 { int h0 = SkillSpectacle.SkippedHeavyBudget, c1 = SkillSpectacle.ChargeSpawned; CombatEvents.RaiseSkillCast(pcs[10101], Magic(Skill(702006, 0, 1f, eSkillTarget.Enemy), 4)); CombatEvents.RaiseSkillCast(pcs[10102], Magic(Skill(702009, 0, 1f, eSkillTarget.Self), 4)); CombatEvents.RaiseSkillCast(pcs[10501], Magic(Skill(702005, 0, 1f, eSkillTarget.Enemy), 4)); sb.AppendLine("⑤ Heavy 3 동시 시전 → charge +" + (SkillSpectacle.ChargeSpawned - c1) + " heavy-skip +" + (SkillSpectacle.SkippedHeavyBudget - h0) + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + "/" + EffectBudget.Limit(EffectBudgetKind.Heavy) + " heldRows=" + SkillSpectacle.HeavyHeldRows + " (기대 2 / 1 / 2)"); // 같은 행의 본체는 이미 잡은 슬롯을 재사용(추가 획득 0) int a0 = SkillSpectacle.HeavyAcquired, b1 = SkillSpectacle.BodySpawned; CombatEvents.RaiseSkillFired(pcs[10101], 0, Magic(Skill(702006, 0, 1f, eSkillTarget.Enemy), 4)); sb.AppendLine(" 같은 행 본체 → body +" + (SkillSpectacle.BodySpawned - b1) + " heavy 추가 획득 +" + (SkillSpectacle.HeavyAcquired - a0) + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + " (기대 1 / 0 / 2)"); SkillSpectacle.Tick(Time.time + 100f); sb.AppendLine(" 만료 → budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + " heldRows=" + SkillSpectacle.HeavyHeldRows + " active=" + SkillSpectacle.ActiveCount + " (기대 0 / 0 / 0)"); } // ⑥ Medium 상한: Medium 본체 5개 → mediumMax 까지만 { int m0 = SkillSpectacle.SkippedMediumBudget, b1 = SkillSpectacle.BodySpawned; var meds = new[] { new[] { 703102, 10101 }, new[] { 703103, 10101 }, new[] { 703105, 10102 }, new[] { 703503, 10501 }, new[] { 703506, 10502 } }; foreach (var m in meds) CombatEvents.RaiseSkillFired(pcs[m[1]], 0, Magic(Skill(m[0], m[1], 0f, eSkillTarget.Enemy), 2)); sb.AppendLine("⑥ Medium 본체 5 → body +" + (SkillSpectacle.BodySpawned - b1) + " medium-skip +" + (SkillSpectacle.SkippedMediumBudget - m0) + " mediumActive=" + SkillSpectacle.MediumActive + " (기대 " + st.mediumMax + " / " + (5 - st.mediumMax) + " / " + st.mediumMax + ")"); SkillSpectacle.Tick(Time.time + 100f); } // ⑦ C8: RuntimeDisabled → 전부 0 { WLSkillSpectacleSettings.RuntimeDisabled = true; int c1 = SkillSpectacle.ChargeSpawned, b1 = SkillSpectacle.BodySpawned, i1 = SkillSpectacle.ImpactSpawned; var d = Skill(703101, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101]; CombatEvents.RaiseSkillCast(pc, Magic(d, 1)); CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1)); CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false); sb.AppendLine("⑦ RuntimeDisabled → spawn +" + (SkillSpectacle.ChargeSpawned - c1 + SkillSpectacle.BodySpawned - b1 + SkillSpectacle.ImpactSpawned - i1) + " (기대 0) active=" + SkillSpectacle.ActiveCount); WLSkillSpectacleSettings.RuntimeDisabled = false; CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1)); sb.AppendLine(" 재활성 → body +" + (SkillSpectacle.BodySpawned - b1) + " (기대 1)"); SkillSpectacle.Tick(Time.time + 100f); } // ⑧ GC 100회 (풀 워밍 후 · SkillCast+SkillFired+HitboxHit+Tick) { var d = Skill(703102, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101]; for (int w = 0; w < 5; w++) { CombatEvents.RaiseSkillCast(pc, Magic(d, 2)); CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 2)); CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false); SkillSpectacle.Tick(Time.time + 100f); } var magic = Magic(d, 2); var pd = Pd(pc, d); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long m0 = GC.GetTotalMemory(false); long mono0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); for (int n = 0; n < 100; n++) { CombatEvents.RaiseSkillCast(pc, magic); CombatEvents.RaiseSkillFired(pc, 0, magic); CombatEvents.RaiseHitboxHit(null, pd, mobIn, false); SkillSpectacle.Tick(Time.time + 100f); } long m1 = GC.GetTotalMemory(false); long mono1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); sb.AppendLine("⑧ GC 100회(cast+fire+hit+tick · 페이로드 재사용) → GetTotalMemory Δ=" + (m1 - m0) + " B · MonoUsed Δ=" + (mono1 - mono0) + " B (기대 0)"); // 이벤트 없이 Tick 만 100회(기준선) m0 = GC.GetTotalMemory(false); for (int n = 0; n < 100; n++) SkillSpectacle.Tick(Time.time + 100f); sb.AppendLine(" 기준선 Tick 100회 Δ=" + (GC.GetTotalMemory(false) - m0) + " B"); } sb.AppendLine("# 종료 " + Counters()); } catch (Exception e) { sb.AppendLine("EXCEPTION " + e); } finally { SkillSpectacle.ClearAll(); foreach (var g in trash) Kill(g); if (!Application.isPlaying) SkillSpectacle.DestroyPools(); SkillSpectacle.ResetDiagnostics(); if (!Application.isPlaying) { var r = GameObject.Find("__WLSkillSpectacle"); if (r != null) UnityEngine.Object.DestroyImmediate(r); } } Save("edit", sb); return sb.ToString(); } // ───────────────────────────────────────────── Title Play 프로브 public static void PlayProbe() { if (!Application.isPlaying) { Debug.LogWarning("[WL811i] Play 모드에서만(Title Play). Edit 모드는 Dump/EditProbe."); return; } var old = GameObject.Find("__wl811i_probe"); if (old != null) UnityEngine.Object.Destroy(old); var go = new GameObject("__wl811i_probe"); var h = go.AddComponent(); h.StartCoroutine(h.Run()); } public class Host : MonoBehaviour { StringBuilder _sb; float _t0; readonly List _trash = new List(); string Pre() { return string.Format("{0,7:F0} ms | f{1,-5} | ", (Time.unscaledTime - _t0) * 1000f, Time.frameCount); } IEnumerator Wait(float s) { float t = Time.unscaledTime; while (Time.unscaledTime - t < s) yield return null; } public IEnumerator Run() { _sb = new StringBuilder(); _t0 = Time.unscaledTime; var st = WLSkillSpectacleSettings.Instance; _sb.AppendLine("# WL811i PlayProbe " + DateTime.Now.ToString("HH:mm:ss") + " scene=" + UnityEngine.SceneManagement.SceneManager.GetActiveScene().name + " core=" + WLCombatCoreSettings.Enabled + " spectacle=" + WLSkillSpectacleSettings.Enabled + " subscribed=" + SkillSpectacle.Subscribed + " ts=" + Time.timeScale); if (st == null || !WLCombatCoreSettings.Enabled) { _sb.AppendLine("asset/core 없음 → 중단"); Save("play", _sb); Destroy(gameObject); yield break; } SkillSpectacle.ResetDiagnostics(); var cam = InstallCamera(new Vector3(0f, 6f, -6f), Vector3.zero); _trash.Add(cam.gameObject); yield return null; // ① 로드(런타임 Addressables) — 프레임 폴링 SkillSpectacle.PreloadAll(); float tl = Time.unscaledTime; while (SkillSpectacle.LoadingCount > 0 && Time.unscaledTime - tl < 20f) yield return null; _sb.AppendLine(Pre() + "① PreloadAll → req=" + SkillSpectacle.LoadRequested + " done=" + SkillSpectacle.LoadCompleted + " fail=" + SkillSpectacle.LoadFailed + " loading=" + SkillSpectacle.LoadingCount + " " + ((Time.unscaledTime - tl) * 1000f).ToString("F0") + " ms · warm inst=" + SkillSpectacle.Instantiated + " runner=" + SkillSpectacleRunner.Exists); var names = new List(); foreach (var r in st.rows) foreach (var v in new[] { r.charge, r.body, r.impact }) if (!string.IsNullOrEmpty(v.prefab) && !names.Contains(v.prefab)) names.Add(v.prefab); foreach (var n in names) _sb.AppendLine(" " + EntryRow(n)); var pcs = new Dictionary(); foreach (var cid in new[] { 10101, 10102, 10501, 10502 }) { var pc = MakePC("__wl811i_pc" + cid, Vector3.zero, cid, Quaternion.identity); pcs[cid] = pc; _trash.Add(pc.gameObject); } var mobIn = MakeMob("__wl811i_mobIn", new Vector3(1.5f, 0f, 0f)); _trash.Add(mobIn.gameObject); var mobOut = MakeMob("__wl811i_mobOut", new Vector3(0f, 0f, -40f)); _trash.Add(mobOut.gameObject); yield return null; _sb.AppendLine(Pre() + "② cam main=" + (Camera.main != null ? Camera.main.name : "NULL") + " inView(0)=" + EffectBudget.IsInView(Vector3.zero) + " inView(out)=" + EffectBudget.IsInView(mobOut.Get_position()) + " pc.IsMainPC=" + pcs[10101].IsMainPC() + " id=" + pcs[10101].Get_ID()); // ③ 16행 실스폰 + 수명 실측(활성 수가 0 이 되는 시각) _sb.AppendLine(Pre() + "③ 16행 (charge/body/impact 스폰 · 활성 0 도달 ms · Heavy 반납)"); var rows = Rows(st); for (int i = 0; i < rows.Length; i++) { var d = Skill(rows[i].id, rows[i].cls, rows[i].cast, rows[i].target); var pc = pcs[rows[i].cls]; int c0 = SkillSpectacle.ChargeSpawned, b0 = SkillSpectacle.BodySpawned, i0 = SkillSpectacle.ImpactSpawned; float t = Time.unscaledTime; CombatEvents.RaiseSkillCast(pc, Magic(d, rows[i].slot)); string sc = SkillSpectacle.LastInfo; yield return null; CombatEvents.RaiseSkillFired(pc, 0, Magic(d, rows[i].slot)); string sf = SkillSpectacle.LastInfo; yield return null; CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false); string si = SkillSpectacle.LastInfo; int peak = SkillSpectacle.ActiveCount; float tz = -1f; float tEnd = t + 6f; while (Time.unscaledTime < tEnd) { yield return null; if (SkillSpectacle.ActiveCount > peak) peak = SkillSpectacle.ActiveCount; if (SkillSpectacle.ActiveCount == 0) { tz = Time.unscaledTime - t; break; } } _sb.AppendLine(Pre() + " " + st.rows[i].classId + "/" + st.rows[i].slot + " " + st.rows[i].skillId + " " + st.rows[i].label + ": charge=" + (SkillSpectacle.ChargeSpawned - c0) + "(" + sc + ") body=" + (SkillSpectacle.BodySpawned - b0) + "(" + sf + ") impact=" + (SkillSpectacle.ImpactSpawned - i0) + "(" + si + ") peakActive=" + peak + " zeroAt=" + (tz >= 0 ? (tz * 1000f).ToString("F0") + " ms" : "미도달(6 s)") + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + " heldRows=" + SkillSpectacle.HeavyHeldRows); if (tz < 0f) SkillSpectacle.ClearAll(); } // ④ 착탄 발동당 상한: 0.05 s 간격 6타 → impactMaxPerFire 까지 { var d = Skill(703101, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101]; CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1)); int i0 = SkillSpectacle.ImpactSpawned, cap0 = SkillSpectacle.SkippedImpactCap; for (int k = 0; k < 6; k++) { CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false); yield return Wait(0.06f); } _sb.AppendLine(Pre() + "④ 착탄 6타(0.06 s 간격) → impact +" + (SkillSpectacle.ImpactSpawned - i0) + " cap-skip +" + (SkillSpectacle.SkippedImpactCap - cap0) + " (기대 " + st.impactMaxPerFire + " / " + (6 - st.impactMaxPerFire) + ") thisFire=" + SkillSpectacle.RowImpactsThisFire(st.FindRow(703101, 10101))); yield return Wait(3.2f); } // ⑤ 절두체 밖 { var d = Skill(703101, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101]; CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1)); int v0 = SkillSpectacle.SkippedOutOfView, i1 = SkillSpectacle.ImpactSpawned; CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobOut, false); _sb.AppendLine(Pre() + "⑤ 절두체 밖 착탄 → outOfView +" + (SkillSpectacle.SkippedOutOfView - v0) + " impact +" + (SkillSpectacle.ImpactSpawned - i1) + " (기대 1 / 0)"); yield return Wait(3.2f); } // ⑥ Heavy 2 동시 + 차지 1 s 수명 + 반납 시각 { int h0 = SkillSpectacle.SkippedHeavyBudget, c1 = SkillSpectacle.ChargeSpawned; float t = Time.unscaledTime; CombatEvents.RaiseSkillCast(pcs[10101], Magic(Skill(702006, 0, 1f, eSkillTarget.Enemy), 4)); CombatEvents.RaiseSkillCast(pcs[10502], Magic(Skill(702012, 0, 1f, eSkillTarget.Enemy), 4)); CombatEvents.RaiseSkillCast(pcs[10501], Magic(Skill(702005, 0, 1f, eSkillTarget.Enemy), 4)); _sb.AppendLine(Pre() + "⑥ Heavy 3 동시 시전 → charge +" + (SkillSpectacle.ChargeSpawned - c1) + " heavy-skip +" + (SkillSpectacle.SkippedHeavyBudget - h0) + " budgetHeavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + "/" + EffectBudget.Limit(EffectBudgetKind.Heavy) + " (기대 2 / 1 / 2)"); float tRel = -1f; while (Time.unscaledTime - t < 3f) { yield return null; if (EffectBudget.Count(EffectBudgetKind.Heavy) == 0) { tRel = Time.unscaledTime - t; break; } } _sb.AppendLine(Pre() + " 차지 만료 → Heavy 0 도달 " + (tRel >= 0 ? (tRel * 1000f).ToString("F0") + " ms (기대 ≈1000 = f_CastingTime)" : "미도달") + " active=" + SkillSpectacle.ActiveCount + " rel=" + SkillSpectacle.HeavyReleased); } // ⑦ 폴백 착탄(HitConfirmed · Beater_pd = 스킬 · HitboxHit 없이) { var d = Skill(703505, 10502, 0f, eSkillTarget.Enemy); var pc = pcs[10502]; CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1)); int fb0 = SkillSpectacle.ImpactFallback, i1 = SkillSpectacle.ImpactSpawned; var pd = Pd(pc, d); var dinfo = new DamageInfo { Beater = pc, Beater_pd = pd, Critical = eStat.None }; dinfo.Damage = 10; dinfo.BaseDmg = 10; yield return null; CombatEvents.RaiseHitConfirmed(mobIn, dinfo); _sb.AppendLine(Pre() + "⑦ HitConfirmed 폴백 → fallback +" + (SkillSpectacle.ImpactFallback - fb0) + " impact +" + (SkillSpectacle.ImpactSpawned - i1) + " (기대 1 / 1)"); // HitboxHit 직후 같은 프레임 HitConfirmed → 중복 0 yield return Wait(0.1f); fb0 = SkillSpectacle.ImpactFallback; i1 = SkillSpectacle.ImpactSpawned; CombatEvents.RaiseHitboxHit(null, pd, mobIn, false); CombatEvents.RaiseHitConfirmed(mobIn, dinfo); _sb.AppendLine(Pre() + " HitboxHit+HitConfirmed 같은 프레임 → fallback +" + (SkillSpectacle.ImpactFallback - fb0) + " impact +" + (SkillSpectacle.ImpactSpawned - i1) + " (기대 0 / 1)"); yield return Wait(3.2f); } // ⑧ C8 { WLSkillSpectacleSettings.RuntimeDisabled = true; int c1 = SkillSpectacle.ChargeSpawned, b1 = SkillSpectacle.BodySpawned, i1 = SkillSpectacle.ImpactSpawned; var d = Skill(703101, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101]; CombatEvents.RaiseSkillCast(pc, Magic(d, 1)); CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1)); CombatEvents.RaiseHitboxHit(null, Pd(pc, d), mobIn, false); yield return null; _sb.AppendLine(Pre() + "⑧ RuntimeDisabled → spawn +" + (SkillSpectacle.ChargeSpawned - c1 + SkillSpectacle.BodySpawned - b1 + SkillSpectacle.ImpactSpawned - i1) + " (기대 0) active=" + SkillSpectacle.ActiveCount); WLSkillSpectacleSettings.RuntimeDisabled = false; CombatEvents.RaiseSkillFired(pc, 0, Magic(d, 1)); _sb.AppendLine(Pre() + " 재활성 → body +" + (SkillSpectacle.BodySpawned - b1) + " (기대 1)"); yield return Wait(3.2f); } // ⑨ GC 100회 (프레임마다 cast+fire+hit · 만료는 러너 Update · 페이로드 재사용) { var d = Skill(703102, 10101, 0f, eSkillTarget.Enemy); var pc = pcs[10101]; var magic = Magic(d, 2); var pd = Pd(pc, d); for (int w = 0; w < 5; w++) { CombatEvents.RaiseSkillCast(pc, magic); CombatEvents.RaiseSkillFired(pc, 0, magic); CombatEvents.RaiseHitboxHit(null, pd, mobIn, false); yield return null; } yield return Wait(3.2f); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); yield return null; long m0 = GC.GetTotalMemory(false); long mono0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); int sk0 = SkillSpectacle.SkippedPoolFull + SkillSpectacle.SkippedMediumBudget; for (int n = 0; n < 100; n++) { CombatEvents.RaiseSkillCast(pc, magic); CombatEvents.RaiseSkillFired(pc, 0, magic); CombatEvents.RaiseHitboxHit(null, pd, mobIn, false); } long m1 = GC.GetTotalMemory(false); long mono1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); _sb.AppendLine(Pre() + "⑨ GC 100회(같은 프레임 · cast+fire+hit) → GetTotalMemory Δ=" + (m1 - m0) + " B · MonoUsed Δ=" + (mono1 - mono0) + " B (기대 0) · 풀/상한 스킵 +" + (SkillSpectacle.SkippedPoolFull + SkillSpectacle.SkippedMediumBudget - sk0)); // 프레임 분산 100회(만료가 끼어드는 경로) yield return Wait(3.2f); GC.Collect(); yield return null; m0 = GC.GetTotalMemory(false); for (int n = 0; n < 100; n++) { CombatEvents.RaiseSkillCast(pc, magic); CombatEvents.RaiseSkillFired(pc, 0, magic); CombatEvents.RaiseHitboxHit(null, pd, mobIn, false); yield return null; } m1 = GC.GetTotalMemory(false); _sb.AppendLine(Pre() + " 프레임 분산 100회 → Δ=" + (m1 - m0) + " B (러너 Update 포함 · 다른 시스템 잡음 가능)"); } _sb.AppendLine(Pre() + "# 종료 " + Counters()); Save("play", _sb); Cleanup(); } void Cleanup() { SkillSpectacle.ClearAll(); foreach (var g in _trash) if (g != null) Destroy(g); _trash.Clear(); SkillSpectacle.ResetDiagnostics(); Destroy(gameObject); } } }