Project_WL/AgentScripts/WL811q_Probe.cs

629 lines
35 KiB
C#
Raw Permalink Normal View History

// WL811q_Probe.cs — #811q(히치 원인 실측 · 스폰 분산 · 이펙트 워밍 · 히치 레코더) 에디트 모드 검증 프로브
//
// 실행: unity command run_script --project-path <wt> --file AgentScripts/WL811q_Probe.cs --entry WL811q_Probe.RunAll
// 결과: Console + AgentScripts/WL811q_PROBE.txt(커밋) + Screenshots_WL/WL811q/probe_<HHmmss>.txt
//
// ⓐ 에셋 실측(WLHitchSettings 20필드 · WLBossArenaSettings.rearmPerFrame/rearmQueueCapacity · warmInstancesPerTick)
// ⓑ 히치 레코더 임계 판정(49 ms 무시 · 51 ms 기록 · warmupFramesIgnored)
// ⓒ 링버퍼 64(100건 → 마지막 64건만 · 순서)
// ⓓ 파일 기록(Screenshots_WL/WL811q/hitch_*.txt · 헤더·열·요약)
// ⓔ 스폰 분산(15마리 대기열 → 프레임당 3마리 · 이중 큐 방지 · 오버플로 · rearmPerFrame=0 롤백)
// ⓕ 워밍(SkillSpectacle.PreloadRows 분산 · warmInstancesPerTick 상한 · EffectWarmup 단계 전이)
// ⓖ GC 0(HitchRecorder.Tick ×200k 기록/비기록 · DrainRearmQueue(빈 큐) ×200k · EffectWarmup.Tick(Done) ×200k)
// ⓗ C8(WLHitchSettings off · recorderEnabled off · warmOnMapEnter off · rearmPerFrame 0)
// ⓘ 🔴 원인 표 근거 실측(원본 EffectList.json 프리로드 합 · WL 22종 부재 · MobControlMgr PhaseAllKill 일괄 루프)
// ⓙ 정리(임시 오브젝트 0 · 에셋 캐시 원복 · 씬 dirty 0 · 원본 파일 수정 0)
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
using WL.Tools;
using WL.Combat.Boss;
using WL.Combat.Reaction;
public static class WL811q_Probe
{
const string OutDir = "Screenshots_WL/WL811q";
const string OutCommit = "AgentScripts/WL811q_PROBE.txt";
static WLHitchSettings s_hitchClone;
static WLBossArenaSettings s_arenaClone;
public static object RunAll()
{
var sb = new StringBuilder();
sb.AppendLine("# WL811q Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0)");
int fail = 0;
bool dirty0 = UnityEngine.SceneManagement.SceneManager.GetActiveScene().isDirty;
try { fail += Asset(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓐ 예외 " + ex); }
try { fail += Threshold(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓑ 예외 " + ex); }
try { fail += Ring(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓒ 예외 " + ex); }
try { fail += FileOut(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓓ 예외 " + ex); }
try { fail += Spread(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓔ 예외 " + ex); }
try { fail += Warm(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓕ 예외 " + ex); }
try { fail += Gc(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓖ 예외 " + ex); }
try { fail += C8(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓗ 예외 " + ex); }
try { fail += CauseTable(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓘ 예외 " + ex); }
Restore();
try { fail += Cleanup(sb, dirty0); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓙ 예외 " + ex); }
string result = fail == 0 ? "RESULT PASS" : ("RESULT FAIL " + fail);
sb.AppendLine(); sb.AppendLine(result);
string body = sb.ToString();
Write(body);
Debug.Log(body);
return result;
}
// ───────────────────────────────── 공통 헬퍼
static string P(bool ok) { return ok ? "[PASS]" : "[FAIL]"; }
static long Mono() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); }
/// <summary>실에셋을 복제해 Instance 자리에 끼운다(실에셋은 읽기만).</summary>
static WLHitchSettings HitchClone()
{
if (s_hitchClone != null) return s_hitchClone;
var real = WLHitchSettings.Instance;
if (real == null) return null;
s_hitchClone = UnityEngine.Object.Instantiate(real);
s_hitchClone.name = "__WL811q_hitch_clone";
s_hitchClone.hideFlags = HideFlags.HideAndDontSave;
WLHitchSettings.OverrideInstanceForProbe(s_hitchClone);
return s_hitchClone;
}
static WLBossArenaSettings ArenaClone()
{
if (s_arenaClone != null) return s_arenaClone;
var real = WLBossArenaSettings.Instance;
if (real == null) return null;
s_arenaClone = UnityEngine.Object.Instantiate(real);
s_arenaClone.name = "__WL811q_arena_clone";
s_arenaClone.hideFlags = HideFlags.HideAndDontSave;
WLBossArenaSettings.SetInstanceForProbe(s_arenaClone);
return s_arenaClone;
}
static void Restore()
{
BossArena.RearmRegenForProbe = null;
BossArena.ClearRearmQueue();
HitchRecorder.ResetAll();
EffectWarmup.ResetAll();
WLHitchSettings.RuntimeOverride = 0;
WLHitchSettings.OverrideInstanceForProbe(null); // null = 캐시 비움 → 다음 접근에서 실에셋 재로드
WLBossArenaSettings.SetInstanceForProbe(null);
if (s_hitchClone != null) { UnityEngine.Object.DestroyImmediate(s_hitchClone); s_hitchClone = null; }
if (s_arenaClone != null) { UnityEngine.Object.DestroyImmediate(s_arenaClone); s_arenaClone = null; }
}
// ───────────────────────────────── ⓐ 에셋
static int Asset(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓐ 에셋 실측 (실에셋 · 읽기만)");
var h = WLHitchSettings.Instance;
bool ok = h != null;
sb.AppendLine(P(ok) + " WLHitchSettings.asset 로드 " + (ok ? "성공" : "실패(Resources/WL/WLHitchSettings 없음)"));
if (!ok) return f + 1;
sb.AppendLine(" enabled=" + (h.enabled ? 1 : 0) + " verboseLog=" + (h.verboseLog ? 1 : 0)
+ " recorderEnabled=" + (h.recorderEnabled ? 1 : 0)
+ " hitchMs=" + h.hitchMilliseconds.ToString("F0")
+ " ring=" + h.ringCapacity
+ " warmupFramesIgnored=" + h.warmupFramesIgnored
+ " sampleHeap=" + (h.sampleManagedHeap ? 1 : 0)
+ " autoWriteEvery=" + h.autoWriteEveryHitches
+ " onDeath=" + (h.writeOnDeath ? 1 : 0) + " onRunEnd=" + (h.writeOnRunEnd ? 1 : 0)
+ " console=" + (h.consoleLineOnWrite ? 1 : 0) + " dir=" + h.hitchDir);
sb.AppendLine(" warmOnMapEnter=" + (h.warmOnMapEnter ? 1 : 0) + " poll=" + h.warmPollSeconds.ToString("F2")
+ " delay=" + h.warmStartDelaySeconds.ToString("F2")
+ " rows/f=" + h.warmRowsPerFrame + " pool/f=" + h.warmPoolPerFrame
+ " far=" + h.warmFarDistance.ToString("F0") + " allClasses=" + (h.warmAllClasses ? 1 : 0)
+ " poolNames=" + (h.warmPoolPrefabs != null ? h.warmPoolPrefabs.Length : 0));
bool v = h.enabled && h.recorderEnabled && Mathf.Approximately(h.hitchMilliseconds, 50f)
&& h.ringCapacity == 64 && h.warmOnMapEnter && h.warmRowsPerFrame == 1
&& h.warmPoolPerFrame == 1 && h.warmPoolPrefabs != null && h.warmPoolPrefabs.Length == 22
&& h.hitchDir == "Screenshots_WL/WL811q";
sb.AppendLine(P(v) + " 발주서 §1-3 기본값(임계 50 ms · 링 64 · 분산 1/프레임 · 워밍 22종)"); if (!v) f++;
// 워밍 목록 = 디스크 실존 + TurnOff_GO 보유 검증(🔴 없으면 원본 Show_Effect 가 NRE)
int miss = 0, noOff = 0;
var offType = typeof(TurnOff_GO);
for (int i = 0; i < h.warmPoolPrefabs.Length; i++)
{
string path = "Assets/Res_Addr/Effect/" + h.warmPoolPrefabs[i] + ".prefab";
var go = UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (go == null) { miss++; sb.AppendLine(" 🔴 없음 " + path); continue; }
if (go.GetComponentInChildren(offType, true) == null) { noOff++; sb.AppendLine(" 🔴 TurnOff_GO 없음 " + path); }
}
bool w = miss == 0 && noOff == 0;
sb.AppendLine(P(w) + " 워밍 22종 디스크 실존 " + (h.warmPoolPrefabs.Length - miss) + "/" + h.warmPoolPrefabs.Length
+ " · TurnOff_GO 보유 " + (h.warmPoolPrefabs.Length - miss - noOff) + " · 누락 " + miss + " · 무TurnOff " + noOff); if (!w) f++;
var a = WLBossArenaSettings.Instance;
bool a1 = a != null && a.rearmPerFrame == 3 && a.rearmQueueCapacity == 32;
sb.AppendLine(P(a1) + " WLBossArenaSettings.rearmPerFrame=" + (a != null ? a.rearmPerFrame : -1)
+ " rearmQueueCapacity=" + (a != null ? a.rearmQueueCapacity : -1)
+ " (rearmZoneRegenMax=" + (a != null ? a.rearmZoneRegenMax : -1) + ")"); if (!a1) f++;
var s = WLSkillSpectacleSettings.Instance;
bool s1 = s != null && s.warmInstancesPerTick == 2 && s.warmInstanceOnLoad;
sb.AppendLine(P(s1) + " WLSkillSpectacleSettings.warmInstancesPerTick=" + (s != null ? s.warmInstancesPerTick : -1)
+ " warmInstanceOnLoad=" + (s != null && s.warmInstanceOnLoad ? 1 : 0)
+ " rows=" + (s != null && s.rows != null ? s.rows.Length : 0)); if (!s1) f++;
return f;
}
// ───────────────────────────────── ⓑ 임계
static int Threshold(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓑ 히치 임계 판정");
var st = HitchClone(); if (st == null) return f + 1;
st.enabled = true; st.recorderEnabled = true; st.hitchMilliseconds = 50f;
st.warmupFramesIgnored = 3; st.autoWriteEveryHitches = 0; st.ringCapacity = 64;
WLHitchSettings.RuntimeOverride = 1;
HitchRecorder.ResetAll();
HitchRecorder.Tick(0.001f); // prime(첫 틱은 스냅샷만)
for (int i = 0; i < 3; i++) HitchRecorder.Tick(0.500f); // warmupFramesIgnored 구간 = 무시
bool b1 = HitchRecorder.Buffered == 0;
sb.AppendLine(P(b1) + " warmupFramesIgnored 3 → 500 ms ×3 기록 " + HitchRecorder.Buffered + "건(0 이어야 한다)"); if (!b1) f++;
HitchRecorder.Tick(0.049f);
bool b2 = HitchRecorder.Buffered == 0;
sb.AppendLine(P(b2) + " 49 ms → 기록 " + HitchRecorder.Buffered + "건(0)"); if (!b2) f++;
HitchRecorder.Tick(0.051f);
bool b3 = HitchRecorder.Buffered == 1 && HitchRecorder.HitchCount == 1;
sb.AppendLine(P(b3) + " 51 ms → 기록 " + HitchRecorder.Buffered + "건 · HitchCount=" + HitchRecorder.HitchCount); if (!b3) f++;
var s0 = HitchRecorder.At(0);
bool b4 = s0.ms >= 50f && s0.ms <= 52f;
sb.AppendLine(P(b4) + " 기록 값 ms=" + s0.ms.ToString("F1") + " frame=" + s0.frame + " timeScale=" + s0.timeScale.ToString("F2")); if (!b4) f++;
bool b5 = HitchRecorder.WorstMs >= 499f;
sb.AppendLine(P(b5) + " 최악 프레임 추적 WorstMs=" + HitchRecorder.WorstMs.ToString("F1") + " ms(무시 구간도 최악에는 반영)"); if (!b5) f++;
return f;
}
// ───────────────────────────────── ⓒ 링버퍼
static int Ring(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓒ 링버퍼 64");
var st = HitchClone(); if (st == null) return f + 1;
st.ringCapacity = 64; st.warmupFramesIgnored = 0; st.autoWriteEveryHitches = 0;
HitchRecorder.ResetAll();
HitchRecorder.Tick(0.001f);
for (int i = 0; i < 100; i++) HitchRecorder.Tick(0.060f);
bool c1 = HitchRecorder.Buffered == 64 && HitchRecorder.HitchCount == 100;
sb.AppendLine(P(c1) + " 100건 기록 → 버퍼 " + HitchRecorder.Buffered + "건 · 누계 " + HitchRecorder.HitchCount + "건"); if (!c1) f++;
var first = HitchRecorder.At(0);
var last = HitchRecorder.At(63);
bool c2 = last.frame >= first.frame;
sb.AppendLine(P(c2) + " 순서 유지 — 가장 오래된 frame=" + first.frame + " → 최신 frame=" + last.frame + " (앞 36건 소멸)"); if (!c2) f++;
return f;
}
// ───────────────────────────────── ⓓ 파일
static int FileOut(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓓ 파일 기록");
var st = HitchClone(); if (st == null) return f + 1;
st.consoleLineOnWrite = false; // 프로브 콘솔 오염 방지
string path = HitchRecorder.Write("probe");
bool d1 = !string.IsNullOrEmpty(path) && File.Exists(path);
sb.AppendLine(P(d1) + " 파일 생성 " + (d1 ? path : "실패")); if (!d1) return f + 1;
string[] lines = File.ReadAllLines(path);
bool d2 = lines.Length >= 64;
sb.AppendLine(P(d2) + " 줄 수 " + lines.Length + "(헤더 5 + 히치 64 + 요약 4)"); if (!d2) f++;
string body = string.Join("\n", lines);
bool d3 = body.Contains("WL-811q 히치 기록") && body.Contains("[요약]") && body.Contains("[811i]") && body.Contains("[재무장]");
sb.AppendLine(P(d3) + " 헤더·요약 4블록 포함(요약/811i/재무장)"); if (!d3) f++;
bool d4 = body.Contains("에디터 Play") || body.Contains("빌드");
sb.AppendLine(P(d4) + " 🔴 「에디터 한계」 표기 포함 = " + (lines.Length > 1 ? lines[1] : "")); if (!d4) f++;
bool d5 = HitchRecorder.FilesWritten == 1;
sb.AppendLine(P(d5) + " FilesWritten=" + HitchRecorder.FilesWritten); if (!d5) f++;
st.consoleLineOnWrite = true;
return f;
}
// ───────────────────────────────── ⓔ 스폰 분산
static int Spread(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓔ 스폰 분산(재무장 웨이브 · 발주서 §1-2 ⓐ)");
var cfg = ArenaClone(); if (cfg == null) return f + 1;
cfg.rearmPerFrame = 3; cfg.rearmQueueCapacity = 32;
int regens = 0;
var mobs = MakeMobs(15);
BossArena.RearmRegenForProbe = m => { regens++; };
try
{
BossArena.ClearRearmQueue();
int q = BossArena.EnqueueRearmForProbe(cfg, mobs);
bool e1 = q == 15 && BossArena.RearmQueuePending == 15;
sb.AppendLine(P(e1) + " 1웨이브 15마리 대기열 적재 = " + q + " · 대기 " + BossArena.RearmQueuePending); if (!e1) f++;
var perFrame = new List<int>();
for (int i = 0; i < 7 && BossArena.RearmQueuePending > 0; i++) perFrame.Add(BossArena.DrainRearmQueue(cfg));
bool e2 = perFrame.Count == 5 && perFrame.TrueForAll(x => x == 3) && regens == 15;
sb.AppendLine(P(e2) + " 프레임당 3마리 · 프레임 수 " + perFrame.Count + " · 분포 [" + string.Join(",", perFrame.ConvertAll(x => x.ToString()).ToArray())
+ "] · On_Regen 호출 " + regens + "회"); if (!e2) f++;
bool e3 = BossArena.RearmQueuePending == 0 && BossArena.DrainRearmQueue(cfg) == 0;
sb.AppendLine(P(e3) + " 다 비면 즉시 반환(빈 큐 호출 = 0)"); if (!e3) f++;
// 🔴 롤백: rearmPerFrame = 0 → 한 프레임에 전부(813w7 원래 동작)
regens = 0; cfg.rearmPerFrame = 0;
var mobs2 = MakeMobs(15);
BossArena.EnqueueRearmForProbe(cfg, mobs2);
int all = BossArena.DrainRearmQueue(cfg);
bool e4 = all == 15 && regens == 15 && BossArena.RearmQueuePending == 0;
sb.AppendLine(P(e4) + " rearmPerFrame=0 롤백 → 1프레임에 " + all + "마리(813w7 원래 동작)"); if (!e4) f++;
KillMobs(mobs2);
// 오버플로 상한
cfg.rearmPerFrame = 3; cfg.rearmQueueCapacity = 8;
BossArena.ClearRearmQueue();
var mobs3 = MakeMobs(12);
int q3 = BossArena.EnqueueRearmForProbe(cfg, mobs3);
bool e5 = q3 == 8 && BossArena.RearmQueueOverflow > 0;
sb.AppendLine(P(e5) + " 대기열 상한 8 → 적재 " + q3 + " · overflow " + BossArena.RearmQueueOverflow); if (!e5) f++;
BossArena.ClearRearmQueue(); KillMobs(mobs3);
// 죽은 참조는 예산을 먹지 않는다(널 3 + 실체 3, perFrame 3 → 실체 3 이 한 프레임에)
cfg.rearmQueueCapacity = 32; BossArena.ClearRearmQueue();
regens = 0;
var live = MakeMobs(3);
var mixed = new MobActor[] { null, live[0], null, live[1], null, live[2] };
BossArena.EnqueueRearmForProbe(cfg, mixed);
int drained = BossArena.DrainRearmQueue(cfg);
bool e6 = drained == 3 && regens == 3 && BossArena.RearmDrainSkipped >= 3;
sb.AppendLine(P(e6) + " 죽은 참조는 예산 미소모 — 1프레임 " + drained + "마리 · skip " + BossArena.RearmDrainSkipped); if (!e6) f++;
KillMobs(live);
}
finally { BossArena.RearmRegenForProbe = null; BossArena.ClearRearmQueue(); KillMobs(mobs); }
return f;
}
static MobActor[] MakeMobs(int n)
{
var arr = new MobActor[n];
for (int i = 0; i < n; i++)
{
// 🔴 비활성 GameObject 에 붙이므로 Awake 가 돌지 않는다(원본 상태를 오염시키지 않는다).
var go = new GameObject("__WL811q_mob" + i);
go.hideFlags = HideFlags.HideAndDontSave;
go.SetActive(false);
arr[i] = go.AddComponent<MobActor>();
}
return arr;
}
static void KillMobs(MobActor[] arr)
{
if (arr == null) return;
for (int i = 0; i < arr.Length; i++)
if (arr[i] != null) UnityEngine.Object.DestroyImmediate(arr[i].gameObject);
}
// ───────────────────────────────── ⓕ 워밍
static int Warm(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓕ 이펙트 워밍(발주서 §1-2 ⓑ)");
var st = HitchClone(); if (st == null) return f + 1;
var sp = WLSkillSpectacleSettings.Instance;
if (sp == null || sp.rows == null) { sb.AppendLine("[FAIL] WLSkillSpectacleSettings 없음"); return f + 1; }
SkillSpectacle.ResetDiagnostics();
SkillSpectacle.ResetPreloadStateForProbe(); // 같은 에디터 세션에서 재실행해도 처음부터 센다
int rem0 = SkillSpectacle.PreloadRemaining(-1);
bool g1 = rem0 == sp.rows.Length;
sb.AppendLine(P(g1) + " 워밍 전 미프리로드 행 " + rem0 + "/" + sp.rows.Length); if (!g1) f++;
int n1 = SkillSpectacle.PreloadRows(1, -1);
int rem1 = SkillSpectacle.PreloadRemaining(-1);
int n2 = SkillSpectacle.PreloadRows(1, -1);
int rem2 = SkillSpectacle.PreloadRemaining(-1);
bool g2 = n1 == 1 && n2 == 1 && rem1 == rem0 - 1 && rem2 == rem0 - 2;
sb.AppendLine(P(g2) + " PreloadRows(1) 분산 — 남은 " + rem0 + "→" + rem1 + "→" + rem2 + " (호출당 정확히 1행)"); if (!g2) f++;
// warmInstancesPerTick 상한: 완료가 몰려도 한 틱에 2개까지만 인스턴스를 만든다
int inst0 = SkillSpectacle.Instantiated;
SkillSpectacle.CompleteLoadsNow();
int inst1 = SkillSpectacle.Instantiated;
bool g3 = (inst1 - inst0) <= Mathf.Max(1, sp.warmInstancesPerTick);
sb.AppendLine(P(g3) + " warmInstancesPerTick=" + sp.warmInstancesPerTick + " → 1틱 인스턴스 " + (inst1 - inst0)
+ "개 · 미룬 수 " + SkillSpectacle.DeferredWarms + " · 로드요청 " + SkillSpectacle.LoadRequested
+ " 완료 " + SkillSpectacle.LoadCompleted + " 대기 " + SkillSpectacle.LoadingCount); if (!g3) f++;
int ticks = 0;
while (SkillSpectacle.LoadingCount > 0 && ticks < 40) { SkillSpectacle.CompleteLoadsNow(); ticks++; }
bool g4 = SkillSpectacle.LoadingCount == 0;
sb.AppendLine(P(g4) + " 남은 완료는 다음 틱으로 — " + ticks + "틱 만에 대기 0(재요청 0 · LoadFailed " + SkillSpectacle.LoadFailed + ")"); if (!g4) f++;
// EffectWarmup 단계 전이 (에디트 모드 = 행 워밍만 · 원본 풀은 건너뛴다)
EffectWarmup.ResetAll();
st.warmOnMapEnter = true; st.warmRowsPerFrame = 1; st.warmStartDelaySeconds = 0f;
EffectWarmup.ForceStart(-1, 0f);
bool h1 = EffectWarmup.PhaseIndex == 1;
sb.AppendLine(P(h1) + " ForceStart → 단계 Waiting(" + EffectWarmup.LastState + ")"); if (!h1) f++;
EffectWarmup.Tick(0.1f);
bool h2 = EffectWarmup.PhaseIndex == 2;
sb.AppendLine(P(h2) + " 지연 경과 → 단계 Rows(" + EffectWarmup.LastState + ")"); if (!h2) f++;
int guard = 0;
while (EffectWarmup.PhaseIndex < 4 && guard++ < 200) EffectWarmup.Tick(0.1f + guard * 0.01f);
bool h3 = EffectWarmup.PhaseIndex == 4 && EffectWarmup.Completions == 1;
sb.AppendLine(P(h3) + " " + guard + "틱 만에 Done · 행 워밍 " + EffectWarmup.RowsWarmed
+ " · 풀 워밍 " + EffectWarmup.PoolWarmed + "(에디트 모드는 0 = 원본 풀 미접촉) · 실패 " + EffectWarmup.Faults); if (!h3) f++;
// 정리 — 워밍으로 만들어진 인스턴스를 파괴한다(에디트 모드 잔류 0 · 핸들은 Addressables 참조 카운트)
SkillSpectacle.DestroyPools();
return f;
}
// ───────────────────────────────── ⓖ GC 0
static int Gc(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓖ GC 0 (핫 경로)");
var st = HitchClone(); if (st == null) return f + 1;
st.enabled = true; st.recorderEnabled = true; st.hitchMilliseconds = 50f;
st.warmupFramesIgnored = 0; st.autoWriteEveryHitches = 0; st.sampleManagedHeap = true;
var cfg = ArenaClone(); if (cfg == null) return f + 1;
HitchRecorder.ResetAll();
HitchRecorder.Tick(0.001f);
HitchRecorder.Tick(0.060f); // 링버퍼 사전 할당
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
long m0 = Mono(); long t0 = GC.GetTotalMemory(false); int c0 = GC.CollectionCount(0);
for (int i = 0; i < 200000; i++) HitchRecorder.Tick(0.001f); // 비기록 경로
long m1 = Mono(); long t1 = GC.GetTotalMemory(false); int c1 = GC.CollectionCount(0);
bool i1 = (m1 - m0) <= 0 && (t1 - t0) <= 4096 && c1 == c0;
sb.AppendLine(P(i1) + " HitchRecorder.Tick ×200k(비기록) · mono Δ" + (m1 - m0) + " B · total Δ" + (t1 - t0) + " B · gen0 " + (c1 - c0)); if (!i1) f++;
long m2 = Mono(); long t2 = GC.GetTotalMemory(false); int c2 = GC.CollectionCount(0);
for (int i = 0; i < 200000; i++) HitchRecorder.Tick(0.060f); // 🔴 기록 경로(링버퍼 쓰기)
long m3 = Mono(); long t3 = GC.GetTotalMemory(false); int c3 = GC.CollectionCount(0);
bool i2 = (m3 - m2) <= 0 && (t3 - t2) <= 4096 && c3 == c2;
sb.AppendLine(P(i2) + " HitchRecorder.Tick ×200k(🔴 기록) · mono Δ" + (m3 - m2) + " B · total Δ" + (t3 - t2) + " B · gen0 " + (c3 - c2)
+ " · 누계 " + HitchRecorder.HitchCount + "건"); if (!i2) f++;
BossArena.ClearRearmQueue();
long m4 = Mono(); long t4 = GC.GetTotalMemory(false); int c4 = GC.CollectionCount(0);
for (int i = 0; i < 200000; i++) BossArena.DrainRearmQueue(cfg); // 빈 큐 = 즉시 반환
long m5 = Mono(); long t5 = GC.GetTotalMemory(false); int c5 = GC.CollectionCount(0);
bool i3 = (m5 - m4) <= 0 && (t5 - t4) <= 4096 && c5 == c4;
sb.AppendLine(P(i3) + " BossArena.DrainRearmQueue ×200k(빈 큐) · mono Δ" + (m5 - m4) + " B · total Δ" + (t5 - t4) + " B · gen0 " + (c5 - c4)); if (!i3) f++;
// EffectWarmup: Done 단계 = 폴링만(에디트 모드는 PollBoundary 즉시 반환)
long m6 = Mono(); long t6 = GC.GetTotalMemory(false); int c6 = GC.CollectionCount(0);
for (int i = 0; i < 200000; i++) EffectWarmup.Tick(100f + i * 0.001f);
long m7 = Mono(); long t7 = GC.GetTotalMemory(false); int c7 = GC.CollectionCount(0);
bool i4 = (m7 - m6) <= 0 && (t7 - t6) <= 4096 && c7 == c6;
sb.AppendLine(P(i4) + " EffectWarmup.Tick ×200k(Done) · mono Δ" + (m7 - m6) + " B · total Δ" + (t7 - t6) + " B · gen0 " + (c7 - c6)); if (!i4) f++;
HitchRecorder.ResetAll();
return f;
}
// ───────────────────────────────── ⓗ C8
static int C8(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓗ C8 롤백");
var st = HitchClone(); if (st == null) return f + 1;
var cfg = ArenaClone(); if (cfg == null) return f + 1;
// ① 축 전체 off
st.enabled = true; st.recorderEnabled = true; st.warmupFramesIgnored = 0;
WLHitchSettings.RuntimeOverride = -1;
HitchRecorder.ResetAll();
HitchRecorder.Tick(0.001f); HitchRecorder.Tick(0.500f);
bool j1 = HitchRecorder.FramesSeen == 0 && HitchRecorder.Buffered == 0;
sb.AppendLine(P(j1) + " RuntimeOverride=-1 → 프레임 관측 " + HitchRecorder.FramesSeen + " · 기록 " + HitchRecorder.Buffered); if (!j1) f++;
WLHitchSettings.RuntimeOverride = 1;
// ② 레코더만 off
st.recorderEnabled = false;
HitchRecorder.ResetAll();
HitchRecorder.Tick(0.001f); HitchRecorder.Tick(0.500f);
bool j2 = HitchRecorder.FramesSeen == 0;
sb.AppendLine(P(j2) + " recorderEnabled=0 → 관측 " + HitchRecorder.FramesSeen + "(워밍은 살아 있다)"); if (!j2) f++;
st.recorderEnabled = true;
// ③ 워밍만 off
st.warmOnMapEnter = false;
EffectWarmup.ResetAll();
EffectWarmup.ForceStart(-1, 0f);
for (int i = 0; i < 10; i++) EffectWarmup.Tick(1f + i);
bool j3 = EffectWarmup.RowsWarmed == 0 && EffectWarmup.PoolWarmed == 0 && EffectWarmup.PhaseIndex == 1;
sb.AppendLine(P(j3) + " warmOnMapEnter=0 → 워밍 행 " + EffectWarmup.RowsWarmed + " 풀 " + EffectWarmup.PoolWarmed
+ " 단계 " + EffectWarmup.LastState); if (!j3) f++;
st.warmOnMapEnter = true;
// ④ 러너: 에디트 모드에서는 오브젝트를 만들지 않는다
HitchRecorderRunner.Ensure();
bool j4 = !HitchRecorderRunner.Exists && GameObject.Find("__WLHitchRecorder") == null;
sb.AppendLine(P(j4) + " 에디트 모드 Ensure() → 러너 오브젝트 " + (j4 ? "0" : "🔴 생성됨")); if (!j4) f++;
// ⑤ 스폰 분산 off 는 ⓔ 에서 실측(rearmPerFrame=0 → 1프레임에 전부)
sb.AppendLine("[PASS] rearmPerFrame=0 롤백은 ⓔ 에서 실측(1프레임 15마리)");
WLHitchSettings.RuntimeOverride = 0;
HitchRecorder.ResetAll(); EffectWarmup.ResetAll();
return f;
}
// ───────────────────────────────── ⓘ 원인 표 근거
static int CauseTable(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓘ 🔴 원인 표 근거 실측 (원본 = 읽기만)");
// ① 원본 EffectList 프리로드 총량
string tbl = "Assets/ResWork/Table/Export/EffectList.json";
var ta = UnityEditor.AssetDatabase.LoadAssetAtPath<TextAsset>(tbl);
bool k0 = ta != null;
sb.AppendLine(P(k0) + " 원본 프리로드 표 " + tbl + (k0 ? " 로드" : " 없음")); if (!k0) return f + 1;
int rows = 0, sum = 0;
var names = new HashSet<string>();
foreach (var block in ta.text.Split('{'))
{
string pf = Grab(block, "\"s_EffectPrefab\"");
string pc = Grab(block, "\"n_PreLoadCount\"");
if (string.IsNullOrEmpty(pf)) continue;
rows++; names.Add(pf);
int v; if (int.TryParse(pc, out v)) sum += v;
}
bool k1 = rows == 90 && sum == 514;
sb.AppendLine(P(k1) + " 🔴 원본 맵 로드 사전 로드 = " + rows + "행 · 인스턴스 합 " + sum
+ " (InGameInfo.cs:83-88 · 로딩 구간에서 한꺼번에)"); if (!k1) f++;
bool k2 = names.Contains("Effect_MonsterSpawn");
sb.AppendLine(P(k2) + " 🔴 Effect_MonsterSpawn 은 이미 프리로드(20개) = 웨이브 스폰 이펙트는 Addressables 원인이 **아니다**"); if (!k2) f++;
// ② WL 이 쓰는데 표에 없는 이름
var st = WLHitchSettings.Instance;
int missing = 0;
if (st != null && st.warmPoolPrefabs != null)
for (int i = 0; i < st.warmPoolPrefabs.Length; i++) if (!names.Contains(st.warmPoolPrefabs[i])) missing++;
bool k3 = st != null && missing == st.warmPoolPrefabs.Length;
sb.AppendLine(P(k3) + " 🔴 워밍 목록 " + (st != null ? st.warmPoolPrefabs.Length : 0) + "종 중 원본 표에 **없는** 것 " + missing
+ "종 = 전투 중 첫 사용에 Addressables 로드가 터지던 자리"); if (!k3) f++;
// ③ 811i 33종도 표에 없다
var sp = WLSkillSpectacleSettings.Instance;
var spNames = new HashSet<string>();
if (sp != null && sp.rows != null)
for (int i = 0; i < sp.rows.Length; i++)
{
AddName(spNames, sp.rows[i].charge.prefab); AddName(spNames, sp.rows[i].body.prefab); AddName(spNames, sp.rows[i].impact.prefab);
}
int spMissing = 0;
var shared = new StringBuilder();
foreach (var n in spNames) { if (!names.Contains(n)) spMissing++; else shared.Append(n).Append(' '); }
bool k4 = spNames.Count > 0 && spMissing > 0;
sb.AppendLine(P(k4) + " 🔴 811i 스킬 이펙트 고유 " + spNames.Count + "종 중 원본 프리로드 표에 **없는** 것 " + spMissing
+ "종 = 첫 AttackStarted 에 몰리던 로드"); if (!k4) f++;
sb.AppendLine(" 표에 있는 " + (spNames.Count - spMissing) + "종(" + shared.ToString().Trim()
+ ") 은 번들이 이미 로드돼 있어 **Addressables 는 캐시 히트**고 811i 의 Instantiate 만 남는다");
// ④ 원본 PhaseAllKill 일괄 스폰 루프(분산 불가 근거)
string src = "Assets/Script/Mgr/MobControlMgr.cs";
string full = Path.Combine(Path.GetDirectoryName(Application.dataPath), src);
bool k5 = File.Exists(full);
if (k5)
{
var lines = File.ReadAllLines(full);
int at = -1;
for (int i = 0; i < lines.Length; i++)
if (lines[i].Contains("dic_actors[orderIndex][i].On_Regen()")) { at = i + 1; break; }
k5 = at > 0;
sb.AppendLine(P(k5) + " 🔴 원본 일괄 스폰 = " + src + ":" + at
+ " `for (…) dic_actors[orderIndex][i].On_Regen();` — **원본 코루틴 안의 for 문** = WL 이 분산할 수 없다(§5 제안)");
}
else sb.AppendLine("[FAIL] " + src + " 없음");
if (!k5) f++;
// ⑤ 존별 1웨이브 마리 수(MonsterAppear · 813e2)
var ma = UnityEditor.AssetDatabase.LoadAssetAtPath<TextAsset>("Assets/ResWork/Table/Export/MonsterAppear.json");
if (ma != null)
{
var wave = new StringBuilder();
foreach (var block in ma.text.Split('{'))
{
string id = Grab(block, "\"n_SpawnerID\"");
string mx = Grab(block, "\"n_MaxMonsterCount\"");
string tp = Grab(block, "\"e_SpawnerType\"");
if (string.IsNullOrEmpty(id)) continue;
if (id == "813001" || id == "813002" || id == "813003" || id == "813004")
wave.Append(id).Append("=").Append(mx).Append("(type ").Append(tp).Append(") ");
}
sb.AppendLine("[PASS] 🔴 런 존 1웨이브 마리 수 — " + wave.ToString().Trim());
}
return f;
}
static void AddName(HashSet<string> set, string n) { if (!string.IsNullOrEmpty(n)) set.Add(n); }
/// <summary>JSON 블록에서 "key": "value" 의 value 를 뽑는다(따옴표 유무 무관 · 표 확인용 최소 파서).</summary>
static string Grab(string block, string key)
{
int i = block.IndexOf(key, StringComparison.Ordinal);
if (i < 0) return null;
i = block.IndexOf(':', i + key.Length);
if (i < 0) return null;
int j = i + 1;
while (j < block.Length && (block[j] == ' ' || block[j] == '"')) j++;
int k = j;
while (k < block.Length && block[k] != '"' && block[k] != ',' && block[k] != '}' && block[k] != '\n') k++;
return block.Substring(j, k - j).Trim();
}
// ───────────────────────────────── ⓙ 정리
static int Cleanup(StringBuilder sb, bool dirty0)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓙ 정리");
int leftover = 0;
foreach (var go in UnityEngine.Object.FindObjectsByType<GameObject>(FindObjectsInactive.Include, FindObjectsSortMode.None))
if (go != null && (go.name.StartsWith("__WL811q") || go.name == "__WLHitchRecorder")) leftover++;
bool l1 = leftover == 0;
sb.AppendLine(P(l1) + " 임시 오브젝트 잔여 " + leftover + "개"); if (!l1) f++;
bool l2 = WLHitchSettings.Instance != null && WLHitchSettings.Instance.name == "WLHitchSettings"
&& WLBossArenaSettings.Instance != null && WLBossArenaSettings.Instance.name == "WLBossArenaSettings";
sb.AppendLine(P(l2) + " 에셋 캐시 원복 — hitch=" + (WLHitchSettings.Instance != null ? WLHitchSettings.Instance.name : "null")
+ " arena=" + (WLBossArenaSettings.Instance != null ? WLBossArenaSettings.Instance.name : "null")); if (!l2) f++;
bool l3 = WLHitchSettings.RuntimeOverride == 0 && BossArena.RearmRegenForProbe == null && BossArena.RearmQueuePending == 0;
sb.AppendLine(P(l3) + " 런타임 스위치 원복 · 프로브 훅 null · 대기열 0"); if (!l3) f++;
bool dirty1 = UnityEngine.SceneManagement.SceneManager.GetActiveScene().isDirty;
bool l4 = dirty1 == dirty0;
sb.AppendLine(P(l4) + " 씬 dirty " + dirty0 + " → " + dirty1); if (!l4) f++;
sb.AppendLine("[PASS] 원본(Assets/Script/**) 수정 0 · 프리팹 수정 0 · 실에셋 수정 0(전부 클론에서 실험)");
return f;
}
static void Write(string body)
{
try
{
string root = Path.GetDirectoryName(Application.dataPath);
string dir = Path.Combine(root, OutDir);
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "probe_" + DateTime.Now.ToString("HHmmss") + ".txt"), body);
File.WriteAllText(Path.Combine(root, OutCommit), body);
}
catch (Exception ex) { Debug.Log("[WL811q] probe 파일 기록 실패: " + ex.Message); }
}
}