// ───────────────────────────────────────────────────────────────────────────── // HitchRecorder.cs — 히치(버벅임) 런타임 레코더 · 링버퍼 64 · GC 0 // // PD 지시 #813(「게임이 자꾸 버벅대는 듯 멈추는 렉」) · 발주서 WL-811q §1-3 (2026-09-10) // // ■ 무엇을 하나 (원본 훅 0 · 새 Manager/Singleton 0 · 러너 = 숨은 오브젝트 1개) // 매 프레임 `Time.unscaledDeltaTime` 을 보고 임계(SO `hitchMilliseconds` 기본 50 ms)를 넘으면 // 그 프레임의 **시각 · 직전 이벤트**를 링버퍼(SO `ringCapacity` 기본 64)에 1칸 적는다. // · 이 프레임에 등장한 몹 수 (`CombatEvents.Spawned.Raised` 델타 = 일괄 스폰의 직접 증거) // · 이 프레임에 죽은 몹 수 (`CombatEvents.Killed.Raised` 델타) // · 원본 이펙트 풀이 이 프레임에 몇 개 늘었나 (`InGameInfo.tf_Effects.childCount` 델타 // = Addressables 로드 + Instantiate 가 실제로 일어난 수 · 811q 원인 표 ②의 계기) // · WL 스킬 이펙트 인스턴스 증가 (`SkillSpectacle.Instantiated` 델타 = 811i) // · GC 0세대 수거 횟수 델타 · 관리 힙 델타(KB) // · 재무장(`BossArena.ZoneRearmMobs`) · 워밍(`EffectWarmup`) 델타 — 우리 완화가 스스로 히치를 // 만들고 있지 않은지 같은 줄에서 보이게 한다. // → `Screenshots_WL/WL811q/hitch_.txt` 로 쓴다. **QA 가 읽는다**(발주서 §1-3). // // ■ GC 0 (발주서 §2 ⓓ) // 기록 경로에 `new`·문자열 결합·박싱이 없다 — 사전 할당 구조체 배열 1개에 값만 쓴다. // 카운터는 전부 이미 존재하는 public static int 를 **읽기만** 한다(구독 0 = 이벤트 디스패치 비용 0). // 문자열은 **파일을 쓸 때만** 만든다(드문 경로 · StringBuilder 1개 재사용). // // ■ 왜 unscaledDeltaTime 인가 // 사망 연출·결과창은 `timeScale = 0` 이다(813z/813w8). scaled 로 재면 그 구간의 히치가 통째로 사라진다. // Q8 의 최악 831 ms 가 바로 사망 구간이었다. // // ■ 🔴 에디터 한계 (발주서 §1-4) // 에디터 Play 는 도메인 리로드·디버거·프로파일러·씬뷰 렌더가 얹혀 실기보다 느리다. // Q8 QA-20 은 **샘플 도중 에디터 명령을 넣으면 최악 프레임이 1104 ms 로 왜곡**된다는 것을 실증했다. // 이 레코더의 숫자는 **에디터 값**이다 — 실기 판정은 APK 빌드 실측(Lead/PD)이 해야 한다. // 빌드에서도 이 파일은 그대로 돌고 `persistentDataPath/Screenshots_WL/WL811q/` 에 쓴다. // // ■ C8 롤백 // `WLHitchSettings` 에셋이 없거나 `enabled = 0` → 러너를 만들지 않는다(오브젝트 0 · Update 0). // `recorderEnabled = 0` → Tick 즉시 반환. // // 🔴 어셈블리 주의: Assets/WL/ 에 .asmdef 를 만들지 말 것. // 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙 — 그 자체가 플레이어 모달이 된다). // ───────────────────────────────────────────────────────────────────────────── using System; using System.IO; using System.Text; using UnityEngine; using WL.Combat.Core; namespace WL.Tools { /// 프레임 히치 레코더. 정적 · 링버퍼 · GC 0. public static class HitchRecorder { /// 히치 1건. struct(배열에 값으로 들어간다 = 할당 0). public struct Sample { public int frame; // Time.frameCount public float realtime; // Time.realtimeSinceStartup public float ms; // 이 프레임 길이(ms · unscaled) public int spawned; // 이 프레임 Spawned 델타 public int killed; // 이 프레임 Killed 델타 public int poolGrew; // 원본 이펙트 풀 childCount 델타 public int wlEffects; // 811i SkillSpectacle.Instantiated 델타 public int gc0; // GC.CollectionCount(0) 델타 public int heapKb; // 관리 힙 델타(KB) public int rearmed; // BossArena.ZoneRearmMobs 델타 public int warmed; // EffectWarmup 진행 델타 public float timeScale; // 그 순간 Time.timeScale(사망·결과창 구간 식별) } // ───────────────────────────────────────── 링버퍼 static Sample[] s_ring; static int s_next, s_count; // ───────────────────────────────────────── 직전 프레임 스냅샷 static int s_prevSpawned, s_prevKilled, s_prevPool, s_prevWlEffects, s_prevGc0, s_prevRearm, s_prevWarm; static long s_prevHeap; static int s_frames; static bool s_primed; // ───────────────────────────────────────── 진단(프로브·QA 가 읽는다) /// 본 프레임 수 · 히치 수 · 파일로 쓴 횟수. public static int FramesSeen, HitchCount, FilesWritten; /// 최악 프레임(ms) · 그 프레임 번호. public static float WorstMs; public static int WorstFrame; /// 임계 이하 프레임까지 포함한 누계 시간(초 · unscaled) — 히치 비율 계산용. public static float ElapsedSeconds; /// 마지막에 쓴 파일 경로 · 마지막 콘솔 1줄. public static string LastFile = "", LastSummary = ""; /// 버퍼에 담긴 건수(덮어쓰기 반영). public static int Buffered { get { return s_count; } } static WLHitchSettings St { get { return WLHitchSettings.Instance; } } // ───────────────────────────────────────────────────────────────────── // 틱 — 러너가 매 프레임 부른다(프로브는 가짜 dt 로 직접 부를 수 있다) // ───────────────────────────────────────────────────────────────────── /// 1프레임 관측. = unscaled 프레임 길이(초). public static void Tick(float dt) { var st = St; if (st == null || !WLHitchSettings.Enabled || !st.recorderEnabled) return; FramesSeen++; ElapsedSeconds += dt; // ── 델타 산출(전부 읽기만 · 할당 0) int spawned = CombatEvents.Spawned.Raised; int killed = CombatEvents.Killed.Raised; int pool = PoolChildCount(); int wlfx = WL.Combat.Reaction.SkillSpectacle.Instantiated; int gc0 = GC.CollectionCount(0); int rearm = WL.Combat.Boss.BossArena.ZoneRearmMobs; int warm = EffectWarmup.WarmedTotal; long heap = st.sampleManagedHeap ? GC.GetTotalMemory(false) : 0L; if (!s_primed) { s_prevSpawned = spawned; s_prevKilled = killed; s_prevPool = pool; s_prevWlEffects = wlfx; s_prevGc0 = gc0; s_prevRearm = rearm; s_prevWarm = warm; s_prevHeap = heap; s_primed = true; return; } float ms = dt * 1000f; if (ms > WorstMs) { WorstMs = ms; WorstFrame = Time.frameCount; } s_frames++; if (s_frames > Mathf.Max(0, st.warmupFramesIgnored) && ms >= Mathf.Max(1f, st.hitchMilliseconds)) Record(st, ms, spawned, killed, pool, wlfx, gc0, rearm, warm, heap); s_prevSpawned = spawned; s_prevKilled = killed; s_prevPool = pool; s_prevWlEffects = wlfx; s_prevGc0 = gc0; s_prevRearm = rearm; s_prevWarm = warm; s_prevHeap = heap; } static void Record(WLHitchSettings st, float ms, int spawned, int killed, int pool, int wlfx, int gc0, int rearm, int warm, long heap) { int cap = Mathf.Clamp(st.ringCapacity, 4, 512); if (s_ring == null || s_ring.Length != cap) { s_ring = new Sample[cap]; s_next = 0; s_count = 0; } ref Sample s = ref s_ring[s_next]; s.frame = Time.frameCount; s.realtime = Time.realtimeSinceStartup; s.ms = ms; s.spawned = spawned - s_prevSpawned; s.killed = killed - s_prevKilled; s.poolGrew = pool - s_prevPool; s.wlEffects = wlfx - s_prevWlEffects; s.gc0 = gc0 - s_prevGc0; s.heapKb = (int)((heap - s_prevHeap) / 1024L); s.rearmed = rearm - s_prevRearm; s.warmed = warm - s_prevWarm; s.timeScale = Time.timeScale; s_next = (s_next + 1) % cap; if (s_count < cap) s_count++; HitchCount++; int every = st.autoWriteEveryHitches; if (every > 0 && HitchCount % every == 0) Write("auto"); } /// 원본 이펙트 풀의 인스턴스 수. 없으면 직전 값을 그대로 돌려준다(델타 0). static int PoolChildCount() { // 🔴 InGameInfo.Ins 는 MonoBehaviourSingletonTemplate 의 **정적 필드**다(실측) — 읽어도 아무것도 만들지 않는다. var info = InGameInfo.Ins; if (info == null) return s_prevPool; var tf = info.tf_Effects; return tf == null ? s_prevPool : tf.childCount; } // ───────────────────────────────────────────────────────────────────── // 파일 기록 (드문 경로 = 여기서만 문자열을 만든다) // ───────────────────────────────────────────────────────────────────── static readonly StringBuilder s_sb = new StringBuilder(4096); /// 지금까지 버퍼에 담긴 히치를 파일로 쓴다. 반환 = 파일 경로(실패 시 빈 문자열). public static string Write(string reason) { var st = St; if (st == null || s_count <= 0) return ""; s_sb.Length = 0; s_sb.Append("# WL-811q 히치 기록 (").Append(reason).Append(")\n"); s_sb.Append("# 실행 = ").Append(Application.isEditor ? "에디터 Play(🔴 실기보다 느리다 · 실기 판정은 APK 빌드)" : "빌드").Append('\n'); s_sb.Append("# 임계 ").Append(st.hitchMilliseconds.ToString("F0")).Append(" ms · 관측 ") .Append(FramesSeen).Append(" 프레임 / ").Append(ElapsedSeconds.ToString("F1")).Append(" s · 히치 ") .Append(HitchCount).Append("건"); if (FramesSeen > 0) s_sb.Append(" (").Append((100f * HitchCount / FramesSeen).ToString("F2")).Append(" %)"); s_sb.Append(" · 최악 ").Append(WorstMs.ToString("F1")).Append(" ms(frame ").Append(WorstFrame).Append(")\n"); s_sb.Append("# 열: frame t(s) ms 등장 처치 풀증가 WL이펙트 GC0 힙KB 재무장 워밍 timeScale\n"); s_sb.Append("# 🔴 「등장」이 12~15 인 줄 = 원본 PhaseAllKill 일괄 스폰(813e2) · 「풀증가」가 0 이 아닌 줄 = Addressables 로드+Instantiate\n"); int cap = s_ring.Length; int start = s_count < cap ? 0 : s_next; for (int k = 0; k < s_count; k++) { ref Sample s = ref s_ring[(start + k) % cap]; s_sb.Append(s.frame).Append(" ") .Append(s.realtime.ToString("F1")).Append(" ") .Append(s.ms.ToString("F1")).Append(" ") .Append(s.spawned).Append(' ') .Append(s.killed).Append(' ') .Append(s.poolGrew).Append(' ') .Append(s.wlEffects).Append(' ') .Append(s.gc0).Append(' ') .Append(s.heapKb).Append(' ') .Append(s.rearmed).Append(' ') .Append(s.warmed).Append(' ') .Append(s.timeScale.ToString("F2")).Append('\n'); } // 요약 — 원인별로 몇 건이 걸렸나(QA 가 이 4줄만 봐도 원인 표와 대조된다) int bySpawn = 0, byPool = 0, byGc = 0, none = 0; for (int k = 0; k < s_count; k++) { ref Sample s = ref s_ring[(start + k) % cap]; bool hit = false; if (s.spawned > 0) { bySpawn++; hit = true; } if (s.poolGrew > 0) { byPool++; hit = true; } if (s.gc0 > 0) { byGc++; hit = true; } if (!hit) none++; } s_sb.Append("\n[요약] 등장 동반 ").Append(bySpawn).Append("건 · 풀증가(Addressables) 동반 ").Append(byPool) .Append("건 · GC 동반 ").Append(byGc).Append("건 · 셋 다 아님 ").Append(none).Append("건\n"); s_sb.Append("[811i] 로드요청 ").Append(WL.Combat.Reaction.SkillSpectacle.LoadRequested) .Append(" 완료 ").Append(WL.Combat.Reaction.SkillSpectacle.LoadCompleted) .Append(" 인스턴스 ").Append(WL.Combat.Reaction.SkillSpectacle.Instantiated) .Append(" · [워밍] 행 ").Append(EffectWarmup.RowsWarmed) .Append(" 풀 ").Append(EffectWarmup.PoolWarmed) .Append(" 상태 ").Append(EffectWarmup.LastState).Append('\n'); s_sb.Append("[재무장] 존 ").Append(WL.Combat.Boss.BossArena.ZoneRearmCount) .Append(" 마리 ").Append(WL.Combat.Boss.BossArena.ZoneRearmMobs) .Append(" · 분산 대기 ").Append(WL.Combat.Boss.BossArena.RearmQueuePending).Append('\n'); string path = WriteFile(st, s_sb.ToString()); if (!string.IsNullOrEmpty(path)) { FilesWritten++; LastFile = path; LastSummary = "[WL811q] hitch " + HitchCount + "건/" + FramesSeen + "프레임 · 최악 " + WorstMs.ToString("F1") + " ms · 등장동반 " + bySpawn + " 풀증가 " + byPool + " GC " + byGc + " · file=" + path; // 🔴 Debug.Log 1줄만(Error/Exception 금지 = 모달 0). verboseLog 와 무관 — QA 가 이 줄을 읽는다. if (st.consoleLineOnWrite) Debug.Log(LastSummary); } return path; } static string WriteFile(WLHitchSettings st, string body) { try { string sub = !string.IsNullOrEmpty(st.hitchDir) ? st.hitchDir : "Screenshots_WL/WL811q"; string root = Application.isEditor ? Path.GetDirectoryName(Application.dataPath) : Application.persistentDataPath; string dir = string.IsNullOrEmpty(root) ? sub : Path.Combine(root, sub); Directory.CreateDirectory(dir); string file = Path.Combine(dir, "hitch_" + DateTime.Now.ToString("HHmmss") + ".txt"); File.WriteAllText(file, body); return file; } catch (Exception ex) { LastSummary = "[WL811q] 기록 실패: " + ex.Message; // 🔴 Debug.LogError 금지(813z) return ""; } } // ───────────────────────────────────────────────────────────────────── // 구독 (사망 · 런 종료 때 자동 기록) // ───────────────────────────────────────────────────────────────────── static bool s_subscribed; internal static void Subscribe() { if (s_subscribed) return; s_subscribed = true; WL.Combat.Survival.DeathFlow.Died.Add(OnDied); WL.Combat.Run.RunEvents.RunEnded.Add(OnRunEnded); } internal static void Unsubscribe() { if (!s_subscribed) return; s_subscribed = false; WL.Combat.Survival.DeathFlow.Died.Remove(OnDied); WL.Combat.Run.RunEvents.RunEnded.Remove(OnRunEnded); } static void OnDied(in WL.Combat.Survival.DeathEvent e) { var st = St; if (st != null && st.writeOnDeath) Write("사망 #" + e.deathCount); } static void OnRunEnded(in WL.Combat.Run.RunResult e) { var st = St; if (st != null && st.writeOnRunEnd) Write("런 종료"); } // ───────────────────────────────────────────────────────────────────── // 진단 · 초기화 // ───────────────────────────────────────────────────────────────────── /// 프로브·재시작용. 버퍼와 카운터를 비운다. public static void ResetAll() { s_ring = null; s_next = s_count = 0; s_frames = 0; s_primed = false; s_prevSpawned = s_prevKilled = s_prevPool = s_prevWlEffects = s_prevGc0 = s_prevRearm = s_prevWarm = 0; s_prevHeap = 0L; FramesSeen = HitchCount = FilesWritten = 0; WorstMs = 0f; WorstFrame = 0; ElapsedSeconds = 0f; LastFile = LastSummary = ""; } /// 프로브용: i 번째(오래된 것부터) 샘플. public static Sample At(int i) { if (s_ring == null || i < 0 || i >= s_count) return default(Sample); int cap = s_ring.Length; int start = s_count < cap ? 0 : s_next; return s_ring[(start + i) % cap]; } } /// HitchRecorder 전용 러너. SO 가 꺼져 있으면 오브젝트를 만들지 않는다(C8). public sealed class HitchRecorderRunner : MonoBehaviour { static HitchRecorderRunner s_runner; public static bool Exists { get { return s_runner != null; } } public static void Ensure() { if (s_runner != null) return; if (!Application.isPlaying) return; var go = new GameObject("__WLHitchRecorder"); DontDestroyOnLoad(go); s_runner = go.AddComponent(); HitchRecorder.Subscribe(); } void Update() { HitchRecorder.Tick(Time.unscaledDeltaTime); EffectWarmup.Tick(Time.unscaledTime); } void OnApplicationQuit() { HitchRecorder.Write("종료"); } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (WLHitchSettings.Enabled) Ensure(); // 꺼져 있으면 오브젝트 0(C8) } } }