Project_WL/Assets/WL/Tools/HitchRecorder.cs

365 lines
20 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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_<HHmmss>.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
{
/// <summary>프레임 히치 레코더. 정적 · 링버퍼 · GC 0.</summary>
public static class HitchRecorder
{
/// <summary>히치 1건. struct(배열에 값으로 들어간다 = 할당 0).</summary>
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 가 읽는다)
/// <summary>본 프레임 수 · 히치 수 · 파일로 쓴 횟수.</summary>
public static int FramesSeen, HitchCount, FilesWritten;
/// <summary>최악 프레임(ms) · 그 프레임 번호.</summary>
public static float WorstMs;
public static int WorstFrame;
/// <summary>임계 이하 프레임까지 포함한 누계 시간(초 · unscaled) — 히치 비율 계산용.</summary>
public static float ElapsedSeconds;
/// <summary>마지막에 쓴 파일 경로 · 마지막 콘솔 1줄.</summary>
public static string LastFile = "", LastSummary = "";
/// <summary>버퍼에 담긴 건수(덮어쓰기 반영).</summary>
public static int Buffered { get { return s_count; } }
static WLHitchSettings St { get { return WLHitchSettings.Instance; } }
// ─────────────────────────────────────────────────────────────────────
// 틱 — 러너가 매 프레임 부른다(프로브는 가짜 dt 로 직접 부를 수 있다)
// ─────────────────────────────────────────────────────────────────────
/// <summary>1프레임 관측. <paramref name="dt"/> = unscaled 프레임 길이(초).</summary>
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");
}
/// <summary>원본 이펙트 풀의 인스턴스 수. 없으면 직전 값을 그대로 돌려준다(델타 0).</summary>
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);
/// <summary>지금까지 버퍼에 담긴 히치를 파일로 쓴다. 반환 = 파일 경로(실패 시 빈 문자열).</summary>
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("런 종료");
}
// ─────────────────────────────────────────────────────────────────────
// 진단 · 초기화
// ─────────────────────────────────────────────────────────────────────
/// <summary>프로브·재시작용. 버퍼와 카운터를 비운다.</summary>
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 = "";
}
/// <summary>프로브용: i 번째(오래된 것부터) 샘플.</summary>
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];
}
}
/// <summary>HitchRecorder 전용 러너. SO 가 꺼져 있으면 오브젝트를 만들지 않는다(C8).</summary>
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<HitchRecorderRunner>();
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)
}
}
}