365 lines
20 KiB
C#
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)
|
|
}
|
|
}
|
|
}
|