// WL811bfix_Probe.cs — #811b-fix(코어 리뷰 FIX 6 + isKill 판정) 검증 프로브 // 에디트 모드 전용 · Play 불필요 · 에셋/씬 무수정(임시 오브젝트는 즉시 파괴) // // 상주 에디터: unity command run_script --file AgentScripts/WL811bfix_Probe.cs --entry WL811bfix_Probe.RunAll // 헤드리스 : 이 파일을 Assets/<무언가>/Editor/ 로 복사 후 // Unity -batchmode -nographics -quit -projectPath <이 worktree> -executeMethod WL811bfix_Probe.RunAll // 결과: Console + Screenshots_WL/WL811bfix/probe_.txt (RESULT PASS / RESULT FAIL n) // // 검사(발주서 WL-811b-fix ⓒ) // ① FIX-1 스로틀 경로 100회 GC 증가 0 B (Set(r, info) 문자열 생성이 verboseLog 안으로 들어갔는가) // ② QA D-1 isKill 판정 4케이스 — 살아 있음(전이 플래그) · 죽음 · 시체 재타격 · 사망통지만(StatTest) // ③ FIX-4 TryHandleHitStop(seconds ≤ 0) → false (기존 코루틴 폴백 유지) // // 에디트 모드 한계: Applied 경로는 틱 러너를 만들며 DontDestroyOnLoad 를 부르는데 이건 Play 전용이라 // 에디트 모드에서 예외가 난다(런타임에는 정상). 그래서 s_lastHitStopStart 를 리플렉션으로 세워 // "직전에 히트스톱이 발동한 상태" 를 만든 뒤 스로틀 경로만 측정한다(오브젝트 생성 0). using System; using System.IO; using System.Reflection; using System.Text; using UnityEngine; using WL.Combat.Core; public static class WL811bfix_Probe { const string OutDir = "Screenshots_WL/WL811bfix"; public static object RunAll() { var sb = new StringBuilder(); sb.AppendLine("# WL811b-fix Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode)"); int fail = 0; try { fail += ThrottleGc(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ① 예외 " + ex); } try { fail += IsKill(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ② 예외 " + ex); } try { fail += InvalidHitStop(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ③ 예외 " + ex); } sb.AppendLine(fail == 0 ? "RESULT PASS" : "RESULT FAIL " + fail); var s = sb.ToString(); Debug.Log(s); try { Directory.CreateDirectory(OutDir); File.WriteAllText(Path.Combine(OutDir, "probe_" + DateTime.Now.ToString("HHmmss") + ".txt"), s); } catch { /* 출력 저장 실패는 판정에 영향 없음 */ } return s; } // ── ① FIX-1: 스로틀 경로 할당 0 ------------------------------------------------- static int ThrottleGc(StringBuilder sb) { var cfg = WLCombatCoreSettings.Instance; sb.AppendLine("asset=" + (cfg != null ? "found" : "NULL") + " coreEnabled=" + WLCombatCoreSettings.Enabled + " arbiterEnabled=" + TimeScaleArbiter.Enabled + " hitStopPerSecondMax=" + (cfg != null ? cfg.hitStopPerSecondMax.ToString("F2") : "?") + " verboseLog=" + (cfg != null && cfg.verboseLog)); if (!TimeScaleArbiter.Enabled) { sb.AppendLine("[FAIL] ① 중재자 비활성 — 측정 불가"); return 1; } // Time.timeScale 은 읽기만 한다 — 에디트 모드에서 대입하면 ProjectSettings/TimeManager.asset 에 그대로 적힌다(실측 사고). TimeScaleArbiter.ResetDiagnostics(); if (!ArmThrottle()) { sb.AppendLine("[FAIL] ① s_lastHitStopStart 리플렉션 실패 — 스로틀 상태를 만들 수 없음"); return 1; } var rThrottled = TimeScaleArbiter.Request(TimeLayer.HitStop, 0.05f, 0.05f); // 워밍업(JIT) · 기대 Throttled for (int i = 0; i < 20; i++) TimeScaleArbiter.Request(TimeLayer.HitStop, 0.05f, 0.05f); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long a0 = Alloc(), m0 = GC.GetTotalMemory(false), p0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); for (int i = 0; i < 100; i++) TimeScaleArbiter.Request(TimeLayer.HitStop, 0.05f, 0.05f); long a1 = Alloc(), m1 = GC.GetTotalMemory(false), p1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); int throttled = TimeScaleArbiter.ThrottledCount; var lastInfo = TimeScaleArbiter.LastInfo; // 읽는 순간에만 조립된다(측정 뒤에 읽는다) Cleanup("__WLTimeScaleArbiter"); long alloc = a1 - a0; bool ok = rThrottled == ArbiterResult.Throttled && throttled >= 121 && alloc == 0L; sb.AppendLine((ok ? "[PASS]" : "[FAIL]") + " ① 스로틀 100회: result=" + rThrottled + " throttledCount=" + throttled + " (기대 121)" + " · GC.GetAllocatedBytesForCurrentThread 증가=" + alloc + " B (기대 0)" + " · GetTotalMemory 증가=" + (m1 - m0) + " B · MonoUsedSize 증가=" + (p1 - p0) + " B" + " · LastInfo=\"" + lastInfo + "\""); return ok ? 0 : 1; } static long Alloc() { try { return GC.GetAllocatedBytesForCurrentThread(); } catch (Exception) { return GC.GetTotalMemory(false); } } /// "방금 히트스톱이 발동했다" 상태를 만든다 — 이후 Request 는 전부 스로틀 경로(오브젝트 생성 0). static bool ArmThrottle() { var f = typeof(TimeScaleArbiter).GetField("s_lastHitStopStart", BindingFlags.NonPublic | BindingFlags.Static); if (f == null) return false; f.SetValue(null, Time.unscaledTime); return true; } // ── ② QA D-1: isKill 판정 -------------------------------------------------------- static int IsKill(StringBuilder sb) { var go = new GameObject("__WL811bfix_victim"); try { var actor = go.AddComponent(); var fDead = typeof(Actor).GetField("DeadStatus", BindingFlags.NonPublic | BindingFlags.Instance); if (fDead == null) { sb.AppendLine("[FAIL] ② Actor.DeadStatus 리플렉션 실패"); return 1; } // 공유 DamageInfo 가 앞 피격자의 사망으로 이미 오염된 상태(QA D-1 원인 재현) var stale = new DamageInfo(); stale.IsDeadByThisDamage = true; var clean = new DamageInfo(); clean.IsDeadByThisDamage = false; CombatEvents.ResetDiagnostics(); fDead.SetValue(actor, false); CombatEvents.RaiseHitConfirmed(actor, stale); bool a = !CombatEvents.LastIsKill; // A 살아 있는 피격자 + 전이 플래그 → false fDead.SetValue(actor, true); CombatEvents.RaiseKilled(actor); CombatEvents.RaiseHitConfirmed(actor, clean); bool b = CombatEvents.LastIsKill; // B 실제 사망(플래그는 false) → true CombatEvents.RaiseHitConfirmed(actor, stale); bool c = !CombatEvents.LastIsKill; // C 같은 프레임 시체 재타격 → false fDead.SetValue(actor, false); CombatEvents.RaiseKilled(actor); CombatEvents.RaiseHitConfirmed(actor, stale); bool d = !CombatEvents.LastIsKill; // D 사망통지만 있고 실제 생존(StatTest) → false int killed = CombatEvents.Killed.Raised, kills = CombatEvents.KillConfirmedCount; bool e = kills == 1; // E 실제 사망 1건 = isKill 1건 bool ok = a && b && c && d && e; sb.AppendLine((ok ? "[PASS]" : "[FAIL]") + " ② isKill: A살아있음+전이플래그=" + (a ? "false✔" : "true✘") + " B사망=" + (b ? "true✔" : "false✘") + " C시체재타격=" + (c ? "false✔" : "true✘") + " D사망통지만=" + (d ? "false✔" : "true✘") + " · HitConfirmed=" + CombatEvents.HitConfirmed.Raised + " Killed=" + killed + " isKillTrue=" + kills + " (기대 1)"); return ok ? 0 : 1; } finally { UnityEngine.Object.DestroyImmediate(go); CombatEvents.ResetDiagnostics(); } } // ── ③ FIX-4: Invalid 요청은 코어가 처리하지 않았다고 답한다 ------------------------ static int InvalidHitStop(StringBuilder sb) { var st = WL.Feel.WLHitFeel.Settings; if (st == null) { sb.AppendLine("[FAIL] ③ WLHitFeelSettings 에셋 없음 — 측정 불가"); return 1; } TimeScaleArbiter.ResetDiagnostics(); ArmThrottle(); // 에디트 모드에서 Applied(러너 생성) 를 피한다 — 정상 요청은 Throttled 로 받는다 bool zero = TimeScaleArbiter.TryHandleHitStop(st, 0f); var rZero = TimeScaleArbiter.LastResult; bool neg = TimeScaleArbiter.TryHandleHitStop(st, -1f); bool valid = TimeScaleArbiter.TryHandleHitStop(st, 0.04f); // 코어가 처리한 요청은 그대로 true var rValid = TimeScaleArbiter.LastResult; TimeScaleArbiter.ReleaseAll(); Cleanup("__WLTimeScaleArbiter"); bool ok = !zero && !neg && valid && rZero == ArbiterResult.Invalid && rValid == ArbiterResult.Throttled; sb.AppendLine((ok ? "[PASS]" : "[FAIL]") + " ③ TryHandleHitStop: seconds=0 → " + zero + "(기대 false · " + rZero + ")" + " · seconds=-1 → " + neg + "(기대 false) · seconds=0.04 → " + valid + "(기대 true · " + rValid + ")" + " · hitStopMode=" + st.hitStopMode); return ok ? 0 : 1; } static void Cleanup(string name) { var go = GameObject.Find(name); if (go != null) UnityEngine.Object.DestroyImmediate(go); } }