[WL-811q] 히치 원인 실측 · 스폰 분산 · 이펙트 워밍 · 히치 레코더 (#813)
This commit is contained in:
parent
22832ff5d0
commit
85550ae546
|
|
@ -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
|
||||
|
|
@ -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); }
|
||||
}
|
||||
}
|
||||
|
|
@ -189,6 +189,13 @@ namespace WL.Combat.Boss
|
|||
const int kMaxRearmZones = 16; // 버퍼 크기지 게임 수치가 아니다
|
||||
static readonly int[] s_rearmIds = new int[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 float s_nextZoneRearmCheck;
|
||||
|
||||
|
|
@ -219,6 +226,14 @@ namespace WL.Combat.Boss
|
|||
public static int ZoneSpawnHoldTicks, ZoneSpawnHoldTargets;
|
||||
/// <summary>813w7 §1-2 ②: 차단을 건너뛴 횟수(게이트 전 · 런 존 제외) — D-45 수리가 실제로 도는지의 증거.</summary>
|
||||
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>
|
||||
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;
|
||||
if (DSUtil.CheckNull(info)) return 0;
|
||||
|
||||
// 🔴 811q §1-2 ⓐ — 이 존의 앞 웨이브가 아직 프레임 분산 중이면 새 웨이브를 얹지 않는다(이중 스폰 방지).
|
||||
// 다른 존은 같이 대기열에 들어갈 수 있다(「다음 런」에서 존 2개를 한 번에 재무장하는 813w7 동작 보존).
|
||||
if (IsZoneQueued(spawnerId)) return 0;
|
||||
|
||||
int want = WaveSize(cfg, spawnerId);
|
||||
if (want <= 0) return 0;
|
||||
|
||||
|
|
@ -1251,11 +1270,28 @@ namespace WL.Combat.Boss
|
|||
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++)
|
||||
{
|
||||
var mob = actors[i] as MobActor;
|
||||
if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) continue;
|
||||
mob.On_Regen(); // 원본 웨이브와 같은 호출(MobControlMgr.cs:221) — 좌표·이펙트·Spawned 이벤트까지 원본 그대로
|
||||
|
||||
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 이벤트까지 원본 그대로
|
||||
ZoneRearmMobs++;
|
||||
}
|
||||
done++;
|
||||
}
|
||||
}
|
||||
|
|
@ -1265,13 +1301,94 @@ namespace WL.Combat.Boss
|
|||
if (done > 0)
|
||||
{
|
||||
ZoneRearmCount++;
|
||||
ZoneRearmMobs += done;
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 1웨이브 마리 수 — 원본 테이블 `n_MaxMonsterCount`(= `MobControlMgr` 이 쓰는 그 값)를 읽고,
|
||||
/// 못 읽으면 SO `rearmZoneRegenMax`. SO 값은 언제나 상한으로 쓴다(안전).
|
||||
|
|
@ -1461,6 +1578,14 @@ namespace WL.Combat.Boss
|
|||
// 🔴 813w6 §1-2 (D-44) — RunEnded 발행이 끝난 뒤로 미뤄 둔 고정을 여기서 실행한다.
|
||||
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) — 빈 존 감시(자체 주기 · 리쉬 주기와 독립).
|
||||
if (cfg.rearmZoneSpawnsEnabled && now >= s_nextZoneRearmCheck)
|
||||
{
|
||||
|
|
@ -1510,6 +1635,9 @@ namespace WL.Combat.Boss
|
|||
ZoneSpawnHoldSkipped = ZoneRearmCount = ZoneRearmMobs = LastRearmZoneId = 0;
|
||||
s_rearmCount = -1; s_nextZoneRearmCheck = 0f;
|
||||
for (int i = 0; i < kMaxRearmZones; i++) s_rearmEmptySince[i] = -1f;
|
||||
// 811q 스폰 분산
|
||||
ClearRearmQueue();
|
||||
RearmQueued = RearmDrained = RearmQueueOverflow = RearmDrainSkipped = 0;
|
||||
// 813w5
|
||||
s_bossHoldUntil = -1f; s_probeTime = -1f;
|
||||
ParkCount = BossHoldTicks = OutsideRunLeashResets = GateMinBlocked = RunEndNotices = 0;
|
||||
|
|
|
|||
|
|
@ -350,6 +350,17 @@ namespace WL.Combat.Boss
|
|||
+ "다음 런 시작 시점에 되살린다(D-45 의 직접 처방). 끄면 위의 빈 존 감시로만 되살아난다.")]
|
||||
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 재발)
|
||||
[Header("게이트 전 보스 UI 숨김 (813w8 · D-23 다음 런 존 사냥 중 만피 보스 바)")]
|
||||
[Tooltip("🔴 게이트가 열리기 전에는 보스 HP 바·등장 배너를 띄우지 않는다. "
|
||||
|
|
|
|||
|
|
@ -83,7 +83,9 @@ namespace WL.Combat.Reaction
|
|||
SkippedDisabled, SkippedNoRow, SkippedNotMainPC, SkippedOutOfView, SkippedHeavyBudget, SkippedMediumBudget,
|
||||
SkippedImpactCap, SkippedImpactInterval, SkippedNoDamage, SkippedNotLoaded, SkippedNoPrefab, SkippedPoolFull,
|
||||
SkippedTrackFull, LoadRequested, LoadCompleted, LoadFailed, Instantiated, Expired, HeavyAcquired, HeavyReleased,
|
||||
TintApplied, PreloadCalls;
|
||||
TintApplied, PreloadCalls,
|
||||
// 811q: 워밍 인스턴스를 다음 프레임으로 미룬 횟수(= 완료가 한 프레임에 몰렸다는 증거)
|
||||
DeferredWarms;
|
||||
public static string LastInfo = "";
|
||||
public static int ActiveCount { get { return s_activeCount; } }
|
||||
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);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── 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"/> < 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)
|
||||
{
|
||||
|
|
@ -520,11 +564,25 @@ namespace WL.Combat.Reaction
|
|||
static void PollLoads()
|
||||
{
|
||||
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--)
|
||||
{
|
||||
var e = s_loading[i];
|
||||
if (!e.handle.IsValid()) { e.loading = false; e.failed = true; LoadFailed++; s_loading.RemoveAt(i); 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;
|
||||
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>
|
||||
public static void ResetDiagnostics()
|
||||
{
|
||||
CastSeen = FiredSeen = HitSeen = ChargeSpawned = BodySpawned = ImpactSpawned = ImpactFallback = 0;
|
||||
SkippedDisabled = SkippedNoRow = SkippedNotMainPC = SkippedOutOfView = SkippedHeavyBudget = SkippedMediumBudget = 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 = "";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,13 @@ namespace WL.Combat.Reaction
|
|||
[Tooltip("프리팹 로드가 끝나면 풀 인스턴스 1개를 미리 만들어 둔다(첫 스폰의 Instantiate 스파이크 제거).")]
|
||||
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 이면 발동 플래시)")]
|
||||
[Tooltip("f_CastingTime = 0 인 슬롯 1~3 의 차지 플래시 수명(초). 기획안 F-0B '차지 0.3 s'.")]
|
||||
|
|
|
|||
|
|
@ -80,4 +80,6 @@ MonoBehaviour:
|
|||
rearmZoneRegenMax: 15
|
||||
rearmZoneRunZonesOnly: 1
|
||||
rearmZoneSpawnsOnRestart: 1
|
||||
rearmPerFrame: 3
|
||||
rearmQueueCapacity: 32
|
||||
hideBossUiBeforeGate: 1
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 811c0a1b2c3d4e5f60718293a4b5c6da
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -19,6 +19,7 @@ MonoBehaviour:
|
|||
preloadOnFirstAttack: 1
|
||||
preloadClassOnFirstCast: 1
|
||||
warmInstanceOnLoad: 1
|
||||
warmInstancesPerTick: 2
|
||||
chargeFlashSeconds: 0.35
|
||||
chargeFollowCaster: 1
|
||||
chargeMaxLifetime: 2
|
||||
|
|
|
|||
|
|
@ -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 < 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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 811c0a1b2c3d4e5f60718293a4b5c6d9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 811c0a1b2c3d4e5f60718293a4b5c6d8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 811c0a1b2c3d4e5f60718293a4b5c6d7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Loading…
Reference in New Issue