Project_WL/AgentScripts/WL811i_Probe.cs

547 lines
40 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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/<이름>_<HHmmss>.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<string>();
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<ParticleSystem>(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<Renderer>(true);
var mats = new List<Material>(); var shaders = new List<string>(); 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<TurnOff_GO>();
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<GameObject>(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());
}
/// <summary>가짜 PC: m_Role=PC(public) · m_ID(protected → 리플렉션) · Play 면 Awake 가 돌고(arr_Renderer·tf_Top) Edit 면 돌지 않는다(앵커 Bottom/Mid 만 씀).</summary>
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<Rigidbody>().isKinematic = true;
go.AddComponent<CapsuleCollider>();
go.AddComponent<Animator>();
var agent = go.AddComponent<NavMeshAgent>(); agent.enabled = false;
var pc = go.AddComponent<PCActor>(); 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<Rigidbody>().isKinematic = true;
go.AddComponent<BoxCollider>();
go.AddComponent<Animator>();
var agent = go.AddComponent<NavMeshAgent>(); 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<Collider>()); else UnityEngine.Object.DestroyImmediate(cube.GetComponent<Collider>());
var mob = go.AddComponent<MobActor>(); 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<Camera>();
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;
}
/// <summary>로드 완료까지 폴링(Edit 모드는 Tick 을 직접 · Addressables AssetDatabase 모드는 보통 즉시 완료).</summary>
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<GameObject>();
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<string>();
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<int, PCActor>();
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<Host>();
h.StartCoroutine(h.Run());
}
public class Host : MonoBehaviour
{
StringBuilder _sb; float _t0;
readonly List<GameObject> _trash = new List<GameObject>();
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<string>();
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<int, PCActor>();
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);
}
}
}