// WL811b_Probe.cs — #811b 전투 액션 코어 골격 검증 프로브 (Play 전용 · 읽기 전용 · 에셋 무수정) // // unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.Watch --args '[10]' // ⓒ 이벤트 순서·ms 로그(10 s · 3콤보 1사이클 이상) // unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.GcCheck // ⓖ 100회 디스패치 GC 증가(바이트) // unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.Arbiter // ⓔ 일시정지 스킵 · 상위 층 스킵 · 스로틀 · 복원 // unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.Toggle --args '[false, 6]' // ⓕ 코어 off 6 s: 디스패치 0 · 타격감(FiredCount) 계속 → 자동 on 복귀 // unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.Dump // ⓗ 설정 에셋 값 · 활성 상태 // unity command run_script --file AgentScripts/WL811b_Probe.cs --entry WL811b_Probe.Status // 이벤트별 Raised/Dispatched · 중재자 · 상한 카운터 // 결과: Screenshots_WL/WL811b/<이름>_.txt + Console // 코어·원본 코드를 수정하지 않는다. 구독은 프로브 종료 시 해제한다. using System; using System.Collections; using System.Text; using UnityEngine; using WL.Combat.Core; public static class WL811b_Probe { const string OutDir = "Screenshots_WL/WL811b"; public static void Watch(float seconds) { Launch(h => h.Co_Watch(seconds <= 0f ? 10f : seconds)); } public static void Arbiter() { Launch(h => h.Co_Arbiter()); } public static void Toggle(bool on, float seconds) { Launch(h => h.Co_Toggle(on, seconds <= 0f ? 6f : seconds)); } public static object Dump() { var sb = new StringBuilder(); var c = WLCombatCoreSettings.Instance; sb.AppendLine("# WL811b Dump " + DateTime.Now.ToString("HH:mm:ss")); sb.AppendLine("asset=" + (c != null ? "found" : "NULL") + " Enabled=" + WLCombatCoreSettings.Enabled + " RuntimeDisabled=" + WLCombatCoreSettings.RuntimeDisabled + " arbiter=" + TimeScaleArbiter.Enabled + " feel=" + (WL.Feel.WLHitFeel.Settings != null ? WL.Feel.WLHitFeel.Settings.enabled.ToString() : "null") + " coreSubscribed=" + WL.Feel.WLHitFeel.CoreSubscribed); if (c != null) sb.AppendLine(JsonUtility.ToJson(c, true)); Save("dump", sb); return sb.ToString(); } public static object Status() { var sb = new StringBuilder(); sb.AppendLine("# WL811b Status " + DateTime.Now.ToString("HH:mm:ss") + " timeScale=" + Time.timeScale.ToString("F2") + " TotalRaised=" + CombatEvents.TotalRaised + " last=" + CombatEvents.LastEvent); sb.AppendLine(Row("AttackStarted", CombatEvents.AttackStarted.Count, CombatEvents.AttackStarted.Raised, CombatEvents.AttackStarted.Dispatched)); sb.AppendLine(Row("ComboStage", CombatEvents.ComboStage.Count, CombatEvents.ComboStage.Raised, CombatEvents.ComboStage.Dispatched)); sb.AppendLine(Row("SwingEffect", CombatEvents.SwingEffect.Count, CombatEvents.SwingEffect.Raised, CombatEvents.SwingEffect.Dispatched)); sb.AppendLine(Row("HitboxSpawned", CombatEvents.HitboxSpawned.Count, CombatEvents.HitboxSpawned.Raised, CombatEvents.HitboxSpawned.Dispatched)); sb.AppendLine(Row("HitboxHit", CombatEvents.HitboxHit.Count, CombatEvents.HitboxHit.Raised, CombatEvents.HitboxHit.Dispatched)); sb.AppendLine(Row("HitConfirmed", CombatEvents.HitConfirmed.Count, CombatEvents.HitConfirmed.Raised, CombatEvents.HitConfirmed.Dispatched)); sb.AppendLine(Row("Killed", CombatEvents.Killed.Count, CombatEvents.Killed.Raised, CombatEvents.Killed.Dispatched)); sb.AppendLine(Row("Dash", CombatEvents.Dash.Count, CombatEvents.Dash.Raised, CombatEvents.Dash.Dispatched)); sb.AppendLine(Row("SkillCast", CombatEvents.SkillCast.Count, CombatEvents.SkillCast.Raised, CombatEvents.SkillCast.Dispatched)); sb.AppendLine(Row("SkillFired", CombatEvents.SkillFired.Count, CombatEvents.SkillFired.Raised, CombatEvents.SkillFired.Dispatched)); sb.AppendLine(Row("Damaged", CombatEvents.Damaged.Count, CombatEvents.Damaged.Raised, CombatEvents.Damaged.Dispatched)); sb.AppendLine(Row("Spawned", CombatEvents.Spawned.Count, CombatEvents.Spawned.Raised, CombatEvents.Spawned.Dispatched)); sb.AppendLine(Row("LevelUp", CombatEvents.LevelUp.Count, CombatEvents.LevelUp.Raised, CombatEvents.LevelUp.Dispatched)); sb.AppendLine("arbiter active=" + TimeScaleArbiter.Active + " applied=" + TimeScaleArbiter.AppliedCount + " ignored=" + TimeScaleArbiter.IgnoredCount + " skipHigher=" + TimeScaleArbiter.SkippedHigherCount + " skipPause=" + TimeScaleArbiter.SkippedPauseCount + " throttled=" + TimeScaleArbiter.ThrottledCount + " preempted=" + TimeScaleArbiter.PreemptedCount + " lastMeasured=" + TimeScaleArbiter.LastMeasured.ToString("F3") + " last=" + TimeScaleArbiter.LastResult + " " + TimeScaleArbiter.LastInfo); sb.AppendLine("feel hit=" + WL.Feel.WLHitFeel.HitCount + " fired=" + WL.Feel.WLHitFeel.FiredCount + " throttled=" + WL.Feel.WLHitFeel.ThrottledCount + " lastHitStop=" + WL.Feel.WLHitFeel.LastHitStopMeasured.ToString("F3")); sb.AppendLine("budget heavy=" + EffectBudget.Count(EffectBudgetKind.Heavy) + "/" + EffectBudget.Limit(EffectBudgetKind.Heavy) + " afterImage=" + EffectBudget.Count(EffectBudgetKind.AfterImage) + "/" + EffectBudget.Limit(EffectBudgetKind.AfterImage) + " dissolve=" + EffectBudget.Count(EffectBudgetKind.Dissolve) + "/" + EffectBudget.Limit(EffectBudgetKind.Dissolve) + " burst=" + EffectBudget.Count(EffectBudgetKind.ClusterBurst) + "/" + EffectBudget.Limit(EffectBudgetKind.ClusterBurst) + " combo=" + ComboTracker.LastInfo); Save("status", sb); return sb.ToString(); } /// ⓖ 100회 디스패치 GC 증가. 구독자 1개(no-op) 등록 후 측정 · Play/Edit 모두 가능(코어 Enabled 필요). public static object GcCheck() { var sb = new StringBuilder(); sb.AppendLine("# WL811b GcCheck " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + WLCombatCoreSettings.Enabled); CombatHandler h = NoOpHitbox; CombatEvents.HitboxSpawned.Add(h); var pos = Vector3.zero; for (int i = 0; i < 20; i++) CombatEvents.RaiseHitboxSpawned(null, "warm", 1f, 1f, pos); // 워밍업(JIT) long m0 = GC.GetTotalMemory(false); long p0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); for (int i = 0; i < 100; i++) CombatEvents.RaiseHitboxSpawned(null, "gc", 1f, 1f, pos); long m1 = GC.GetTotalMemory(false); long p1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); CombatEvents.HitboxSpawned.Remove(h); sb.AppendLine("100 dispatch: GC.GetTotalMemory delta=" + (m1 - m0) + " B · Profiler.MonoUsed delta=" + (p1 - p0) + " B · noop calls=" + s_noop + " (기준 < 1024 B)"); Save("gc", sb); return sb.ToString(); } static int s_noop; static void NoOpHitbox(in HitboxSpawnedEvent e) { s_noop++; } // ── 공용 static string Row(string n, int subs, int raised, int dispatched) { return string.Format(" {0,-14} subs={1} raised={2} dispatched={3}", n, subs, raised, dispatched); } 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("[WL811b] 저장 " + path); } catch (Exception e) { Debug.LogWarning("[WL811b] 저장 실패 " + e.Message); } Debug.Log(sb.ToString()); } static void Launch(Func make) { var old = GameObject.Find("__wl811b_probe"); if (old != null) UnityEngine.Object.DestroyImmediate(old); var go = new GameObject("__wl811b_probe"); var h = go.AddComponent(); h.StartCoroutine(make(h)); } public class Host : MonoBehaviour { StringBuilder _sb; float _t0; string Pre() { return string.Format("{0,7:F0} | f{1,-5} | ts={2:F2} | ", (Time.unscaledTime - _t0) * 1000f, Time.frameCount, Time.timeScale); } static string N(UnityEngine.Object o) { return o != null ? o.name : "null"; } // ── 구독자(메서드 그룹 · 종료 시 해제) void OnAttack(in AttackStartedEvent e) { _sb.AppendLine(Pre() + "AttackStarted actor=" + N(e.actor) + " idx=" + e.attackIndex + " speed=" + e.animSpeed.ToString("F2") + " target=" + N(e.target)); } void OnCombo(in ComboStageEvent e) { _sb.AppendLine(Pre() + "ComboStage stage=" + e.stage + " clip=" + e.clip + " nt=" + e.normalizedTime.ToString("F3") + " last=" + e.isLast); } void OnSwing(in SwingEffectEvent e) { _sb.AppendLine(Pre() + "SwingEffect effect=" + e.effect + " int=" + e.intParam + " handled=" + e.handled); } void OnHitbox(in HitboxSpawnedEvent e) { _sb.AppendLine(Pre() + "HitboxSpawned prefab=" + e.prefab + " addDmg=" + e.addDmg.ToString("F2") + " life=" + e.lifetime.ToString("F2")); } void OnHitboxHit(in HitboxHitEvent e) { _sb.AppendLine(Pre() + "HitboxHit proj=" + N(e.projectile) + " victim=" + N(e.victim) + " noDmg=" + e.noDmg + " skill=" + e.isSkill); } void OnHitConfirmed(in HitConfirmedEvent e) { _sb.AppendLine(Pre() + "HitConfirmed victim=" + N(e.victim) + " dmg=" + e.damage.ToString("F0") + " cri=" + e.critical + " kill=" + e.isKill + " byPC=" + e.byMainPC + " feelFired=" + WL.Feel.WLHitFeel.FiredCount); } void OnKilled(in KilledEvent e) { _sb.AppendLine(Pre() + "Killed victim=" + N(e.victim) + " killer=" + N(e.killer) + " sub=" + e.subRole + " id=" + e.id + " direct=" + e.byDirectHit); } void OnDash(in DashEvent e) { _sb.AppendLine(Pre() + (e.began ? "DashBegan " : "DashEnded ") + "dist=" + e.distance.ToString("F2") + " attack=" + e.endedInAttack); } void OnSkillCast(in SkillCastEvent e) { _sb.AppendLine(Pre() + "SkillCast skill=" + (e.skill != null ? e.skill.n_SkillID.ToString() : "null") + " slot=" + e.slot); } void OnSkillFired(in SkillFiredEvent e) { _sb.AppendLine(Pre() + "SkillFired param=" + e.eventParam); } void OnDamaged(in DamagedEvent e) { _sb.AppendLine(Pre() + "Damaged(PC) from=" + N(e.attacker) + " dmg=" + e.damage.ToString("F0") + " invincible=" + e.invincible); } void OnSpawned(in SpawnedEvent e) { _sb.AppendLine(Pre() + "Spawned actor=" + N(e.actor) + " boss=" + e.isBoss + " elite=" + e.isElite); } void OnLevelUp(in LevelUpEvent e) { _sb.AppendLine(Pre() + "LevelUp lv=" + e.newLevel); } void Subscribe(bool on) { if (on) { CombatEvents.AttackStarted.Add(OnAttack); CombatEvents.ComboStage.Add(OnCombo); CombatEvents.SwingEffect.Add(OnSwing); CombatEvents.HitboxSpawned.Add(OnHitbox); CombatEvents.HitboxHit.Add(OnHitboxHit); CombatEvents.HitConfirmed.Add(OnHitConfirmed); CombatEvents.Killed.Add(OnKilled); CombatEvents.Dash.Add(OnDash); CombatEvents.SkillCast.Add(OnSkillCast); CombatEvents.SkillFired.Add(OnSkillFired); CombatEvents.Damaged.Add(OnDamaged); CombatEvents.Spawned.Add(OnSpawned); CombatEvents.LevelUp.Add(OnLevelUp); } else { CombatEvents.AttackStarted.Remove(OnAttack); CombatEvents.ComboStage.Remove(OnCombo); CombatEvents.SwingEffect.Remove(OnSwing); CombatEvents.HitboxSpawned.Remove(OnHitbox); CombatEvents.HitboxHit.Remove(OnHitboxHit); CombatEvents.HitConfirmed.Remove(OnHitConfirmed); CombatEvents.Killed.Remove(OnKilled); CombatEvents.Dash.Remove(OnDash); CombatEvents.SkillCast.Remove(OnSkillCast); CombatEvents.SkillFired.Remove(OnSkillFired); CombatEvents.Damaged.Remove(OnDamaged); CombatEvents.Spawned.Remove(OnSpawned); CombatEvents.LevelUp.Remove(OnLevelUp); } } /// ⓒ 이벤트 순서·타이밍(자동 교전을 그대로 관찰 · 조작 없음). public IEnumerator Co_Watch(float seconds) { _sb = new StringBuilder(); _t0 = Time.unscaledTime; _sb.AppendLine("# WL811b Watch " + DateTime.Now.ToString("HH:mm:ss") + " " + seconds.ToString("F1") + " s · Enabled=" + WLCombatCoreSettings.Enabled + " arbiter=" + TimeScaleArbiter.Enabled + " coreSubscribed=" + WL.Feel.WLHitFeel.CoreSubscribed + " pc=" + N(MyValue.MyPC)); _sb.AppendLine("# 열: t(ms unscaled) | frame | timeScale | 이벤트 페이로드"); int fired0 = WL.Feel.WLHitFeel.FiredCount, hit0 = WL.Feel.WLHitFeel.HitCount, applied0 = TimeScaleArbiter.AppliedCount; float lastScale = Time.timeScale; Subscribe(true); while (Time.unscaledTime - _t0 < seconds) { yield return null; if (!Mathf.Approximately(Time.timeScale, lastScale)) { _sb.AppendLine(Pre() + "timeScale " + lastScale.ToString("F2") + " → " + Time.timeScale.ToString("F2") + " (arbiter " + TimeScaleArbiter.Active + ")"); lastScale = Time.timeScale; } } Subscribe(false); _sb.AppendLine("# 요약: TotalRaised=" + CombatEvents.TotalRaised + " feel hit+" + (WL.Feel.WLHitFeel.HitCount - hit0) + " fired+" + (WL.Feel.WLHitFeel.FiredCount - fired0) + " arbiterApplied+" + (TimeScaleArbiter.AppliedCount - applied0) + " lastHitStop=" + WL.Feel.WLHitFeel.LastHitStopMeasured.ToString("F3") + " s · timeScale=" + Time.timeScale.ToString("F2")); Save("watch", _sb); Destroy(gameObject); } /// ⓔ 중재자: 외부 일시정지 스킵 · 상위 층 스킵 · 스로틀 · 복원. timeScale 은 끝에 1 로 되돌린다. public IEnumerator Co_Arbiter() { _sb = new StringBuilder(); _t0 = Time.unscaledTime; _sb.AppendLine("# WL811b Arbiter " + DateTime.Now.ToString("HH:mm:ss") + " Enabled=" + TimeScaleArbiter.Enabled); var feel = WL.Feel.WLHitFeel.Settings; float hs = feel != null ? feel.hitStopDuration : 0.04f; float hsScale = feel != null ? feel.hitStopTimeScale : 0f; TimeScaleArbiter.ResetDiagnostics(); float saved = Time.timeScale; // ① 외부 일시정지(0) 중 히트스톱 요청 → SkippedPause Time.timeScale = 0f; yield return null; var r1 = TimeScaleArbiter.Request(TimeLayer.HitStop, hsScale, hs); _sb.AppendLine(Pre() + "① pause(0) 중 HitStop 요청 → " + r1 + " (" + TimeScaleArbiter.LastInfo + ") 기대=SkippedPause"); Time.timeScale = 1f; yield return null; // ② Cinematic 활성 중 HitStop → SkippedHigher · Cinematic 종료 후 1 복원 var r2a = TimeScaleArbiter.Request(TimeLayer.Cinematic, WLCombatCoreSettings.Instance.cinematicDefaultScale, 0.3f); yield return null; var r2b = TimeScaleArbiter.Request(TimeLayer.HitStop, hsScale, hs); _sb.AppendLine(Pre() + "② Cinematic → " + r2a + " · 그 중 HitStop → " + r2b + " 기대=SkippedHigher · timeScale=" + Time.timeScale.ToString("F2")); float w0 = Time.unscaledTime; while (Time.unscaledTime - w0 < 0.5f) yield return null; _sb.AppendLine(Pre() + " 0.5 s 후 timeScale=" + Time.timeScale.ToString("F2") + " (기대 1.00) measured=" + TimeScaleArbiter.LastMeasured.ToString("F3") + " active=" + TimeScaleArbiter.Active); // ③ HitStop 활성 중 Cinematic → 선점(Preempted) · 종료 후 복원 var r3a = TimeScaleArbiter.Request(TimeLayer.HitStop, hsScale, 0.3f); yield return null; var r3b = TimeScaleArbiter.Request(TimeLayer.Cinematic, 0.5f, 0.2f); _sb.AppendLine(Pre() + "③ HitStop → " + r3a + " · 그 중 Cinematic → " + r3b + " 기대=Applied(preempt) preempted=" + TimeScaleArbiter.PreemptedCount + " timeScale=" + Time.timeScale.ToString("F2")); w0 = Time.unscaledTime; while (Time.unscaledTime - w0 < 0.4f) yield return null; _sb.AppendLine(Pre() + " 0.4 s 후 timeScale=" + Time.timeScale.ToString("F2") + " (기대 1.00) active=" + TimeScaleArbiter.Active); // ④ 스로틀: 0.5 s 안에 HitStop 5회 요청 → Applied 1 · Throttled/Ignored 나머지 (1.5/s → 최소 간격 0.667 s) int applied0 = TimeScaleArbiter.AppliedCount, thr0 = TimeScaleArbiter.ThrottledCount, ign0 = TimeScaleArbiter.IgnoredCount; w0 = Time.unscaledTime; for (int i = 0; i < 5; i++) { TimeScaleArbiter.Request(TimeLayer.HitStop, hsScale, 0.03f); float t = Time.unscaledTime; while (Time.unscaledTime - t < 0.1f) yield return null; } _sb.AppendLine(Pre() + "④ 0.5 s 안 HitStop 5회 → applied+" + (TimeScaleArbiter.AppliedCount - applied0) + " throttled+" + (TimeScaleArbiter.ThrottledCount - thr0) + " ignored+" + (TimeScaleArbiter.IgnoredCount - ign0) + " 기대 applied 1 · 초당 ≤ 1.5"); w0 = Time.unscaledTime; while (Time.unscaledTime - w0 < 0.3f) yield return null; // ⑤ 실측 길이: HitStop 1회 → measured ≈ hitStopDuration w0 = Time.unscaledTime; while (Time.unscaledTime - w0 < 0.7f) yield return null; // 스로틀 간격 확보 var r5 = TimeScaleArbiter.Request(TimeLayer.HitStop, hsScale, hs); w0 = Time.unscaledTime; while (Time.unscaledTime - w0 < hs + 0.1f) yield return null; _sb.AppendLine(Pre() + "⑤ HitStop " + hs.ToString("F3") + " s → " + r5 + " measured=" + TimeScaleArbiter.LastMeasured.ToString("F3") + " (기대 " + hs.ToString("F3") + " ±1프레임) feel.LastHitStopMeasured=" + WL.Feel.WLHitFeel.LastHitStopMeasured.ToString("F3") + " timeScale=" + Time.timeScale.ToString("F2")); TimeScaleArbiter.ReleaseAll(); Time.timeScale = saved > 0f ? saved : 1f; _sb.AppendLine("# 종료 timeScale=" + Time.timeScale.ToString("F2") + " active=" + TimeScaleArbiter.Active); Save("arbiter", _sb); Destroy(gameObject); } /// ⓕ 코어 런타임 스위치: off 동안 디스패치 0 · 타격감은 기존 경로로 계속 → 끝에 원복. public IEnumerator Co_Toggle(bool on, float seconds) { _sb = new StringBuilder(); _t0 = Time.unscaledTime; bool prev = WLCombatCoreSettings.RuntimeDisabled; WLCombatCoreSettings.RuntimeDisabled = !on; int raised0 = CombatEvents.TotalRaised, fired0 = WL.Feel.WLHitFeel.FiredCount, hit0 = WL.Feel.WLHitFeel.HitCount; float minScale = 1f; _sb.AppendLine("# WL811b Toggle on=" + on + " " + seconds.ToString("F1") + " s · Enabled=" + WLCombatCoreSettings.Enabled + " arbiter=" + TimeScaleArbiter.Enabled); while (Time.unscaledTime - _t0 < seconds) { yield return null; minScale = Mathf.Min(minScale, Time.timeScale); } _sb.AppendLine("결과: TotalRaised+" + (CombatEvents.TotalRaised - raised0) + " (기대 " + (on ? ">0" : "0") + ") · feel hit+" + (WL.Feel.WLHitFeel.HitCount - hit0) + " fired+" + (WL.Feel.WLHitFeel.FiredCount - fired0) + " · 최소 timeScale=" + minScale.ToString("F2") + "(히트스톱 발생 여부) · lastHitStop=" + WL.Feel.WLHitFeel.LastHitStopMeasured.ToString("F3")); WLCombatCoreSettings.RuntimeDisabled = prev; _sb.AppendLine("# 원복 RuntimeDisabled=" + WLCombatCoreSettings.RuntimeDisabled + " Enabled=" + WLCombatCoreSettings.Enabled); Save("toggle", _sb); Destroy(gameObject); } } }