Merge branch 'wl/gameplay/WL-811q-hitch-mitigation'

This commit is contained in:
깃 관리자 2026-09-10 01:05:52 +09:00
commit 615110cca0
16 changed files with 1745 additions and 5 deletions

View File

@ -0,0 +1,77 @@
# WL811q Probe 2026-09-10 00:57:32 (edit mode · Play 0)
## ⓐ 에셋 실측 (실에셋 · 읽기만)
[PASS] WLHitchSettings.asset 로드 성공
enabled=1 verboseLog=0 recorderEnabled=1 hitchMs=50 ring=64 warmupFramesIgnored=30 sampleHeap=1 autoWriteEvery=16 onDeath=1 onRunEnd=1 console=1 dir=Screenshots_WL/WL811q
warmOnMapEnter=1 poll=0.25 delay=1.50 rows/f=1 pool/f=1 far=100000 allClasses=0 poolNames=22
[PASS] 발주서 §1-3 기본값(임계 50 ms · 링 64 · 분산 1/프레임 · 워밍 22종)
[PASS] 워밍 22종 디스크 실존 22/22 · TurnOff_GO 보유 22 · 누락 0 · 무TurnOff 0
[PASS] WLBossArenaSettings.rearmPerFrame=3 rearmQueueCapacity=32 (rearmZoneRegenMax=15)
[PASS] WLSkillSpectacleSettings.warmInstancesPerTick=2 warmInstanceOnLoad=1 rows=16
## ⓑ 히치 임계 판정
[PASS] warmupFramesIgnored 3 → 500 ms ×3 기록 0건(0 이어야 한다)
[PASS] 49 ms → 기록 0건(0)
[PASS] 51 ms → 기록 1건 · HitchCount=1
[PASS] 기록 값 ms=51.0 frame=12 timeScale=1.00
[PASS] 최악 프레임 추적 WorstMs=500.0 ms(무시 구간도 최악에는 반영)
## ⓒ 링버퍼 64
[PASS] 100건 기록 → 버퍼 64건 · 누계 100건
[PASS] 순서 유지 — 가장 오래된 frame=12 → 최신 frame=12 (앞 36건 소멸)
## ⓓ 파일 기록
[PASS] 파일 생성 E:\NerdNavis\WL_wt\gameplay2\Screenshots_WL/WL811q\hitch_005732.txt
[PASS] 줄 수 73(헤더 5 + 히치 64 + 요약 4)
[PASS] 헤더·요약 4블록 포함(요약/811i/재무장)
[PASS] 🔴 「에디터 한계」 표기 포함 = # 실행 = 에디터 Play(🔴 실기보다 느리다 · 실기 판정은 APK 빌드)
[PASS] FilesWritten=1
## ⓔ 스폰 분산(재무장 웨이브 · 발주서 §1-2 ⓐ)
[PASS] 1웨이브 15마리 대기열 적재 = 15 · 대기 15
[PASS] 프레임당 3마리 · 프레임 수 5 · 분포 [3,3,3,3,3] · On_Regen 호출 15회
[PASS] 다 비면 즉시 반환(빈 큐 호출 = 0)
[PASS] rearmPerFrame=0 롤백 → 1프레임에 15마리(813w7 원래 동작)
[PASS] 대기열 상한 8 → 적재 8 · overflow 1
[PASS] 죽은 참조는 예산 미소모 — 1프레임 3마리 · skip 3
## ⓕ 이펙트 워밍(발주서 §1-2 ⓑ)
[PASS] 워밍 전 미프리로드 행 16/16
[PASS] PreloadRows(1) 분산 — 남은 16→15→14 (호출당 정확히 1행)
[PASS] warmInstancesPerTick=2 → 1틱 인스턴스 2개 · 미룬 수 1 · 로드요청 4 완료 2 대기 2
[PASS] 남은 완료는 다음 틱으로 — 1틱 만에 대기 0(재요청 0 · LoadFailed 0)
[PASS] ForceStart → 단계 Waiting(Waiting)
[PASS] 지연 경과 → 단계 Rows(Rows)
[PASS] 15틱 만에 Done · 행 워밍 14 · 풀 워밍 0(에디트 모드는 0 = 원본 풀 미접촉) · 실패 0
## ⓖ GC 0 (핫 경로)
[PASS] HitchRecorder.Tick ×200k(비기록) · mono Δ0 B · total Δ0 B · gen0 0
[PASS] HitchRecorder.Tick ×200k(🔴 기록) · mono Δ0 B · total Δ0 B · gen0 0 · 누계 200001건
[PASS] BossArena.DrainRearmQueue ×200k(빈 큐) · mono Δ0 B · total Δ0 B · gen0 0
[PASS] EffectWarmup.Tick ×200k(Done) · mono Δ0 B · total Δ0 B · gen0 0
## ⓗ C8 롤백
[PASS] RuntimeOverride=-1 → 프레임 관측 0 · 기록 0
[PASS] recorderEnabled=0 → 관측 0(워밍은 살아 있다)
[PASS] warmOnMapEnter=0 → 워밍 행 0 풀 0 단계 Waiting
[PASS] 에디트 모드 Ensure() → 러너 오브젝트 0
[PASS] rearmPerFrame=0 롤백은 ⓔ 에서 실측(1프레임 15마리)
## ⓘ 🔴 원인 표 근거 실측 (원본 = 읽기만)
[PASS] 원본 프리로드 표 Assets/ResWork/Table/Export/EffectList.json 로드
[PASS] 🔴 원본 맵 로드 사전 로드 = 90행 · 인스턴스 합 514 (InGameInfo.cs:83-88 · 로딩 구간에서 한꺼번에)
[PASS] 🔴 Effect_MonsterSpawn 은 이미 프리로드(20개) = 웨이브 스폰 이펙트는 Addressables 원인이 **아니다**
[PASS] 🔴 워밍 목록 22종 중 원본 표에 **없는** 것 22종 = 전투 중 첫 사용에 Addressables 로드가 터지던 자리
[PASS] 🔴 811i 스킬 이펙트 고유 33종 중 원본 프리로드 표에 **없는** 것 27종 = 첫 AttackStarted 에 몰리던 로드
표에 있는 6종(Effect_Hit_10101 Effect_PCDash Effect_Hit_10102 Effect_SwordShield Effect_STR_EXTRA_SPLASH Effect_Hit_10501) 은 번들이 이미 로드돼 있어 **Addressables 는 캐시 히트**고 811i 의 Instantiate 만 남는다
[PASS] 🔴 원본 일괄 스폰 = Assets/Script/Mgr/MobControlMgr.cs:221 `for (…) dic_actors[orderIndex][i].On_Regen();` — **원본 코루틴 안의 for 문** = WL 이 분산할 수 없다(§5 제안)
[PASS] 🔴 런 존 1웨이브 마리 수 — 813001=12(type PhaseAllKill) 813002=15(type PhaseAllKill) 813003=14(type PhaseAllKill) 813004=8(type PhaseAllKill)
## ⓙ 정리
[PASS] 임시 오브젝트 잔여 0개
[PASS] 에셋 캐시 원복 — hitch=WLHitchSettings arena=WLBossArenaSettings
[PASS] 런타임 스위치 원복 · 프로브 훅 null · 대기열 0
[PASS] 씬 dirty False → False
[PASS] 원본(Assets/Script/**) 수정 0 · 프리팹 수정 0 · 실에셋 수정 0(전부 클론에서 실험)
RESULT PASS

View File

@ -0,0 +1,628 @@
// 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); }
}
}

View File

@ -189,6 +189,13 @@ namespace WL.Combat.Boss
const int kMaxRearmZones = 16; // 버퍼 크기지 게임 수치가 아니다 const int kMaxRearmZones = 16; // 버퍼 크기지 게임 수치가 아니다
static readonly int[] s_rearmIds = new int[kMaxRearmZones]; static readonly int[] s_rearmIds = new int[kMaxRearmZones];
static readonly float[] s_rearmEmptySince = new float[kMaxRearmZones]; static readonly float[] s_rearmEmptySince = new float[kMaxRearmZones];
// ── 811q §1-2 ⓐ 스폰 분산: 재무장 대상 몹을 담아 두고 프레임당 N 마리씩 깨운다.
static MobActor[] s_rearmQueue;
static int s_rearmQueueCount, s_rearmQueueNext;
// 대기열에 이미 들어 있는 존 id — 같은 존을 두 번 얹지 않는다(이중 스폰 방지). 존은 서로 겹치지 않고 같이 들어갈 수 있다.
static readonly int[] s_rearmQueueZoneIds = new int[kMaxRearmZones];
static int s_rearmQueueZoneCount;
static int s_rearmCount = -1; // 1 = 아직 목록을 안 잡았다 static int s_rearmCount = -1; // 1 = 아직 목록을 안 잡았다
static float s_nextZoneRearmCheck; static float s_nextZoneRearmCheck;
@ -219,6 +226,14 @@ namespace WL.Combat.Boss
public static int ZoneSpawnHoldTicks, ZoneSpawnHoldTargets; public static int ZoneSpawnHoldTicks, ZoneSpawnHoldTargets;
/// <summary>813w7 §1-2 ②: 차단을 건너뛴 횟수(게이트 전 · 런 존 제외) — D-45 수리가 실제로 도는지의 증거.</summary> /// <summary>813w7 §1-2 ②: 차단을 건너뛴 횟수(게이트 전 · 런 존 제외) — D-45 수리가 실제로 도는지의 증거.</summary>
public static int ZoneSpawnHoldSkipped; public static int ZoneSpawnHoldSkipped;
// ───────────────────────────────────────── 811q 스폰 분산 진단
/// <summary>분산 대기열에 남은 마리 수(0 = 분산 없음/완료). 히치 레코더가 프레임 델타로 읽는다.</summary>
public static int RearmQueuePending { get { return s_rearmQueueCount - s_rearmQueueNext; } }
/// <summary>분산으로 대기열에 넣은 총 마리 수 · 대기열을 통해 실제로 깨운 총 마리 수 · 대기열 상한에 걸려 버린 수.</summary>
public static int RearmQueued, RearmDrained, RearmQueueOverflow;
/// <summary>대기열 드레인 중 이미 살아나 있어 건너뛴 수(중복 방지가 실제로 걸린 증거).</summary>
public static int RearmDrainSkipped;
/// <summary>이 런의 게이트 판정 기준 처치 수(= TotalZoneKills 런 시작 스냅샷).</summary> /// <summary>이 런의 게이트 판정 기준 처치 수(= TotalZoneKills 런 시작 스냅샷).</summary>
public static int GateKills { get { int v = TotalZoneKills - s_totalKillBase; return v > 0 ? v : 0; } } public static int GateKills { get { int v = TotalZoneKills - s_totalKillBase; return v > 0 ? v : 0; } }
@ -1232,6 +1247,10 @@ namespace WL.Combat.Boss
var info = ActorInfo.Ins; var info = ActorInfo.Ins;
if (DSUtil.CheckNull(info)) return 0; if (DSUtil.CheckNull(info)) return 0;
// 🔴 811q §1-2 ⓐ — 이 존의 앞 웨이브가 아직 프레임 분산 중이면 새 웨이브를 얹지 않는다(이중 스폰 방지).
// 다른 존은 같이 대기열에 들어갈 수 있다(「다음 런」에서 존 2개를 한 번에 재무장하는 813w7 동작 보존).
if (IsZoneQueued(spawnerId)) return 0;
int want = WaveSize(cfg, spawnerId); int want = WaveSize(cfg, spawnerId);
if (want <= 0) return 0; if (want <= 0) return 0;
@ -1251,11 +1270,28 @@ namespace WL.Combat.Boss
if (!DSUtil.CheckNull(a) && a.isActiveAndEnabled && !a.IsDead()) return 0; if (!DSUtil.CheckNull(a) && a.isActiveAndEnabled && !a.IsDead()) return 0;
} }
// 🔴 811q §1-2 ⓐ — perFrame > 0 이면 여기서 깨우지 않고 **대기열에 담기만** 한다.
// On_Regen() 1회 = SetActive(true)(애니메이터 리바인드) + NavMeshAgent.enabled + Set_Warp
// + 스폰 이펙트 1개 + Spawned 이벤트. 15마리를 한 프레임에 하면 그 프레임이 통째로 길어진다.
int perFrame = cfg.rearmPerFrame;
if (perFrame > 0) EnsureRearmQueue(cfg);
for (int i = 0; i < actors.Count && done < want; i++) for (int i = 0; i < actors.Count && done < want; i++)
{ {
var mob = actors[i] as MobActor; var mob = actors[i] as MobActor;
if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) continue; if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) continue;
if (perFrame > 0)
{
if (s_rearmQueueCount >= s_rearmQueue.Length) { RearmQueueOverflow++; break; }
s_rearmQueue[s_rearmQueueCount++] = mob;
RearmQueued++;
}
else
{
mob.On_Regen(); // 원본 웨이브와 같은 호출(MobControlMgr.cs:221) — 좌표·이펙트·Spawned 이벤트까지 원본 그대로 mob.On_Regen(); // 원본 웨이브와 같은 호출(MobControlMgr.cs:221) — 좌표·이펙트·Spawned 이벤트까지 원본 그대로
ZoneRearmMobs++;
}
done++; done++;
} }
} }
@ -1265,13 +1301,94 @@ namespace WL.Combat.Boss
if (done > 0) if (done > 0)
{ {
ZoneRearmCount++; ZoneRearmCount++;
ZoneRearmMobs += done;
LastRearmZoneId = spawnerId; LastRearmZoneId = spawnerId;
Log("존 " + spawnerId + " 재무장(" + reason + ") — " + done + "/" + want + "마리"); if (cfg.rearmPerFrame > 0 && s_rearmQueueZoneCount < s_rearmQueueZoneIds.Length)
s_rearmQueueZoneIds[s_rearmQueueZoneCount++] = spawnerId;
Log("존 " + spawnerId + " 재무장(" + reason + ") — " + done + "/" + want + "마리"
+ (cfg.rearmPerFrame > 0 ? " (프레임당 " + cfg.rearmPerFrame + "마리 분산)" : ""));
} }
return done; return done;
} }
/// <summary>이 존이 이미 분산 대기열에 들어 있는가.</summary>
static bool IsZoneQueued(int spawnerId)
{
for (int i = 0; i < s_rearmQueueZoneCount; i++) if (s_rearmQueueZoneIds[i] == spawnerId) return true;
return false;
}
/// <summary>분산 대기열 확보(용량은 SO · 버퍼 크기).</summary>
static void EnsureRearmQueue(WLBossArenaSettings cfg)
{
int cap = Mathf.Clamp(cfg.rearmQueueCapacity, 4, 256);
if (s_rearmQueue == null || s_rearmQueue.Length != cap) { s_rearmQueue = new MobActor[cap]; s_rearmQueueCount = s_rearmQueueNext = 0; s_rearmQueueZoneCount = 0; }
if (s_rearmQueueNext >= s_rearmQueueCount) { s_rearmQueueCount = s_rearmQueueNext = 0; s_rearmQueueZoneCount = 0; } // 다 비었으면 앞으로 되감는다
}
/// <summary>
/// 811q §1-2 ⓐ — 대기열에서 프레임당 <c>rearmPerFrame</c> 마리만 깨운다. Tick 이 매 프레임 부른다.
/// 반환 = 이번 프레임에 깨운 마리 수. 🔴 대기열이 비어 있으면 할당·호출 0(즉시 반환).
/// 프로브가 직접 부를 수 있다.
/// </summary>
public static int DrainRearmQueue(WLBossArenaSettings cfg)
{
if (cfg == null || s_rearmQueue == null) return 0;
int pending = s_rearmQueueCount - s_rearmQueueNext;
if (pending <= 0) return 0;
int per = cfg.rearmPerFrame;
if (per <= 0) per = pending; // 분산을 끄면 남은 것을 한 번에 비운다
int done = 0;
while (done < per && s_rearmQueueNext < s_rearmQueueCount)
{
var mob = s_rearmQueue[s_rearmQueueNext];
s_rearmQueue[s_rearmQueueNext] = null; // 참조를 남기지 않는다(씬 전환 누수 0)
s_rearmQueueNext++;
if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) { RearmDrainSkipped++; continue; }
try
{
var probe = RearmRegenForProbe;
if (probe != null) probe(mob); else mob.On_Regen(); // 실기는 항상 원본 호출(MobControlMgr.cs:221 과 동일)
ZoneRearmMobs++; RearmDrained++;
}
catch (System.Exception ex) { Log("분산 재무장 실패 — " + ex.Message); }
done++;
}
if (s_rearmQueueNext >= s_rearmQueueCount) { s_rearmQueueCount = s_rearmQueueNext = 0; s_rearmQueueZoneCount = 0; }
return done;
}
/// <summary>
/// 프로브 전용 대체 호출 — null 이 아니면 <c>On_Regen()</c> 대신 이것을 부른다.
/// 에디트 모드에는 살아 있는 <c>MobActor</c>(스탯·NavMeshAgent·애니메이터)가 없어 원본 호출을 태울 수 없다.
/// 🔴 실기에서는 항상 null 이다(누구도 대입하지 않는다). Play 종료 시 도메인 리로드로 자동 null.
/// </summary>
public static System.Action<MobActor> RearmRegenForProbe;
/// <summary>프로브 전용 — 대기열에 직접 넣는다(실기 경로는 <c>RearmZone</c>). 반환 = 넣은 수.</summary>
public static int EnqueueRearmForProbe(WLBossArenaSettings cfg, MobActor[] mobs)
{
if (cfg == null || mobs == null) return 0;
EnsureRearmQueue(cfg);
int n = 0;
for (int i = 0; i < mobs.Length; i++)
{
if (s_rearmQueueCount >= s_rearmQueue.Length) { RearmQueueOverflow++; break; }
s_rearmQueue[s_rearmQueueCount++] = mobs[i];
RearmQueued++; n++;
}
return n;
}
/// <summary>대기열을 비운다(씬 전환 · 런 종료 · C8).</summary>
public static void ClearRearmQueue()
{
if (s_rearmQueue != null) System.Array.Clear(s_rearmQueue, 0, s_rearmQueue.Length);
s_rearmQueueCount = s_rearmQueueNext = 0;
s_rearmQueueZoneCount = 0;
}
/// <summary> /// <summary>
/// 1웨이브 마리 수 — 원본 테이블 `n_MaxMonsterCount`(= `MobControlMgr` 이 쓰는 그 값)를 읽고, /// 1웨이브 마리 수 — 원본 테이블 `n_MaxMonsterCount`(= `MobControlMgr` 이 쓰는 그 값)를 읽고,
/// 못 읽으면 SO `rearmZoneRegenMax`. SO 값은 언제나 상한으로 쓴다(안전). /// 못 읽으면 SO `rearmZoneRegenMax`. SO 값은 언제나 상한으로 쓴다(안전).
@ -1461,6 +1578,14 @@ namespace WL.Combat.Boss
// 🔴 813w6 §1-2 (D-44) — RunEnded 발행이 끝난 뒤로 미뤄 둔 고정을 여기서 실행한다. // 🔴 813w6 §1-2 (D-44) — RunEnded 발행이 끝난 뒤로 미뤄 둔 고정을 여기서 실행한다.
if (s_parkPending) PumpDeferredPark(); if (s_parkPending) PumpDeferredPark();
// 🔴 811q §1-2 ⓐ — 재무장 웨이브를 프레임당 rearmPerFrame 마리로 나눠 깨운다.
// 대기열이 비어 있으면 즉시 반환(할당 0 · 프레임당 비용 0).
if (RearmQueuePending > 0)
{
try { DrainRearmQueue(cfg); }
catch (System.Exception ex) { ClearRearmQueue(); Log("분산 드레인 중단 — " + ex.Message); }
}
// 🔴 813w7 §1-2 ② (D-45) — 빈 존 감시(자체 주기 · 리쉬 주기와 독립). // 🔴 813w7 §1-2 ② (D-45) — 빈 존 감시(자체 주기 · 리쉬 주기와 독립).
if (cfg.rearmZoneSpawnsEnabled && now >= s_nextZoneRearmCheck) if (cfg.rearmZoneSpawnsEnabled && now >= s_nextZoneRearmCheck)
{ {
@ -1510,6 +1635,9 @@ namespace WL.Combat.Boss
ZoneSpawnHoldSkipped = ZoneRearmCount = ZoneRearmMobs = LastRearmZoneId = 0; ZoneSpawnHoldSkipped = ZoneRearmCount = ZoneRearmMobs = LastRearmZoneId = 0;
s_rearmCount = -1; s_nextZoneRearmCheck = 0f; s_rearmCount = -1; s_nextZoneRearmCheck = 0f;
for (int i = 0; i < kMaxRearmZones; i++) s_rearmEmptySince[i] = -1f; for (int i = 0; i < kMaxRearmZones; i++) s_rearmEmptySince[i] = -1f;
// 811q 스폰 분산
ClearRearmQueue();
RearmQueued = RearmDrained = RearmQueueOverflow = RearmDrainSkipped = 0;
// 813w5 // 813w5
s_bossHoldUntil = -1f; s_probeTime = -1f; s_bossHoldUntil = -1f; s_probeTime = -1f;
ParkCount = BossHoldTicks = OutsideRunLeashResets = GateMinBlocked = RunEndNotices = 0; ParkCount = BossHoldTicks = OutsideRunLeashResets = GateMinBlocked = RunEndNotices = 0;

View File

@ -350,6 +350,17 @@ namespace WL.Combat.Boss
+ "다음 런 시작 시점에 되살린다(D-45 의 직접 처방). 끄면 위의 빈 존 감시로만 되살아난다.")] + "다음 런 시작 시점에 되살린다(D-45 의 직접 처방). 끄면 위의 빈 존 감시로만 되살아난다.")]
public bool rearmZoneSpawnsOnRestart = true; public bool rearmZoneSpawnsOnRestart = true;
[Tooltip("🔴 WL-811q §1-2 ⓐ 스폰 분산 — 재무장 웨이브(12~15마리)를 **프레임당 이 마리 수**로 나눠 깨운다. "
+ "MobActor.On_Regen() 1회 = SetActive(true)(애니메이터 리바인드) + NavMeshAgent.enabled + Set_Warp + "
+ "스폰 이펙트 1개 + Spawned 이벤트다 — 15마리를 한 프레임에 하면 그 프레임이 통째로 길어진다. "
+ "0 = 예전처럼 한 프레임에 전부(C8 롤백 값 = 813w7 원래 동작). "
+ "🔴 원본 `MobControlMgr` 의 PhaseAllKill 일괄 스폰(:220-221)은 이 값으로 나눌 수 없다 — "
+ "그것은 원본 코루틴 안의 for 문이다(811q 보고 §5 원본 후보).")]
public int rearmPerFrame = 3;
[Tooltip("분산 대기열의 칸 수(버퍼 크기지 게임 수치가 아니다). 1웨이브 상한 15 보다 넉넉해야 한다.")]
public int rearmQueueCapacity = 32;
// ─────────────────────────────────────────── 813w8 §1-2 게이트 전 보스 UI 숨김(D-23 재발) // ─────────────────────────────────────────── 813w8 §1-2 게이트 전 보스 UI 숨김(D-23 재발)
[Header("게이트 전 보스 UI 숨김 (813w8 · D-23 다음 런 존 사냥 중 만피 보스 바)")] [Header("게이트 전 보스 UI 숨김 (813w8 · D-23 다음 런 존 사냥 중 만피 보스 바)")]
[Tooltip("🔴 게이트가 열리기 전에는 보스 HP 바·등장 배너를 띄우지 않는다. " [Tooltip("🔴 게이트가 열리기 전에는 보스 HP 바·등장 배너를 띄우지 않는다. "

View File

@ -83,7 +83,9 @@ namespace WL.Combat.Reaction
SkippedDisabled, SkippedNoRow, SkippedNotMainPC, SkippedOutOfView, SkippedHeavyBudget, SkippedMediumBudget, SkippedDisabled, SkippedNoRow, SkippedNotMainPC, SkippedOutOfView, SkippedHeavyBudget, SkippedMediumBudget,
SkippedImpactCap, SkippedImpactInterval, SkippedNoDamage, SkippedNotLoaded, SkippedNoPrefab, SkippedPoolFull, SkippedImpactCap, SkippedImpactInterval, SkippedNoDamage, SkippedNotLoaded, SkippedNoPrefab, SkippedPoolFull,
SkippedTrackFull, LoadRequested, LoadCompleted, LoadFailed, Instantiated, Expired, HeavyAcquired, HeavyReleased, SkippedTrackFull, LoadRequested, LoadCompleted, LoadFailed, Instantiated, Expired, HeavyAcquired, HeavyReleased,
TintApplied, PreloadCalls; TintApplied, PreloadCalls,
// 811q: 워밍 인스턴스를 다음 프레임으로 미룬 횟수(= 완료가 한 프레임에 몰렸다는 증거)
DeferredWarms;
public static string LastInfo = ""; public static string LastInfo = "";
public static int ActiveCount { get { return s_activeCount; } } public static int ActiveCount { get { return s_activeCount; } }
public static int MediumActive { get { return s_mediumActive; } } public static int MediumActive { get { return s_mediumActive; } }
@ -305,6 +307,48 @@ namespace WL.Combat.Reaction
for (int i = 0; i < st.rows.Length; i++) ResolveRow(st.rows[i].skillId, st.rows[i].classId, st); for (int i = 0; i < st.rows.Length; i++) ResolveRow(st.rows[i].skillId, st.rows[i].classId, st);
} }
// ───────────────────────────────────────── WL-811q §1-2 ⓑ 사전 워밍(프레임 분산)
//
// 🔴 왜 필요한가: 811i 의 33종은 원본 `EffectList.json` 프리로드 표(90행 · 인스턴스 514)에
// **한 종도 들어 있지 않다**(실측). 지금은 첫 `AttackStarted` 에 그 클래스 4행(프리팹 최대 12개)이
// 한꺼번에 로드되고, 완료 콜백이 몰린 프레임에 `warmInstanceOnLoad` 인스턴스가 같이 만들어진다.
// 맵 진입(로딩 구간)으로 앞당기고, 그때도 **프레임당 N 행**으로 나눈다.
/// <summary>아직 프리로드하지 않은 행 수(워밍 진행률).</summary>
public static int PreloadRemaining(int classId)
{
var st = St;
if (st == null || st.rows == null) return 0;
EnsureTables(st);
int n = 0;
for (int i = 0; i < st.rows.Length; i++)
if (!s_preloadedRow[i] && (classId < 0 || st.rows[i].classId == classId)) n++;
return n;
}
/// <summary>
/// 아직 프리로드하지 않은 행을 최대 <paramref name="maxRows"/> 개만 프리로드한다.
/// <paramref name="classId"/> &lt; 0 이면 클래스 무관 전 행. 반환 = 이번에 건 행 수.
/// 🔴 할당 = 행당 엔트리 3개(프리팹당 1회 · 이미 있으면 0) — 프레임당 할당은 maxRows 로 제한된다.
/// </summary>
public static int PreloadRows(int maxRows, int classId)
{
var st = St;
if (st == null || st.rows == null || maxRows == 0) return 0;
EnsureTables(st);
int done = 0;
for (int i = 0; i < st.rows.Length; i++)
{
if (s_preloadedRow[i]) continue;
if (classId >= 0 && st.rows[i].classId != classId) continue;
s_preloadedRow[i] = true;
ResolveRow(st.rows[i].skillId, st.rows[i].classId, st);
PreloadCalls++;
if (maxRows > 0 && ++done >= maxRows) break;
}
return done;
}
// ───────────────────────────────────────── 스폰 // ───────────────────────────────────────── 스폰
static Vector3 Anchor(Actor a, eEffectLocation loc) static Vector3 Anchor(Actor a, eEffectLocation loc)
{ {
@ -520,11 +564,25 @@ namespace WL.Combat.Reaction
static void PollLoads() static void PollLoads()
{ {
var st = St; var st = St;
// 🔴 WL-811q §1-2 ⓑ — 한 틱에 만들 워밍 인스턴스 수를 제한한다.
// Addressables 완료 콜백은 같은 프레임 끝에 몰려 들어온다(33종 중 여러 개가 한 프레임에 끝난다).
// 예산을 다 쓰면 남은 완료는 **다음 프레임으로 미룬다**(entry 는 s_loading 에 그대로 = 재요청 0).
// 0 = 무제한 = 811i 원래 동작(C8 롤백 값).
int budget = st != null ? st.warmInstancesPerTick : 0;
for (int i = s_loading.Count - 1; i >= 0; i--) for (int i = s_loading.Count - 1; i >= 0; i--)
{ {
var e = s_loading[i]; var e = s_loading[i];
if (!e.handle.IsValid()) { e.loading = false; e.failed = true; LoadFailed++; s_loading.RemoveAt(i); continue; } if (!e.handle.IsValid()) { e.loading = false; e.failed = true; LoadFailed++; s_loading.RemoveAt(i); continue; }
if (!e.handle.IsDone) continue; if (!e.handle.IsDone) continue;
bool willWarm = st != null && st.warmInstanceOnLoad && e.count == 0
&& e.handle.Status == AsyncOperationStatus.Succeeded && e.handle.Result != null;
if (willWarm && st.warmInstancesPerTick > 0)
{
if (budget <= 0) { DeferredWarms++; break; } // 예산 소진 — 다음 프레임에 마저 만든다
budget--;
}
e.loading = false; e.loading = false;
if (e.handle.Status == AsyncOperationStatus.Succeeded && e.handle.Result != null) if (e.handle.Status == AsyncOperationStatus.Succeeded && e.handle.Result != null)
{ {
@ -591,13 +649,23 @@ namespace WL.Combat.Reaction
} }
} }
/// <summary>
/// 프로브용(811q) — 「이 행은 이미 프리로드했다」 표시를 지운다. 다음 <c>PreloadRows</c> 가 처음부터 다시 센다.
/// 🔴 게임 경로에서는 부르지 않는다(같은 프리팹을 두 번 로드할 이유가 없다 — Addressables 참조 카운트만 는다).
/// </summary>
public static void ResetPreloadStateForProbe()
{
if (s_preloadedRow != null) System.Array.Clear(s_preloadedRow, 0, s_preloadedRow.Length);
if (s_rows != null) for (int i = 0; i < s_rows.Length; i++) s_rows[i].resolved = false;
}
/// <summary>프로브용 카운터 초기화(구독·풀 유지).</summary> /// <summary>프로브용 카운터 초기화(구독·풀 유지).</summary>
public static void ResetDiagnostics() public static void ResetDiagnostics()
{ {
CastSeen = FiredSeen = HitSeen = ChargeSpawned = BodySpawned = ImpactSpawned = ImpactFallback = 0; CastSeen = FiredSeen = HitSeen = ChargeSpawned = BodySpawned = ImpactSpawned = ImpactFallback = 0;
SkippedDisabled = SkippedNoRow = SkippedNotMainPC = SkippedOutOfView = SkippedHeavyBudget = SkippedMediumBudget = 0; SkippedDisabled = SkippedNoRow = SkippedNotMainPC = SkippedOutOfView = SkippedHeavyBudget = SkippedMediumBudget = 0;
SkippedImpactCap = SkippedImpactInterval = SkippedNoDamage = SkippedNotLoaded = SkippedNoPrefab = SkippedPoolFull = SkippedTrackFull = 0; SkippedImpactCap = SkippedImpactInterval = SkippedNoDamage = SkippedNotLoaded = SkippedNoPrefab = SkippedPoolFull = SkippedTrackFull = 0;
Instantiated = Expired = HeavyAcquired = HeavyReleased = TintApplied = PreloadCalls = 0; Instantiated = Expired = HeavyAcquired = HeavyReleased = TintApplied = PreloadCalls = DeferredWarms = 0;
LastInfo = ""; LastInfo = "";
} }

View File

@ -143,6 +143,13 @@ namespace WL.Combat.Reaction
[Tooltip("프리팹 로드가 끝나면 풀 인스턴스 1개를 미리 만들어 둔다(첫 스폰의 Instantiate 스파이크 제거).")] [Tooltip("프리팹 로드가 끝나면 풀 인스턴스 1개를 미리 만들어 둔다(첫 스폰의 Instantiate 스파이크 제거).")]
public bool warmInstanceOnLoad = true; public bool warmInstanceOnLoad = true;
[Tooltip("🔴 WL-811q §1-2 ⓑ — 한 틱(프레임)에 만들 워밍 인스턴스 수의 상한. "
+ "Addressables 완료 콜백은 프레임 끝에 **몰려서** 들어온다(33종 중 여러 개가 같은 프레임에 끝난다) — "
+ "그때 warmInstanceOnLoad 가 Instantiate 를 한꺼번에 돌리면 그 자체가 히치가 된다. "
+ "예산을 넘긴 완료는 다음 프레임으로 미뤄진다(재요청 0 · 지연 1~2 프레임). "
+ "0 = 무제한 = 811i 원래 동작(C8 롤백 값).")]
public int warmInstancesPerTick = 2;
// ─────────────────────────────────────────── 차지 // ─────────────────────────────────────────── 차지
[Header("차지 (SkillCast · f_CastingTime>0 이면 그 시간 · 0 이면 발동 플래시)")] [Header("차지 (SkillCast · f_CastingTime>0 이면 그 시간 · 0 이면 발동 플래시)")]
[Tooltip("f_CastingTime = 0 인 슬롯 1~3 의 차지 플래시 수명(초). 기획안 F-0B '차지 0.3 s'.")] [Tooltip("f_CastingTime = 0 인 슬롯 1~3 의 차지 플래시 수명(초). 기획안 F-0B '차지 0.3 s'.")]

View File

@ -80,4 +80,6 @@ MonoBehaviour:
rearmZoneRegenMax: 15 rearmZoneRegenMax: 15
rearmZoneRunZonesOnly: 1 rearmZoneRunZonesOnly: 1
rearmZoneSpawnsOnRestart: 1 rearmZoneSpawnsOnRestart: 1
rearmPerFrame: 3
rearmQueueCapacity: 32
hideBossUiBeforeGate: 1 hideBossUiBeforeGate: 1

View File

@ -0,0 +1,56 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 811c0a1b2c3d4e5f60718293a4b5c6d7, type: 3}
m_Name: WLHitchSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.Tools.WLHitchSettings
enabled: 1
verboseLog: 0
recorderEnabled: 1
hitchMilliseconds: 50
ringCapacity: 64
warmupFramesIgnored: 30
sampleManagedHeap: 1
autoWriteEveryHitches: 16
writeOnDeath: 1
writeOnRunEnd: 1
consoleLineOnWrite: 1
hitchDir: Screenshots_WL/WL811q
warmOnMapEnter: 1
warmPollSeconds: 0.25
warmStartDelaySeconds: 1.5
warmRowsPerFrame: 1
warmPoolPerFrame: 1
warmPoolPrefabs:
- Effect_Slash_10101_1
- Effect_Slash_10101_2
- Effect_Slash_10101_3
- Effect_Slash_10102_1
- Effect_Slash_10102_2
- Effect_Slash_10102_3
- Effect_Slash_10102_4
- Effect_Slash_10201_1
- Effect_Slash_10201_2
- Effect_Slash_10201_3
- Effect_Slash_10202_1
- Effect_Slash_10202_2
- Effect_Slash_10202_3
- Effect_Slash_10202_4
- Effect_Slash_10501_1
- Effect_Slash_10501_2
- Effect_Slash_10501_3
- Effect_Slash_10502_1
- Effect_Slash_10502_2
- Effect_WLStab
- Effect_WLSwingArc
- BoomHit
warmFarDistance: 100000
warmAllClasses: 0

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 811c0a1b2c3d4e5f60718293a4b5c6da
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -19,6 +19,7 @@ MonoBehaviour:
preloadOnFirstAttack: 1 preloadOnFirstAttack: 1
preloadClassOnFirstCast: 1 preloadClassOnFirstCast: 1
warmInstanceOnLoad: 1 warmInstanceOnLoad: 1
warmInstancesPerTick: 2
chargeFlashSeconds: 0.35 chargeFlashSeconds: 0.35
chargeFollowCaster: 1 chargeFollowCaster: 1
chargeMaxLifetime: 2 chargeMaxLifetime: 2

View File

@ -0,0 +1,206 @@
// ─────────────────────────────────────────────────────────────────────────────
// EffectWarmup.cs — 맵 진입 시 이펙트 사전 워밍(프레임 분산) · 히치 1차 완화 ⓑ
//
// PD 지시 #813(버벅임) · 발주서 WL-811q §1-2 ⓑ (2026-09-10)
//
// ■ 관측된 원인 (코드·테이블 실측)
// ① 원본은 맵 로드 때 `EffectList.json` 90행을 돌며 **인스턴스 514개**를 미리 만든다
// (`InGameInfo.cs:83-88` · `n_PreLoadCount` 합 = 이펙트 312 + 투사체 202).
// 그래서 `Effect_MonsterSpawn`(프리로드 20)·`Effect_Hit_*`(각 10) 같은 것은 **이미 워밍돼 있다**.
// ② 🔴 그런데 **WL 이 쓰는 원본 풀 이펙트 22종**(검기 21 + `BoomHit`)은 그 표에 **한 종도 없다**(실측).
// → 전투 중 첫 사용에서 `InGameInfo.Get_Effect` → `Load_Effect` → **Addressables 로드 + Instantiate**.
// ③ 🔴 811i 스킬 이펙트 **33종**도 그 표에 없다 — 지금은 **첫 `AttackStarted`** 에 그 클래스 4행이
// 한꺼번에 로드되고(811i §1 ④), 완료 콜백이 몰린 프레임에 워밍 인스턴스가 같이 만들어진다.
//
// ■ 무엇을 하나 (원본 훅 0 · 원본 파일 수정 0)
// 맵 진입 경계(메인 PC 교체 또는 맵 ID 변경 — `AutoCombatOnEnter` 와 **같은 판정**)를 보고,
// `warmStartDelaySeconds` 뒤부터 **프레임당 N 개씩** 나눠서
// ⓐ 811i 행 → `SkillSpectacle.PreloadRows(warmRowsPerFrame, classId)`
// ⓑ 원본 풀 22종 → `InGameInfo.Show_Effect(name, Vector3.one * warmFarDistance)`
// (원본 자신의 사전 로드 `InGameInfo.cs:87` 과 **같은 좌표·같은 경로**. 프리팹의 `TurnOff_GO.OnEnable`
// → `Set_Off()` 가 수명 뒤 스스로 꺼서 풀로 돌려놓는다 = 화면에 보이지 않고 잔류도 없다.)
// ⇒ 첫 전투에서 터지던 로드·Instantiate 를 **로딩 직후 조용한 구간**으로 옮기고, 그 구간에서도 나눈다.
//
// ■ 🔴 안전
// · `warmPoolPrefabs` 의 이름은 프리팹에 `TurnOff_GO` 가 있어야 한다(없으면 원본 `Show_Effect` 가
// `GetComponent<TurnOff_GO>()` null 에서 NRE). **기본 22종은 디스크에서 전수 확인**했다(811q 보고 §4).
// · 워밍은 맵 경계당 1회. 실패해도 런을 죽이지 않게 전체를 try/catch 로 감싸고 한 번 실패하면 그 판은 멈춘다.
// · `Application.isPlaying` 이 아니면 아무것도 하지 않는다(에디트 모드 오염 0 · 프로브는 별도 진입점).
//
// ■ C8 롤백
// `WLHitchSettings.enabled = 0` → 러너 자체가 없다 · `warmOnMapEnter = 0` → 워밍 0
// (그러면 811i 는 예전처럼 첫 `AttackStarted` 에 프리로드하고 22종은 첫 사용에 로드된다 = 100 % 기존 동작).
//
// 🔴 어셈블리 주의: Assets/WL/ 에 .asmdef 를 만들지 말 것.
// 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using WL.Combat.Reaction;
namespace WL.Tools
{
/// <summary>맵 진입 이펙트 워밍. 정적 · 러너는 HitchRecorderRunner 가 겸한다(새 오브젝트 0).</summary>
public static class EffectWarmup
{
enum Phase { Idle = 0, Waiting = 1, Rows = 2, Pool = 3, Done = 4, Faulted = 5 }
static Phase s_phase;
static PCActor s_lastPc;
static int s_lastMapId = int.MinValue;
static float s_nextPoll, s_startAt;
static int s_poolIndex, s_classId = -1;
// ───────────────────────────────────────── 진단(프로브·레코더가 읽는다)
/// <summary>맵 경계를 본 횟수 · 워밍한 811i 행 수 · 워밍한 원본 풀 이름 수.</summary>
public static int Boundaries, RowsWarmed, PoolWarmed;
/// <summary>워밍 완료(=Done) 도달 횟수 · 실패 횟수.</summary>
public static int Completions, Faults;
/// <summary>레코더가 프레임 델타로 쓰는 진행 총계.</summary>
public static int WarmedTotal { get { return RowsWarmed + PoolWarmed; } }
/// <summary>현재 단계 이름(파일 요약에 그대로 들어간다).</summary>
public static string LastState { get { return s_phase.ToString(); } }
public static string LastLog = "";
static WLHitchSettings St { get { return WLHitchSettings.Instance; } }
// ─────────────────────────────────────────────────────────────────────
// 틱 (HitchRecorderRunner.Update 가 부른다)
// ─────────────────────────────────────────────────────────────────────
/// <summary>1프레임. <paramref name="now"/> = Time.unscaledTime.</summary>
public static void Tick(float now)
{
var st = St;
if (st == null || !WLHitchSettings.Enabled || !st.warmOnMapEnter) return;
if (s_phase == Phase.Faulted) return;
// 🔴 에디트 모드에서는 경계 감시·원본 풀 접근을 하지 않는다(오염 0). 행 워밍만 프로브가 돌릴 수 있다.
try
{
if (now >= s_nextPoll)
{
s_nextPoll = now + Mathf.Max(0.05f, st.warmPollSeconds);
PollBoundary(st, now);
}
switch (s_phase)
{
case Phase.Waiting:
if (now >= s_startAt) { s_phase = Phase.Rows; }
break;
case Phase.Rows:
StepRows(st);
break;
case Phase.Pool:
StepPool(st);
break;
}
}
catch (System.Exception ex)
{
s_phase = Phase.Faulted; Faults++;
LastLog = "중단(예외) — " + ex.Message;
if (st.verboseLog) Debug.Log("[EffectWarmup] " + LastLog); // 🔴 LogError 금지(813z)
}
}
/// <summary>맵 진입 경계 판정 — AutoCombatOnEnter 와 같은 규칙(메인 PC 교체 또는 맵 ID 변경).</summary>
static void PollBoundary(WLHitchSettings st, float now)
{
if (!Application.isPlaying) return; // 에디트 모드 오염 0(프로브는 ForceStart 로 들어온다)
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc)) { s_lastPc = null; return; }
// InGameInfo 가 아직 없으면 경계를 「소비」하지 않는다 — 다음 폴링에서 같은 경계를 다시 본다.
var info = InGameInfo.Ins;
if (DSUtil.CheckNull(info)) return;
int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue;
bool pcChanged = !ReferenceEquals(pc, s_lastPc);
bool mapChanged = map != s_lastMapId;
if (!pcChanged && !mapChanged) return;
s_lastPc = pc;
s_lastMapId = map;
Boundaries++;
s_classId = st.warmAllClasses ? -1 : pc.Get_ID();
s_poolIndex = 0;
s_startAt = now + Mathf.Max(0f, st.warmStartDelaySeconds);
s_phase = Phase.Waiting;
LastLog = "맵 진입 경계 — 워밍 예약";
if (st.verboseLog) Debug.Log("[EffectWarmup] " + LastLog + " map=" + map + " class=" + s_classId);
}
/// <summary>811i 행 워밍 1프레임분.</summary>
static void StepRows(WLHitchSettings st)
{
int n = SkillSpectacle.PreloadRows(Mathf.Max(0, st.warmRowsPerFrame), s_classId);
RowsWarmed += n;
if (n == 0 || SkillSpectacle.PreloadRemaining(s_classId) <= 0) s_phase = Phase.Pool;
}
/// <summary>원본 풀 워밍 1프레임분.</summary>
static void StepPool(WLHitchSettings st)
{
var names = st.warmPoolPrefabs;
if (names == null || names.Length == 0) { Finish(st); return; }
if (!Application.isPlaying) { Finish(st); return; } // 에디트 모드는 원본 풀을 만들지 않는다
var info = InGameInfo.Ins;
if (DSUtil.CheckNull(info)) { Finish(st); return; }
int per = st.warmPoolPerFrame;
if (per <= 0) per = names.Length;
Vector3 far = Vector3.one * st.warmFarDistance;
for (int k = 0; k < per && s_poolIndex < names.Length; k++)
{
string name = names[s_poolIndex++];
if (string.IsNullOrEmpty(name)) continue;
// 원본 경로 그대로 — 풀에 없으면 Load_Effect(Addressables) 후 인스턴스 1개가 남는다.
info.Show_Effect(name, far);
PoolWarmed++;
}
if (s_poolIndex >= names.Length) Finish(st);
}
static void Finish(WLHitchSettings st)
{
s_phase = Phase.Done;
Completions++;
LastLog = "워밍 완료 — 행 " + RowsWarmed + " · 풀 " + PoolWarmed;
if (st.verboseLog) Debug.Log("[EffectWarmup] " + LastLog);
}
// ─────────────────────────────────────────────────────────────────────
// 프로브 진입점 · 초기화
// ─────────────────────────────────────────────────────────────────────
/// <summary>프로브용: 경계 폴링을 건너뛰고 지금 바로 워밍을 시작한다(classId &lt; 0 = 전 클래스).</summary>
public static void ForceStart(int classId, float now)
{
s_classId = classId;
s_poolIndex = 0;
s_startAt = now;
s_phase = Phase.Waiting;
Boundaries++;
}
/// <summary>프로브용: 지금 단계(0 Idle · 1 Waiting · 2 Rows · 3 Pool · 4 Done · 5 Faulted).</summary>
public static int PhaseIndex { get { return (int)s_phase; } }
/// <summary>프로브·재시작용.</summary>
public static void ResetAll()
{
s_phase = Phase.Idle;
s_lastPc = null; s_lastMapId = int.MinValue;
s_nextPoll = s_startAt = 0f;
s_poolIndex = 0; s_classId = -1;
Boundaries = RowsWarmed = PoolWarmed = Completions = Faults = 0;
LastLog = "";
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 811c0a1b2c3d4e5f60718293a4b5c6d9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,364 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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)
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 811c0a1b2c3d4e5f60718293a4b5c6d8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,151 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLHitchSettings.cs — 히치(버벅임) 계측 · 1차 완화 값의 단일 출처 (SOT · C45)
//
// PD 지시 #813(「게임이 자꾸 버벅대는 듯 멈추는 렉」) · 발주서 WL-811q §1-2 · §1-3 (2026-09-10)
//
// ■ 왜 이 SO 하나에 「레코더 + 워밍」이 같이 있나
// 둘 다 811q 히치 축이고, QA·PD 가 A/B 를 할 때 **한 에셋만 열면 되게** 하려는 것이다.
// 스폰 분산(`rearmPerFrame`)만은 재무장 코드가 있는 `WLBossArenaSettings` 에 뒀다(같은 기능 옆).
//
// ■ 근거(실측)
// · Q6 `qa/W2-6_재채점_v4.md` 7-3 = 히치 32프레임/3275 · 최악 **637.1 ms** · 최저 fps 1.6 (군중 구간)
// · Q8 `qa/W2-8_재채점_v6.md` 7-3 = 히치 6프레임/3520 · 최악 **1003.2 ms**(에디터 스톨 혼입 「가능성」)
// · Q12 `qa/W2-12_회귀.md` 7-3 = 히치 8프레임/3573 · 최악 **263.9 ms**
// · 원본 `Assets/ResWork/Table/Export/EffectList.json` = 90행 · **프리로드 인스턴스 합 514**
// (이펙트 312 · 투사체 202) — `InGameInfo.Start()` 가 맵 로드 때 한꺼번에 만든다.
// 🔴 그런데 WL 이 쓰는 원본 풀 이펙트 이름 **22종**(검기 21 + `BoomHit`)은 이 표에 **없다**
// → 전투 도중 첫 사용에서 Addressables 로드 + Instantiate 가 그대로 터진다. 그것이 warmPoolPrefabs 다.
//
// ■ C8 롤백
// 에셋이 없거나 `enabled = 0` → 러너 0 · 레코더 0 · 워밍 0 = 100 % 기존 동작.
// `recorderEnabled = 0` → 계측만 끈다 / `warmOnMapEnter = 0` → 워밍만 끈다.
//
// 🔴 어셈블리 주의: Assets/WL/ 에 .asmdef 를 만들지 말 것(Actor/InGameInfo 가 Assembly-CSharp 에 있다).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Tools
{
/// <summary>히치 계측·완화 값. 에셋 = Assets/WL/Combat/Settings/Resources/WL/WLHitchSettings.asset</summary>
[CreateAssetMenu(fileName = "WLHitchSettings", menuName = "WL/Hitch Settings", order = 327)]
public sealed class WLHitchSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLHitchSettings";
static WLHitchSettings s_cached;
static bool s_lookupDone;
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null = 기능 전체 off.</summary>
public static WLHitchSettings Instance
{
get
{
if (!s_lookupDone) { s_cached = Resources.Load<WLHitchSettings>(ResourcesPath); s_lookupDone = true; }
return s_cached;
}
}
/// <summary>프로브·A/B 전용. 0 = 에셋의 enabled · 1 = 강제 on · -1 = 강제 off.</summary>
public static int RuntimeOverride;
/// <summary>축 전체가 살아 있는가. false 면 러너조차 만들지 않는다(C8).</summary>
public static bool Enabled
{
get
{
var i = Instance;
if (i == null || RuntimeOverride < 0) return false;
return RuntimeOverride > 0 || i.enabled;
}
}
/// <summary>
/// 프로브가 임시 에셋을 끼워 넣을 때 쓴다(실에셋은 읽기만).
/// null 을 넣으면 캐시가 비워져 다음 접근에서 실에셋을 다시 로드한다.
/// 🔴 런타임 게임 경로에서는 절대 부르지 않는다.
/// </summary>
public static void OverrideInstanceForProbe(WLHitchSettings st) { s_cached = st; s_lookupDone = st != null; }
// ═══════════════════════════════════════════ 공통
[Header("공통 (C8)")]
[Tooltip("이 축 전체 on/off. 끄면 러너 오브젝트도 만들지 않는다 = 흔적 0.")]
public bool enabled = true;
[Tooltip("콘솔 로그를 낸다. 🔴 문자열 결합은 이 값이 켜졌을 때만 일어난다(프레임당 할당 0 유지).")]
public bool verboseLog = false;
// ═══════════════════════════════════════════ ① 히치 레코더 (발주서 §1-3)
[Header("① 히치 레코더 (Screenshots_WL/WL811q/hitch_*.txt · QA 가 읽는다)")]
[Tooltip("프레임 시간을 재서 링버퍼에 기록한다. 끄면 Update 가 즉시 반환한다.")]
public bool recorderEnabled = true;
[Tooltip("🔴 이 시간(ms)을 넘는 프레임을 「히치」로 본다. 발주서 §1-3 기본 50 ms. "
+ "60 fps = 16.7 ms 이므로 50 ms 는 약 3프레임분이다. QA 채점 기준과 맞추려면 여기만 바꾼다.")]
public float hitchMilliseconds = 50f;
[Tooltip("링버퍼 칸 수(발주서 §1-3 = 64). 게임 수치가 아니라 버퍼 크기다. 넘치면 오래된 것부터 덮어쓴다.")]
public int ringCapacity = 64;
[Tooltip("첫 N 프레임은 무시한다 — 맵 로드 직후 프레임은 항상 길다(로딩 화면 구간).")]
public int warmupFramesIgnored = 30;
[Tooltip("GC.GetTotalMemory(false) 로 힙 델타(KB)를 같이 적는다. 끄면 GC 열이 0 으로 나온다.")]
public bool sampleManagedHeap = true;
[Tooltip("히치가 이 개수만큼 쌓일 때마다 파일을 쓴다(0 = 자동 기록 없음 · 종료·사망·런종료에만).")]
public int autoWriteEveryHitches = 16;
[Tooltip("PC 사망(DeathFlow.Died) 때 파일을 쓴다 — Q8 최악 831 ms 가 사망 구간이었다.")]
public bool writeOnDeath = true;
[Tooltip("런 종료(RunEvents.RunEnded) 때 파일을 쓴다 — 한 판이 통째로 한 파일이 된다.")]
public bool writeOnRunEnd = true;
[Tooltip("파일을 쓸 때 콘솔에 1줄 요약을 남긴다(verboseLog 와 무관 · QA 가 이 줄만 봐도 된다).")]
public bool consoleLineOnWrite = true;
[Tooltip("기록 폴더(프로젝트 루트 기준 · 빌드는 persistentDataPath 기준). Assets 밖 = 임포트 0.")]
public string hitchDir = "Screenshots_WL/WL811q";
// ═══════════════════════════════════════════ ② 이펙트 사전 워밍 (발주서 §1-2 ⓑ)
[Header("② 이펙트 사전 워밍 (맵 진입 = 로딩 구간으로 비용을 앞당긴다)")]
[Tooltip("🔴 맵 진입(메인 PC 교체 또는 맵 ID 변경 · AutoCombatOnEnter 와 같은 경계)에서 워밍을 시작한다. "
+ "끄면 811i 는 예전대로 **첫 AttackStarted** 에 프리로드한다(= 첫 스킬 직전에 로드가 몰린다).")]
public bool warmOnMapEnter = true;
[Tooltip("맵 경계 감시 주기(초 · unscaled). 폴링 자체는 할당 0.")]
public float warmPollSeconds = 0.25f;
[Tooltip("경계를 본 뒤 이만큼(초) 기다렸다가 워밍을 시작한다 — 맵 로드 직후의 원본 프리로드 514개와 겹치지 않게.")]
public float warmStartDelaySeconds = 1.5f;
[Tooltip("🔴 한 프레임에 요청할 811i 행 수(3단 = 프리팹 최대 3개/행). 0 = 한 프레임에 전부(= 워밍이 스스로 히치가 된다).")]
public int warmRowsPerFrame = 1;
[Tooltip("🔴 한 프레임에 워밍할 원본 풀 이펙트 이름 수. 0 = 한 프레임에 전부.")]
public int warmPoolPerFrame = 1;
[Tooltip("원본 풀(InGameInfo.Show_Effect) 로 미리 1개씩 만들어 둘 이펙트 이름. "
+ "🔴 기본값 22종 = WL 이 쓰는데 EffectList.json 프리로드 표에 **없는** 것 전부(실측 · 검기 21 + BoomHit). "
+ "🔴 여기에 넣는 이름은 프리팹에 TurnOff_GO 가 있어야 한다(없으면 원본 Show_Effect 가 NRE) — "
+ "기본 22종은 디스크에서 전수 확인했다.")]
public string[] warmPoolPrefabs = new string[]
{
"Effect_Slash_10101_1", "Effect_Slash_10101_2", "Effect_Slash_10101_3",
"Effect_Slash_10102_1", "Effect_Slash_10102_2", "Effect_Slash_10102_3", "Effect_Slash_10102_4",
"Effect_Slash_10201_1", "Effect_Slash_10201_2", "Effect_Slash_10201_3",
"Effect_Slash_10202_1", "Effect_Slash_10202_2", "Effect_Slash_10202_3", "Effect_Slash_10202_4",
"Effect_Slash_10501_1", "Effect_Slash_10501_2", "Effect_Slash_10501_3",
"Effect_Slash_10502_1", "Effect_Slash_10502_2",
"Effect_WLStab", "Effect_WLSwingArc", "BoomHit"
};
[Tooltip("워밍 인스턴스를 놓는 좌표(원본 InGameInfo.Start 의 사전 로드와 같은 곳 = Vector3.one * 이 값). "
+ "TurnOff_GO 가 수명 뒤 스스로 꺼서 풀로 돌아간다.")]
public float warmFarDistance = 100000f;
[Tooltip("🔴 811i 스킬 이펙트를 클래스 무관 **전 행** 워밍한다. 끄면 메인 PC 클래스 행만 워밍한다(로드량 1/3).")]
public bool warmAllClasses = false;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 811c0a1b2c3d4e5f60718293a4b5c6d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: